From 076f9fea6ba9201fefa1ce582964c0b38f8cfa65 Mon Sep 17 00:00:00 2001 From: Guster Date: Tue, 21 May 2024 23:16:55 +0800 Subject: [PATCH] expense_manager --- expense_manager/.gitignore | 46 + expense_manager/README.md | 11 + expense_manager/analysis_options.yaml | 32 + .../lib/components/action_delete_icon.dart | 21 + .../lib/components/amount_dialog.dart | 122 ++ .../lib/components/amount_input_view.dart | 44 + .../lib/components/button/custom_button.dart | 104 ++ .../components/button/negative_button.dart | 24 + .../components/button/positive_button.dart | 25 + .../components/category_editor_dialog.dart | 90 ++ .../lib/components/category_pie_chart.dart | 50 + .../lib/components/category_radio_button.dart | 41 + .../lib/components/category_square_box.dart | 22 + .../lib/components/custom_divider.dart | 13 + .../lib/components/date_field.dart | 42 + .../components/date_range_filter_view.dart | 54 + .../lib/components/dialog/custom_dialog.dart | 69 ++ .../lib/components/dialog/delete_dialog.dart | 62 + .../lib/components/expense_float_button.dart | 40 + .../components/form/custom_text_field.dart | 191 +++ .../lib/components/label_widget.dart | 29 + .../components/misc/bottom_bar_container.dart | 37 + .../lib/components/misc/custom_card.dart | 38 + .../lib/components/misc/custom_scaffold.dart | 55 + .../components/misc/custom_search_bar.dart | 44 + .../lib/components/misc/placeholder_view.dart | 52 + .../components/misc/rounded_container.dart | 44 + .../lib/components/month_filter_view.dart | 63 + .../lib/components/section_view.dart | 36 + expense_manager/lib/components/tag_chip.dart | 29 + .../lib/components/tag_input_dialog.dart | 131 ++ .../lib/components/tag_list_item.dart | 92 ++ .../lib/components/text_input_dialog.dart | 59 + .../components/transaction_group_item.dart | 60 + .../lib/components/transaction_list_item.dart | 76 ++ expense_manager/lib/constants.dart | 53 + .../controllers/add_expense_controller.dart | 117 ++ .../controllers/add_income_controller.dart | 44 + .../lib/controllers/base_controller.dart | 3 + .../controllers/categories_controller.dart | 35 + .../category_editor_controller.dart | 31 + .../category_expenses_controller.dart | 32 + .../category_report_controller.dart | 98 ++ .../lib/controllers/expenses_controller.dart | 52 + .../lib/controllers/home_controller.dart | 63 + .../monthly_report_controller.dart | 292 +++++ .../lib/controllers/settings_controller.dart | 51 + .../controllers/tag_expenses_controller.dart | 20 + .../lib/controllers/tag_input_controller.dart | 43 + .../lib/controllers/tags_controller.dart | 52 + .../controllers/transactions_controller.dart | 48 + .../lib/fl_chart/custom_line_chart.dart | 130 ++ .../lib/fl_chart/custom_pie_chart.dart | 84 ++ expense_manager/lib/fl_chart/index.dart | 2 + expense_manager/lib/index.dart | 1 + expense_manager/lib/main.dart | 9 + expense_manager/lib/misc/colors.dart | 7 + .../lib/misc/custom_theme_data.dart | 102 ++ expense_manager/lib/misc/debouncer.dart | 14 + expense_manager/lib/misc/extensions.dart | 102 ++ expense_manager/lib/misc/utils.dart | 152 +++ expense_manager/lib/models/category.dart | 74 ++ expense_manager/lib/models/category.g.dart | 1050 ++++++++++++++++ expense_manager/lib/models/expense.dart | 55 + expense_manager/lib/models/expense.g.dart | 1069 +++++++++++++++++ expense_manager/lib/models/income.dart | 27 + expense_manager/lib/models/income.g.dart | 924 ++++++++++++++ expense_manager/lib/models/serializable.dart | 3 + expense_manager/lib/models/tag.dart | 67 ++ expense_manager/lib/models/tag.g.dart | 714 +++++++++++ expense_manager/lib/models/transaction.dart | 32 + expense_manager/lib/my_app.dart | 21 + .../lib/pages/add_expense_page.dart | 182 +++ .../lib/pages/add_income_page.dart | 89 ++ .../lib/pages/categories_page.dart | 78 ++ .../lib/pages/category_report_page.dart | 96 ++ expense_manager/lib/pages/expenses_page.dart | 116 ++ expense_manager/lib/pages/home_page.dart | 214 ++++ expense_manager/lib/pages/image_page.dart | 53 + expense_manager/lib/pages/main_page.dart | 53 + .../lib/pages/monthly_report_page.dart | 239 ++++ expense_manager/lib/pages/reports_page.dart | 67 ++ expense_manager/lib/pages/settings_page.dart | 99 ++ expense_manager/lib/pages/tags_page.dart | 64 + .../lib/pages/transactions_page.dart | 46 + .../lib/podo/home_banner_item.dart | 13 + expense_manager/lib/pubsub.dart | 28 + .../lib/repositories/base_repo.dart | 19 + .../lib/repositories/category_repo.dart | 68 ++ .../lib/repositories/expense_repo.dart | 90 ++ .../lib/repositories/income_repo.dart | 82 ++ .../lib/repositories/repo_factory.dart | 47 + .../lib/repositories/service_factory.dart | 14 + .../lib/repositories/tag_repo.dart | 73 ++ .../lib/services/app_init_service.dart | 17 + .../lib/services/base_service.dart | 11 + .../lib/services/data_service.dart | 115 ++ .../lib/services/shared_pref_service.dart | 46 + .../lib/services/transaction_service.dart | 87 ++ expense_manager/pubspec.yaml | 108 ++ 100 files changed, 9835 insertions(+) create mode 100644 expense_manager/.gitignore create mode 100644 expense_manager/README.md create mode 100644 expense_manager/analysis_options.yaml create mode 100644 expense_manager/lib/components/action_delete_icon.dart create mode 100644 expense_manager/lib/components/amount_dialog.dart create mode 100644 expense_manager/lib/components/amount_input_view.dart create mode 100644 expense_manager/lib/components/button/custom_button.dart create mode 100644 expense_manager/lib/components/button/negative_button.dart create mode 100644 expense_manager/lib/components/button/positive_button.dart create mode 100644 expense_manager/lib/components/category_editor_dialog.dart create mode 100644 expense_manager/lib/components/category_pie_chart.dart create mode 100644 expense_manager/lib/components/category_radio_button.dart create mode 100644 expense_manager/lib/components/category_square_box.dart create mode 100644 expense_manager/lib/components/custom_divider.dart create mode 100644 expense_manager/lib/components/date_field.dart create mode 100644 expense_manager/lib/components/date_range_filter_view.dart create mode 100644 expense_manager/lib/components/dialog/custom_dialog.dart create mode 100644 expense_manager/lib/components/dialog/delete_dialog.dart create mode 100644 expense_manager/lib/components/expense_float_button.dart create mode 100644 expense_manager/lib/components/form/custom_text_field.dart create mode 100644 expense_manager/lib/components/label_widget.dart create mode 100644 expense_manager/lib/components/misc/bottom_bar_container.dart create mode 100644 expense_manager/lib/components/misc/custom_card.dart create mode 100644 expense_manager/lib/components/misc/custom_scaffold.dart create mode 100644 expense_manager/lib/components/misc/custom_search_bar.dart create mode 100644 expense_manager/lib/components/misc/placeholder_view.dart create mode 100644 expense_manager/lib/components/misc/rounded_container.dart create mode 100644 expense_manager/lib/components/month_filter_view.dart create mode 100644 expense_manager/lib/components/section_view.dart create mode 100644 expense_manager/lib/components/tag_chip.dart create mode 100644 expense_manager/lib/components/tag_input_dialog.dart create mode 100644 expense_manager/lib/components/tag_list_item.dart create mode 100644 expense_manager/lib/components/text_input_dialog.dart create mode 100644 expense_manager/lib/components/transaction_group_item.dart create mode 100644 expense_manager/lib/components/transaction_list_item.dart create mode 100644 expense_manager/lib/constants.dart create mode 100644 expense_manager/lib/controllers/add_expense_controller.dart create mode 100644 expense_manager/lib/controllers/add_income_controller.dart create mode 100644 expense_manager/lib/controllers/base_controller.dart create mode 100644 expense_manager/lib/controllers/categories_controller.dart create mode 100644 expense_manager/lib/controllers/category_editor_controller.dart create mode 100644 expense_manager/lib/controllers/category_expenses_controller.dart create mode 100644 expense_manager/lib/controllers/category_report_controller.dart create mode 100644 expense_manager/lib/controllers/expenses_controller.dart create mode 100644 expense_manager/lib/controllers/home_controller.dart create mode 100644 expense_manager/lib/controllers/monthly_report_controller.dart create mode 100644 expense_manager/lib/controllers/settings_controller.dart create mode 100644 expense_manager/lib/controllers/tag_expenses_controller.dart create mode 100644 expense_manager/lib/controllers/tag_input_controller.dart create mode 100644 expense_manager/lib/controllers/tags_controller.dart create mode 100644 expense_manager/lib/controllers/transactions_controller.dart create mode 100644 expense_manager/lib/fl_chart/custom_line_chart.dart create mode 100644 expense_manager/lib/fl_chart/custom_pie_chart.dart create mode 100644 expense_manager/lib/fl_chart/index.dart create mode 100644 expense_manager/lib/index.dart create mode 100644 expense_manager/lib/main.dart create mode 100644 expense_manager/lib/misc/colors.dart create mode 100644 expense_manager/lib/misc/custom_theme_data.dart create mode 100644 expense_manager/lib/misc/debouncer.dart create mode 100644 expense_manager/lib/misc/extensions.dart create mode 100644 expense_manager/lib/misc/utils.dart create mode 100644 expense_manager/lib/models/category.dart create mode 100644 expense_manager/lib/models/category.g.dart create mode 100644 expense_manager/lib/models/expense.dart create mode 100644 expense_manager/lib/models/expense.g.dart create mode 100644 expense_manager/lib/models/income.dart create mode 100644 expense_manager/lib/models/income.g.dart create mode 100644 expense_manager/lib/models/serializable.dart create mode 100644 expense_manager/lib/models/tag.dart create mode 100644 expense_manager/lib/models/tag.g.dart create mode 100644 expense_manager/lib/models/transaction.dart create mode 100644 expense_manager/lib/my_app.dart create mode 100644 expense_manager/lib/pages/add_expense_page.dart create mode 100644 expense_manager/lib/pages/add_income_page.dart create mode 100644 expense_manager/lib/pages/categories_page.dart create mode 100644 expense_manager/lib/pages/category_report_page.dart create mode 100644 expense_manager/lib/pages/expenses_page.dart create mode 100644 expense_manager/lib/pages/home_page.dart create mode 100644 expense_manager/lib/pages/image_page.dart create mode 100644 expense_manager/lib/pages/main_page.dart create mode 100644 expense_manager/lib/pages/monthly_report_page.dart create mode 100644 expense_manager/lib/pages/reports_page.dart create mode 100644 expense_manager/lib/pages/settings_page.dart create mode 100644 expense_manager/lib/pages/tags_page.dart create mode 100644 expense_manager/lib/pages/transactions_page.dart create mode 100644 expense_manager/lib/podo/home_banner_item.dart create mode 100644 expense_manager/lib/pubsub.dart create mode 100644 expense_manager/lib/repositories/base_repo.dart create mode 100644 expense_manager/lib/repositories/category_repo.dart create mode 100644 expense_manager/lib/repositories/expense_repo.dart create mode 100644 expense_manager/lib/repositories/income_repo.dart create mode 100644 expense_manager/lib/repositories/repo_factory.dart create mode 100644 expense_manager/lib/repositories/service_factory.dart create mode 100644 expense_manager/lib/repositories/tag_repo.dart create mode 100644 expense_manager/lib/services/app_init_service.dart create mode 100644 expense_manager/lib/services/base_service.dart create mode 100644 expense_manager/lib/services/data_service.dart create mode 100644 expense_manager/lib/services/shared_pref_service.dart create mode 100644 expense_manager/lib/services/transaction_service.dart create mode 100644 expense_manager/pubspec.yaml diff --git a/expense_manager/.gitignore b/expense_manager/.gitignore new file mode 100644 index 00000000..03992d2c --- /dev/null +++ b/expense_manager/.gitignore @@ -0,0 +1,46 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ +libisar.dylib +tmp + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/expense_manager/README.md b/expense_manager/README.md new file mode 100644 index 00000000..c09d5d1f --- /dev/null +++ b/expense_manager/README.md @@ -0,0 +1,11 @@ +# Introduction + +`expense_manager` is a simple yet functional personal expense manager app built with Flutter. It allows you to: + +- Track your daily expense +- Track your income +- View your expenses and incomes +- Group your expenses into categories +- Tag your expenses easily! +- A dashboard that shows the overall earnings +- Beautiful report pages with charts diff --git a/expense_manager/analysis_options.yaml b/expense_manager/analysis_options.yaml new file mode 100644 index 00000000..a8216a9c --- /dev/null +++ b/expense_manager/analysis_options.yaml @@ -0,0 +1,32 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + file_names: false + prefer_const_constructors: false + prefer_const_literals_to_create_immutables: false + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/expense_manager/lib/components/action_delete_icon.dart b/expense_manager/lib/components/action_delete_icon.dart new file mode 100644 index 00000000..f33a0602 --- /dev/null +++ b/expense_manager/lib/components/action_delete_icon.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class ActionDeleteIcon extends StatelessWidget { + final VoidCallback onTap; + + const ActionDeleteIcon({ + super.key, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: EdgeInsets.all(12), + child: Icon(Icons.delete, color: Colors.white), + ), + ); + } +} diff --git a/expense_manager/lib/components/amount_dialog.dart b/expense_manager/lib/components/amount_dialog.dart new file mode 100644 index 00000000..5aea4ec8 --- /dev/null +++ b/expense_manager/lib/components/amount_dialog.dart @@ -0,0 +1,122 @@ +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/components/dialog/custom_dialog.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class AmountDialog extends StatelessWidget { + final int amount; + final ValueChanged onAmountChanged; + + const AmountDialog({ + super.key, + this.amount = 0, + required this.onAmountChanged, + }); + + @override + Widget build(BuildContext context) { + return CustomDialog( + maxWidth: 600, + child: GetBuilder<_AmountDialogController>( + init: _AmountDialogController(amount: amount), + builder: (controller) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: + EdgeInsets.symmetric(horizontal: 16).copyWith(top: 16), + child: Text( + controller.amount.toMoney(), + style: Theme.of(context).textTheme.displaySmall, + ), + ), + + // number pads + GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + childAspectRatio: 2.3, + children: controller.keys.map((numKey) { + return InkWell( + onTap: () { + controller.calculateAmount(numKey); + }, + child: Center( + child: numKey == 'back' + ? Icon(Icons.backspace) + : Text(numKey, style: TextStyle(fontSize: 21)), + ), + ); + }).toList(), + ), + + Padding( + padding: EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + child: Text( + 'CANCEL', + style: TextStyle(color: Colors.grey), + ), + onPressed: () => goBack(context), + ), + TextButton( + child: Text('OK'), + onPressed: () { + onAmountChanged.call(controller.amount); + goBack(context); + }, + ), + ], + ), + ), + ], + ); + }), + ); + } +} + +class _AmountDialogController extends GetxController { + final List keys = [ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '', + '0', + 'back' + ]; + int amount; + + _AmountDialogController({required this.amount}); + + void calculateAmount(String numKey) { + if (numKey.isEmpty) return; + + switch (numKey) { + case 'back': + String amountText = amount.toString(); + amountText = amountText.length <= 1 + ? '0' + : amountText.substring(0, amountText.length - 1); + amount = int.parse(amountText); + break; + default: + final amountText = amount.toString() + numKey; + amount = int.parse(amountText); + } + + update(); + } +} diff --git a/expense_manager/lib/components/amount_input_view.dart b/expense_manager/lib/components/amount_input_view.dart new file mode 100644 index 00000000..2415e8e2 --- /dev/null +++ b/expense_manager/lib/components/amount_input_view.dart @@ -0,0 +1,44 @@ +import 'package:expense_manager/misc/extensions.dart'; +import 'package:flutter/material.dart'; +import 'amount_dialog.dart'; + +class AmountInputView extends StatelessWidget { + final int initialAmount; + final ValueChanged onChange; + + const AmountInputView({ + super.key, + this.initialAmount = 0, + required this.onChange, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => _showAmountDialog(context), + child: Padding( + padding: EdgeInsets.all(8), + child: Align( + alignment: Alignment.centerRight, + child: Text( + initialAmount.toMoney(), + textAlign: TextAlign.right, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), + ); + } + + void _showAmountDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AmountDialog( + amount: initialAmount, + onAmountChanged: (int amount) { + onChange.call(amount); + }, + ), + ); + } +} diff --git a/expense_manager/lib/components/button/custom_button.dart b/expense_manager/lib/components/button/custom_button.dart new file mode 100644 index 00000000..9ec10ec1 --- /dev/null +++ b/expense_manager/lib/components/button/custom_button.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import '../../misc/colors.dart'; + +class CustomButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final BorderRadius borderRadius; + final Color color; + final Color textColor; + final Color borderColor; + final Color? shadowColor; + final Icon? leftIcon; + final Icon? rightIcon; + final EdgeInsetsGeometry? padding; + final double elevation; + final bool disabled; + final bool loading; + final double? height; + final bool useCustomShape; + + const CustomButton( + this.text, { + super.key, + this.onPressed, + this.disabled = false, + this.borderRadius = const BorderRadius.all(Radius.circular(8.0)), + this.color = CustomColors.primary, + this.textColor = Colors.white, + this.borderColor = Colors.transparent, + this.leftIcon, + this.rightIcon, + this.padding, + this.shadowColor, + this.elevation = 0, + this.loading = false, + this.height, + this.useCustomShape = true, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height ?? 45, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + foregroundColor: textColor, + backgroundColor: color, + elevation: 2, + shadowColor: shadowColor, + shape: useCustomShape + ? RoundedRectangleBorder( + side: BorderSide( + color: borderColor, + ), + borderRadius: borderRadius, + ) + : null, + ), + onPressed: disabled + ? null + : () { + if (loading) return; + onPressed?.call(); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (leftIcon != null) + Align( + alignment: Alignment.centerLeft, + child: leftIcon, + ), + Align( + alignment: Alignment.center, + child: loading + ? _renderProgressBar() + : Text( + text, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16.0), + ), + ), + if (rightIcon != null) + Align( + alignment: Alignment.centerRight, + child: rightIcon, + ) + ], + ), + ), + ); + } + + Widget _renderProgressBar() { + return const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + strokeWidth: 2, + ), + ); + } +} diff --git a/expense_manager/lib/components/button/negative_button.dart b/expense_manager/lib/components/button/negative_button.dart new file mode 100644 index 00000000..39a7684f --- /dev/null +++ b/expense_manager/lib/components/button/negative_button.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import '../../misc/utils.dart'; + +class NegativeButton extends StatelessWidget { + final String title; + final VoidCallback? onPressed; + final ButtonStyle? style; + + const NegativeButton({ + super.key, + this.title = 'Cancel', + this.onPressed, + this.style, + }); + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onPressed ?? () => goBack(context), + style: style ?? TextButton.styleFrom(foregroundColor: Colors.grey), + child: Text(title), + ); + } +} diff --git a/expense_manager/lib/components/button/positive_button.dart b/expense_manager/lib/components/button/positive_button.dart new file mode 100644 index 00000000..75b172e0 --- /dev/null +++ b/expense_manager/lib/components/button/positive_button.dart @@ -0,0 +1,25 @@ +import 'package:expense_manager/misc/colors.dart'; +import 'package:flutter/material.dart'; + +class PositiveButton extends StatelessWidget { + final String title; + final VoidCallback onPressed; + final ButtonStyle? style; + + const PositiveButton({ + super.key, + required this.onPressed, + this.style, + this.title = 'Done', + }); + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onPressed, + style: + style ?? TextButton.styleFrom(foregroundColor: CustomColors.primary), + child: Text(title), + ); + } +} diff --git a/expense_manager/lib/components/category_editor_dialog.dart b/expense_manager/lib/components/category_editor_dialog.dart new file mode 100644 index 00000000..80dc2335 --- /dev/null +++ b/expense_manager/lib/components/category_editor_dialog.dart @@ -0,0 +1,90 @@ +import 'package:expense_manager/controllers/category_editor_controller.dart'; +import 'package:expense_manager/components/button/negative_button.dart'; +import 'package:expense_manager/components/button/positive_button.dart'; +import 'package:expense_manager/components/dialog/custom_dialog.dart'; +import 'package:expense_manager/components/form/custom_text_field.dart'; +import 'package:expense_manager/components/label_widget.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/models/category.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_material_color_picker/flutter_material_color_picker.dart'; +import 'package:get/get.dart'; + +class CategoryEditorDialog extends StatelessWidget { + final Category? category; + final VoidCallback onComplete; + + CategoryEditorDialog({ + super.key, + this.category, + required this.onComplete, + }) { + Get.replace(CategoryEditorController(category: category)); + } + + bool get updating => category != null; + + CategoryEditorController get _controller => + Get.find(); + + @override + Widget build(BuildContext context) { + return CustomDialog( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "${updating ? 'Update' : 'New'} Category", + style: Theme.of(context).textTheme.titleLarge, + ), + SizedBox(height: 16), + CustomTextField( + label: 'Category', + controller: _controller.nameController, + ), + SizedBox(height: 16), + CustomTextField( + label: 'Budget', + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + controller: _controller.budgetController, + ), + SizedBox(height: 16), + GetBuilder(builder: (controller) { + return LabelWidget( + label: 'Color', + widget: MaterialColorPicker( + selectedColor: controller.selectedColor, + onColorChange: (value) => controller.selectedColor = value, + ), + ); + }), + SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + NegativeButton(), + PositiveButton( + title: updating ? 'Update' : 'Add', + onPressed: () => update(context), + ), + ], + ), + ], + ), + ), + ); + } + + Future update(BuildContext context) async { + await _controller.addOrUpdateCategory(); + onComplete.call(); + + if (context.mounted) { + goBack(context); + } + } +} diff --git a/expense_manager/lib/components/category_pie_chart.dart b/expense_manager/lib/components/category_pie_chart.dart new file mode 100644 index 00000000..22b2537a --- /dev/null +++ b/expense_manager/lib/components/category_pie_chart.dart @@ -0,0 +1,50 @@ +import 'package:expense_manager/controllers/category_report_controller.dart'; +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/fl_chart/custom_pie_chart.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +class CategoryPieChart extends StatelessWidget { + final CategoryReportController controller; + final Function(int index)? onAreaTouched; + + const CategoryPieChart({ + super.key, + required this.controller, + this.onAreaTouched, + }); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + CustomPieChart( + height: 300, + centerSpaceRadius: 70, + sectionsSpace: 0, + overrideColor: true, + sections: controller.categories.map((category) { + final value = + controller.categoryValueMap[category.id]?.amount.toDouble() ?? + 0; + return PieChartSectionData( + value: value, + title: controller.getValuePercentage(category), + radius: controller.isCategorySelected(category) ? 60 : 50, + color: hexToColor(category.colorCode ?? '#AAAAAA'), + ); + }).toList(), + onAreaTouched: onAreaTouched, + ), + Column( + children: [ + Text(controller.selectedCategory?.name ?? ''), + Text(controller.selectedCategoryAmountCents.toMoney()), + ], + ), + ], + ); + } +} diff --git a/expense_manager/lib/components/category_radio_button.dart b/expense_manager/lib/components/category_radio_button.dart new file mode 100644 index 00000000..7f754f6d --- /dev/null +++ b/expense_manager/lib/components/category_radio_button.dart @@ -0,0 +1,41 @@ +import 'package:expense_manager/misc/extensions.dart'; +import 'package:flutter/material.dart'; +import 'category_square_box.dart'; + +class CategoryRadioButton extends StatelessWidget { + final String title; + final Color color; + final int value; + final int currentValue; + final ValueChanged onChange; + + const CategoryRadioButton({ + super.key, + required this.title, + required this.onChange, + required this.value, + required this.currentValue, + this.color = Colors.yellow, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => onChange.call(value), + child: Row( + children: [ + CategorySquareBox(colorCode: color.toHex()), + SizedBox(width: 16), + Expanded(child: Text(title)), + SizedBox(width: 8), + Radio( + value: value, + groupValue: currentValue, + visualDensity: VisualDensity.compact, + onChanged: (value) {}, + ), + ], + ), + ); + } +} diff --git a/expense_manager/lib/components/category_square_box.dart b/expense_manager/lib/components/category_square_box.dart new file mode 100644 index 00000000..bc24f6ab --- /dev/null +++ b/expense_manager/lib/components/category_square_box.dart @@ -0,0 +1,22 @@ +import 'package:expense_manager/misc/utils.dart'; +import 'package:flutter/cupertino.dart'; + +class CategorySquareBox extends StatelessWidget { + final String? colorCode; + + const CategorySquareBox({ + super.key, + this.colorCode, + }); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: hexToColor(colorCode ?? '#EEEEEE'), + borderRadius: BorderRadius.circular(8), + ), + child: SizedBox.square(dimension: 30), + ); + } +} diff --git a/expense_manager/lib/components/custom_divider.dart b/expense_manager/lib/components/custom_divider.dart new file mode 100644 index 00000000..158c5847 --- /dev/null +++ b/expense_manager/lib/components/custom_divider.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class CustomDivider extends StatelessWidget { + const CustomDivider({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Divider(height: 0), + ); + } +} diff --git a/expense_manager/lib/components/date_field.dart b/expense_manager/lib/components/date_field.dart new file mode 100644 index 00000000..3fbef6bc --- /dev/null +++ b/expense_manager/lib/components/date_field.dart @@ -0,0 +1,42 @@ +import 'package:expense_manager/components/form/custom_text_field.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:flutter/material.dart'; + +class DateField extends StatelessWidget { + final DateTime initialDate; + final ValueChanged onChange; + + const DateField({ + super.key, + required this.initialDate, + required this.onChange, + }); + + get dateLabel => formatDate(initialDate, 'dd/MM/yyyy'); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => _showDatePicker(context), + child: CustomTextField( + label: "Date", + editable: false, + useCustomLabel: true, + isDense: true, + text: dateLabel, + border: OutlineInputBorder(), + ), + ); + } + + void _showDatePicker(BuildContext context) async { + final date = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: DateTime.now().subtract(Duration(days: 100 * 365)), + lastDate: DateTime.now().add(Duration(days: 100 * 365)), + ); + if (date == null) return; + onChange.call(date); + } +} diff --git a/expense_manager/lib/components/date_range_filter_view.dart b/expense_manager/lib/components/date_range_filter_view.dart new file mode 100644 index 00000000..3d6d1e25 --- /dev/null +++ b/expense_manager/lib/components/date_range_filter_view.dart @@ -0,0 +1,54 @@ +import 'package:expense_manager/constants.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class DateRangeFilterView extends StatelessWidget { + final dates = [].obs; + final EdgeInsets? padding; + final Function(String range) onDateSelected; + + DateRangeFilterView({ + super.key, + this.padding, + required this.onDateSelected, + }) { + dates.assignAll(kDateRange); + } + + DateRangeFilterController get _controller => + Get.find(); + + @override + Widget build(BuildContext context) { + return GetX( + init: DateRangeFilterController(), + builder: (controller) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: dates.map((e) { + return InkWell( + onTap: () { + controller.selectedRange.value = e; + onDateSelected(e); + }, + child: Padding( + padding: EdgeInsets.all(8), + child: Text( + e.toUpperCase(), + style: TextStyle(color: getSelectedColor(e)), + ), + ), + ); + }).toList(), + ); + }); + } + + Color? getSelectedColor(String range) { + return _controller.selectedRange.value == range ? Colors.green : null; + } +} + +class DateRangeFilterController extends GetxController { + final selectedRange = "1y".obs; +} diff --git a/expense_manager/lib/components/dialog/custom_dialog.dart b/expense_manager/lib/components/dialog/custom_dialog.dart new file mode 100644 index 00000000..b2280981 --- /dev/null +++ b/expense_manager/lib/components/dialog/custom_dialog.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +class CustomDialog extends StatelessWidget { + final Color? backgroundColor; + final double? elevation; + final Duration insetAnimationDuration; + final Curve insetAnimationCurve; + final ShapeBorder? shape; + final EdgeInsets margin; + final double minWidth; + final double maxWidth; + final double maxHeight; + final Widget child; + + const CustomDialog({ + super.key, + required this.child, + this.margin = const EdgeInsets.all(16), + this.backgroundColor, + this.elevation, + this.insetAnimationDuration = const Duration(milliseconds: 100), + this.insetAnimationCurve = Curves.decelerate, + this.shape, + this.minWidth = 280.0, + this.maxWidth = double.infinity, + this.maxHeight = double.infinity, + }); + + static const RoundedRectangleBorder _defaultDialogShape = + RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16.0))); + static const double _defaultElevation = 24.0; + + @override + Widget build(BuildContext context) { + final DialogTheme dialogTheme = DialogTheme.of(context); + return AnimatedPadding( + padding: MediaQuery.of(context).viewInsets + margin, + duration: insetAnimationDuration, + curve: insetAnimationCurve, + child: MediaQuery.removeViewInsets( + removeLeft: true, + removeTop: true, + removeRight: true, + removeBottom: true, + context: context, + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: minWidth, + maxWidth: maxWidth, + maxHeight: maxHeight, + ), + child: Material( + color: backgroundColor ?? + dialogTheme.backgroundColor ?? + Theme.of(context).dialogBackgroundColor, + elevation: + elevation ?? dialogTheme.elevation ?? _defaultElevation, + shape: shape ?? dialogTheme.shape ?? _defaultDialogShape, + type: MaterialType.card, + child: child, + ), + ), + ), + ), + ); + } +} diff --git a/expense_manager/lib/components/dialog/delete_dialog.dart b/expense_manager/lib/components/dialog/delete_dialog.dart new file mode 100644 index 00000000..090e0084 --- /dev/null +++ b/expense_manager/lib/components/dialog/delete_dialog.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import '../../misc/utils.dart'; +import '../button/custom_button.dart'; +import 'custom_dialog.dart'; + +class DeleteDialog extends StatelessWidget { + final String title; + final VoidCallback onDelete; + final Widget? subtitle; + + const DeleteDialog({ + super.key, + required this.title, + required this.onDelete, + this.subtitle, + }); + + @override + Widget build(BuildContext context) { + return CustomDialog( + margin: EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: Theme.of(context) + .textTheme + .titleLarge! + .copyWith(fontWeight: FontWeight.w600), + ), + if (subtitle != null) subtitle!, + const Padding(padding: EdgeInsets.all(8)), + Row( + children: [ + Expanded( + child: CustomButton( + 'Cancel', + color: Colors.white, + textColor: Colors.grey.shade700, + borderColor: Colors.grey, + onPressed: () => goBack(context), + ), + ), + const Padding(padding: EdgeInsets.all(4)), + Expanded( + child: CustomButton( + 'Delete', + color: Colors.red, + onPressed: () => onDelete.call(), + ), + ) + ], + ), + ], + ), + ), + ); + } +} diff --git a/expense_manager/lib/components/expense_float_button.dart b/expense_manager/lib/components/expense_float_button.dart new file mode 100644 index 00000000..f3eb8468 --- /dev/null +++ b/expense_manager/lib/components/expense_float_button.dart @@ -0,0 +1,40 @@ +import 'package:expense_manager/misc/colors.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/pages/add_expense_page.dart'; +import 'package:expense_manager/pages/add_income_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_speed_dial/flutter_speed_dial.dart'; + +class ExpenseFloatButton extends StatelessWidget { + final bool showIncome; + + const ExpenseFloatButton({ + super.key, + this.showIncome = true, + }); + + @override + Widget build(BuildContext context) { + return SpeedDial( + backgroundColor: Colors.blue.shade700, + children: [ + SpeedDialChild( + backgroundColor: Colors.red.shade700, + label: 'Add Expense', + shape: CircleBorder(), + onTap: () => gotoPage(context, AddExpensePage()), + child: Icon(Icons.remove, color: Colors.white), + ), + if (showIncome) + SpeedDialChild( + backgroundColor: CustomColors.primary, + label: 'Add Income', + shape: CircleBorder(), + onTap: () => gotoPage(context, AddIncomePage()), + child: Icon(Icons.attach_money, color: Colors.white), + ), + ], + child: Icon(Icons.add, color: Colors.white), + ); + } +} diff --git a/expense_manager/lib/components/form/custom_text_field.dart b/expense_manager/lib/components/form/custom_text_field.dart new file mode 100644 index 00000000..8b2d9d64 --- /dev/null +++ b/expense_manager/lib/components/form/custom_text_field.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; + +class CustomTextField extends StatelessWidget { + final String? label; + final String? hint; + final String? text; + final ValueChanged? onChanged; + final TextInputType keyboardType; + final FormFieldValidator? validator; + final Widget? suffixIcon; + final Widget? prefixIcon; + final bool obscureText; + final int? maxLines; + final TextInputAction? textInputAction; + final TextCapitalization textCapitalization; + final TextEditingController? controller; + final InputBorder? border; + final FloatingLabelBehavior floatingLabelBehavior; + final ValueChanged? onSubmit; + final String? prefixText; + final TextAlign textAlign; + final TextAlignVertical? textAlignVertical; + final FocusNode? focusNode; + final int? maxLength; + final List inputFormatters; + final String? errorText; + final Color? fillColor; + final bool filled; + final bool enabled; + final EdgeInsets? contentPadding; + final bool editable; + final bool useCustomLabel; + final bool isDense; + final AutovalidateMode? autoValidateMode; + final String? counterText; + + const CustomTextField({ + super.key, + this.label, + this.hint, + this.text, + this.onChanged, + this.keyboardType = TextInputType.text, + this.validator, + this.suffixIcon, + this.prefixIcon, + this.obscureText = false, + this.maxLines = 1, + this.textInputAction, + this.textCapitalization = TextCapitalization.sentences, + this.controller, + this.border, + this.floatingLabelBehavior = FloatingLabelBehavior.always, + this.onSubmit, + this.prefixText, + this.textAlign = TextAlign.start, + this.textAlignVertical, + this.focusNode, + this.maxLength, + this.inputFormatters = const [], + this.enabled = true, + this.errorText, + this.fillColor = Colors.white, + this.filled = false, + this.contentPadding, + this.editable = true, + this.useCustomLabel = false, + this.isDense = true, + this.autoValidateMode, + this.counterText, + }); + + @override + Widget build(BuildContext context) { + if (!editable) { + return _buildNonEditableTextField(context); + } + + final textField = TextFormField( + key: hint != null ? Key(hint!) : null, + onChanged: onChanged, + initialValue: text, + keyboardType: keyboardType, + maxLines: maxLines, + validator: validator, + obscureText: obscureText, + textCapitalization: textCapitalization, + textInputAction: textInputAction, + controller: controller, + textAlign: textAlign, + textAlignVertical: textAlignVertical, + focusNode: focusNode, + maxLength: maxLength, + enabled: enabled, + autovalidateMode: autoValidateMode, + inputFormatters: [ + ...inputFormatters, + if (keyboardType == TextInputType.number) + FilteringTextInputFormatter.digitsOnly, + ], + onFieldSubmitted: (v) { + if (onSubmit != null) { + FocusScope.of(context).nextFocus(); + } + onSubmit?.call(v); + }, + decoration: InputDecoration( + isDense: isDense, + hintText: hint, + labelText: useCustomLabel ? null : label, + floatingLabelBehavior: floatingLabelBehavior, + border: border ?? + const OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey), + ), + enabledBorder: border ?? + const OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey), + ), + suffixIcon: suffixIcon, + prefixIcon: prefixIcon, + prefixText: prefixText, + errorText: errorText, + counterText: counterText, + suffixIconConstraints: + const BoxConstraints(maxWidth: 30, maxHeight: 30), + fillColor: fillColor, + contentPadding: contentPadding, + filled: filled, + ), + ); + + if (useCustomLabel) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCustomLabelText(label ?? 'Label'), + SizedBox(height: 3), + textField, + ], + ); + } + + return textField; + } + + Widget _buildNonEditableTextField(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCustomLabelText(label ?? 'Label'), + SizedBox(height: 3), + DecoratedBox( + decoration: BoxDecoration( + color: Get.isDarkMode ? null : Colors.grey.shade200, + border: border != null ? Border.all(color: Colors.grey) : null, + borderRadius: BorderRadius.circular(3), + ), + child: Padding( + padding: EdgeInsets.all(13), + child: SizedBox( + width: MediaQuery.of(context).size.width, + child: Text( + text ?? '', + style: TextStyle(fontSize: 16), + ), + ), + ), + ), + SizedBox(height: 5), + if (errorText?.isNotEmpty ?? false) + Text( + errorText!, + style: TextStyle( + color: Colors.red, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } + + Widget _buildCustomLabelText(String value) { + return Text( + value, + style: TextStyle(fontWeight: FontWeight.bold), + ); + } +} diff --git a/expense_manager/lib/components/label_widget.dart b/expense_manager/lib/components/label_widget.dart new file mode 100644 index 00000000..bfce468d --- /dev/null +++ b/expense_manager/lib/components/label_widget.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class LabelWidget extends StatelessWidget { + final Widget widget; + final String label; + const LabelWidget({ + super.key, + required this.widget, + required this.label, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith(color: Colors.grey.shade700), + ), + SizedBox(height: 8), + widget, + ], + ); + } +} diff --git a/expense_manager/lib/components/misc/bottom_bar_container.dart b/expense_manager/lib/components/misc/bottom_bar_container.dart new file mode 100644 index 00000000..403e1eee --- /dev/null +++ b/expense_manager/lib/components/misc/bottom_bar_container.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class BottomBarContainer extends StatelessWidget { + final Widget child; + final EdgeInsets? padding; + final Color? color; + final BorderRadius? borderRadius; + + const BottomBarContainer({ + super.key, + required this.child, + this.padding, + this.color = Colors.white, + this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Container( + padding: padding ?? EdgeInsets.all(16), + decoration: BoxDecoration( + color: color, + borderRadius: borderRadius, + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.5), + blurRadius: 10, + offset: Offset(0, -10), // changes position of shadow + ), + ], + ), + child: child, + ), + ); + } +} diff --git a/expense_manager/lib/components/misc/custom_card.dart b/expense_manager/lib/components/misc/custom_card.dart new file mode 100644 index 00000000..910cef9d --- /dev/null +++ b/expense_manager/lib/components/misc/custom_card.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +class CustomCard extends StatelessWidget { + final Widget? child; + final bool noPadding; + final double cardRadius; + final GestureTapCallback? onTap; + final Color? color; + + const CustomCard({ + super.key, + this.child, + this.noPadding = false, + this.cardRadius = 8, + this.onTap, + this.color, + }); + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(cardRadius)), + elevation: 8, + color: color, + surfaceTintColor: color, + child: InkWell( + onTap: onTap, + child: noPadding + ? child + : Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ); + } +} diff --git a/expense_manager/lib/components/misc/custom_scaffold.dart b/expense_manager/lib/components/misc/custom_scaffold.dart new file mode 100644 index 00000000..414c26aa --- /dev/null +++ b/expense_manager/lib/components/misc/custom_scaffold.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import '../../misc/colors.dart'; + +class CustomScaffold extends StatelessWidget { + final String? title; + final Widget body; + final EdgeInsetsGeometry? padding; + final bool hideAppBar; + final Widget? bottomNavigationBar; + final Widget? floatingActionButton; + final Widget? drawer; + final PreferredSizeWidget? appBar; + final Color? appBarColor; + final List? appBarActions; + final PreferredSizeWidget? bottom; + final bool safeArea; + + const CustomScaffold({ + super.key, + this.title, + required this.body, + this.padding, + this.hideAppBar = false, + this.bottomNavigationBar, + this.floatingActionButton, + this.drawer, + this.appBar, + this.appBarColor, + this.appBarActions, + this.bottom, + this.safeArea = false, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + drawer: drawer, + appBar: hideAppBar + ? null + : appBar ?? + AppBar( + title: title != null ? Text(title!) : null, + backgroundColor: appBarColor ?? CustomColors.primary, + actions: appBarActions, + bottom: bottom, + ), + body: Padding( + padding: padding ?? EdgeInsets.zero, + child: safeArea ? SafeArea(child: body) : body, + ), + bottomNavigationBar: bottomNavigationBar, + floatingActionButton: floatingActionButton, + ); + } +} diff --git a/expense_manager/lib/components/misc/custom_search_bar.dart b/expense_manager/lib/components/misc/custom_search_bar.dart new file mode 100644 index 00000000..fdcbd82f --- /dev/null +++ b/expense_manager/lib/components/misc/custom_search_bar.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import '../../misc/debouncer.dart'; +import '../form/custom_text_field.dart'; + +class CustomSearchBar extends StatelessWidget { + final String? label; + final ValueChanged? onSearch; + final bool enabled; + final Color? fillColor; + + final _debouncer = Debouncer(500); + + CustomSearchBar({ + super.key, + this.label, + this.onSearch, + this.enabled = true, + this.fillColor, + }); + + @override + Widget build(BuildContext context) { + return CustomTextField( + filled: true, + fillColor: fillColor, + prefixIcon: Icon(Icons.search), + hint: label ?? 'Search', + enabled: enabled, + contentPadding: EdgeInsets.zero, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + width: 0, + style: BorderStyle.none, + ), + ), + onChanged: (value) { + _debouncer.run(() { + onSearch?.call(value); + }); + }, + ); + } +} diff --git a/expense_manager/lib/components/misc/placeholder_view.dart b/expense_manager/lib/components/misc/placeholder_view.dart new file mode 100644 index 00000000..cebc7115 --- /dev/null +++ b/expense_manager/lib/components/misc/placeholder_view.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class PlaceholderView extends StatelessWidget { + final bool show; + final bool loading; + final String title; + final String description; + final Widget? child; + + const PlaceholderView({ + super.key, + this.show = false, + this.loading = false, + required this.title, + required this.description, + this.child, + }); + + @override + Widget build(BuildContext context) { + // loading indicator + if (loading) { + return const Center(child: CircularProgressIndicator()); + } + + // placeholder + if (show) { + return Center( + child: SizedBox( + width: 300, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text( + title, + style: const TextStyle( + fontSize: 30, fontWeight: FontWeight.bold), + ), + ), + Text(description, textAlign: TextAlign.center), + ], + ), + ), + ); + } + + // render actual content + return child ?? SizedBox(); + } +} diff --git a/expense_manager/lib/components/misc/rounded_container.dart b/expense_manager/lib/components/misc/rounded_container.dart new file mode 100644 index 00000000..784c1cf5 --- /dev/null +++ b/expense_manager/lib/components/misc/rounded_container.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +class RoundedContainer extends StatelessWidget { + final Widget child; + final EdgeInsets? padding; + final Color? color; + final double radius; + final double? width; + final double? height; + final VoidCallback? onTap; + + const RoundedContainer({ + super.key, + required this.child, + this.padding, + this.color, + this.radius = 8, + this.width, + this.height, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final widget = Container( + width: width, + height: height, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: const Color(0xFFD3D3D3)), + ), + padding: padding ?? EdgeInsets.all(16), + child: child, + ); + + return onTap != null + ? InkWell( + onTap: onTap, + child: widget, + ) + : widget; + } +} diff --git a/expense_manager/lib/components/month_filter_view.dart b/expense_manager/lib/components/month_filter_view.dart new file mode 100644 index 00000000..a7ab4ebe --- /dev/null +++ b/expense_manager/lib/components/month_filter_view.dart @@ -0,0 +1,63 @@ +import 'package:expense_manager/misc/extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class MonthFilterView extends StatelessWidget { + final dates = [].obs; + final EdgeInsets? padding; + final Function(DateTime dt) onDateSelected; + + MonthFilterView({ + super.key, + this.padding, + required this.onDateSelected, + }) { + final now = DateTime.now(); + dates.assignAll([ + now, + now.lastMonth(numOfMonth: 1), + now.lastMonth(numOfMonth: 2), + now.lastMonth(numOfMonth: 3), + now.lastMonth(numOfMonth: 4), + now.lastMonth(numOfMonth: 5), + ]); + } + + MonthFilterController get _controller => Get.find(); + + @override + Widget build(BuildContext context) { + return GetX( + init: MonthFilterController(), + builder: (controller) { + return Padding( + padding: padding ?? EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: dates.map((e) { + return InkWell( + onTap: () { + controller.selectedMonth.value = e; + onDateSelected(e); + }, + child: Text( + e.format('MMM'), + style: TextStyle(color: getSelectedColor(e)), + ), + ); + }).toList(), + ), + ); + }); + } + + Color? getSelectedColor(DateTime e) { + return _controller.selectedMonth.value.isSameMonthAs(e) == true + ? Colors.green + : null; + } +} + +class MonthFilterController extends GetxController { + final selectedMonth = DateTime.now().obs; +} diff --git a/expense_manager/lib/components/section_view.dart b/expense_manager/lib/components/section_view.dart new file mode 100644 index 00000000..15fc300a --- /dev/null +++ b/expense_manager/lib/components/section_view.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class SectionView extends StatelessWidget { + final Widget child; + final String title; + final double top; + final Widget? actionButton; + + const SectionView({ + super.key, + required this.title, + required this.child, + this.top = 8, + this.actionButton, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + if (actionButton != null) actionButton!, + ], + ), + child, + ], + ); + } +} diff --git a/expense_manager/lib/components/tag_chip.dart b/expense_manager/lib/components/tag_chip.dart new file mode 100644 index 00000000..20503859 --- /dev/null +++ b/expense_manager/lib/components/tag_chip.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class TagChip extends StatelessWidget { + final String text; + final VoidCallback? onDeleted; + + const TagChip({ + super.key, + required this.text, + this.onDeleted, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.all(2), + child: InputChip( + label: Text(text), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: Colors.grey), + ), + deleteIconColor: Colors.grey, + onPressed: () {}, + onDeleted: onDeleted, + ), + ); + } +} diff --git a/expense_manager/lib/components/tag_input_dialog.dart b/expense_manager/lib/components/tag_input_dialog.dart new file mode 100644 index 00000000..4ee808f7 --- /dev/null +++ b/expense_manager/lib/components/tag_input_dialog.dart @@ -0,0 +1,131 @@ +import 'package:expense_manager/components/tag_chip.dart'; +import 'package:expense_manager/controllers/tag_input_controller.dart'; +import 'package:expense_manager/components/dialog/custom_dialog.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/models/tag.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; +import 'package:get/get.dart'; + +class TagInputDialog extends StatelessWidget { + final List tags; + final ValueChanged> onChange; + + const TagInputDialog({ + super.key, + required this.tags, + required this.onChange, + }); + + @override + Widget build(BuildContext context) { + return CustomDialog( + child: GetBuilder( + init: TagInputController(tags: tags), + builder: (controller) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // search field + _buildSearchField(controller), + + SizedBox(height: 32), + Divider(), + + // tags result + Padding( + padding: EdgeInsets.all(8), + child: SizedBox( + width: MediaQuery.of(context).size.width, + height: 300, + child: Wrap( + children: controller.tags.map((e) { + return TagChip( + text: e.name, + onDeleted: () { + controller.removeTag(e); + onChange(controller.tags); + }, + ); + }).toList(), + ), + ), + ), + + // close button + Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: TextButton( + onPressed: () => goBack(context), + child: Text('Close'), + ), + ), + ), + ], + ), + ], + ); + }), + ); + } + + Widget _buildSearchField(TagInputController controller) { + return Padding( + padding: EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: TypeAheadField( + focusNode: controller.focusNode, + controller: controller.inputController, + emptyBuilder: (context) { + if (controller.newTagText.trim().isEmpty) { + return SizedBox(); + } + return InkWell( + onTap: () async { + await controller.addTag(controller.newTagText); + onChange(controller.tags); + }, + child: Padding( + padding: EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(controller.newTagText), + Icon(Icons.add), + ], + ), + ), + ); + }, + suggestionsCallback: (pattern) { + return controller.searchTag(pattern); + }, + onSelected: (Tag suggestion) async { + await controller.addTag(suggestion.name); + onChange(controller.tags); + }, + builder: (context, controller, focusNode) { + return TextField( + controller: controller, + focusNode: focusNode, + decoration: InputDecoration(hintText: "Search or add tags"), + ); + }, + itemBuilder: (context, itemData) { + return Padding( + padding: EdgeInsets.all(16), + child: Text(itemData.name), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/expense_manager/lib/components/tag_list_item.dart b/expense_manager/lib/components/tag_list_item.dart new file mode 100644 index 00000000..4917b44d --- /dev/null +++ b/expense_manager/lib/components/tag_list_item.dart @@ -0,0 +1,92 @@ +import 'package:expense_manager/controllers/tag_expenses_controller.dart'; +import 'package:expense_manager/controllers/tags_controller.dart'; +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/models/tag.dart'; +import 'package:expense_manager/pages/expenses_page.dart'; +import 'package:flutter/material.dart'; +import 'text_input_dialog.dart'; + +class TagListItem extends StatelessWidget { + final Tag tag; + final TagsController controller; + final bool hideAmount; + + const TagListItem({ + super.key, + required this.tag, + required this.controller, + this.hideAmount = false, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: EdgeInsets.symmetric(horizontal: 12), + onTap: () => gotoPage( + context, + ExpensesPage( + pageTitle: "#${tag.name}", + controller: TagExpensesController(tagId: tag.id!), + ), + ), + leading: Icon( + Icons.tag, + color: Colors.grey, + ), + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(tag.name), + Text(obfuscateText(tag.totalAmountCents.toMoney(), obfuscate: hideAmount)), + ], + ), + trailing: PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + child: Text('Rename'), + onTap: () { + Future.delayed(Duration.zero, () { + _showTagRenameDialog(context, tag); + }); + }, + ), + PopupMenuItem( + child: Text('Delete'), + onTap: () { + Future.delayed(Duration.zero, () { + _showTagDeletionDialog(context, tag); + }); + }, + ), + ]; + }, + child: Icon(Icons.more_vert), + ), + ); + } + + void _showTagDeletionDialog(BuildContext context, Tag tag) { + showDecisionDialog( + context, + title: "Delete this Tag (${tag.name})?", + positiveButtonText: "Delete", + onPositivePressed: () => controller.deleteTag(tag), + ); + } + + void _showTagRenameDialog(BuildContext context, Tag tag) { + showDialog( + context: context, + builder: (context) { + return TextInputDialog( + controller: controller.renameController..text = tag.name, + title: "Rename \"${tag.name}\"", + focusNode: FocusNode()..requestFocus(), + onComplete: () => controller.renameTag(tag, controller.renameController.text), + ); + }, + ); + } +} diff --git a/expense_manager/lib/components/text_input_dialog.dart b/expense_manager/lib/components/text_input_dialog.dart new file mode 100644 index 00000000..b650e693 --- /dev/null +++ b/expense_manager/lib/components/text_input_dialog.dart @@ -0,0 +1,59 @@ +import 'package:expense_manager/components/button/negative_button.dart'; +import 'package:expense_manager/components/button/positive_button.dart'; +import 'package:expense_manager/components/dialog/custom_dialog.dart'; +import 'package:expense_manager/components/form/custom_text_field.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:flutter/material.dart'; + +class TextInputDialog extends StatelessWidget { + final String hint; + final String title; + final TextEditingController controller; + + final VoidCallback onComplete; + final FocusNode? focusNode; + + const TextInputDialog({ + super.key, + required this.controller, + required this.title, + required this.onComplete, + this.hint = '', + this.focusNode, + }); + + @override + Widget build(BuildContext context) { + return CustomDialog( + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: Theme.of(context).textTheme.titleLarge), + SizedBox(height: 16), + CustomTextField( + hint: hint, + controller: controller, + focusNode: focusNode, + ), + SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + NegativeButton(), + PositiveButton( + onPressed: () { + onComplete(); + goBack(context); + }, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/expense_manager/lib/components/transaction_group_item.dart b/expense_manager/lib/components/transaction_group_item.dart new file mode 100644 index 00000000..43afb17b --- /dev/null +++ b/expense_manager/lib/components/transaction_group_item.dart @@ -0,0 +1,60 @@ +import 'package:expense_manager/components/transaction_list_item.dart'; +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/models/transaction.dart'; +import 'package:flutter/material.dart'; + +class TransactionGroupItem extends StatelessWidget { + final bool hideAmount; + final bool initiallyExpanded; + final String groupName; + final List transactions; + + const TransactionGroupItem({ + super.key, + required this.transactions, + this.hideAmount = false, + this.initiallyExpanded = false, + this.groupName = '', + }); + + @override + Widget build(BuildContext context) { + final totalSum = transactions.sumAmounts(); + return Theme( + data: Theme.of(context).copyWith(dividerColor: Color(0xFFE6E6E6)), + child: ExpansionTile( + initiallyExpanded: initiallyExpanded, + controlAffinity: ListTileControlAffinity.leading, + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + groupName, + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(fontWeight: FontWeight.bold), + ), + Text( + obfuscateText( + totalSum.toMoney(), + obfuscate: hideAmount, + ), + style: TextStyle( + color: totalSum.isNegative ? Colors.red : Colors.green.shade600, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + children: transactions.map((e) { + return TransactionListItem( + transaction: e, + hideAmount: hideAmount, + ); + }).toList(), + ), + ); + } +} diff --git a/expense_manager/lib/components/transaction_list_item.dart b/expense_manager/lib/components/transaction_list_item.dart new file mode 100644 index 00000000..94f1289f --- /dev/null +++ b/expense_manager/lib/components/transaction_list_item.dart @@ -0,0 +1,76 @@ +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:expense_manager/models/income.dart'; +import 'package:expense_manager/models/transaction.dart'; +import 'package:expense_manager/pages/add_expense_page.dart'; +import 'package:expense_manager/pages/add_income_page.dart'; +import 'package:flutter/material.dart'; + +class TransactionListItem extends StatelessWidget { + final Transaction transaction; + final bool hideAmount; + + const TransactionListItem({ + super.key, + required this.transaction, + this.hideAmount = false, + }); + + bool get isExpense => transaction is Expense; + bool get isIncome => transaction is Income; + + @override + Widget build(BuildContext context) { + return ListTile( + onTap: () => _gotoPage(context), + minVerticalPadding: 8, + leading: SizedBox.square( + dimension: 39, + child: isExpense + ? DecoratedBox( + decoration: BoxDecoration( + color: _categoryColor, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + transaction.note.isEmpty + ? "" + : transaction.note.substring(0, 1).toUpperCase(), + style: TextStyle(color: Colors.white), + ), + ), + ) + : Icon(Icons.attach_money), + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(transaction.note), + Text( + friendlyDate(transaction.date), + style: TextStyle(color: Colors.grey), + ), + ], + ), + trailing: Text( + obfuscateText(transaction.amountCents.toMoney(), obfuscate: hideAmount), + style: TextStyle(fontSize: 16), + ), + ); + } + + Color get _categoryColor { + return hexToColor( + (transaction as Expense).category.value?.colorCode ?? '#FF9800'); + } + + void _gotoPage(BuildContext context) { + if (isExpense) { + gotoPage(context, AddExpensePage(expense: transaction as Expense)); + } else { + gotoPage(context, AddIncomePage(income: transaction as Income)); + } + } +} diff --git a/expense_manager/lib/constants.dart b/expense_manager/lib/constants.dart new file mode 100644 index 00000000..a371f70d --- /dev/null +++ b/expense_manager/lib/constants.dart @@ -0,0 +1,53 @@ +import 'package:get_it/get_it.dart'; +import 'package:isar/isar.dart'; +import 'package:expense_manager/services/shared_pref_service.dart'; +import 'package:expense_manager/repositories/category_repo.dart'; +import 'package:expense_manager/repositories/expense_repo.dart'; +import 'package:expense_manager/repositories/income_repo.dart'; +import 'package:expense_manager/repositories/tag_repo.dart'; +import 'package:expense_manager/services/data_service.dart'; +import 'package:expense_manager/services/transaction_service.dart'; + +final getIt = GetIt.instance; +late Isar kIsar; + +const kAppName = 'expense_manager'; + +// shared preferences +const kDateRange = ["3m", "6m", "1y", "ytd", "5y", "10y"]; +const prefHomeMonthCount = 'home_month_count'; +const prefCurrency = 'currency'; +const prefAppInit = 'pref_app_initialised'; + +// strings +String? kCurrency; + +// repos +ExpenseRepo get kExpenseRepo => getIt.get(); +CategoryRepo get kCategoryRepo => getIt.get(); +IncomeRepo get kIncomeRepo => getIt.get(); +TagRepo get kTagRepo => getIt.get(); + +// services +TransactionService get kTransactionService => getIt.get(); +SharedPrefService get kSharedPrefService => SharedPrefService(); +DataService get kDataService => getIt.get(); + +List<({String symbol, String name})> currencies = [ + (symbol: '\$', name: "Dollar"), + (symbol: '€', name: "Euro"), + (symbol: '£', name: "British Pound Sterling"), + (symbol: '¥', name: "Japanese Yen"), + (symbol: 'CHF', name: "Swiss Franc"), + (symbol: '¥', name: "Chinese Yuan"), + (symbol: '₹', name: "Indian Rupee"), + (symbol: 'R\$', name: "Brazilian Real"), + (symbol: 'R', name: "South African Rand"), + (symbol: '₽', name: "Russian Ruble"), + (symbol: 'RM', name: "Malaysian Ringgit"), + (symbol: 'S\$', name: "Singapore Dollar"), + (symbol: 'kr', name: "Krona"), + (symbol: '₩', name: "South Korean Won"), +]; + +enum DataLoaderState { initiated, loading, loaded } diff --git a/expense_manager/lib/controllers/add_expense_controller.dart b/expense_manager/lib/controllers/add_expense_controller.dart new file mode 100644 index 00000000..3bc64013 --- /dev/null +++ b/expense_manager/lib/controllers/add_expense_controller.dart @@ -0,0 +1,117 @@ +import 'package:expense_manager/components/amount_dialog.dart'; +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/controllers/base_controller.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/models/category.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:expense_manager/models/tag.dart'; +import 'package:expense_manager/pubsub.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class AddExpenseController extends BaseController { + final List categories = []; + final List tags = []; + final List removeTags = []; + late Expense expense; + + final noteController = TextEditingController(); + + bool get isEdit => expense.id != null; + bool get isNew => expense.id == null; + + AddExpenseController(Expense e) { + expense = e.clone() as Expense; + tags.assignAll(expense.tags); + } + + get dateLabel => formatDate(expense.date, 'dd/MM/yyyy'); + + @override + void onInit() { + super.onInit(); + noteController.text = expense.note; + loadCategories(); + } + + @override + void onReady() { + super.onReady(); + + if (isNew) { + Get.dialog(AmountDialog( + amount: expense.amountCents, + onAmountChanged: (amount) => updateAmount(amount), + )); + } + } + + void loadCategories() { + kCategoryRepo.findAll().then((value) { + categories.clear(); + categories.addAll(value); + update(); + }); + } + + Future saveExpense() async { + expense.note = noteController.text; + + // check existing tags + for (var tag in tags) { + if (!tag.isFromRemark) continue; + final existingTag = + await kTagRepo.findByName(tag.name, caseSensitive: false); + if (existingTag != null) { + tag.id = existingTag.id; + } + } + + await kTransactionService.saveExpense( + expense: expense, + newTags: tags, + removedTags: removeTags, + ); + + PubSub.onExpenseUpdated(expense); + } + + void updateAmount(int amount) { + expense.amountCents = amount; + update(); + } + + void updateDate(DateTime date) { + expense.date = date; + update(); + } + + void excludeCalculation(bool excluded) { + expense.isAdHoc = excluded; + update(); + } + + Future setCategory(int categoryId) async { + final category = await kCategoryRepo.find(categoryId); + expense.category.value = category; + update(); + } + + Future deleteExpense() async { + await kExpenseRepo.delete(expense); + PubSub.onExpenseUpdated(expense); + } + + void updateTags(List tags) { + removeTags.assignAll(this.tags.toSet().difference(tags.toSet())); + this.tags.assignAll(tags); + update(); + } + + Future removeTagFromExpense(Tag tag) async { + tags.remove(tag); + removeTags.addIf(!removeTags.contains(tag), tag); + update(); + } +} diff --git a/expense_manager/lib/controllers/add_income_controller.dart b/expense_manager/lib/controllers/add_income_controller.dart new file mode 100644 index 00000000..116d2e6f --- /dev/null +++ b/expense_manager/lib/controllers/add_income_controller.dart @@ -0,0 +1,44 @@ +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/models/income.dart'; +import 'package:expense_manager/pubsub.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class AddIncomeController extends GetxController { + late Income income; + + final noteController = TextEditingController(); + + AddIncomeController(Income i) { + income = i.clone() as Income; + } + + @override + void onInit() { + super.onInit(); + noteController.text = income.note; + } + + void updateAmount(int amount) { + income.amountCents = amount; + update(); + } + + void updateDate(DateTime date) { + income.date = date; + update(); + } + + Future save() async { + income.note = noteController.text; + + await kTransactionService.saveIncome(income: income); + PubSub.onIncomeUpdated(income); + } + + Future deleteIncome() async { + await kIncomeRepo.delete(income); + PubSub.onIncomeUpdated(income); + } +} diff --git a/expense_manager/lib/controllers/base_controller.dart b/expense_manager/lib/controllers/base_controller.dart new file mode 100644 index 00000000..2528452f --- /dev/null +++ b/expense_manager/lib/controllers/base_controller.dart @@ -0,0 +1,3 @@ +import 'package:get/get.dart'; + +class BaseController extends GetxController {} diff --git a/expense_manager/lib/controllers/categories_controller.dart b/expense_manager/lib/controllers/categories_controller.dart new file mode 100644 index 00000000..078ffedc --- /dev/null +++ b/expense_manager/lib/controllers/categories_controller.dart @@ -0,0 +1,35 @@ +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/models/category.dart'; +import 'package:get/get.dart'; + +class CategoriesController extends GetxController { + final List categories = []; + bool loading = false; + bool dataLoaded = false; + + @override + void onInit() { + super.onInit(); + + loadDataWithState( + data: () => loadCategories(), + onStateChange: (state) { + loading = state == DataLoaderState.loading; + dataLoaded = state == DataLoaderState.loaded; + update(); + }, + ); + } + + Future loadCategories() async { + final result = await kCategoryRepo.findAll(); + categories.assignAll(result); + update(); + } + + Future deleteCategory(Category category) async { + await kCategoryRepo.delete(category); + loadCategories(); + } +} diff --git a/expense_manager/lib/controllers/category_editor_controller.dart b/expense_manager/lib/controllers/category_editor_controller.dart new file mode 100644 index 00000000..88650327 --- /dev/null +++ b/expense_manager/lib/controllers/category_editor_controller.dart @@ -0,0 +1,31 @@ +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/models/category.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class CategoryEditorController extends GetxController { + final Category? category; + Color? selectedColor; + + final nameController = TextEditingController(); + final budgetController = TextEditingController(); + + CategoryEditorController({this.category}) { + nameController.text = category?.name ?? ''; + budgetController.text = category?.budgetCents.toString() ?? ''; + selectedColor = + category?.colorCode != null ? hexToColor(category!.colorCode!) : null; + } + + Future addOrUpdateCategory() async { + final updatedCategory = category ?? Category(); + updatedCategory.name = nameController.text; + updatedCategory.budgetCents = + int.parse(budgetController.text.isEmpty ? '0' : budgetController.text) * + 100; + updatedCategory.colorCode = selectedColor?.toHex(); + await kCategoryRepo.save(updatedCategory); + } +} diff --git a/expense_manager/lib/controllers/category_expenses_controller.dart b/expense_manager/lib/controllers/category_expenses_controller.dart new file mode 100644 index 00000000..c71e10b3 --- /dev/null +++ b/expense_manager/lib/controllers/category_expenses_controller.dart @@ -0,0 +1,32 @@ +import 'package:expense_manager/controllers/expenses_controller.dart'; +import 'package:expense_manager/models/category.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:isar/isar.dart'; + +class CategoryExpensesController extends ExpensesController { + final Category category; + final DateTime? startDate; + final DateTime? endDate; + + CategoryExpensesController({ + required this.category, + this.startDate, + this.endDate, + }); + + @override + Future> getExpenses() async { + await category.expenses.load(); + List result; + if (startDate != null && endDate != null) { + result = await category.expenses + .filter() + .dateBetween(startDate!, endDate!) + .sortByDateDesc() + .findAll(); + } else { + result = await category.expenses.filter().sortByDateDesc().findAll(); + } + return await Future.value(result); + } +} diff --git a/expense_manager/lib/controllers/category_report_controller.dart b/expense_manager/lib/controllers/category_report_controller.dart new file mode 100644 index 00000000..bb15152d --- /dev/null +++ b/expense_manager/lib/controllers/category_report_controller.dart @@ -0,0 +1,98 @@ +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/models/category.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:get/get.dart'; +import 'package:isar/isar.dart'; + +class CategoryReportController extends GetxController { + final List categories = []; + final Map categoryValueMap = {}; + Category? selectedCategory; + double total = 0; + DateTime? selectedDateTime; + + int get selectedCategoryAmountCents { + return categoryValueMap[selectedCategory?.id]?.amount ?? 0; + } + + @override + void onInit() { + super.onInit(); + final now = DateTime.now(); + selectedDateTime = now; + loadChartData( + startDate: now.startDate(), + endDate: now.endDate(), + ); + } + + Future loadChartData({ + DateTime? startDate, + DateTime? endDate, + }) async { + total = 0; + categories.assignAll(await kCategoryRepo.findAllWithExpenses( + startDate: startDate, + endDate: endDate, + )); + + for (var category in categories) { + var amount = 0; + if (startDate != null && endDate != null) { + final query = category.expenses + .filter() + .dateBetween(startDate, endDate) + .findAll(); + amount = (await query).sumAmounts(); + } else { + amount = category.expenses.toList().sumAmounts(); + } + + total += amount; + categoryValueMap[category.id!] = _ChartData( + amount: amount, + percentage: (amount / total) * 100, + ); + } + + update(); + } + + void selectChartArea(int index) { + selectedCategory = categories[index]; + update(); + } + + bool isCategorySelected(Category category) { + return category.id == selectedCategory?.id; + } + + String getValuePercentage(Category category) { + final value = categoryValueMap[category.id]?.amount.toDouble() ?? 0; + return "${((value / total) * 100).toStringAsFixed(0)}%"; + } + + int getValueAmount(Category category) { + final value = categoryValueMap[category.id]?.amount ?? 0; + return value; + } + + void filterByDate(DateTime dt) { + selectedDateTime = dt; + loadChartData( + startDate: dt.startDate(), + endDate: dt.endDate(), + ); + } +} + +class _ChartData { + final int amount; + final double percentage; + + _ChartData({ + required this.amount, + required this.percentage, + }); +} diff --git a/expense_manager/lib/controllers/expenses_controller.dart b/expense_manager/lib/controllers/expenses_controller.dart new file mode 100644 index 00000000..8bf27e07 --- /dev/null +++ b/expense_manager/lib/controllers/expenses_controller.dart @@ -0,0 +1,52 @@ +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/controllers/base_controller.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/models/expense.dart'; + +abstract class ExpensesController extends BaseController { + final transactions = >{}; + bool loading = false; + bool dataLoaded = false; + + int get totalCount => transactions.length; + + int get totalSum { + return transactions.values.toList().fold(0, (previousValue, element) { + return previousValue += element.sumAmounts(); + }); + } + + @override + void onInit() { + super.onInit(); + + loadDataWithState( + data: () => loadExpenses(), + onStateChange: (state) { + loading = state == DataLoaderState.loading; + dataLoaded = state == DataLoaderState.loaded; + update(); + }, + ); + } + + Future> getExpenses(); + + Future loadExpenses() async { + final expenses = await getExpenses(); + + // group by year/month + transactions.clear(); + for (var e in expenses) { + final key = friendlyDate(e.date, dateFormat: "MMMM yyyy"); + if (transactions[key] == null) { + transactions[key] = [e]; + } else { + transactions[key]!.add(e); + } + } + + update(); + } +} diff --git a/expense_manager/lib/controllers/home_controller.dart b/expense_manager/lib/controllers/home_controller.dart new file mode 100644 index 00000000..218e5e2a --- /dev/null +++ b/expense_manager/lib/controllers/home_controller.dart @@ -0,0 +1,63 @@ +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/services/shared_pref_service.dart'; +import 'package:expense_manager/models/category.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:expense_manager/podo/home_banner_item.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:isar/isar.dart'; + +class HomeController extends GetxController { + final bannerController = PageController(); + final categories = [].obs; + final bannerItems = [].obs; + final bannerPageIndex = 0.obs; + + @override + void onInit() { + super.onInit(); + loadData(); + } + + Future loadData() async { + loadCategories(); + loadBannerData(); + } + + void onPageChanged(int index) { + loadCategories(); + bannerPageIndex.value = index; + } + + Future loadCategories() async { + final value = await kCategoryRepo.findAll(); + categories.assignAll(value); + + final now = DateTime.now(); + final date = now.copyWith(month: now.month - bannerPageIndex.value); + for (var cat in categories) { + final result = await cat.expenses + .filter() + .dateBetween(date.startDate(), date.endDate()) + .findAll(); + cat.amount = result.fold(0, (total, e) => total + e.amountCents); + } + + categories.refresh(); + } + + Future loadBannerData() async { + final now = DateTime.now().startDate(); + final numOfMonth = + await SharedPrefService().getInt(prefHomeMonthCount) ?? 3; + final tasks = >[]; + for (var i = 0; i < numOfMonth; i++) { + tasks.add( + kTransactionService.getMonthlyData(month: now.month - i), + ); + } + final results = await Future.wait(tasks); + bannerItems.assignAll(results); + } +} diff --git a/expense_manager/lib/controllers/monthly_report_controller.dart b/expense_manager/lib/controllers/monthly_report_controller.dart new file mode 100644 index 00000000..a6621258 --- /dev/null +++ b/expense_manager/lib/controllers/monthly_report_controller.dart @@ -0,0 +1,292 @@ +import 'dart:math'; +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:expense_manager/models/income.dart'; +import 'package:expense_manager/models/transaction.dart'; +import 'package:get/get.dart'; + +class MonthlyReportController extends GetxController { + final List> expenseData = []; + final List> incomeData = []; + final List> earningsData = []; + final _chartDisplayMap = {}; + final filter = _Filter( + startDate: DateTime.now().lastMonth(numOfMonth: 5).startDate(), + endDate: DateTime.now().endDate(), + ); + ({int expense, int income, int earnings, int year, int month})? touchedData; + + double? yAxisInterval; + double lowerBound = 0; + double upperBound = 0; + + List> get largerTransactionData { + if (expenseData.isLargerThan(incomeData)) return expenseData; + return incomeData; + } + + List> get smallerTransactionData { + if (expenseData.isLargerThan(incomeData)) return incomeData; + return expenseData; + } + + DateTime get touchDataDate { + if (touchedData == null) return DateTime.now(); + return DateTime.now().copyWith( + year: touchedData?.year, + month: touchedData?.month, + ); + } + + bool get isLargeDataSet => largerTransactionData.length > 12; + double get xAxisInterval => + earningsData.length <= 6 ? 1 : (earningsData.length / 6); + + @override + void onInit() { + super.onInit(); + loadChartData("1y").then((value) { + // default to last month touched and show all data + if (expenseData.isNotEmpty) { + onChartTouched(expenseData.length - 1); + } + updateChartDisplay(1, true); + updateChartDisplay(2, true); + updateChartDisplay(3, true); + }); + } + + Future loadChartData(String range) async { + // update filter + filter.updateWithRange(range); + + await loadExpenses(); + await loadIncomes(); + padExpenseOrIncomeData(); + loadEarningsData(); + update(); + } + + Future loadExpenses() async { + final expenses = + await kExpenseRepo.findByDateRange(filter.startDate, filter.endDate); + if (expenses.isEmpty) { + expenseData.clear(); + return; + } + + expenseData.clear(); + + var i = 0; + MonthlySpotData? spotData; + for (var expense in expenses) { + if (spotData == null || !spotData.isSameMonth(expense.date)) { + spotData = MonthlySpotData( + id: i, + year: expense.date.year, + month: expense.date.month, + totalAmountCents: expense.amountCents, + transactions: [expense], + ); + expenseData.add(spotData); + i++; + } else { + spotData.increment(expense.amountCents); + spotData.transactions.add(expense); + } + } + } + + Future loadIncomes() async { + final incomes = + await kIncomeRepo.findByDateRange(filter.startDate, filter.endDate); + if (incomes.isEmpty) { + incomeData.clear(); + return; + } + + incomeData.clear(); + + var i = 0; + MonthlySpotData? spotData; + for (var income in incomes) { + if (spotData == null || !spotData.isSameMonth(income.date)) { + spotData = MonthlySpotData( + id: i, + year: income.date.year, + month: income.date.month, + totalAmountCents: income.amountCents, + transactions: [income], + ); + incomeData.add(spotData); + i++; + } else { + spotData.increment(income.amountCents); + spotData.transactions.add(income); + } + } + } + + void loadEarningsData() { + earningsData.clear(); + + num minValue = -1; + num maxValue = -1; + for (var i = 0; i < largerTransactionData.length; i++) { + final expense = expenseData[i]; + final income = incomeData[i]; + final earningsAmount = + max(0, income.totalAmountCents - expense.totalAmountCents); + earningsData.add(MonthlySpotData( + id: i, + year: expense.year, + month: expense.month, + totalAmountCents: earningsAmount, + transactions: [], + )); + + // store min, max + if (minValue.isNegative) { + minValue = min(earningsAmount, + min(expense.totalAmountCents, income.totalAmountCents)); + } + minValue = min( + minValue, + min(earningsAmount, + min(expense.totalAmountCents, income.totalAmountCents)), + ); + maxValue = max( + maxValue, + max(earningsAmount, + max(expense.totalAmountCents, income.totalAmountCents)), + ); + } + yAxisInterval = + calculateChartInterval(minValue, maxValue, interval: 4) / 100; + lowerBound = ((minValue / 1.1) / 100).roundToDouble(); + upperBound = ((maxValue / 100) + yAxisInterval!).toDouble(); + } + + // pad the smaller data list with dummy data so that both have the same size + void padExpenseOrIncomeData() { + if (expenseData.isSameSizeAs(incomeData)) return; + + final extraLength = (expenseData.length - incomeData.length).abs(); + final smallerInitialLength = smallerTransactionData.length; + for (var i = 0; i < extraLength; i++) { + final data = largerTransactionData[smallerInitialLength + i]; + if (expenseData.isLargerThan(incomeData)) { + smallerTransactionData.add(MonthlySpotData( + id: smallerInitialLength + i, + year: data.year, + month: data.month, + totalAmountCents: 0, + transactions: [], + )); + } else { + smallerTransactionData.add(MonthlySpotData( + id: smallerInitialLength + i, + year: data.year, + month: data.month, + totalAmountCents: 0, + transactions: [], + )); + } + } + } + + void onChartTouched(int index) { + touchedData = ( + expense: expenseData[index].totalAmountCents, + income: incomeData[index].totalAmountCents, + earnings: incomeData[index].totalAmountCents - + expenseData[index].totalAmountCents, + month: expenseData[index].month, + year: expenseData[index].year, + ); + update(); + } + + bool shouldDisplayInChart(int id) { + return _chartDisplayMap[id] == true; + } + + void updateChartDisplay(int id, bool checked) { + if (checked) { + _chartDisplayMap[id] = true; + } else { + _chartDisplayMap.remove(id); + } + update(); + } +} + +class _Filter { + DateTime? startDate; + DateTime? endDate; + + _Filter({this.startDate, this.endDate}); + + void updateWithRange(String range) { + endDate = DateTime.now().endDate(); + switch (range) { + case '3m': + startDate = DateTime.now().lastMonth(numOfMonth: 2).startDate(); + break; + case '6m': + startDate = DateTime.now().lastMonth(numOfMonth: 5).startDate(); + break; + case '1y': + startDate = DateTime.now().lastMonth(numOfMonth: 11).startDate(); + break; + case 'ytd': + startDate = DateTime.now().copyWith(month: 1).startDate(); + break; + case '5y': + startDate = DateTime.now().lastMonth(numOfMonth: 5 * 11).startDate(); + break; + case '10y': + startDate = DateTime.now().lastMonth(numOfMonth: 10 * 11).startDate(); + break; + default: + } + } +} + +class MonthlySpotData { + final int id; + final int year; + final int month; + int totalAmountCents; + final List transactions; + + MonthlySpotData({ + required this.id, + required this.year, + required this.month, + required this.totalAmountCents, + required this.transactions, + }); + + String get friendlyMonth { + if (month == 0) return ''; + return DateTime.now().copyWith(month: month, day: 1).format('MMM'); + } + + String get friendlyMonthYear { + if (month == 0) return ''; + return DateTime.now() + .copyWith(month: month, year: year, day: 1) + .format('M/yy'); + } + + bool isSameMonth(DateTime date) { + return year == date.year && month == date.month; + } + + void increment(int amount) { + totalAmountCents = totalAmountCents + amount; + } +} diff --git a/expense_manager/lib/controllers/settings_controller.dart b/expense_manager/lib/controllers/settings_controller.dart new file mode 100644 index 00000000..aa48fa74 --- /dev/null +++ b/expense_manager/lib/controllers/settings_controller.dart @@ -0,0 +1,51 @@ +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/services/data_service.dart'; +import 'package:get/get.dart'; + +class SettingsController extends GetxController { + final loadingTestData = false.obs; + final homeMonthCount = 3.obs; + final currency = '\$'.obs; + + int get currencyIndex { + final result = currencies + .firstWhereOrNull((element) => element.symbol == currency.value); + return result == null ? 0 : currencies.indexOf(result); + } + + @override + void onInit() { + super.onInit(); + loadDefaultValues(); + } + + Future loadDefaultValues() async { + homeMonthCount.value = + await kSharedPrefService.getInt(prefHomeMonthCount) ?? 3; + currency.value = await kSharedPrefService.getString(prefCurrency) ?? '\$'; + } + + setHomeMonthCount(int value) async { + homeMonthCount.value = value; + await kSharedPrefService.setInt(prefHomeMonthCount, value); + } + + setCurrency(String value) async { + currency.value = value; + await kSharedPrefService.setString(prefCurrency, value); + kCurrency = value; + } + + Future logout() async { + await DataService().clearAll(); + } + + Future loadTestingData() async { + await DataService().loadTestingData(); + } + + Future clearData() async { + await kDataService.clearAll(); + update(); + } +} diff --git a/expense_manager/lib/controllers/tag_expenses_controller.dart b/expense_manager/lib/controllers/tag_expenses_controller.dart new file mode 100644 index 00000000..04e2aa4a --- /dev/null +++ b/expense_manager/lib/controllers/tag_expenses_controller.dart @@ -0,0 +1,20 @@ +import 'package:expense_manager/controllers/expenses_controller.dart'; +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:expense_manager/models/tag.dart'; +import 'package:isar/isar.dart'; + +class TagExpensesController extends ExpensesController { + final int tagId; + late Tag tag; + + TagExpensesController({required this.tagId}) { + tag = kTagRepo.findSync(tagId)!; + } + + @override + Future> getExpenses() async { + await tag.expenses.load(); + return await tag.expenses.filter().sortByDateDesc().findAll(); + } +} diff --git a/expense_manager/lib/controllers/tag_input_controller.dart b/expense_manager/lib/controllers/tag_input_controller.dart new file mode 100644 index 00000000..f890db83 --- /dev/null +++ b/expense_manager/lib/controllers/tag_input_controller.dart @@ -0,0 +1,43 @@ +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/models/tag.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class TagInputController extends GetxController { + final List tags = []; + final inputController = TextEditingController(); + final focusNode = FocusNode(); + + TagInputController({required List tags}) { + this.tags.assignAll(tags); + } + + String get newTagText => inputController.text.trim().toLowerCase(); + + @override + void onInit() { + super.onInit(); + focusNode.requestFocus(); + } + + Future addTag(String newTag) async { + if (newTag.isEmpty) return; + if (tags.firstWhereOrNull((element) => element.name == newTag) != null) + return; + + final existingTag = await kTagRepo.findByName(newTag); + final tag = existingTag ?? Tag.create(name: newTag); + tags.add(tag); + inputController.clear(); + update(); + } + + Future> searchTag(String pattern) async { + return await kTagRepo.search(pattern.trim()); + } + + void removeTag(Tag e) { + tags.removeWhere((element) => element.name == e.name); + update(); + } +} diff --git a/expense_manager/lib/controllers/tags_controller.dart b/expense_manager/lib/controllers/tags_controller.dart new file mode 100644 index 00000000..9fc93650 --- /dev/null +++ b/expense_manager/lib/controllers/tags_controller.dart @@ -0,0 +1,52 @@ +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/models/tag.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class TagsController extends GetxController { + final List tags = []; + final renameController = TextEditingController(); + bool loading = false; + bool dataLoaded = false; + + get hasTags => tags.isNotEmpty; + + @override + void onInit() { + super.onInit(); + + loadDataWithState( + data: () => searchTags(), + onStateChange: (state) { + loading = state == DataLoaderState.loading; + dataLoaded = state == DataLoaderState.loaded; + update(); + }, + ); + } + + Future searchTags({ + String text = '', + }) async { + if (text.trim().isNotEmpty && text.length < 3) return; + final tags = text.trim().isEmpty + ? await kTagRepo.findAll() + : await kTagRepo.search(text); + this.tags.assignAll(tags); + + update(); + } + + Future deleteTag(Tag tag) async { + await kTagRepo.delete(tag); + tags.remove(tag); + update(); + } + + Future renameTag(Tag tag, String newName) async { + tag.name = newName.trim().toLowerCase(); + await kTagRepo.save(tag); + update(); + } +} diff --git a/expense_manager/lib/controllers/transactions_controller.dart b/expense_manager/lib/controllers/transactions_controller.dart new file mode 100644 index 00000000..2d89a801 --- /dev/null +++ b/expense_manager/lib/controllers/transactions_controller.dart @@ -0,0 +1,48 @@ +import 'package:expense_manager/misc/debouncer.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/models/transaction.dart'; +import 'package:get/get.dart'; + +class TransactionsController extends GetxController { + final transactions = >{}; + final debouncer = Debouncer(500); + bool loading = false; + bool dataLoaded = false; + + bool get showPlaceholder => transactions.isEmpty && dataLoaded; + + @override + void onInit() { + super.onInit(); + + loadDataWithState( + data: () => loadData(), + onStateChange: (state) { + loading = state == DataLoaderState.loading; + dataLoaded = state == DataLoaderState.loaded; + update(); + }, + ); + } + + Future loadData() async { + final List expenses = await kExpenseRepo.findAll(); + final List incomes = await kIncomeRepo.findAll(); + final combined = [expenses, incomes].expand((e) => e).toList(); + combined.sort((a, b) => b.date.compareTo(a.date)); + + // group by year/month + transactions.clear(); + for (var e in combined) { + final key = friendlyDate(e.date, dateFormat: "MMMM yyyy"); + if (transactions[key] == null) { + transactions[key] = [e]; + } else { + transactions[key]!.add(e); + } + } + + update(); + } +} diff --git a/expense_manager/lib/fl_chart/custom_line_chart.dart b/expense_manager/lib/fl_chart/custom_line_chart.dart new file mode 100644 index 00000000..6d3e816e --- /dev/null +++ b/expense_manager/lib/fl_chart/custom_line_chart.dart @@ -0,0 +1,130 @@ +import 'package:expense_manager/misc/extensions.dart'; +import 'package:flutter/material.dart'; +import 'dart:math' as math; +import 'index.dart'; + +class CustomLineChart extends StatelessWidget { + final List data; + final double? height; + final double? width; + final GetTitleWidgetFunction? getBottomTitlesWidget; + final AxisTitles? leftTitles; + final AxisTitles? topTitles; + final AxisTitles? rightTitles; + final AxisTitles? bottomTitles; + final Color? backgroundColor; + final FlGridData? gridData; + final double? minY; + final double? maxY; + final double? minX; + final double? maxX; + final LineTouchData? lineTouchData; + final double? leftTitlesInterval; + + const CustomLineChart({ + super.key, + required this.data, + this.width, + this.height = 200, + this.getBottomTitlesWidget, + this.leftTitles, + this.topTitles, + this.rightTitles, + this.bottomTitles, + this.backgroundColor, + this.gridData, + this.minX, + this.minY, + this.maxY, + this.maxX, + this.lineTouchData, + this.leftTitlesInterval, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width ?? MediaQuery.of(context).size.width, + height: height, + child: LineChart( + LineChartData( + lineBarsData: data, + backgroundColor: backgroundColor ?? Color(0xFF222222), + clipData: FlClipData.all(), + minX: minX, + maxX: maxX, + minY: minY ?? _getMin(), + maxY: maxY, + gridData: gridData ?? + FlGridData( + drawVerticalLine: false, + ), + titlesData: FlTitlesData( + bottomTitles: bottomTitles ?? + AxisTitles( + sideTitles: SideTitles( + interval: 1, + showTitles: true, + reservedSize: 30, + getTitlesWidget: getBottomTitlesWidget ?? + (value, meta) { + return SideTitleWidget( + axisSide: meta.axisSide, + space: 3, + child: Text(value.toString()), + ); + }, + ), + ), + topTitles: topTitles ?? + AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + leftTitles: leftTitles ?? + AxisTitles( + sideTitles: SideTitles( + reservedSize: 33, + interval: leftTitlesInterval, + showTitles: true, + getTitlesWidget: (value, meta) { + return SideTitleWidget( + axisSide: meta.axisSide, + fitInside: SideTitleFitInsideData.disable(), + child: Text( + value.shortForm(), + style: TextStyle(fontSize: 12, color: Colors.white), + ), + ); + }, + ), + ), + rightTitles: rightTitles ?? + AxisTitles( + sideTitles: SideTitles( + showTitles: false, + reservedSize: 30, + ), + ), + ), + borderData: FlBorderData(show: false), + lineTouchData: lineTouchData ?? + LineTouchData( + touchCallback: (event, response) {}, + touchTooltipData: LineTouchTooltipData( + tooltipBgColor: Color(0xEEFFFFFF), + ), + ), + ), + ), + ); + } + + double _getMin() { + final values = data + .map((e) => e.spots.map((e) => e.y)) + .expand((element) => element) + .toList(); + if (values.isEmpty) return 0; + return values.reduce(math.min) / 1.2; + } +} diff --git a/expense_manager/lib/fl_chart/custom_pie_chart.dart b/expense_manager/lib/fl_chart/custom_pie_chart.dart new file mode 100644 index 00000000..8f7ef626 --- /dev/null +++ b/expense_manager/lib/fl_chart/custom_pie_chart.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'index.dart'; + +class CustomPieChart extends StatefulWidget { + final List sections; + final double? height; + final bool overrideColor; + final Function(int index)? onAreaTouched; + final double? centerSpaceRadius; + final double? sectionsSpace; + + const CustomPieChart({ + super.key, + required this.sections, + this.height = 200, + this.overrideColor = false, + this.onAreaTouched, + this.centerSpaceRadius, + this.sectionsSpace, + }); + + @override + State createState() => _CustomPieChartState(); +} + +class _CustomPieChartState extends State { + int _touchedIndex = -1; + final List _colors = [ + Colors.red, + Colors.orange, + Colors.green, + Colors.teal.shade600, + Colors.blue, + Colors.cyan, + Colors.deepPurple, + Colors.red.shade800, + Colors.orange.shade800, + Colors.green.shade800, + Colors.blue.shade800, + ]; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: widget.height, + child: PieChart(PieChartData( + sectionsSpace: widget.sectionsSpace, + centerSpaceRadius: widget.centerSpaceRadius, + sections: widget.sections + .asMap() + .map((i, data) { + return MapEntry( + i, + data.copyWith( + titleStyle: TextStyle( + color: Colors.white, + fontSize: i == _touchedIndex ? 19 : null, + ), + color: widget.overrideColor ? data.color : _colors[i], + radius: i == _touchedIndex ? 50 : null, + ), + ); + }) + .values + .toList(), + pieTouchData: PieTouchData( + touchCallback: (FlTouchEvent event, pieTouchResponse) { + setState(() { + if (!event.isInterestedForInteractions || + pieTouchResponse == null || + pieTouchResponse.touchedSection == null) { + widget.onAreaTouched?.call(_touchedIndex); + _touchedIndex = -1; + return; + } + _touchedIndex = + pieTouchResponse.touchedSection!.touchedSectionIndex; + }); + }, + ), + )), + ); + } +} diff --git a/expense_manager/lib/fl_chart/index.dart b/expense_manager/lib/fl_chart/index.dart new file mode 100644 index 00000000..50cbea79 --- /dev/null +++ b/expense_manager/lib/fl_chart/index.dart @@ -0,0 +1,2 @@ +// Installation: flutter pub add fl_chart +export 'package:fl_chart/fl_chart.dart'; diff --git a/expense_manager/lib/index.dart b/expense_manager/lib/index.dart new file mode 100644 index 00000000..0de83e75 --- /dev/null +++ b/expense_manager/lib/index.dart @@ -0,0 +1 @@ +export 'package:json_annotation/json_annotation.dart'; diff --git a/expense_manager/lib/main.dart b/expense_manager/lib/main.dart new file mode 100644 index 00000000..dc9c5251 --- /dev/null +++ b/expense_manager/lib/main.dart @@ -0,0 +1,9 @@ +import 'package:expense_manager/pages/main_page.dart'; +import 'package:expense_manager/services/app_init_service.dart'; +import 'package:flutter/material.dart'; +import 'my_app.dart'; + +Future main() async { + await AppInitService.init(); + runApp(MyApp(initialPage: MainPage())); +} diff --git a/expense_manager/lib/misc/colors.dart b/expense_manager/lib/misc/colors.dart new file mode 100644 index 00000000..79ac28c2 --- /dev/null +++ b/expense_manager/lib/misc/colors.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + +class CustomColors { + static const primary = Color(0xFF7E57C2); + static const primaryLight = Color(0xFFCCE4D5); + static const darkModeBlack = Color(0xFF222222); +} diff --git a/expense_manager/lib/misc/custom_theme_data.dart b/expense_manager/lib/misc/custom_theme_data.dart new file mode 100644 index 00000000..af986567 --- /dev/null +++ b/expense_manager/lib/misc/custom_theme_data.dart @@ -0,0 +1,102 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'colors.dart'; + +class CustomThemeData { + static const _defaultFontSize = 14.0; + static const _defaultTextHeight = 1.5; + + final String? fontFamily; + + final _iconTheme = IconThemeData( + color: Colors.black, + ); + + /// for cursor iOS + final _cupertinoOverrideTheme = CupertinoThemeData( + primaryColor: Colors.black, + ); + + final _textTheme = TextTheme( + bodyLarge: TextStyle( + fontSize: _defaultFontSize, + height: _defaultTextHeight, + ), + bodyMedium: TextStyle( + fontSize: _defaultFontSize, + height: _defaultTextHeight, + ), + displayLarge: TextStyle(fontWeight: FontWeight.bold), + displayMedium: TextStyle(fontWeight: FontWeight.bold), + displaySmall: TextStyle(fontWeight: FontWeight.bold), + headlineMedium: TextStyle(fontWeight: FontWeight.bold), + headlineSmall: TextStyle(fontWeight: FontWeight.bold), + titleLarge: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 19, + ), + ); + + final _bottomNavigationBarTheme = BottomNavigationBarThemeData( + selectedItemColor: Colors.green, + ); + + final _dialogTheme = DialogTheme( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ); + + final _cardTheme = CardTheme( + elevation: 3, + surfaceTintColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ); + + final _appBarTheme = AppBarTheme( + elevation: 0, + foregroundColor: Colors.white, + ); + + final listTileTheme = ListTileThemeData( + contentPadding: EdgeInsets.symmetric(horizontal: 16), + ); + + CustomThemeData({ + this.fontFamily, + }); + + ThemeData get theme => ThemeData( + useMaterial3: true, + fontFamily: fontFamily, // change font family eg. "OpenSans" + primaryColor: CustomColors.primary, + primaryColorDark: CustomColors.primaryLight, + iconTheme: _iconTheme, + cupertinoOverrideTheme: _cupertinoOverrideTheme, + textTheme: _textTheme, + appBarTheme: _appBarTheme, + bottomNavigationBarTheme: _bottomNavigationBarTheme, + dialogTheme: _dialogTheme, + cardTheme: _cardTheme, + listTileTheme: listTileTheme, + ); + + ThemeData get darkTheme { + return ThemeData.dark().copyWith( + primaryColor: CustomColors.primary, + primaryColorDark: CustomColors.primaryLight, + iconTheme: _iconTheme.copyWith( + color: Colors.white, + ), + cupertinoOverrideTheme: _cupertinoOverrideTheme, + textTheme: _textTheme, + appBarTheme: _appBarTheme, + bottomNavigationBarTheme: _bottomNavigationBarTheme, + dialogTheme: _dialogTheme, + cardTheme: _cardTheme, + listTileTheme: listTileTheme, + ); + } +} diff --git a/expense_manager/lib/misc/debouncer.dart b/expense_manager/lib/misc/debouncer.dart new file mode 100644 index 00000000..68b5bc6c --- /dev/null +++ b/expense_manager/lib/misc/debouncer.dart @@ -0,0 +1,14 @@ +import 'dart:async'; +import 'dart:ui'; + +class Debouncer { + final int milliseconds; + Timer? _timer; + + Debouncer(this.milliseconds); + + run(VoidCallback action) { + _timer?.cancel(); + _timer = Timer(Duration(milliseconds: milliseconds), action); + } +} diff --git a/expense_manager/lib/misc/extensions.dart b/expense_manager/lib/misc/extensions.dart new file mode 100644 index 00000000..397c068e --- /dev/null +++ b/expense_manager/lib/misc/extensions.dart @@ -0,0 +1,102 @@ +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:expense_manager/models/income.dart'; +import 'package:expense_manager/models/transaction.dart'; +import '../models/tag.dart'; +import 'dart:ui'; +import 'package:intl/intl.dart'; +import 'utils.dart'; +import 'dart:math' as math; +import 'package:isar/isar.dart'; + +extension TransactionListParsing on List { + int sumAmounts() => fold(0, (total, element) => total + element.amountCents); +} + +extension TransactionParsing on Transaction { + Transaction clone() { + if (id == null) return this; + + if (this is Expense) { + return kExpenseRepo.findSync(id!)!; + } else if (this is Income) { + return kIncomeRepo.findSync(id!)!; + } + + throw "Unknown transaction for cloning"; + } +} + +extension ExpenseParsing on Expense { + Future saveTags( + List tagsToRemove, + List newTags, { + bool transaction = true, + }) async { + if (tagsToRemove.isNotEmpty) { + tags.removeAll(tagsToRemove); + } + + if (newTags.isNotEmpty) { + tags.addAll(newTags); + } + await kExpenseRepo.save(this, transaction: transaction); + } +} + +extension IntParsing on int { + String toMoney({String? currency}) { + return formatMoney(this / 100, currency: currency ?? kCurrency); + } +} + +extension DoubleParsing on double { + String shortForm() { + if (this >= 10000) return "${this ~/ 1000}K"; + if (this >= 1000) return "${(this / 1000).toStringAsFixed(0)}K"; + return toInt().toString(); + } +} + +extension ColorParsing on Color { + String toHex() => + '#${(value & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toUpperCase()}'; +} + +extension DateTimeParsing on DateTime { + DateTime startDate() => copyWith( + day: 1, hour: 0, minute: 0, second: 0, millisecond: 0, microsecond: 0); + DateTime endDate() => + copyWith(month: month + 1, day: 0, hour: 23, minute: 59, second: 59); + DateTime lastMonth({int numOfMonth = 1}) => + copyWith(month: month - numOfMonth, day: 1); + DateTime nextMonth({int numOfMonth = 1}) => + copyWith(month: month + numOfMonth, day: 1); + bool isSameMonthAs(DateTime dt) => year == dt.year && month == dt.month; + String format(String dateFormat) => DateFormat(dateFormat).format(this); +} + +extension DoubleListParsing on List { + double min() => reduce(math.min); +} + +extension ListParsing on List { + bool isLargerThan(List list) => length > list.length; + bool isSameSizeAs(List list) => length == list.length; +} + +extension StringParsing on String { + void clear() => this == ''; +} + +/// Extension specifically for isar package +Isar get _isar { + if (Isar.getInstance() == null) throw "Isar instance is not found"; + return Isar.getInstance()!; +} + +extension IsarLinksParsing on IsarLinks { + Future saveWithTrnx() async { + await _isar.writeTxn(() => save()); + } +} diff --git a/expense_manager/lib/misc/utils.dart b/expense_manager/lib/misc/utils.dart new file mode 100644 index 00000000..ea2d8cee --- /dev/null +++ b/expense_manager/lib/misc/utils.dart @@ -0,0 +1,152 @@ +import 'package:expense_manager/constants.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +void gotoPage( + BuildContext context, + Widget destination, { + Route? route, +}) { + Navigator.of(context).push(route ?? + CupertinoPageRoute(builder: (_) { + return destination; + })); +} + +void gotoPageAndClearStack( + BuildContext context, + Widget destination, { + Route? route, +}) { + Navigator.of(context).pushAndRemoveUntil( + route ?? + CupertinoPageRoute(builder: (_) { + return destination; + }), + (route) => false); +} + +void goBack(BuildContext context) { + Navigator.of(context).pop(); +} + +void showDecisionDialog( + BuildContext context, { + String? title, + String? content, + String? positiveButtonText, + bool barrierDismissible = false, + Function? onPositivePressed, +}) { + showDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (_) { + return AlertDialog( + title: (title != null) ? Text(title) : null, + content: (content != null) ? Text(content) : null, + actions: [ + TextButton( + child: Text("Cancel"), + onPressed: () { + goBack(context); + }, + ), + TextButton( + child: Text(positiveButtonText ?? "OK"), + onPressed: () { + goBack(context); + onPositivePressed?.call(); + }, + ), + ], + ); + }); +} + +void showCupertinoModal( + BuildContext context, { + required List children, + required ValueChanged onSelectedItemChanged, + bool top = false, + double height = 216, + double itemExtent = 36, + int initialItem = 0, +}) { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => Container( + height: height, + padding: const EdgeInsets.only(top: 6.0), + margin: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + color: CupertinoColors.systemBackground.resolveFrom(context), + child: SafeArea( + top: top, + child: CupertinoPicker( + itemExtent: itemExtent, + onSelectedItemChanged: onSelectedItemChanged, + scrollController: FixedExtentScrollController( + initialItem: initialItem, + ), + children: children, + ), + ), + ), + ); +} + +String friendlyDate(DateTime? dt, {String? dateFormat}) { + if (dt == null) return ''; + var format = DateFormat(dateFormat ?? "MMM d, yyyy"); + return format.format(dt); +} + +String formatDate(DateTime dt, String format) { + final now = DateTime.now(); + if (dt.year == now.year && dt.month == now.month && dt.day == now.day) { + return "Today"; + } else if (dt.year == now.year && dt.month == now.month && dt.day == now.day - 1) { + return "Yesterday"; + } + return DateFormat(format).format(dt); +} + +String formatMoney(num amount, {String? currency = 'RM'}) { + return NumberFormat("$currency#,##0.00", "en_US").format(amount); +} + +Color hexToColor(String hexColor) { + return Color(int.parse(hexColor.substring(1, 7), radix: 16) + 0xFF000000); +} + +String obfuscateText(String text, {bool obfuscate = false}) { + return obfuscate ? '*****' : text; +} + +num calculateChartInterval(num min, num max, {num interval = 6}) { + final lowerBound = min ~/ 1000 * 1000; + final upperBound = max ~/ 1000 * 1000; + final xInterval = ((lowerBound + upperBound) / interval).round(); + return xInterval; +} + +Future loadDataWithState({ + required Future Function() data, + required Function(DataLoaderState state) onStateChange, +}) async { + bool dataLoaded = false; + onStateChange(DataLoaderState.initiated); + Future.delayed(Duration(milliseconds: 500), () { + if (dataLoaded) return; + onStateChange(DataLoaderState.loading); + }); + + await data(); + dataLoaded = true; + onStateChange(DataLoaderState.loaded); +} + +class Utils {} diff --git a/expense_manager/lib/models/category.dart b/expense_manager/lib/models/category.dart new file mode 100644 index 00000000..edef53d1 --- /dev/null +++ b/expense_manager/lib/models/category.dart @@ -0,0 +1,74 @@ +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/index.dart'; +import 'package:expense_manager/models/serializable.dart'; +import 'package:expense_manager/misc/extensions.dart'; +import 'package:isar/isar.dart'; +import 'expense.dart'; + +part 'category.g.dart'; + +@collection +@JsonSerializable() +class Category implements Serializable { + Id? id; + String? name; + String? colorCode; + int iconId = 0; + int priority = 0; + int budgetCents = 0; + + @Backlink(to: 'category') + final expenses = IsarLinks(); + + @ignore + @JsonKey(includeFromJson: false, includeToJson: false) + bool isSelected = false; + + @ignore + @JsonKey(includeFromJson: false, includeToJson: false) + double get budget => budgetCents / 100; + + @ignore + int amount = 0; + + Category({ + this.name, + this.colorCode, + this.iconId = 0, + this.priority = 0, + this.budgetCents = 0, + }); + + @ignore + int get totalAmountCents => expenses.toList().sumAmounts(); + + static Future fromJson(json) async { + final obj = _$CategoryFromJson(json); + await kCategoryRepo.save(obj); + + final List> mExpenses = json['expenses'] + ?.map>((e) async => await Expense.fromJson(e)) + .toList() ?? + []; + for (var element in obj.expenses) { + await kExpenseRepo.save(element); + } + + for (var element in mExpenses) { + obj.expenses.add(await element); + } + + await obj.expenses.saveWithTrnx(); + return obj; + } + + @override + Map toJson({bool includeLinks = false}) { + final json = _$CategoryToJson(this); + if (includeLinks) { + expenses.loadSync(); + json['expenses'] = expenses.map((e) => e.toJson()).toList(); + } + return json; + } +} diff --git a/expense_manager/lib/models/category.g.dart b/expense_manager/lib/models/category.g.dart new file mode 100644 index 00000000..4d3fa856 --- /dev/null +++ b/expense_manager/lib/models/category.g.dart @@ -0,0 +1,1050 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'category.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetCategoryCollection on Isar { + IsarCollection get categorys => this.collection(); +} + +const CategorySchema = CollectionSchema( + name: r'Category', + id: 5751694338128944171, + properties: { + r'budgetCents': PropertySchema( + id: 0, + name: r'budgetCents', + type: IsarType.long, + ), + r'colorCode': PropertySchema( + id: 1, + name: r'colorCode', + type: IsarType.string, + ), + r'iconId': PropertySchema( + id: 2, + name: r'iconId', + type: IsarType.long, + ), + r'name': PropertySchema( + id: 3, + name: r'name', + type: IsarType.string, + ), + r'priority': PropertySchema( + id: 4, + name: r'priority', + type: IsarType.long, + ) + }, + estimateSize: _categoryEstimateSize, + serialize: _categorySerialize, + deserialize: _categoryDeserialize, + deserializeProp: _categoryDeserializeProp, + idName: r'id', + indexes: {}, + links: { + r'expenses': LinkSchema( + id: 8414127524801899185, + name: r'expenses', + target: r'Expense', + single: false, + linkName: r'category', + ) + }, + embeddedSchemas: {}, + getId: _categoryGetId, + getLinks: _categoryGetLinks, + attach: _categoryAttach, + version: '3.1.0+1', +); + +int _categoryEstimateSize( + Category object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + { + final value = object.colorCode; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + { + final value = object.name; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + return bytesCount; +} + +void _categorySerialize( + Category object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeLong(offsets[0], object.budgetCents); + writer.writeString(offsets[1], object.colorCode); + writer.writeLong(offsets[2], object.iconId); + writer.writeString(offsets[3], object.name); + writer.writeLong(offsets[4], object.priority); +} + +Category _categoryDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = Category( + budgetCents: reader.readLongOrNull(offsets[0]) ?? 0, + colorCode: reader.readStringOrNull(offsets[1]), + iconId: reader.readLongOrNull(offsets[2]) ?? 0, + name: reader.readStringOrNull(offsets[3]), + priority: reader.readLongOrNull(offsets[4]) ?? 0, + ); + object.id = id; + return object; +} + +P _categoryDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readLongOrNull(offset) ?? 0) as P; + case 1: + return (reader.readStringOrNull(offset)) as P; + case 2: + return (reader.readLongOrNull(offset) ?? 0) as P; + case 3: + return (reader.readStringOrNull(offset)) as P; + case 4: + return (reader.readLongOrNull(offset) ?? 0) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _categoryGetId(Category object) { + return object.id ?? Isar.autoIncrement; +} + +List> _categoryGetLinks(Category object) { + return [object.expenses]; +} + +void _categoryAttach(IsarCollection col, Id id, Category object) { + object.id = id; + object.expenses.attach(col, col.isar.collection(), r'expenses', id); +} + +extension CategoryQueryWhereSort on QueryBuilder { + QueryBuilder anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension CategoryQueryWhere on QueryBuilder { + QueryBuilder idEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: id, + upper: id, + )); + }); + } + + QueryBuilder idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder idGreaterThan(Id id, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder idLessThan(Id id, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + )); + }); + } +} + +extension CategoryQueryFilter + on QueryBuilder { + QueryBuilder budgetCentsEqualTo( + int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'budgetCents', + value: value, + )); + }); + } + + QueryBuilder + budgetCentsGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'budgetCents', + value: value, + )); + }); + } + + QueryBuilder budgetCentsLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'budgetCents', + value: value, + )); + }); + } + + QueryBuilder budgetCentsBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'budgetCents', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder colorCodeIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'colorCode', + )); + }); + } + + QueryBuilder colorCodeIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'colorCode', + )); + }); + } + + QueryBuilder colorCodeEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'colorCode', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'colorCode', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'colorCode', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'colorCode', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'colorCode', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'colorCode', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'colorCode', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'colorCode', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'colorCode', + value: '', + )); + }); + } + + QueryBuilder + colorCodeIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'colorCode', + value: '', + )); + }); + } + + QueryBuilder iconIdEqualTo( + int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'iconId', + value: value, + )); + }); + } + + QueryBuilder iconIdGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'iconId', + value: value, + )); + }); + } + + QueryBuilder iconIdLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'iconId', + value: value, + )); + }); + } + + QueryBuilder iconIdBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'iconId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder idIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'id', + )); + }); + } + + QueryBuilder idIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'id', + )); + }); + } + + QueryBuilder idEqualTo(Id? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idGreaterThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idLessThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idBetween( + Id? lower, + Id? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder nameIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'name', + )); + }); + } + + QueryBuilder nameIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'name', + )); + }); + } + + QueryBuilder nameEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'name', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'name', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'name', + value: '', + )); + }); + } + + QueryBuilder nameIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'name', + value: '', + )); + }); + } + + QueryBuilder priorityEqualTo( + int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'priority', + value: value, + )); + }); + } + + QueryBuilder priorityGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'priority', + value: value, + )); + }); + } + + QueryBuilder priorityLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'priority', + value: value, + )); + }); + } + + QueryBuilder priorityBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'priority', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } +} + +extension CategoryQueryObject + on QueryBuilder {} + +extension CategoryQueryLinks + on QueryBuilder { + QueryBuilder expenses( + FilterQuery q) { + return QueryBuilder.apply(this, (query) { + return query.link(q, r'expenses'); + }); + } + + QueryBuilder expensesLengthEqualTo( + int length) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'expenses', length, true, length, true); + }); + } + + QueryBuilder expensesIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'expenses', 0, true, 0, true); + }); + } + + QueryBuilder expensesIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'expenses', 0, false, 999999, true); + }); + } + + QueryBuilder + expensesLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'expenses', 0, true, length, include); + }); + } + + QueryBuilder + expensesLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'expenses', length, include, 999999, true); + }); + } + + QueryBuilder expensesLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength( + r'expenses', lower, includeLower, upper, includeUpper); + }); + } +} + +extension CategoryQuerySortBy on QueryBuilder { + QueryBuilder sortByBudgetCents() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'budgetCents', Sort.asc); + }); + } + + QueryBuilder sortByBudgetCentsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'budgetCents', Sort.desc); + }); + } + + QueryBuilder sortByColorCode() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'colorCode', Sort.asc); + }); + } + + QueryBuilder sortByColorCodeDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'colorCode', Sort.desc); + }); + } + + QueryBuilder sortByIconId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'iconId', Sort.asc); + }); + } + + QueryBuilder sortByIconIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'iconId', Sort.desc); + }); + } + + QueryBuilder sortByName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.asc); + }); + } + + QueryBuilder sortByNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.desc); + }); + } + + QueryBuilder sortByPriority() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'priority', Sort.asc); + }); + } + + QueryBuilder sortByPriorityDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'priority', Sort.desc); + }); + } +} + +extension CategoryQuerySortThenBy + on QueryBuilder { + QueryBuilder thenByBudgetCents() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'budgetCents', Sort.asc); + }); + } + + QueryBuilder thenByBudgetCentsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'budgetCents', Sort.desc); + }); + } + + QueryBuilder thenByColorCode() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'colorCode', Sort.asc); + }); + } + + QueryBuilder thenByColorCodeDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'colorCode', Sort.desc); + }); + } + + QueryBuilder thenByIconId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'iconId', Sort.asc); + }); + } + + QueryBuilder thenByIconIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'iconId', Sort.desc); + }); + } + + QueryBuilder thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder thenByName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.asc); + }); + } + + QueryBuilder thenByNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.desc); + }); + } + + QueryBuilder thenByPriority() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'priority', Sort.asc); + }); + } + + QueryBuilder thenByPriorityDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'priority', Sort.desc); + }); + } +} + +extension CategoryQueryWhereDistinct + on QueryBuilder { + QueryBuilder distinctByBudgetCents() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'budgetCents'); + }); + } + + QueryBuilder distinctByColorCode( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'colorCode', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByIconId() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'iconId'); + }); + } + + QueryBuilder distinctByName( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'name', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByPriority() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'priority'); + }); + } +} + +extension CategoryQueryProperty + on QueryBuilder { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder budgetCentsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'budgetCents'); + }); + } + + QueryBuilder colorCodeProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'colorCode'); + }); + } + + QueryBuilder iconIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'iconId'); + }); + } + + QueryBuilder nameProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'name'); + }); + } + + QueryBuilder priorityProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'priority'); + }); + } +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Category _$CategoryFromJson(Map json) => Category( + name: json['name'] as String?, + colorCode: json['colorCode'] as String?, + iconId: json['iconId'] as int? ?? 0, + priority: json['priority'] as int? ?? 0, + budgetCents: json['budgetCents'] as int? ?? 0, + ) + ..id = json['id'] as int? + ..amount = json['amount'] as int; + +Map _$CategoryToJson(Category instance) => { + 'id': instance.id, + 'name': instance.name, + 'colorCode': instance.colorCode, + 'iconId': instance.iconId, + 'priority': instance.priority, + 'budgetCents': instance.budgetCents, + 'amount': instance.amount, + }; diff --git a/expense_manager/lib/models/expense.dart b/expense_manager/lib/models/expense.dart new file mode 100644 index 00000000..6f78e87d --- /dev/null +++ b/expense_manager/lib/models/expense.dart @@ -0,0 +1,55 @@ +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/index.dart'; +import 'package:expense_manager/models/serializable.dart'; +import 'package:expense_manager/models/tag.dart'; +import 'package:expense_manager/models/transaction.dart'; +import 'package:isar/isar.dart'; +import 'category.dart'; + +part 'expense.g.dart'; + +@collection +@JsonSerializable() +class Expense extends Transaction implements Serializable { + final category = IsarLink(); + final tags = IsarLinks(); + + Expense({ + super.amountCents, + super.note, + super.isAdHoc, + super.weekDay, + super.accountId, + }); + + bool get isNew => id == null; + + static Future fromJson(json) async { + json['attachments'] = json['attachments'] ?? []; + + final obj = _$ExpenseFromJson(json); + if (json['category'] != null) { + final cat = await Category.fromJson(json['category']); + await kCategoryRepo.save(cat); + obj.category.value = cat; + } + for (var element in json['tags'] ?? []) { + final tag = await Tag.fromJson(element); + await kTagRepo.save(tag); + obj.tags.add(tag); + } + return obj; + } + + @override + Map toJson({bool includeLinks = false}) { + final json = _$ExpenseToJson(this); + if (includeLinks) { + category.loadSync(); + tags.loadSync(); + json['category'] = category.value?.toJson(); + json['tags'] = tags.map((e) => e.toJson()).toList(); + } + return json; + } +} diff --git a/expense_manager/lib/models/expense.g.dart b/expense_manager/lib/models/expense.g.dart new file mode 100644 index 00000000..c9ee4c71 --- /dev/null +++ b/expense_manager/lib/models/expense.g.dart @@ -0,0 +1,1069 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'expense.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetExpenseCollection on Isar { + IsarCollection get expenses => this.collection(); +} + +const ExpenseSchema = CollectionSchema( + name: r'Expense', + id: -4604318666888508206, + properties: { + r'accountId': PropertySchema( + id: 0, + name: r'accountId', + type: IsarType.long, + ), + r'amountCents': PropertySchema( + id: 1, + name: r'amountCents', + type: IsarType.long, + ), + r'date': PropertySchema( + id: 2, + name: r'date', + type: IsarType.dateTime, + ), + r'isAdHoc': PropertySchema( + id: 3, + name: r'isAdHoc', + type: IsarType.bool, + ), + r'isNew': PropertySchema( + id: 4, + name: r'isNew', + type: IsarType.bool, + ), + r'note': PropertySchema( + id: 5, + name: r'note', + type: IsarType.string, + ), + r'weekDay': PropertySchema( + id: 6, + name: r'weekDay', + type: IsarType.long, + ) + }, + estimateSize: _expenseEstimateSize, + serialize: _expenseSerialize, + deserialize: _expenseDeserialize, + deserializeProp: _expenseDeserializeProp, + idName: r'id', + indexes: {}, + links: { + r'category': LinkSchema( + id: 6933751262338072598, + name: r'category', + target: r'Category', + single: true, + ), + r'tags': LinkSchema( + id: 6058914787170933232, + name: r'tags', + target: r'Tag', + single: false, + ) + }, + embeddedSchemas: {}, + getId: _expenseGetId, + getLinks: _expenseGetLinks, + attach: _expenseAttach, + version: '3.1.0+1', +); + +int _expenseEstimateSize( + Expense object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + bytesCount += 3 + object.note.length * 3; + return bytesCount; +} + +void _expenseSerialize( + Expense object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeLong(offsets[0], object.accountId); + writer.writeLong(offsets[1], object.amountCents); + writer.writeDateTime(offsets[2], object.date); + writer.writeBool(offsets[3], object.isAdHoc); + writer.writeBool(offsets[4], object.isNew); + writer.writeString(offsets[5], object.note); + writer.writeLong(offsets[6], object.weekDay); +} + +Expense _expenseDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = Expense( + accountId: reader.readLongOrNull(offsets[0]), + amountCents: reader.readLongOrNull(offsets[1]) ?? 0, + isAdHoc: reader.readBoolOrNull(offsets[3]) ?? false, + note: reader.readStringOrNull(offsets[5]) ?? "", + weekDay: reader.readLongOrNull(offsets[6]) ?? 0, + ); + object.date = reader.readDateTime(offsets[2]); + object.id = id; + return object; +} + +P _expenseDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readLongOrNull(offset)) as P; + case 1: + return (reader.readLongOrNull(offset) ?? 0) as P; + case 2: + return (reader.readDateTime(offset)) as P; + case 3: + return (reader.readBoolOrNull(offset) ?? false) as P; + case 4: + return (reader.readBool(offset)) as P; + case 5: + return (reader.readStringOrNull(offset) ?? "") as P; + case 6: + return (reader.readLongOrNull(offset) ?? 0) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _expenseGetId(Expense object) { + return object.id ?? Isar.autoIncrement; +} + +List> _expenseGetLinks(Expense object) { + return [object.category, object.tags]; +} + +void _expenseAttach(IsarCollection col, Id id, Expense object) { + object.id = id; + object.category.attach(col, col.isar.collection(), r'category', id); + object.tags.attach(col, col.isar.collection(), r'tags', id); +} + +extension ExpenseQueryWhereSort on QueryBuilder { + QueryBuilder anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension ExpenseQueryWhere on QueryBuilder { + QueryBuilder idEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: id, + upper: id, + )); + }); + } + + QueryBuilder idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder idGreaterThan(Id id, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder idLessThan(Id id, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + )); + }); + } +} + +extension ExpenseQueryFilter + on QueryBuilder { + QueryBuilder accountIdIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'accountId', + )); + }); + } + + QueryBuilder accountIdIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'accountId', + )); + }); + } + + QueryBuilder accountIdEqualTo( + int? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'accountId', + value: value, + )); + }); + } + + QueryBuilder accountIdGreaterThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'accountId', + value: value, + )); + }); + } + + QueryBuilder accountIdLessThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'accountId', + value: value, + )); + }); + } + + QueryBuilder accountIdBetween( + int? lower, + int? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'accountId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder amountCentsEqualTo( + int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'amountCents', + value: value, + )); + }); + } + + QueryBuilder amountCentsGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'amountCents', + value: value, + )); + }); + } + + QueryBuilder amountCentsLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'amountCents', + value: value, + )); + }); + } + + QueryBuilder amountCentsBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'amountCents', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder dateEqualTo( + DateTime value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'date', + value: value, + )); + }); + } + + QueryBuilder dateGreaterThan( + DateTime value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'date', + value: value, + )); + }); + } + + QueryBuilder dateLessThan( + DateTime value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'date', + value: value, + )); + }); + } + + QueryBuilder dateBetween( + DateTime lower, + DateTime upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'date', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder idIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'id', + )); + }); + } + + QueryBuilder idIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'id', + )); + }); + } + + QueryBuilder idEqualTo(Id? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idGreaterThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idLessThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idBetween( + Id? lower, + Id? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder isAdHocEqualTo( + bool value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'isAdHoc', + value: value, + )); + }); + } + + QueryBuilder isNewEqualTo( + bool value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'isNew', + value: value, + )); + }); + } + + QueryBuilder noteEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'note', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'note', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'note', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'note', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'note', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'note', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'note', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'note', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'note', + value: '', + )); + }); + } + + QueryBuilder noteIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'note', + value: '', + )); + }); + } + + QueryBuilder weekDayEqualTo( + int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'weekDay', + value: value, + )); + }); + } + + QueryBuilder weekDayGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'weekDay', + value: value, + )); + }); + } + + QueryBuilder weekDayLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'weekDay', + value: value, + )); + }); + } + + QueryBuilder weekDayBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'weekDay', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } +} + +extension ExpenseQueryObject + on QueryBuilder {} + +extension ExpenseQueryLinks + on QueryBuilder { + QueryBuilder category( + FilterQuery q) { + return QueryBuilder.apply(this, (query) { + return query.link(q, r'category'); + }); + } + + QueryBuilder categoryIsNull() { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'category', 0, true, 0, true); + }); + } + + QueryBuilder tags( + FilterQuery q) { + return QueryBuilder.apply(this, (query) { + return query.link(q, r'tags'); + }); + } + + QueryBuilder tagsLengthEqualTo( + int length) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'tags', length, true, length, true); + }); + } + + QueryBuilder tagsIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'tags', 0, true, 0, true); + }); + } + + QueryBuilder tagsIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'tags', 0, false, 999999, true); + }); + } + + QueryBuilder tagsLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'tags', 0, true, length, include); + }); + } + + QueryBuilder tagsLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'tags', length, include, 999999, true); + }); + } + + QueryBuilder tagsLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength( + r'tags', lower, includeLower, upper, includeUpper); + }); + } +} + +extension ExpenseQuerySortBy on QueryBuilder { + QueryBuilder sortByAccountId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'accountId', Sort.asc); + }); + } + + QueryBuilder sortByAccountIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'accountId', Sort.desc); + }); + } + + QueryBuilder sortByAmountCents() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'amountCents', Sort.asc); + }); + } + + QueryBuilder sortByAmountCentsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'amountCents', Sort.desc); + }); + } + + QueryBuilder sortByDate() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'date', Sort.asc); + }); + } + + QueryBuilder sortByDateDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'date', Sort.desc); + }); + } + + QueryBuilder sortByIsAdHoc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isAdHoc', Sort.asc); + }); + } + + QueryBuilder sortByIsAdHocDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isAdHoc', Sort.desc); + }); + } + + QueryBuilder sortByIsNew() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isNew', Sort.asc); + }); + } + + QueryBuilder sortByIsNewDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isNew', Sort.desc); + }); + } + + QueryBuilder sortByNote() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'note', Sort.asc); + }); + } + + QueryBuilder sortByNoteDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'note', Sort.desc); + }); + } + + QueryBuilder sortByWeekDay() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'weekDay', Sort.asc); + }); + } + + QueryBuilder sortByWeekDayDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'weekDay', Sort.desc); + }); + } +} + +extension ExpenseQuerySortThenBy + on QueryBuilder { + QueryBuilder thenByAccountId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'accountId', Sort.asc); + }); + } + + QueryBuilder thenByAccountIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'accountId', Sort.desc); + }); + } + + QueryBuilder thenByAmountCents() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'amountCents', Sort.asc); + }); + } + + QueryBuilder thenByAmountCentsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'amountCents', Sort.desc); + }); + } + + QueryBuilder thenByDate() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'date', Sort.asc); + }); + } + + QueryBuilder thenByDateDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'date', Sort.desc); + }); + } + + QueryBuilder thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder thenByIsAdHoc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isAdHoc', Sort.asc); + }); + } + + QueryBuilder thenByIsAdHocDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isAdHoc', Sort.desc); + }); + } + + QueryBuilder thenByIsNew() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isNew', Sort.asc); + }); + } + + QueryBuilder thenByIsNewDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isNew', Sort.desc); + }); + } + + QueryBuilder thenByNote() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'note', Sort.asc); + }); + } + + QueryBuilder thenByNoteDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'note', Sort.desc); + }); + } + + QueryBuilder thenByWeekDay() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'weekDay', Sort.asc); + }); + } + + QueryBuilder thenByWeekDayDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'weekDay', Sort.desc); + }); + } +} + +extension ExpenseQueryWhereDistinct + on QueryBuilder { + QueryBuilder distinctByAccountId() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'accountId'); + }); + } + + QueryBuilder distinctByAmountCents() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'amountCents'); + }); + } + + QueryBuilder distinctByDate() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'date'); + }); + } + + QueryBuilder distinctByIsAdHoc() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'isAdHoc'); + }); + } + + QueryBuilder distinctByIsNew() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'isNew'); + }); + } + + QueryBuilder distinctByNote( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'note', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByWeekDay() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'weekDay'); + }); + } +} + +extension ExpenseQueryProperty + on QueryBuilder { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder accountIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'accountId'); + }); + } + + QueryBuilder amountCentsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'amountCents'); + }); + } + + QueryBuilder dateProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'date'); + }); + } + + QueryBuilder isAdHocProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'isAdHoc'); + }); + } + + QueryBuilder isNewProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'isNew'); + }); + } + + QueryBuilder noteProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'note'); + }); + } + + QueryBuilder weekDayProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'weekDay'); + }); + } +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Expense _$ExpenseFromJson(Map json) => Expense( + amountCents: json['amountCents'] as int? ?? 0, + note: json['note'] as String? ?? "", + isAdHoc: json['isAdHoc'] as bool? ?? false, + weekDay: json['weekDay'] as int? ?? 0, + accountId: json['accountId'] as int?, + ) + ..id = json['id'] as int? + ..date = DateTime.parse(json['date'] as String); + +Map _$ExpenseToJson(Expense instance) => { + 'id': instance.id, + 'amountCents': instance.amountCents, + 'note': instance.note, + 'date': instance.date.toIso8601String(), + 'weekDay': instance.weekDay, + 'isAdHoc': instance.isAdHoc, + 'accountId': instance.accountId, + }; diff --git a/expense_manager/lib/models/income.dart b/expense_manager/lib/models/income.dart new file mode 100644 index 00000000..c4bb6581 --- /dev/null +++ b/expense_manager/lib/models/income.dart @@ -0,0 +1,27 @@ +import 'package:expense_manager/index.dart'; +import 'package:expense_manager/models/serializable.dart'; +import 'package:expense_manager/models/transaction.dart'; +import 'package:isar/isar.dart'; + +part 'income.g.dart'; + +@collection +@JsonSerializable() +class Income extends Transaction implements Serializable { + Income({ + super.amountCents, + super.note, + super.isAdHoc, + super.accountId, + }); + + factory Income.fromJson(json) { + final obj = _$IncomeFromJson(json); + return obj; + } + + @override + Map toJson({bool includeLinks = false}) { + return _$IncomeToJson(this); + } +} diff --git a/expense_manager/lib/models/income.g.dart b/expense_manager/lib/models/income.g.dart new file mode 100644 index 00000000..73340bba --- /dev/null +++ b/expense_manager/lib/models/income.g.dart @@ -0,0 +1,924 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'income.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetIncomeCollection on Isar { + IsarCollection get incomes => this.collection(); +} + +const IncomeSchema = CollectionSchema( + name: r'Income', + id: -267602993667790363, + properties: { + r'accountId': PropertySchema( + id: 0, + name: r'accountId', + type: IsarType.long, + ), + r'amountCents': PropertySchema( + id: 1, + name: r'amountCents', + type: IsarType.long, + ), + r'date': PropertySchema( + id: 2, + name: r'date', + type: IsarType.dateTime, + ), + r'isAdHoc': PropertySchema( + id: 3, + name: r'isAdHoc', + type: IsarType.bool, + ), + r'note': PropertySchema( + id: 4, + name: r'note', + type: IsarType.string, + ), + r'weekDay': PropertySchema( + id: 5, + name: r'weekDay', + type: IsarType.long, + ) + }, + estimateSize: _incomeEstimateSize, + serialize: _incomeSerialize, + deserialize: _incomeDeserialize, + deserializeProp: _incomeDeserializeProp, + idName: r'id', + indexes: {}, + links: {}, + embeddedSchemas: {}, + getId: _incomeGetId, + getLinks: _incomeGetLinks, + attach: _incomeAttach, + version: '3.1.0+1', +); + +int _incomeEstimateSize( + Income object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + bytesCount += 3 + object.note.length * 3; + return bytesCount; +} + +void _incomeSerialize( + Income object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeLong(offsets[0], object.accountId); + writer.writeLong(offsets[1], object.amountCents); + writer.writeDateTime(offsets[2], object.date); + writer.writeBool(offsets[3], object.isAdHoc); + writer.writeString(offsets[4], object.note); + writer.writeLong(offsets[5], object.weekDay); +} + +Income _incomeDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = Income( + accountId: reader.readLongOrNull(offsets[0]), + amountCents: reader.readLongOrNull(offsets[1]) ?? 0, + isAdHoc: reader.readBoolOrNull(offsets[3]) ?? false, + note: reader.readStringOrNull(offsets[4]) ?? "", + ); + object.date = reader.readDateTime(offsets[2]); + object.id = id; + object.weekDay = reader.readLong(offsets[5]); + return object; +} + +P _incomeDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readLongOrNull(offset)) as P; + case 1: + return (reader.readLongOrNull(offset) ?? 0) as P; + case 2: + return (reader.readDateTime(offset)) as P; + case 3: + return (reader.readBoolOrNull(offset) ?? false) as P; + case 4: + return (reader.readStringOrNull(offset) ?? "") as P; + case 5: + return (reader.readLong(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _incomeGetId(Income object) { + return object.id ?? Isar.autoIncrement; +} + +List> _incomeGetLinks(Income object) { + return []; +} + +void _incomeAttach(IsarCollection col, Id id, Income object) { + object.id = id; +} + +extension IncomeQueryWhereSort on QueryBuilder { + QueryBuilder anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension IncomeQueryWhere on QueryBuilder { + QueryBuilder idEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: id, + upper: id, + )); + }); + } + + QueryBuilder idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder idGreaterThan(Id id, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder idLessThan(Id id, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + )); + }); + } +} + +extension IncomeQueryFilter on QueryBuilder { + QueryBuilder accountIdIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'accountId', + )); + }); + } + + QueryBuilder accountIdIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'accountId', + )); + }); + } + + QueryBuilder accountIdEqualTo( + int? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'accountId', + value: value, + )); + }); + } + + QueryBuilder accountIdGreaterThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'accountId', + value: value, + )); + }); + } + + QueryBuilder accountIdLessThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'accountId', + value: value, + )); + }); + } + + QueryBuilder accountIdBetween( + int? lower, + int? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'accountId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder amountCentsEqualTo( + int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'amountCents', + value: value, + )); + }); + } + + QueryBuilder amountCentsGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'amountCents', + value: value, + )); + }); + } + + QueryBuilder amountCentsLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'amountCents', + value: value, + )); + }); + } + + QueryBuilder amountCentsBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'amountCents', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder dateEqualTo( + DateTime value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'date', + value: value, + )); + }); + } + + QueryBuilder dateGreaterThan( + DateTime value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'date', + value: value, + )); + }); + } + + QueryBuilder dateLessThan( + DateTime value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'date', + value: value, + )); + }); + } + + QueryBuilder dateBetween( + DateTime lower, + DateTime upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'date', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder idIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'id', + )); + }); + } + + QueryBuilder idIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'id', + )); + }); + } + + QueryBuilder idEqualTo(Id? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idGreaterThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idLessThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idBetween( + Id? lower, + Id? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder isAdHocEqualTo( + bool value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'isAdHoc', + value: value, + )); + }); + } + + QueryBuilder noteEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'note', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'note', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'note', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'note', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'note', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'note', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteContains(String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'note', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'note', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder noteIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'note', + value: '', + )); + }); + } + + QueryBuilder noteIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'note', + value: '', + )); + }); + } + + QueryBuilder weekDayEqualTo( + int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'weekDay', + value: value, + )); + }); + } + + QueryBuilder weekDayGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'weekDay', + value: value, + )); + }); + } + + QueryBuilder weekDayLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'weekDay', + value: value, + )); + }); + } + + QueryBuilder weekDayBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'weekDay', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } +} + +extension IncomeQueryObject on QueryBuilder {} + +extension IncomeQueryLinks on QueryBuilder {} + +extension IncomeQuerySortBy on QueryBuilder { + QueryBuilder sortByAccountId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'accountId', Sort.asc); + }); + } + + QueryBuilder sortByAccountIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'accountId', Sort.desc); + }); + } + + QueryBuilder sortByAmountCents() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'amountCents', Sort.asc); + }); + } + + QueryBuilder sortByAmountCentsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'amountCents', Sort.desc); + }); + } + + QueryBuilder sortByDate() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'date', Sort.asc); + }); + } + + QueryBuilder sortByDateDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'date', Sort.desc); + }); + } + + QueryBuilder sortByIsAdHoc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isAdHoc', Sort.asc); + }); + } + + QueryBuilder sortByIsAdHocDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isAdHoc', Sort.desc); + }); + } + + QueryBuilder sortByNote() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'note', Sort.asc); + }); + } + + QueryBuilder sortByNoteDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'note', Sort.desc); + }); + } + + QueryBuilder sortByWeekDay() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'weekDay', Sort.asc); + }); + } + + QueryBuilder sortByWeekDayDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'weekDay', Sort.desc); + }); + } +} + +extension IncomeQuerySortThenBy on QueryBuilder { + QueryBuilder thenByAccountId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'accountId', Sort.asc); + }); + } + + QueryBuilder thenByAccountIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'accountId', Sort.desc); + }); + } + + QueryBuilder thenByAmountCents() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'amountCents', Sort.asc); + }); + } + + QueryBuilder thenByAmountCentsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'amountCents', Sort.desc); + }); + } + + QueryBuilder thenByDate() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'date', Sort.asc); + }); + } + + QueryBuilder thenByDateDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'date', Sort.desc); + }); + } + + QueryBuilder thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder thenByIsAdHoc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isAdHoc', Sort.asc); + }); + } + + QueryBuilder thenByIsAdHocDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isAdHoc', Sort.desc); + }); + } + + QueryBuilder thenByNote() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'note', Sort.asc); + }); + } + + QueryBuilder thenByNoteDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'note', Sort.desc); + }); + } + + QueryBuilder thenByWeekDay() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'weekDay', Sort.asc); + }); + } + + QueryBuilder thenByWeekDayDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'weekDay', Sort.desc); + }); + } +} + +extension IncomeQueryWhereDistinct on QueryBuilder { + QueryBuilder distinctByAccountId() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'accountId'); + }); + } + + QueryBuilder distinctByAmountCents() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'amountCents'); + }); + } + + QueryBuilder distinctByDate() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'date'); + }); + } + + QueryBuilder distinctByIsAdHoc() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'isAdHoc'); + }); + } + + QueryBuilder distinctByNote( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'note', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByWeekDay() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'weekDay'); + }); + } +} + +extension IncomeQueryProperty on QueryBuilder { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder accountIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'accountId'); + }); + } + + QueryBuilder amountCentsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'amountCents'); + }); + } + + QueryBuilder dateProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'date'); + }); + } + + QueryBuilder isAdHocProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'isAdHoc'); + }); + } + + QueryBuilder noteProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'note'); + }); + } + + QueryBuilder weekDayProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'weekDay'); + }); + } +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Income _$IncomeFromJson(Map json) => Income( + amountCents: json['amountCents'] as int? ?? 0, + note: json['note'] as String? ?? "", + isAdHoc: json['isAdHoc'] as bool? ?? false, + accountId: json['accountId'] as int?, + ) + ..id = json['id'] as int? + ..date = DateTime.parse(json['date'] as String) + ..weekDay = json['weekDay'] as int; + +Map _$IncomeToJson(Income instance) => { + 'id': instance.id, + 'amountCents': instance.amountCents, + 'note': instance.note, + 'date': instance.date.toIso8601String(), + 'weekDay': instance.weekDay, + 'isAdHoc': instance.isAdHoc, + 'accountId': instance.accountId, + }; diff --git a/expense_manager/lib/models/serializable.dart b/expense_manager/lib/models/serializable.dart new file mode 100644 index 00000000..8dd2f026 --- /dev/null +++ b/expense_manager/lib/models/serializable.dart @@ -0,0 +1,3 @@ +abstract class Serializable { + Map toJson({bool includeLinks = false}); +} diff --git a/expense_manager/lib/models/tag.dart b/expense_manager/lib/models/tag.dart new file mode 100644 index 00000000..8dad3039 --- /dev/null +++ b/expense_manager/lib/models/tag.dart @@ -0,0 +1,67 @@ +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/index.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:expense_manager/models/serializable.dart'; +import 'package:isar/isar.dart'; + +part 'tag.g.dart'; + +@collection +@JsonSerializable() +class Tag implements Serializable { + Id? id; + late String name; + String? colorCode; + + final expenses = IsarLinks(); + + @ignore + bool isFromRemark = false; + + @ignore + int amount = 0; + + @ignore + int get totalAmountCents => expenses.toList().sumAmounts(); + + Tag(); + + Tag.create({ + required this.name, + this.id, + this.colorCode, + this.isFromRemark = false, + }); + + static Future fromJson(json, {bool includeLinks = true}) async { + final obj = _$TagFromJson(json); + + if (includeLinks) { + for (var element in json['expenses'] ?? []) { + final expense = await Expense.fromJson(element); + await kExpenseRepo.save(expense); + obj.expenses.add(expense); + } + } + return obj; + } + + Future> expensesBetweenDates( + {DateTime? start, DateTime? end}) async { + if (start == null || end == null) { + return (await expenses.filter().findAll()).toList(); + } + return expenses.filter().dateBetween(start, end).findAll(); + } + + @override + Map toJson({bool includeLinks = false}) { + final json = _$TagToJson(this); + if (includeLinks) { + expenses.loadSync(); + json['expenses'] = expenses.map((e) => e.toJson()).toList(); + } + return json; + } +} diff --git a/expense_manager/lib/models/tag.g.dart b/expense_manager/lib/models/tag.g.dart new file mode 100644 index 00000000..717f2591 --- /dev/null +++ b/expense_manager/lib/models/tag.g.dart @@ -0,0 +1,714 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tag.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetTagCollection on Isar { + IsarCollection get tags => this.collection(); +} + +const TagSchema = CollectionSchema( + name: r'Tag', + id: 4007045862261149568, + properties: { + r'colorCode': PropertySchema( + id: 0, + name: r'colorCode', + type: IsarType.string, + ), + r'name': PropertySchema( + id: 1, + name: r'name', + type: IsarType.string, + ) + }, + estimateSize: _tagEstimateSize, + serialize: _tagSerialize, + deserialize: _tagDeserialize, + deserializeProp: _tagDeserializeProp, + idName: r'id', + indexes: {}, + links: { + r'expenses': LinkSchema( + id: -638182423922439003, + name: r'expenses', + target: r'Expense', + single: false, + ) + }, + embeddedSchemas: {}, + getId: _tagGetId, + getLinks: _tagGetLinks, + attach: _tagAttach, + version: '3.1.0+1', +); + +int _tagEstimateSize( + Tag object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + { + final value = object.colorCode; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + bytesCount += 3 + object.name.length * 3; + return bytesCount; +} + +void _tagSerialize( + Tag object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.colorCode); + writer.writeString(offsets[1], object.name); +} + +Tag _tagDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = Tag(); + object.colorCode = reader.readStringOrNull(offsets[0]); + object.id = id; + object.name = reader.readString(offsets[1]); + return object; +} + +P _tagDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readStringOrNull(offset)) as P; + case 1: + return (reader.readString(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _tagGetId(Tag object) { + return object.id ?? Isar.autoIncrement; +} + +List> _tagGetLinks(Tag object) { + return [object.expenses]; +} + +void _tagAttach(IsarCollection col, Id id, Tag object) { + object.id = id; + object.expenses.attach(col, col.isar.collection(), r'expenses', id); +} + +extension TagQueryWhereSort on QueryBuilder { + QueryBuilder anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension TagQueryWhere on QueryBuilder { + QueryBuilder idEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: id, + upper: id, + )); + }); + } + + QueryBuilder idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder idGreaterThan(Id id, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder idLessThan(Id id, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + )); + }); + } +} + +extension TagQueryFilter on QueryBuilder { + QueryBuilder colorCodeIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'colorCode', + )); + }); + } + + QueryBuilder colorCodeIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'colorCode', + )); + }); + } + + QueryBuilder colorCodeEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'colorCode', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'colorCode', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'colorCode', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'colorCode', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'colorCode', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'colorCode', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeContains(String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'colorCode', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeMatches(String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'colorCode', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder colorCodeIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'colorCode', + value: '', + )); + }); + } + + QueryBuilder colorCodeIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'colorCode', + value: '', + )); + }); + } + + QueryBuilder idIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'id', + )); + }); + } + + QueryBuilder idIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'id', + )); + }); + } + + QueryBuilder idEqualTo(Id? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idGreaterThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idLessThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idBetween( + Id? lower, + Id? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder nameEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'name', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameContains(String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameMatches(String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'name', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'name', + value: '', + )); + }); + } + + QueryBuilder nameIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'name', + value: '', + )); + }); + } +} + +extension TagQueryObject on QueryBuilder {} + +extension TagQueryLinks on QueryBuilder { + QueryBuilder expenses( + FilterQuery q) { + return QueryBuilder.apply(this, (query) { + return query.link(q, r'expenses'); + }); + } + + QueryBuilder expensesLengthEqualTo( + int length) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'expenses', length, true, length, true); + }); + } + + QueryBuilder expensesIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'expenses', 0, true, 0, true); + }); + } + + QueryBuilder expensesIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'expenses', 0, false, 999999, true); + }); + } + + QueryBuilder expensesLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'expenses', 0, true, length, include); + }); + } + + QueryBuilder expensesLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'expenses', length, include, 999999, true); + }); + } + + QueryBuilder expensesLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength( + r'expenses', lower, includeLower, upper, includeUpper); + }); + } +} + +extension TagQuerySortBy on QueryBuilder { + QueryBuilder sortByColorCode() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'colorCode', Sort.asc); + }); + } + + QueryBuilder sortByColorCodeDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'colorCode', Sort.desc); + }); + } + + QueryBuilder sortByName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.asc); + }); + } + + QueryBuilder sortByNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.desc); + }); + } +} + +extension TagQuerySortThenBy on QueryBuilder { + QueryBuilder thenByColorCode() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'colorCode', Sort.asc); + }); + } + + QueryBuilder thenByColorCodeDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'colorCode', Sort.desc); + }); + } + + QueryBuilder thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder thenByName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.asc); + }); + } + + QueryBuilder thenByNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.desc); + }); + } +} + +extension TagQueryWhereDistinct on QueryBuilder { + QueryBuilder distinctByColorCode( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'colorCode', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByName( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'name', caseSensitive: caseSensitive); + }); + } +} + +extension TagQueryProperty on QueryBuilder { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder colorCodeProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'colorCode'); + }); + } + + QueryBuilder nameProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'name'); + }); + } +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Tag _$TagFromJson(Map json) => Tag() + ..id = json['id'] as int? + ..name = json['name'] as String + ..colorCode = json['colorCode'] as String? + ..isFromRemark = json['isFromRemark'] as bool + ..amount = json['amount'] as int; + +Map _$TagToJson(Tag instance) => { + 'id': instance.id, + 'name': instance.name, + 'colorCode': instance.colorCode, + 'isFromRemark': instance.isFromRemark, + 'amount': instance.amount, + }; diff --git a/expense_manager/lib/models/transaction.dart b/expense_manager/lib/models/transaction.dart new file mode 100644 index 00000000..dd4b3746 --- /dev/null +++ b/expense_manager/lib/models/transaction.dart @@ -0,0 +1,32 @@ +import 'package:expense_manager/index.dart'; +import 'package:isar/isar.dart'; + +abstract class Transaction { + Id? id; + int amountCents = 0; + String note = ""; + DateTime date = DateTime.now(); + int weekDay = 0; + bool isAdHoc = false; + int? accountId; + + @ignore + @JsonKey(includeFromJson: false, includeToJson: false) + int year = 0; + + @ignore + @JsonKey(includeFromJson: false, includeToJson: false) + int month = 0; + + @ignore + @JsonKey(includeFromJson: false, includeToJson: false) + double get amount => amountCents / 100; + + Transaction({ + this.amountCents = 0, + this.note = "", + this.isAdHoc = false, + this.weekDay = 0, + this.accountId, + }); +} diff --git a/expense_manager/lib/my_app.dart b/expense_manager/lib/my_app.dart new file mode 100644 index 00000000..149a0585 --- /dev/null +++ b/expense_manager/lib/my_app.dart @@ -0,0 +1,21 @@ +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/misc/custom_theme_data.dart'; +import 'package:expense_manager/pages/main_page.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class MyApp extends StatelessWidget { + final Widget initialPage; + + const MyApp({super.key, required this.initialPage}); + + @override + Widget build(BuildContext context) { + return GetMaterialApp( + title: kAppName, + theme: CustomThemeData().theme, + darkTheme: CustomThemeData().darkTheme, + home: MainPage(), + ); + } +} diff --git a/expense_manager/lib/pages/add_expense_page.dart b/expense_manager/lib/pages/add_expense_page.dart new file mode 100644 index 00000000..76bb2740 --- /dev/null +++ b/expense_manager/lib/pages/add_expense_page.dart @@ -0,0 +1,182 @@ +import 'package:expense_manager/components/action_delete_icon.dart'; +import 'package:expense_manager/components/amount_input_view.dart'; +import 'package:expense_manager/components/category_editor_dialog.dart'; +import 'package:expense_manager/components/date_field.dart'; +import 'package:expense_manager/components/section_view.dart'; +import 'package:expense_manager/components/category_radio_button.dart'; +import 'package:expense_manager/components/tag_chip.dart'; +import 'package:expense_manager/components/tag_input_dialog.dart'; +import 'package:expense_manager/controllers/add_expense_controller.dart'; +import 'package:expense_manager/components/button/custom_button.dart'; +import 'package:expense_manager/components/form/custom_text_field.dart'; +import 'package:expense_manager/components/misc/bottom_bar_container.dart'; +import 'package:expense_manager/components/misc/custom_scaffold.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class AddExpensePage extends StatelessWidget { + final Expense? expense; + + const AddExpensePage({ + super.key, + this.expense, + }); + + AddExpenseController get _controller => Get.find(); + + @override + Widget build(BuildContext context) { + return CustomScaffold( + title: 'Expense', + appBarActions: [ + ActionDeleteIcon(onTap: () => _showDeletionDialog(context)), + ], + bottomNavigationBar: BottomBarContainer( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + child: CustomButton( + 'Save', + onPressed: () => _saveExpense(context), + ), + ), + body: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(16), + child: GetBuilder( + init: AddExpenseController(expense ?? Expense()), + builder: (controller) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // amount + AmountInputView( + initialAmount: controller.expense.amountCents, + onChange: (int amount) => + _controller.updateAmount(amount), + ), + + Divider(), + + // categories + SectionView( + title: 'CATEGORY', + actionButton: IconButton( + icon: Icon(Icons.add, color: Colors.green), + onPressed: () => _showCategoryDialog(context), + ), + child: Column( + children: [ + ...controller.categories.map((category) { + return CategoryRadioButton( + title: category.name ?? '', + color: + hexToColor(category.colorCode ?? '#000000'), + value: category.id!, + currentValue: + controller.expense.category.value?.id ?? + controller.categories.first.id!, + onChange: (categoryId) => + controller.setCategory(categoryId), + ); + }), + ], + ), + ), + + SizedBox(height: 16), + + // date & notes + DateField( + initialDate: controller.expense.date, + onChange: (date) => _controller.updateDate(date), + ), + SizedBox(height: 16), + CustomTextField( + label: 'Notes', + useCustomLabel: true, + isDense: true, + controller: controller.noteController, + border: OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey)), + ), + + SizedBox(height: 32), + + // more info + SectionView( + title: 'MORE INFO', + child: Column( + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: Text( + 'Tags', + style: TextStyle(fontSize: 15), + ), + title: Wrap( + children: controller.tags.map((tag) { + return TagChip( + text: tag.name, + onDeleted: () => + controller.removeTagFromExpense(tag), + ); + }).toList(), + ), + trailing: IconButton( + onPressed: () => _showTagInputDialog(context), + icon: Icon(Icons.add, color: Colors.blue), + ), + ), + ], + ), + ), + ], + ); + }), + ), + ), + ); + } + + void _saveExpense(BuildContext context) { + _controller.saveExpense(); + goBack(context); + } + + void _showCategoryDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) { + return CategoryEditorDialog( + onComplete: () => _controller.loadCategories(), + ); + }, + ); + } + + void _showDeletionDialog(BuildContext context) { + showDecisionDialog( + context, + title: "Delete this expense?", + onPositivePressed: () { + _controller.deleteExpense().then((value) => goBack(context)); + }, + ); + } + + void _showTagInputDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) { + return TagInputDialog( + tags: _controller.tags.toList(), + onChange: (tags) { + _controller.updateTags(tags); + }, + ); + }, + ); + } +} diff --git a/expense_manager/lib/pages/add_income_page.dart b/expense_manager/lib/pages/add_income_page.dart new file mode 100644 index 00000000..daa7e385 --- /dev/null +++ b/expense_manager/lib/pages/add_income_page.dart @@ -0,0 +1,89 @@ +import 'package:expense_manager/components/action_delete_icon.dart'; +import 'package:expense_manager/components/amount_input_view.dart'; +import 'package:expense_manager/components/date_field.dart'; +import 'package:expense_manager/controllers/add_income_controller.dart'; +import 'package:expense_manager/components/button/custom_button.dart'; +import 'package:expense_manager/components/form/custom_text_field.dart'; +import 'package:expense_manager/components/misc/custom_scaffold.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/models/income.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class AddIncomePage extends StatelessWidget { + final Income? income; + + const AddIncomePage({ + super.key, + this.income, + }); + + AddIncomeController get _controller => Get.find(); + + @override + Widget build(BuildContext context) { + return CustomScaffold( + title: "Income", + appBarActions: [ + ActionDeleteIcon(onTap: () => _showDeletionDialog(context)), + ], + body: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(16), + child: GetBuilder( + init: AddIncomeController(income ?? Income()), + builder: (controller) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // amount + AmountInputView( + initialAmount: controller.income.amountCents, + onChange: (value) => controller.updateAmount(value), + ), + + Divider(), + + DateField( + initialDate: controller.income.date, + onChange: (date) => controller.updateDate(date), + ), + + SizedBox(height: 8), + + ListTile( + contentPadding: EdgeInsets.zero, + leading: Text('Notes'), + title: CustomTextField( + border: UnderlineInputBorder(), + controller: controller.noteController, + ), + ), + + SizedBox(height: 32), + + // save button + CustomButton( + "Save", + onPressed: () { + controller.save().then((value) => goBack(context)); + }, + ), + ], + ); + }), + ), + ), + ); + } + + void _showDeletionDialog(BuildContext context) { + showDecisionDialog( + context, + title: "Delete this income?", + onPositivePressed: () { + _controller.deleteIncome().then((value) => goBack(context)); + }, + ); + } +} diff --git a/expense_manager/lib/pages/categories_page.dart b/expense_manager/lib/pages/categories_page.dart new file mode 100644 index 00000000..6103d7e7 --- /dev/null +++ b/expense_manager/lib/pages/categories_page.dart @@ -0,0 +1,78 @@ +import 'package:expense_manager/components/category_editor_dialog.dart'; +import 'package:expense_manager/components/category_square_box.dart'; +import 'package:expense_manager/controllers/categories_controller.dart'; +import 'package:expense_manager/components/custom_divider.dart'; +import 'package:expense_manager/components/misc/custom_scaffold.dart'; +import 'package:expense_manager/components/misc/placeholder_view.dart'; +import 'package:expense_manager/misc/colors.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/models/category.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_speed_dial/flutter_speed_dial.dart'; +import 'package:get/get.dart'; + +class CategoriesPage extends StatelessWidget { + const CategoriesPage({super.key}); + + CategoriesController get _controller => Get.find(); + + @override + Widget build(BuildContext context) { + return CustomScaffold( + title: 'Categories', + floatingActionButton: SpeedDial( + icon: Icons.add, + foregroundColor: Colors.white, + backgroundColor: CustomColors.primary, + onPress: () => _showCategoryDialog(context), + ), + body: GetBuilder( + init: CategoriesController(), + builder: (controller) { + return PlaceholderView( + show: controller.categories.isEmpty && controller.dataLoaded, + loading: controller.loading, + title: "No Categories", + description: + "Looks like you don't have any category yet. Click the \"+\" button below to add one.", + child: ListView.separated( + padding: EdgeInsets.only(top: 8, bottom: 100), + itemCount: controller.categories.length, + separatorBuilder: (context, index) => CustomDivider(), + itemBuilder: (context, index) { + final category = controller.categories[index]; + return ListTile( + leading: CategorySquareBox(colorCode: category.colorCode), + title: Text(category.name ?? ''), + trailing: InkWell( + onTap: () => _showDeletionDialog(context, category), + child: Icon(Icons.delete), + ), + onTap: () => + _showCategoryDialog(context, category: category), + ); + }, + ), + ); + }), + ); + } + + _showCategoryDialog(BuildContext context, {Category? category}) { + showDialog( + context: context, + builder: (context) => CategoryEditorDialog( + category: category, + onComplete: () => _controller.loadCategories(), + ), + ); + } + + _showDeletionDialog(BuildContext context, Category category) { + showDecisionDialog( + context, + title: "Delete \"${category.name}\"?", + onPositivePressed: () => _controller.deleteCategory(category), + ); + } +} diff --git a/expense_manager/lib/pages/category_report_page.dart b/expense_manager/lib/pages/category_report_page.dart new file mode 100644 index 00000000..38753a17 --- /dev/null +++ b/expense_manager/lib/pages/category_report_page.dart @@ -0,0 +1,96 @@ +import 'package:expense_manager/components/category_pie_chart.dart'; +import 'package:expense_manager/components/month_filter_view.dart'; +import 'package:expense_manager/controllers/category_expenses_controller.dart'; +import 'package:expense_manager/controllers/category_report_controller.dart'; +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/pages/expenses_page.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class CategoryReportPage extends StatelessWidget { + const CategoryReportPage({super.key}); + + CategoryReportController get _controller => + Get.find(); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: CategoryReportController(), + builder: (controller) { + return CustomScrollView( + slivers: [ + // date filter + SliverToBoxAdapter( + child: MonthFilterView( + onDateSelected: (DateTime dt) => controller.filterByDate(dt), + ), + ), + + // pie chart + SliverToBoxAdapter( + child: CategoryPieChart( + controller: _controller, + onAreaTouched: (index) { + _controller.selectChartArea(index); + }, + ), + ), + + SliverToBoxAdapter( + child: SizedBox(height: 16), + ), + + // breakdown + _buildCategoryBreakdown(), + ], + ); + }, + ); + } + + Widget _buildCategoryBreakdown() { + return SliverList( + delegate: SliverChildBuilderDelegate( + childCount: _controller.categories.length, + (context, index) { + final category = _controller.categories[index]; + return Column( + children: [ + Divider(height: 0), + ListTile( + dense: true, + onTap: () { + gotoPage( + context, + ExpensesPage( + controller: CategoryExpensesController( + category: category, + startDate: _controller.selectedDateTime?.startDate(), + endDate: _controller.selectedDateTime?.endDate(), + ), + ), + ); + }, + title: Row( + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: hexToColor(category.colorCode ?? '#EEEEEE')), + child: SizedBox(width: 10, height: 30), + ), + SizedBox(width: 16), + Expanded(child: Text(category.name ?? '')), + Text(_controller.getValueAmount(category).toMoney()), + ], + ), + trailing: Text(_controller.getValuePercentage(category)), + ), + ], + ); + }, + ), + ); + } +} diff --git a/expense_manager/lib/pages/expenses_page.dart b/expense_manager/lib/pages/expenses_page.dart new file mode 100644 index 00000000..4e606439 --- /dev/null +++ b/expense_manager/lib/pages/expenses_page.dart @@ -0,0 +1,116 @@ +import 'package:expense_manager/components/expense_float_button.dart'; +import 'package:expense_manager/components/transaction_group_item.dart'; +import 'package:expense_manager/controllers/expenses_controller.dart'; +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/components/misc/bottom_bar_container.dart'; +import 'package:expense_manager/components/misc/custom_scaffold.dart'; +import 'package:expense_manager/components/misc/placeholder_view.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class ExpensesPage extends StatelessWidget { + final String pageTitle; + final ExpensesController controller; + final bool showExpenseFloatButton; + + const ExpensesPage({ + super.key, + required this.controller, + this.pageTitle = 'Expenses', + this.showExpenseFloatButton = false, + }); + + @override + Widget build(BuildContext context) { + return CustomScaffold( + title: pageTitle, + floatingActionButton: showExpenseFloatButton ? ExpenseFloatButton() : null, + bottomNavigationBar: _buildBottomContainer(context), + body: GetBuilder( + init: controller, + builder: (controller) { + return PlaceholderView( + title: 'No Expenses Yet', + description: "Looks like you don't have any expenses yet", + show: controller.transactions.isEmpty && controller.dataLoaded, + loading: controller.loading, + child: ListView.builder( + itemCount: controller.transactions.keys.length, + itemBuilder: (context, index) { + final expenses = controller.transactions.values.toList()[index]; + final groupName = controller.transactions.keys.toList()[index]; + + return TransactionGroupItem( + transactions: expenses, + groupName: groupName, + initiallyExpanded: index == 0, + ); + }, + ), + ); + }, + ), + ); + } + + Widget _buildBottomContainer(BuildContext context) { + return GetBuilder( + init: controller, + builder: (controller) { + return BottomBarContainer( + padding: EdgeInsets.zero, + borderRadius: BorderRadius.circular(32), + color: Colors.white, + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + title: Row( + children: [ + Icon(Icons.info, color: Colors.orange), + SizedBox(width: 10), + Text( + 'Summary', + style: Theme.of(context) + .textTheme + .titleLarge! + .copyWith(fontWeight: FontWeight.w600), + ), + ], + ), + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Divider(), + ), + ListTile( + visualDensity: VisualDensity.compact, + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text( + 'No. of Transactions', + style: TextStyle(fontWeight: FontWeight.normal), + ), + trailing: Text( + controller.totalCount.toString(), + style: TextStyle(fontSize: 15), + ), + ), + ListTile( + visualDensity: VisualDensity.compact, + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text( + 'Total (\$)', + style: TextStyle(fontWeight: FontWeight.normal), + ), + trailing: Text( + controller.totalSum.toMoney(), + style: TextStyle(fontSize: 15), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/expense_manager/lib/pages/home_page.dart b/expense_manager/lib/pages/home_page.dart new file mode 100644 index 00000000..6712523c --- /dev/null +++ b/expense_manager/lib/pages/home_page.dart @@ -0,0 +1,214 @@ +import 'package:expense_manager/components/expense_float_button.dart'; +import 'package:expense_manager/controllers/category_expenses_controller.dart'; +import 'package:expense_manager/controllers/home_controller.dart'; +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/components/misc/custom_card.dart'; +import 'package:expense_manager/components/misc/custom_scaffold.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/models/category.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:page_view_dot_indicator/page_view_dot_indicator.dart'; +import 'expenses_page.dart'; + +class HomePage extends StatelessWidget { + HomePage({super.key}) { + Get.lazyReplace(() => HomeController()); + } + + HomeController get _controller => Get.find(); + + @override + Widget build(BuildContext context) { + return CustomScaffold( + title: 'Home', + floatingActionButton: ExpenseFloatButton(showIncome: true), + body: GetX( + builder: (controller) { + return SingleChildScrollView( + child: Column( + children: [ + // total income & expense of a month + SizedBox( + height: 200, + child: PageView( + controller: controller.bannerController, + onPageChanged: (index) => controller.onPageChanged(index), + children: controller.bannerItems.map((e) { + return buildBannerWidget( + month: e.month.toUpperCase(), + totalEarnings: e.totalEarnings, + totalIncome: e.totalIncome, + totalExpense: e.totalExpense, + ); + }).toList(), + ), + ), + + // pager indicator + if (controller.bannerItems.isNotEmpty) + PageViewDotIndicator( + currentItem: controller.bannerPageIndex.value, + count: controller.bannerItems.length, + unselectedColor: Colors.orange.shade100, + selectedColor: Colors.orange, + size: Size(10, 10), + unselectedSize: Size(10, 10), + ), + + SizedBox(height: 16), + + // categories + SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: CustomCard( + color: Colors.white, + child: SizedBox( + width: MediaQuery.of(context).size.width, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Text( + 'Categories', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), + ), + SizedBox(height: 16), + ...controller.categories.map((category) { + return _buildCategoryItem(context, category); + }), + ], + ), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } + + Widget buildBannerWidget({ + required String month, + required int totalEarnings, + required int totalIncome, + required int totalExpense, + }) { + return Column( + children: [ + SizedBox(height: 16), + buildBannerItem( + title: month, + amount: totalEarnings, + valueColor: Colors.green, + ), + SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + buildBannerItem(title: 'INCOME', amount: totalIncome), + buildBannerItem(title: 'EXPENSE', amount: totalExpense), + ], + ), + ], + ); + } + + Widget buildBannerItem({ + required String title, + required int amount, + Color? valueColor, + Widget? rightIcon, + }) { + final child = Column( + children: [ + Text( + title, + style: TextStyle( + color: Colors.grey, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 8), + Text( + amount.toMoney(), + style: TextStyle( + color: valueColor, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + ], + ); + + return Padding( + padding: EdgeInsets.all(8), + child: rightIcon != null + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [child, rightIcon], + ) + : child, + ); + } + + Widget _buildCategoryItem(BuildContext context, Category category) { + final now = DateTime.now(); + final date = now.copyWith(month: now.month - _controller.bannerPageIndex.value); + final totalSpending = category.amount; + final progress = (totalSpending / 100) / (category.budgetCents == 0 ? 1 : category.budget); + // final totalSpending = category.expenseAmountsIn(date.year, date.month); + + return InkWell( + onTap: () => gotoPage( + context, + ExpensesPage( + controller: CategoryExpensesController( + category: category, + startDate: date.startDate(), + endDate: date.endDate(), + ), + )), + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + Row( + children: [ + Expanded(child: Text(category.name ?? '')), + Row( + children: [ + Text(totalSpending.toMoney()), + Text(' / '), + Text( + category.budgetCents.toMoney(), + style: TextStyle(color: Colors.red), + ), + ], + ), + ], + ), + SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: progress, + color: hexToColor(category.colorCode ?? '#000000'), + minHeight: 10, + ), + ), + ], + ), + ), + ); + } +} diff --git a/expense_manager/lib/pages/image_page.dart b/expense_manager/lib/pages/image_page.dart new file mode 100644 index 00000000..c04184e2 --- /dev/null +++ b/expense_manager/lib/pages/image_page.dart @@ -0,0 +1,53 @@ +import 'package:expense_manager/misc/utils.dart'; +import 'package:flutter/material.dart'; + +enum ImageSourceType { + network, + file, +} + +class ImagePage extends StatelessWidget { + final String imageSource; + final ImageSourceType sourceType; + + const ImagePage({ + super.key, + required this.imageSource, + this.sourceType = ImageSourceType.network, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black, + ), + child: Stack( + children: [ + Center( + child: Image( + fit: BoxFit.fill, + image: NetworkImage(imageSource), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 64, left: 16), + child: Align( + alignment: Alignment.topLeft, + child: IconButton( + onPressed: () => goBack(context), + icon: Icon( + Icons.close, + size: 39, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/expense_manager/lib/pages/main_page.dart b/expense_manager/lib/pages/main_page.dart new file mode 100644 index 00000000..9a44129a --- /dev/null +++ b/expense_manager/lib/pages/main_page.dart @@ -0,0 +1,53 @@ +import 'package:expense_manager/misc/colors.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'home_page.dart'; +import 'reports_page.dart'; +import 'settings_page.dart'; +import 'tags_page.dart'; +import 'transactions_page.dart'; + +class MainPage extends StatelessWidget { + final _pageIndex = 0.obs; + + MainPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Obx(() => _buildPage(_pageIndex.value)), + bottomNavigationBar: Obx(() => BottomNavigationBar( + onTap: (value) => _changePage(value), + type: BottomNavigationBarType.fixed, + selectedItemColor: CustomColors.primary, + currentIndex: _pageIndex.value, + items: [ + BottomNavigationBarItem(label: 'Home', icon: Icon(Icons.home)), + BottomNavigationBarItem( + label: 'History', icon: Icon(Icons.list_alt_outlined)), + BottomNavigationBarItem( + label: 'Tags', icon: Icon(Icons.local_offer)), + BottomNavigationBarItem( + label: 'Statistics', icon: Icon(Icons.bar_chart)), + BottomNavigationBarItem( + label: 'Settings', icon: Icon(Icons.settings)), + ], + )), + ); + } + + Widget _buildPage(int pageIndex) { + final pages = { + 0: HomePage(), + 1: TransactionsPage(), + 2: TagsPage(), + 3: ReportsPage(), + 4: SettingsPage(), + }; + return pages[pageIndex] ?? HomePage(); + } + + void _changePage(int value) { + _pageIndex.value = value; + } +} diff --git a/expense_manager/lib/pages/monthly_report_page.dart b/expense_manager/lib/pages/monthly_report_page.dart new file mode 100644 index 00000000..766ae70e --- /dev/null +++ b/expense_manager/lib/pages/monthly_report_page.dart @@ -0,0 +1,239 @@ +import 'package:expense_manager/components/date_range_filter_view.dart'; +import 'package:expense_manager/controllers/monthly_report_controller.dart'; +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/components/misc/rounded_container.dart'; +import 'package:expense_manager/fl_chart/custom_line_chart.dart'; +import 'package:expense_manager/models/transaction.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class MonthlyReportPage extends StatelessWidget { + const MonthlyReportPage({super.key}); + + MonthlyReportController get _controller => + Get.find(); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: MonthlyReportController(), + builder: (controller) { + return SingleChildScrollView( + child: GetBuilder( + init: MonthlyReportController(), + builder: (controller) { + return Padding( + padding: EdgeInsets.all(8).copyWith(left: 0, right: 0), + child: Column( + children: [ + // date filter + DateRangeFilterView( + onDateSelected: (String range) { + controller.loadChartData(range); + }, + ), + + _buildChart(context), + + SizedBox(height: 16), + + _buildChartDetails(), + ], + ), + ); + }), + ); + }, + ); + } + + Widget _buildChart(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration(color: Color(0xFF222222)), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16).copyWith(top: 16), + child: CustomLineChart( + minY: 0, + minX: 0, + maxY: _controller.upperBound, + data: [ + if (_controller.expenseData.isNotEmpty) + _buildLineChartBarData( + _controller.expenseData, + color: Color(0xAAFF0000), + fillColor: Color(0x33FF0000), + show: _controller.shouldDisplayInChart(1), + ), + if (_controller.incomeData.isNotEmpty) + _buildLineChartBarData( + _controller.incomeData, + color: Color(0xAA00FF00), + fillColor: Color(0x2200FF00), + show: _controller.shouldDisplayInChart(2), + ), + if (_controller.earningsData.isNotEmpty) + _buildLineChartBarData( + _controller.earningsData, + color: Color(0xFF42A5F5), + fillColor: Color(0x3342A5F5), + show: _controller.shouldDisplayInChart(3), + ), + ], + leftTitlesInterval: _controller.yAxisInterval, + bottomTitles: AxisTitles( + sideTitles: SideTitles( + interval: _controller.xAxisInterval.roundToDouble(), + showTitles: true, + reservedSize: 30, + getTitlesWidget: (value, meta) { + if (value >= _controller.largerTransactionData.length) + return Text(''); + if (value == _controller.largerTransactionData.length - 1 && + _controller.isLargeDataSet) return Text(''); + + final transaction = _controller.expenseData[value.toInt()]; + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text( + _controller.isLargeDataSet + ? transaction.friendlyMonthYear + : transaction.friendlyMonth, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 13, + color: Colors.white, + ), + ), + ); + }, + ), + ), + lineTouchData: LineTouchData( + touchCallback: (event, res) { + final spots = res?.lineBarSpots ?? []; + if (spots.isEmpty) return; + _controller.onChartTouched(spots[0].spotIndex); + }, + touchTooltipData: LineTouchTooltipData( + tooltipBgColor: Color(0xEEFFFFFF), + maxContentWidth: 300, + getTooltipItems: (spots) { + final index = spots[0].spotIndex; + return [ + LineTooltipItem( + _controller.earningsData[index].friendlyMonthYear, + TextStyle(), + ), + LineTooltipItem( + "Expenses: ${_controller.expenseData[index].totalAmountCents.toMoney()}", + TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + ), + LineTooltipItem( + "Income: ${_controller.incomeData[index].totalAmountCents.toMoney()}", + TextStyle( + color: Colors.green.shade700, + fontWeight: FontWeight.bold), + ), + ]; + }, + ), + ), + ), + ), + ); + } + + LineChartBarData _buildLineChartBarData( + List> data, { + Color? color, + Color? fillColor, + bool show = true, + }) { + return LineChartBarData( + isCurved: true, + show: show, + color: color, + dotData: FlDotData( + show: false, + getDotPainter: (spot, percentage, bar, index) { + return FlDotCirclePainter(radius: 1); + }, + ), + belowBarData: BarAreaData( + show: true, + color: fillColor, + ), + spots: data.map((e) { + final spot = FlSpot(e.id.toDouble(), e.totalAmountCents / 100); + return spot; + }).toList(), + ); + } + + Widget _buildChartDetails() { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: RoundedContainer( + color: Colors.grey.shade200, + child: Column( + children: [ + SizedBox(height: 16), + Text( + _controller.touchDataDate.format('MMM yyyy'), + style: TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + _buildSpotDetailItem( + id: 1, + title: 'Expense', + value: _controller.touchedData?.expense.toMoney() ?? '', + ), + _buildSpotDetailItem( + id: 2, + title: 'Income', + value: _controller.touchedData?.income.toMoney() ?? '', + ), + _buildSpotDetailItem( + id: 3, + title: 'Earnings', + value: _controller.touchedData?.earnings.toMoney() ?? '', + ), + SizedBox(height: 8), + ], + ), + ), + ); + } + + Widget _buildSpotDetailItem({ + required int id, + required String title, + required String value, + }) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Text( + title, + style: TextStyle(fontWeight: FontWeight.bold), + ), + Expanded( + child: Text( + value, + textAlign: TextAlign.end, + ), + ), + SizedBox(width: 16), + Checkbox( + visualDensity: VisualDensity.compact, + value: _controller.shouldDisplayInChart(id), + onChanged: (value) { + _controller.updateChartDisplay(id, value == true); + }, + ), + ], + ), + ); + } +} diff --git a/expense_manager/lib/pages/reports_page.dart b/expense_manager/lib/pages/reports_page.dart new file mode 100644 index 00000000..19c30092 --- /dev/null +++ b/expense_manager/lib/pages/reports_page.dart @@ -0,0 +1,67 @@ +import 'package:expense_manager/components/month_filter_view.dart'; +import 'package:expense_manager/components/misc/custom_scaffold.dart'; +import 'package:expense_manager/misc/colors.dart'; +import 'package:expense_manager/pages/category_report_page.dart'; +import 'package:expense_manager/pages/monthly_report_page.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class ReportsPage extends StatelessWidget { + final reportType = ReportType.category.obs; + + ReportsPage({super.key}) { + Get.put(MonthFilterController()); + } + + @override + Widget build(BuildContext context) { + return CustomScaffold( + title: 'Reports', + body: Obx( + () => Column( + children: [ + SizedBox(height: 16), + CupertinoSlidingSegmentedControl( + backgroundColor: CustomColors.primaryLight, + groupValue: reportType.value, + onValueChanged: (value) => reportType.value = value!, + children: { + ReportType.category: Text( + 'Category', + style: TextStyle( + fontWeight: FontWeight.bold, color: _getThumbColor(ReportType.category)), + ), + ReportType.monthly: Text( + 'Monthly', + style: TextStyle( + fontWeight: FontWeight.bold, color: _getThumbColor(ReportType.monthly)), + ), + }, + ), + + SizedBox(height: 8), + + // content + Expanded(child: _buildPage(reportType.value)), + ], + ), + )); + } + + Widget _buildPage(ReportType reportType) { + switch (reportType) { + case ReportType.monthly: + return MonthlyReportPage(); + case ReportType.category: + default: + return CategoryReportPage(); + } + } + + Color? _getThumbColor(ReportType value) { + return reportType.value == value ? null : Colors.black54; + } +} + +enum ReportType { category, monthly } diff --git a/expense_manager/lib/pages/settings_page.dart b/expense_manager/lib/pages/settings_page.dart new file mode 100644 index 00000000..3961191e --- /dev/null +++ b/expense_manager/lib/pages/settings_page.dart @@ -0,0 +1,99 @@ +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/controllers/settings_controller.dart'; +import 'package:expense_manager/components/misc/custom_scaffold.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'categories_page.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + SettingsController get _controller => Get.find(); + + @override + Widget build(BuildContext context) { + return CustomScaffold( + title: 'Settings', + body: GetX( + init: SettingsController(), + builder: (controller) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + leading: Icon(Icons.list), + title: Text('Categories'), + onTap: () => gotoPage(context, CategoriesPage()), + ), + ListTile( + leading: Icon(Icons.home), + title: + Text("Home page months (${controller.homeMonthCount})"), + onTap: () => _showHomePageMonthPicker(context), + ), + ListTile( + leading: Icon(Icons.currency_exchange), + title: Text("Currency (${controller.currency})"), + onTap: () => _showCurrencyPicker(context), + ), + ListTile( + leading: Icon(Icons.data_array), + title: Text('Load sample data'), + onTap: () async { + await controller.loadTestingData(); + }, + trailing: controller.loadingTestData.value + ? CircularProgressIndicator() + : null, + ), + ListTile( + leading: Icon(Icons.clear), + title: Text('Clear data'), + onTap: () async { + await controller.clearData(); + }, + trailing: controller.loadingTestData.value + ? CircularProgressIndicator() + : null, + ), + ], + ), + ); + }), + ); + } + + void _showHomePageMonthPicker(BuildContext context) { + final numbers = [1, 2, 3, 4, 5, 6]; + showCupertinoModal( + context, + initialItem: numbers.indexOf(_controller.homeMonthCount.value), + onSelectedItemChanged: (value) { + _controller.setHomeMonthCount(numbers[value]); + }, + children: numbers.map((e) { + return Text(e.toString()); + }).toList(), + ); + } + + void _showCurrencyPicker(BuildContext context) { + showCupertinoModal( + context, + initialItem: _controller.currencyIndex, + itemExtent: 50, + height: 300, + onSelectedItemChanged: (value) { + _controller.setCurrency(currencies[value].symbol); + }, + children: currencies.map((e) { + return Padding( + padding: const EdgeInsets.only(top: 10), + child: Text("${e.name} - ${e.symbol}"), + ); + }).toList(), + ); + } +} diff --git a/expense_manager/lib/pages/tags_page.dart b/expense_manager/lib/pages/tags_page.dart new file mode 100644 index 00000000..a9edd044 --- /dev/null +++ b/expense_manager/lib/pages/tags_page.dart @@ -0,0 +1,64 @@ +import 'package:expense_manager/components/tag_list_item.dart'; +import 'package:expense_manager/controllers/tags_controller.dart'; +import 'package:expense_manager/components/custom_divider.dart'; +import 'package:expense_manager/components/misc/custom_scaffold.dart'; +import 'package:expense_manager/components/misc/placeholder_view.dart'; +import 'package:expense_manager/components/misc/custom_search_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class TagsPage extends StatelessWidget { + const TagsPage({super.key}); + + TagsController get _controller => Get.find(); + + @override + Widget build(BuildContext context) { + return CustomScaffold( + title: "Tags", + body: GetBuilder( + init: TagsController(), + builder: (controller) { + return Column( + children: [ + // search bar + Padding( + padding: EdgeInsets.all(10).copyWith(bottom: 0), + child: CustomSearchBar( + onSearch: (value) => _controller.searchTags(text: value), + ), + ), + + // tags + Expanded( + child: PlaceholderView( + title: "No tags", + description: + "Looks like you haven't added any tag to your expenses yet.", + show: !controller.hasTags && controller.dataLoaded, + loading: controller.loading, + child: _buildTags(context), + ), + ), + ], + ); + }, + ), + ); + } + + Widget _buildTags(BuildContext context) { + return ListView.separated( + padding: EdgeInsets.only(top: 8, bottom: 100), + itemCount: _controller.tags.length, + separatorBuilder: (context, index) => CustomDivider(), + itemBuilder: (context, index) { + final tag = _controller.tags[index]; + return TagListItem( + tag: tag, + controller: _controller, + ); + }, + ); + } +} diff --git a/expense_manager/lib/pages/transactions_page.dart b/expense_manager/lib/pages/transactions_page.dart new file mode 100644 index 00000000..fe93a5bd --- /dev/null +++ b/expense_manager/lib/pages/transactions_page.dart @@ -0,0 +1,46 @@ +import 'package:expense_manager/components/expense_float_button.dart'; +import 'package:expense_manager/components/transaction_group_item.dart'; +import 'package:expense_manager/controllers/transactions_controller.dart'; +import 'package:expense_manager/components/misc/custom_scaffold.dart'; +import 'package:expense_manager/components/misc/placeholder_view.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class TransactionsPage extends StatelessWidget { + TransactionsPage({super.key}) { + Get.lazyReplace(() => TransactionsController()); + } + + @override + Widget build(BuildContext context) { + return CustomScaffold( + title: 'Transactions', + floatingActionButton: ExpenseFloatButton(showIncome: true), + body: GetBuilder( + builder: (controller) { + return PlaceholderView( + show: controller.showPlaceholder, + loading: controller.loading, + title: 'No Expenses Yet', + description: + "Looks like you haven't added any expenses yet. Tap the \"+\" button below to add one.", + child: ListView.builder( + padding: EdgeInsets.only(bottom: 50), + itemCount: controller.transactions.keys.length, + itemBuilder: (context, index) { + final expenses = controller.transactions.values.toList()[index]; + final groupName = controller.transactions.keys.toList()[index]; + + return TransactionGroupItem( + transactions: expenses, + groupName: groupName, + initiallyExpanded: index == 0, + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/expense_manager/lib/podo/home_banner_item.dart b/expense_manager/lib/podo/home_banner_item.dart new file mode 100644 index 00000000..30749bff --- /dev/null +++ b/expense_manager/lib/podo/home_banner_item.dart @@ -0,0 +1,13 @@ +class HomeBannerItem { + final String month; + final int totalEarnings; + final int totalIncome; + final int totalExpense; + + HomeBannerItem({ + required this.month, + required this.totalEarnings, + required this.totalIncome, + required this.totalExpense, + }); +} diff --git a/expense_manager/lib/pubsub.dart b/expense_manager/lib/pubsub.dart new file mode 100644 index 00000000..80ae73fe --- /dev/null +++ b/expense_manager/lib/pubsub.dart @@ -0,0 +1,28 @@ +import 'package:expense_manager/controllers/transactions_controller.dart'; +import 'package:expense_manager/controllers/home_controller.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:expense_manager/models/income.dart'; +import 'package:get/get.dart'; +import 'controllers/expenses_controller.dart'; + +class PubSub { + static S? _find() { + try { + return Get.find(); + } catch (e) { + print(e); + } + return null; + } + + static void onExpenseUpdated(Expense expense) { + _find()?.loadData(); + _find()?.loadData(); + _find()?.loadExpenses(); + } + + static void onIncomeUpdated(Income income) { + _find()?.loadData(); + _find()?.loadData(); + } +} diff --git a/expense_manager/lib/repositories/base_repo.dart b/expense_manager/lib/repositories/base_repo.dart new file mode 100644 index 00000000..e2750faa --- /dev/null +++ b/expense_manager/lib/repositories/base_repo.dart @@ -0,0 +1,19 @@ +import 'package:isar/isar.dart'; + +abstract class BaseRepo { + static late Isar _isar; + + static set setIsar(Isar instance) { + _isar = instance; + } + + Isar get isar => _isar; + + Future save(T instance, {bool transaction = true}); + void saveSync(T instance, {bool transaction = false}); + Future> findAll(); + Future find(int id); + T? findSync(int id); + Future delete(T instance); + Future deleteAll(); +} diff --git a/expense_manager/lib/repositories/category_repo.dart b/expense_manager/lib/repositories/category_repo.dart new file mode 100644 index 00000000..e61944bb --- /dev/null +++ b/expense_manager/lib/repositories/category_repo.dart @@ -0,0 +1,68 @@ +import 'package:expense_manager/models/category.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:expense_manager/repositories/base_repo.dart'; +import 'package:isar/isar.dart'; + +class CategoryRepo extends BaseRepo { + @override + Future save(Category instance, {bool transaction = true}) async { + func() async { + await isar.categorys.put(instance); + } + + if (transaction) { + await isar.writeTxn(func); + } else { + await func.call(); + } + } + + @override + void saveSync(Category instance, {bool transaction = false}) { + if (transaction) { + isar.writeTxnSync(() { + isar.categorys.putSync(instance); + }); + } + } + + @override + Future> findAll() async { + return await isar.categorys.where().findAll(); + } + + @override + Future find(int id) async { + return await isar.categorys.get(id); + } + + @override + Category? findSync(int id) { + return isar.categorys.getSync(id); + } + + @override + Future delete(Category instance) async { + if (instance.id == null) return false; + return await isar.writeTxn(() => isar.categorys.delete(instance.id!)); + } + + Future> findAllWithExpenses({ + DateTime? startDate, + DateTime? endDate, + }) async { + var query = isar.categorys.filter().expensesIsNotEmpty(); + + if (startDate != null && endDate != null) { + query = query.expenses((q) => q.dateBetween(startDate, endDate)); + } + return await query.findAll(); + } + + @override + Future deleteAll() async { + await isar.writeTxn(() async { + await isar.categorys.clear(); + }); + } +} diff --git a/expense_manager/lib/repositories/expense_repo.dart b/expense_manager/lib/repositories/expense_repo.dart new file mode 100644 index 00000000..f3a2dfd7 --- /dev/null +++ b/expense_manager/lib/repositories/expense_repo.dart @@ -0,0 +1,90 @@ +import 'package:expense_manager/models/expense.dart'; +import 'package:expense_manager/repositories/base_repo.dart'; +import 'package:isar/isar.dart'; + +class ExpenseRepo extends BaseRepo { + @override + Future save(Expense instance, {bool transaction = true}) async { + func() async { + await isar.expenses.put(instance); + await instance.category.save(); + await instance.tags.save(); + } + + if (transaction) { + await isar.writeTxn(func); + } else { + await func.call(); + } + } + + @override + void saveSync(Expense instance, {bool transaction = false}) { + func() { + isar.expenses.putSync(instance); + instance.category.saveSync(); + instance.tags.saveSync(); + } + + if (transaction) { + isar.writeTxnSync(func); + } else { + func.call(); + } + } + + @override + Future> findAll() async { + return await isar.expenses.where().sortByDateDesc().findAll(); + } + + @override + Future find(int id) async { + return await isar.expenses.get(id); + } + + @override + Expense? findSync(int id) { + return isar.expenses.getSync(id); + } + + @override + Future delete(Expense instance) async { + if (instance.id == null) return false; + return await isar.writeTxn(() => isar.expenses.delete(instance.id!)); + } + + Future> findByDateRange( + DateTime? startDate, + DateTime? endDate, { + bool sortByDate = true, + bool excludeAdHoc = true, + }) async { + if (startDate == null || endDate == null) return []; + var query = isar.expenses + .filter() + .dateBetween(startDate, endDate) + .and() + .isAdHocEqualTo(!excludeAdHoc); + if (sortByDate) { + return await query.sortByDate().findAll(); + } + return await query.findAll(); + } + + @override + Future deleteAll() async { + await isar.writeTxn(() async { + await isar.expenses.clear(); + }); + } + + Future> findByAccountId(int accountId) async { + return await isar.expenses + .where() + .filter() + .accountIdEqualTo(accountId) + .sortByDateDesc() + .findAll(); + } +} diff --git a/expense_manager/lib/repositories/income_repo.dart b/expense_manager/lib/repositories/income_repo.dart new file mode 100644 index 00000000..c8462e13 --- /dev/null +++ b/expense_manager/lib/repositories/income_repo.dart @@ -0,0 +1,82 @@ +import 'package:expense_manager/models/income.dart'; +import 'package:expense_manager/repositories/base_repo.dart'; +import 'package:isar/isar.dart'; + +class IncomeRepo extends BaseRepo { + @override + Future save(Income instance, {bool transaction = true}) async { + func() async { + await isar.incomes.put(instance); + } + + if (transaction) { + await isar.writeTxn(func); + } else { + await func.call(); + } + } + + @override + void saveSync(Income instance, {bool transaction = false}) { + if (transaction) { + isar.writeTxnSync(() { + isar.incomes.putSync(instance); + }); + } + } + + @override + Future> findAll() async { + return await isar.incomes.where().sortByDateDesc().findAll(); + } + + @override + Future find(int id) async { + return await isar.incomes.get(id); + } + + @override + Future delete(Income instance) async { + if (instance.id == null) return false; + return await isar.writeTxn(() => isar.incomes.delete(instance.id!)); + } + + @override + Income? findSync(int id) { + return isar.incomes.getSync(id); + } + + Future> findByDateRange( + DateTime? startDate, + DateTime? endDate, { + bool sortByDate = true, + bool excludeAdHoc = true, + }) async { + if (startDate == null || endDate == null) return []; + var query = isar.incomes + .filter() + .dateBetween(startDate, endDate) + .and() + .isAdHocEqualTo(!excludeAdHoc); + if (sortByDate) { + return await query.sortByDate().findAll(); + } + return await query.findAll(); + } + + @override + Future deleteAll() async { + await isar.writeTxn(() async { + await isar.incomes.clear(); + }); + } + + Future> findByAccountId(int accountId) async { + return await isar.incomes + .where() + .filter() + .accountIdEqualTo(accountId) + .sortByDateDesc() + .findAll(); + } +} diff --git a/expense_manager/lib/repositories/repo_factory.dart b/expense_manager/lib/repositories/repo_factory.dart new file mode 100644 index 00000000..69d94f3c --- /dev/null +++ b/expense_manager/lib/repositories/repo_factory.dart @@ -0,0 +1,47 @@ +import 'dart:io'; +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/models/category.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:expense_manager/models/income.dart'; +import 'package:expense_manager/models/tag.dart'; +import 'package:expense_manager/repositories/base_repo.dart'; +import 'package:expense_manager/repositories/category_repo.dart'; +import 'package:expense_manager/repositories/expense_repo.dart'; +import 'package:expense_manager/repositories/income_repo.dart'; +import 'package:expense_manager/repositories/tag_repo.dart'; +import 'package:expense_manager/services/base_service.dart'; +import 'package:isar/isar.dart'; +import 'package:path_provider/path_provider.dart'; + +class RepoFactory { + static Future init({bool unitTest = false}) async { + if (unitTest) { + await Isar.initializeIsarCore(download: true); + } + + final dir = unitTest + ? Directory.systemTemp.createTempSync() + : (await getApplicationDocumentsDirectory()); + final isar = await Isar.open( + [ + ExpenseSchema, + CategorySchema, + IncomeSchema, + TagSchema, + ], + directory: dir.path, + ); + kIsar = isar; + BaseRepo.setIsar = isar; + BaseService.setIsar = isar; + + registerRepos(); + } + + static registerRepos() { + getIt.registerLazySingleton(() => ExpenseRepo()); + getIt.registerLazySingleton(() => CategoryRepo()); + getIt.registerLazySingleton(() => IncomeRepo()); + getIt.registerLazySingleton(() => TagRepo()); + } +} diff --git a/expense_manager/lib/repositories/service_factory.dart b/expense_manager/lib/repositories/service_factory.dart new file mode 100644 index 00000000..5b06aefd --- /dev/null +++ b/expense_manager/lib/repositories/service_factory.dart @@ -0,0 +1,14 @@ +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/services/data_service.dart'; +import 'package:expense_manager/services/transaction_service.dart'; + +class ServiceFactory { + static Future init() async { + registerServices(); + } + + static registerServices() { + getIt.registerLazySingleton(() => TransactionService()); + getIt.registerLazySingleton(() => DataService()); + } +} diff --git a/expense_manager/lib/repositories/tag_repo.dart b/expense_manager/lib/repositories/tag_repo.dart new file mode 100644 index 00000000..50693381 --- /dev/null +++ b/expense_manager/lib/repositories/tag_repo.dart @@ -0,0 +1,73 @@ +import 'package:expense_manager/models/tag.dart'; +import 'package:expense_manager/repositories/base_repo.dart'; +import 'package:isar/isar.dart'; + +class TagRepo extends BaseRepo { + @override + Future save(Tag instance, {bool transaction = true}) async { + func() async { + await isar.tags.put(instance); + await instance.expenses.save(); + } + + if (transaction) { + await isar.writeTxn(func); + } else { + await func(); + } + } + + @override + void saveSync(Tag instance, {bool transaction = false}) { + if (transaction) { + isar.writeTxnSync(() { + isar.tags.putSync(instance); + instance.expenses.saveSync(); + }); + } + } + + @override + Future> findAll() async { + return await isar.tags.where().sortByName().findAll(); + } + + @override + Future find(int id) async { + return await isar.tags.get(id); + } + + @override + Future delete(Tag instance) async { + if (instance.id == null) return false; + return await isar.writeTxn(() => isar.tags.delete(instance.id!)); + } + + @override + Tag? findSync(int id) { + return isar.tags.getSync(id); + } + + Future> search(String pattern) async { + if (pattern.length < 2) return []; + return await isar.tags + .filter() + .nameMatches("*$pattern*", caseSensitive: false) + .sortByName() + .findAll(); + } + + Future findByName(String name, {bool caseSensitive = true}) async { + return await isar.tags + .filter() + .nameEqualTo(name, caseSensitive: caseSensitive) + .findFirst(); + } + + @override + Future deleteAll() async { + await isar.writeTxn(() async { + await isar.tags.clear(); + }); + } +} diff --git a/expense_manager/lib/services/app_init_service.dart b/expense_manager/lib/services/app_init_service.dart new file mode 100644 index 00000000..fb57ed6f --- /dev/null +++ b/expense_manager/lib/services/app_init_service.dart @@ -0,0 +1,17 @@ +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/repositories/repo_factory.dart'; +import 'package:expense_manager/repositories/service_factory.dart'; +import 'package:flutter/material.dart'; + +class AppInitService { + static Future init() async { + WidgetsFlutterBinding.ensureInitialized(); + await RepoFactory.init(); + await ServiceFactory.init(); + + kCurrency = await kSharedPrefService.getString(prefCurrency) ?? '\$'; + + // app initialisation + await kDataService.initialiseAppValues(); + } +} diff --git a/expense_manager/lib/services/base_service.dart b/expense_manager/lib/services/base_service.dart new file mode 100644 index 00000000..28c608f4 --- /dev/null +++ b/expense_manager/lib/services/base_service.dart @@ -0,0 +1,11 @@ +import 'package:isar/isar.dart'; + +class BaseService { + static late Isar _isar; + + static set setIsar(Isar instance) { + _isar = instance; + } + + Isar get isar => _isar; +} diff --git a/expense_manager/lib/services/data_service.dart b/expense_manager/lib/services/data_service.dart new file mode 100644 index 00000000..cc90b6e0 --- /dev/null +++ b/expense_manager/lib/services/data_service.dart @@ -0,0 +1,115 @@ +import 'dart:math'; +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/models/category.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:expense_manager/models/income.dart'; +import 'package:expense_manager/models/tag.dart'; +import 'package:expense_manager/services/base_service.dart'; +import 'package:faker/faker.dart'; + +class DataService extends BaseService { + Future initialiseAppValues() async { + final initialised = await kSharedPrefService.getBoolean(prefAppInit); + if (initialised) return; + + final categories = [ + Category( + name: 'Food & Drinks', budgetCents: 1000 * 100, colorCode: '#FDD835'), + Category( + name: 'Entertainment', budgetCents: 500 * 100, colorCode: '#E53935'), + Category( + name: 'Transportation', budgetCents: 300 * 100, colorCode: '#8D6E63'), + Category(name: 'Education', budgetCents: 300 * 100, colorCode: '#FF9800'), + Category(name: 'Health', budgetCents: 300 * 100, colorCode: '#43A047'), + Category(name: 'Travel', budgetCents: 2000 * 100, colorCode: '#1E88E5'), + ]; + for (var element in categories) { + await kCategoryRepo.save(element); + } + await kSharedPrefService.setBoolean(prefAppInit, true); + } + + Future loadTestingData() async { + // clear everything first + await kCategoryRepo.deleteAll(); + await kTagRepo.deleteAll(); + await kExpenseRepo.deleteAll(); + await kIncomeRepo.deleteAll(); + + final faker = Faker(); + + // categories + final categories = [ + Category( + name: 'Entertainment', budgetCents: 500 * 100, colorCode: '#E53935'), + Category( + name: 'Food & Drinks', budgetCents: 1000 * 100, colorCode: '#FDD835'), + Category( + name: 'Transportation', budgetCents: 300 * 100, colorCode: '#8D6E63'), + Category(name: 'Education', budgetCents: 300 * 100, colorCode: '#FF9800'), + Category(name: 'Health', budgetCents: 300 * 100, colorCode: '#43A047'), + Category(name: 'Travel', budgetCents: 2000 * 100, colorCode: '#1E88E5'), + ]; + for (var element in categories) { + await kCategoryRepo.save(element); + } + + // tags + final tags = [ + Tag.create(name: 'food', colorCode: '#E53935'), + Tag.create(name: 'love', colorCode: '#FDD835'), + Tag.create(name: 'speed', colorCode: '#8D6E63'), + Tag.create(name: 'lorem', colorCode: '#FF9800'), + Tag.create(name: 'ipsum', colorCode: '#43A047'), + Tag.create(name: 'okay', colorCode: '#1E88E5'), + ]; + for (var element in tags) { + await kTagRepo.save(element); + } + + // expenses TTM + final now = DateTime.now(); + for (var i = 0; i < 12; i++) { + var dt = now.lastMonth(numOfMonth: i); + for (var i = 0; i < 50; i++) { + final date = dt.copyWith( + year: dt.year, + month: dt.month, + day: Random().nextInt(dt.day + 1), + ); + final expense = Expense( + amountCents: random.integer(100 * 100, min: 10 * 100), + note: faker.lorem.word(), + ) + ..date = date + ..category.value = + categories[random.integer(categories.length - 1, min: 0)]; + + final tag = tags[random.integer(tags.length - 1)]; + tag.expenses.add(expense); + expense.tags.add(tag); + await kExpenseRepo.save(expense); + await kTagRepo.save(tag); + } + } + + // incomes TTM + for (var i = 0; i < 12; i++) { + var date = now.lastMonth(numOfMonth: i); + final income = Income( + amountCents: random.integer(15000 * 100, min: 10000 * 100), + note: faker.lorem.word(), + )..date = date; + await kIncomeRepo.save(income); + } + } + + // for development purposes only + Future clearAll() async { + await kCategoryRepo.deleteAll(); + await kExpenseRepo.deleteAll(); + await kIncomeRepo.deleteAll(); + await kTagRepo.deleteAll(); + } +} diff --git a/expense_manager/lib/services/shared_pref_service.dart b/expense_manager/lib/services/shared_pref_service.dart new file mode 100644 index 00000000..17c13052 --- /dev/null +++ b/expense_manager/lib/services/shared_pref_service.dart @@ -0,0 +1,46 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class SharedPrefService { + static final _sharedPrefService = SharedPrefService._internal(); + + factory SharedPrefService() { + return _sharedPrefService; + } + + SharedPrefService._internal(); + + Future setInt(String key, int value) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.setInt(key, value); + } + + Future getInt(String key) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(key); + } + + Future setBoolean(String key, bool value) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.setBool(key, value); + } + + Future getBoolean(String key) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(key) ?? false; + } + + Future setString(String key, String value) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.setString(key, value); + } + + Future getString(String key) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(key); + } + + Future delete(String key) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.remove(key); + } +} diff --git a/expense_manager/lib/services/transaction_service.dart b/expense_manager/lib/services/transaction_service.dart new file mode 100644 index 00000000..c22c307f --- /dev/null +++ b/expense_manager/lib/services/transaction_service.dart @@ -0,0 +1,87 @@ +import 'package:expense_manager/constants.dart'; +import 'package:expense_manager/misc/extensions.dart'; +import 'package:expense_manager/misc/utils.dart'; +import 'package:expense_manager/models/expense.dart'; +import 'package:expense_manager/models/income.dart'; +import 'package:expense_manager/models/tag.dart'; +import 'package:expense_manager/podo/home_banner_item.dart'; +import 'package:expense_manager/services/base_service.dart'; +import 'package:isar/isar.dart'; + +class TransactionService extends BaseService { + Future getMonthlyData({required int month}) async { + final start = DateTime.now().copyWith(month: month, day: 1).startDate(); + final end = start.endDate(); + final expenses = + await kExpenseRepo.findByDateRange(start, end, sortByDate: false); + final incomes = + await kIncomeRepo.findByDateRange(start, end, sortByDate: false); + + final totalIncome = incomes.fold(0, (total, e) => total + e.amountCents); + final totalExpense = expenses.fold(0, (total, e) => total + e.amountCents); + return HomeBannerItem( + month: friendlyDate(start, dateFormat: 'MMMM'), + totalEarnings: totalIncome - totalExpense, + totalIncome: totalIncome, + totalExpense: totalExpense, + ); + } + + Future> findTagsByExpenseDates(DateTime start, DateTime end, + {String searchText = '', + bool sortAlphabetically = true, + bool desc = false, + las}) async { + final tags = await isar.tags + .filter() + .nameMatches("*$searchText*", caseSensitive: false) + .expenses((e) => e.dateBetween(start.startDate(), end.endDate())) + .findAll(); + return tags; + } + + Future removeTagFromExpense(Tag tag, Expense expense) async { + if (tag.id != null) { + expense.tags.remove(tag); + await kExpenseRepo.save(expense); + + tag.expenses.remove(expense); + await kTagRepo.save(tag); + } + } + + Future saveExpense({ + required Expense expense, + List? newTags, + List? removedTags, + }) async { + await isar.writeTxn(() async { + // save expense if it's a new one + if (expense.isNew) { + await kExpenseRepo.save(expense, transaction: false); + } + + // save tags + for (var tag in newTags ?? []) { + tag.expenses.add(expense); + await kTagRepo.save(tag, transaction: false); + } + + // remove deleted tags + final tagsToRemove = + (removedTags ?? []).where((e) => e.id != null).toList(); + for (var tag in tagsToRemove) { + tag.expenses.remove(expense); + await kTagRepo.save(tag, transaction: false); + } + + await expense.saveTags(tagsToRemove, newTags ?? [], transaction: false); + }); + } + + Future saveIncome({ + required Income income, + }) async { + await kIncomeRepo.save(income); + } +} diff --git a/expense_manager/pubspec.yaml b/expense_manager/pubspec.yaml new file mode 100644 index 00000000..d88d4eb3 --- /dev/null +++ b/expense_manager/pubspec.yaml @@ -0,0 +1,108 @@ +name: expense_manager +description: Finance manager +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: "none" # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ">=3.2.1 <4.0.0" + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.6 + get: ^4.6.6 + intl: ^0.18.1 + shared_preferences: ^2.2.2 + shimmer: ^3.0.0 + fl_chart: ^0.65.0 + local_auth: ^2.1.6 + file_picker: ^6.1.1 + isar: ^3.1.0+1 + isar_flutter_libs: ^3.1.0+1 + path_provider: ^2.1.1 + flutter_speed_dial: ^7.0.0 + page_view_dot_indicator: ^2.3.0 + flutter_material_color_picker: ^1.1.0+2 + get_it: ^7.6.0 + flutter_typeahead: ^5.0.1 + faker: ^2.1.0 + flutter_timezone: ^1.0.8 + timezone: ^0.9.2 + share_plus: ^7.2.1 + csv: ^5.1.1 + image_picker: ^1.0.7 + uuid: ^4.3.3 + +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: any + isar_generator: ^3.1.0+1 + flutter_lints: ^3.0.1 + json_serializable: ^6.7.1 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - assets/ + # - assets/images/ + # - assets/json/ + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages