diff --git a/lib/constants/constants.dart b/lib/constants/constants.dart index 6509f922..6b7310d1 100644 --- a/lib/constants/constants.dart +++ b/lib/constants/constants.dart @@ -20,6 +20,7 @@ const Map iconList = { 'device_thermostat': Icons.device_thermostat, 'dry_cleaning': Icons.dry_cleaning, 'work': Icons.work, + 'question_mark': Icons.question_mark, }; const Map accountIconList = { @@ -31,6 +32,7 @@ const Map accountIconList = { // colors const categoryColorList = [ + category0, category1, category2, category3, @@ -43,6 +45,7 @@ const categoryColorList = [ ]; const darkCategoryColorList = [ + darkCategory0, darkCategory1, darkCategory2, darkCategory3, diff --git a/lib/constants/style.dart b/lib/constants/style.dart index 062d7731..8f9e2a44 100644 --- a/lib/constants/style.dart +++ b/lib/constants/style.dart @@ -35,6 +35,7 @@ const grey1 = Color(0xFF666666); const grey2 = Color(0xFFB9BABC); const grey3 = Color(0xFFF4F4F4); +const category0 = Color(0xFFB9BABC); const category1 = Color(0xFFEDC31C); const category2 = Color(0xFFF68428); const category3 = Color(0xFFFF4754); @@ -71,6 +72,7 @@ const darkGrey2 = Color(0xFFC6C7C8); const darkGrey3 = Color(0xFF181E25); const darkGrey4 = Color(0xFF2E3338); +const darkCategory0 = Color(0xFFB9BABC); const darkCategory1 = Color(0xFFE3B912); const darkCategory2 = Color(0xFFF6740C); const darkCategory3 = Color(0xFFFA3240); diff --git a/lib/database/migrations/0004_category_marked_as_deleted.dart b/lib/database/migrations/0004_category_marked_as_deleted.dart new file mode 100644 index 00000000..6b6793aa --- /dev/null +++ b/lib/database/migrations/0004_category_marked_as_deleted.dart @@ -0,0 +1,25 @@ +// ignore_for_file: file_names + +import 'package:sqflite/sqflite.dart'; +import '../migration_base.dart'; + +// Models +import '/model/category_transaction.dart'; + +class CategoryMarkedAsDeleted extends Migration { + CategoryMarkedAsDeleted() + : super( + version: 4, + description: 'Add deleted column to CategoryTransaction model', + ); + + @override + Future up(Database db) async { + const integerNotNull = 'INTEGER NOT NULL'; + + // CategoryTransactionTable + await db.execute(''' + ALTER TABLE `$categoryTransactionTable` ADD COLUMN `${CategoryTransactionFields.deleted}` $integerNotNull DEFAULT 0; + '''); + } +} diff --git a/lib/database/migrations/0005_uncategorized_default_category.dart b/lib/database/migrations/0005_uncategorized_default_category.dart new file mode 100644 index 00000000..3cbb4b0d --- /dev/null +++ b/lib/database/migrations/0005_uncategorized_default_category.dart @@ -0,0 +1,25 @@ +// ignore_for_file: file_names + +import 'package:sqflite/sqflite.dart'; +import '../migration_base.dart'; + +// Models +import '/model/category_transaction.dart'; + +class UncategorizedDefaultCategory extends Migration { + UncategorizedDefaultCategory() + : super( + version: 5, + description: 'Create default "Uncategorized" category', + ); + + @override + Future up(Database db) async { + // Default "Uncategorized" Category + await db.execute(''' + INSERT INTO `$categoryTransactionTable`(`${CategoryTransactionFields.id}`, `${CategoryTransactionFields.name}`, `${CategoryTransactionFields.type}`, `${CategoryTransactionFields.symbol}`, `${CategoryTransactionFields.color}`, `${CategoryTransactionFields.note}`, `${CategoryTransactionFields.parent}`, `${CategoryTransactionFields.deleted}`, `${CategoryTransactionFields.createdAt}`, `${CategoryTransactionFields.updatedAt}`) VALUES + (0, "Uncategorized", "IN", "question_mark", 0, 'This is a default category for no categorized transactions', null, '0', '${DateTime.now()}', '${DateTime.now()}'), + (1, "Uncategorized", "OUT", "question_mark", 0, 'This is a default category for no categorized transactions', null, '0', '${DateTime.now()}', '${DateTime.now()}'); + '''); + } +} diff --git a/lib/database/migrations/migration_registry.dart b/lib/database/migrations/migration_registry.dart index ca6ba1cf..f000b6b6 100644 --- a/lib/database/migrations/migration_registry.dart +++ b/lib/database/migrations/migration_registry.dart @@ -14,6 +14,8 @@ library; import '0001_initial_schema.dart'; import '0002_account_net_worth.dart'; import '0003_recurring_transaction_type.dart'; +import '0004_category_marked_as_deleted.dart'; +import '0005_uncategorized_default_category.dart'; import '../migration_base.dart'; /// Returns all available migrations in execution order. @@ -27,6 +29,8 @@ List getMigrations() { InitialSchema(), AccountNetWorth(), RecurringTransactionType(), + CategoryMarkedAsDeleted(), + UncategorizedDefaultCategory(), // Add future migrations here ]; } diff --git a/lib/database/sossoldi_database.dart b/lib/database/sossoldi_database.dart index dfcde371..202c3859 100644 --- a/lib/database/sossoldi_database.dart +++ b/lib/database/sossoldi_database.dart @@ -205,14 +205,16 @@ class SossoldiDatabase { // Add fake categories await _database?.execute(''' - INSERT INTO categoryTransaction(id, name, type, symbol, color, note, parent, createdAt, updatedAt) VALUES - (10, "Out", "OUT", "restaurant", 0, '', null, '${DateTime.now()}', '${DateTime.now()}'), - (11, "Home", "OUT", "home", 1, '', null, '${DateTime.now()}', '${DateTime.now()}'), - (12, "Furniture","OUT", "home", 2, '', 11, '${DateTime.now()}', '${DateTime.now()}'), - (13, "Shopping", "OUT", "shopping_cart", 3, '', null, '${DateTime.now()}', '${DateTime.now()}'), - (14, "Leisure", "OUT", "subscriptions", 4, '', null, '${DateTime.now()}', '${DateTime.now()}'), - (15, "Transports", "OUT", "directions_car_rounded", 6, '', null, '${DateTime.now()}', '${DateTime.now()}'), - (16, "Salary", "IN", "work", 5, '', null, '${DateTime.now()}', '${DateTime.now()}'); + INSERT OR IGNORE INTO categoryTransaction(id, name, type, symbol, color, note, parent, deleted, createdAt, updatedAt) VALUES + (0, "Uncategorized", "IN", "question_mark", 0, 'This is a default category for no categorized transactions', null, '0', '${DateTime.now()}', '${DateTime.now()}'), + (1, "Uncategorized", "OUT", "question_mark", 0, 'This is a default category for no categorized transactions', null, '0', '${DateTime.now()}', '${DateTime.now()}'), + (10, "Out", "OUT", "restaurant", 1, '', null, 0, '${DateTime.now()}', '${DateTime.now()}'), + (11, "Home", "OUT", "home", 2, '', null, 0, '${DateTime.now()}', '${DateTime.now()}'), + (12, "Furniture","OUT", "home", 3, '', 11, 0, '${DateTime.now()}', '${DateTime.now()}'), + (13, "Shopping", "OUT", "shopping_cart", 4, '', null, 0, '${DateTime.now()}', '${DateTime.now()}'), + (14, "Leisure", "OUT", "subscriptions", 5, '', null, 0, '${DateTime.now()}', '${DateTime.now()}'), + (15, "Transports", "OUT", "directions_car_rounded", 6, '', null, 0, '${DateTime.now()}', '${DateTime.now()}'), + (16, "Salary", "IN", "work", 5, '', null, 0, '${DateTime.now()}', '${DateTime.now()}'); '''); // Add currencies diff --git a/lib/model/category_transaction.dart b/lib/model/category_transaction.dart index 1244a26b..ab1bcba3 100644 --- a/lib/model/category_transaction.dart +++ b/lib/model/category_transaction.dart @@ -12,6 +12,7 @@ class CategoryTransactionFields extends BaseEntityFields { static String color = 'color'; static String note = 'note'; static String parent = 'parent'; + static String deleted = 'deleted'; static String createdAt = BaseEntityFields.getCreatedAt; static String updatedAt = BaseEntityFields.getUpdatedAt; @@ -23,11 +24,49 @@ class CategoryTransactionFields extends BaseEntityFields { color, note, parent, + deleted, BaseEntityFields.createdAt, BaseEntityFields.updatedAt ]; } +class CategoryFilter { + final bool showSystemCategories; + final bool showDeletedCategories; + + const CategoryFilter({ + this.showSystemCategories = false, + this.showDeletedCategories = false, + }); + + //Avoid useless Riverpod recostructions + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is CategoryFilter && + other.showSystemCategories == showSystemCategories && + other.showDeletedCategories == showDeletedCategories; + } + + @override + int get hashCode => + showSystemCategories.hashCode ^ showDeletedCategories.hashCode; +} + +const userCategoriesFilter = CategoryFilter( + showSystemCategories: false, + showDeletedCategories: false, +); + +const availableCategoriesFilter = CategoryFilter( + showSystemCategories: true, + showDeletedCategories: false, +); +const allCategoriesFilter = CategoryFilter( + showSystemCategories: true, + showDeletedCategories: true, +); + enum CategoryTransactionType { income, expense } Map categoryTypeMap = { @@ -42,6 +81,7 @@ class CategoryTransaction extends BaseEntity { final int color; final String? note; final int? parent; + final bool deleted; const CategoryTransaction({ super.id, @@ -51,6 +91,7 @@ class CategoryTransaction extends BaseEntity { required this.color, this.note, this.parent, + required this.deleted, super.createdAt, super.updatedAt, }); @@ -63,6 +104,7 @@ class CategoryTransaction extends BaseEntity { int? color, String? note, int? parent, + bool? deleted, DateTime? createdAt, DateTime? updatedAt}) => CategoryTransaction( @@ -73,6 +115,7 @@ class CategoryTransaction extends BaseEntity { color: color ?? this.color, note: note ?? this.note, parent: parent ?? this.parent, + deleted: deleted ?? this.deleted, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt); @@ -86,6 +129,7 @@ class CategoryTransaction extends BaseEntity { color: json[CategoryTransactionFields.color] as int, note: json[CategoryTransactionFields.note] as String?, parent: json[CategoryTransactionFields.parent] as int?, + deleted: json[CategoryTransactionFields.deleted] == 1 ? true : false, createdAt: DateTime.parse(json[BaseEntityFields.createdAt] as String), updatedAt: DateTime.parse(json[BaseEntityFields.updatedAt] as String)); @@ -99,6 +143,7 @@ class CategoryTransaction extends BaseEntity { CategoryTransactionFields.color: color, CategoryTransactionFields.note: note, CategoryTransactionFields.parent: parent, + CategoryTransactionFields.deleted: deleted ? 1 : 0, BaseEntityFields.createdAt: update ? createdAt?.toIso8601String() : DateTime.now().toIso8601String(), @@ -141,6 +186,39 @@ class CategoryTransactionMethods extends SossoldiDatabase { return result.map((json) => CategoryTransaction.fromJson(json)).toList(); } + Future> selectCategories( + CategoryFilter filter) async { + final db = await database; + + String whereClause = ''; + List whereArgs = []; + + // showSystemCategories == false => no uncategorized + if (!filter.showSystemCategories) { + whereClause = + '${CategoryTransactionFields.id} != ? AND ${CategoryTransactionFields.id} != ?'; + whereArgs = [0, 1]; + } + + // showDeletedCategories == false => no deleted + if (!filter.showDeletedCategories) { + if (whereClause.isNotEmpty) { + whereClause += ' AND '; + } + whereClause += '${CategoryTransactionFields.deleted} = ?'; + whereArgs.add(0); + } + + final result = await db.query( + categoryTransactionTable, + where: whereClause.isNotEmpty ? whereClause : null, + whereArgs: whereArgs.isNotEmpty ? whereArgs : null, + orderBy: orderByASC, + ); + + return result.map((json) => CategoryTransaction.fromJson(json)).toList(); + } + Future> selectCategoriesByType( CategoryTransactionType type) async { final db = await database; @@ -173,6 +251,17 @@ class CategoryTransactionMethods extends SossoldiDatabase { ); } + Future markAsDeleted(int id) async { + final db = await database; + + return await db.update( + categoryTransactionTable, + {CategoryTransactionFields.deleted: 1}, + where: '${CategoryTransactionFields.id} = ?', + whereArgs: [id], + ); + } + Future deleteById(int id) async { final db = await database; diff --git a/lib/pages/add_page/widgets/category_selector.dart b/lib/pages/add_page/widgets/category_selector.dart index a64e9656..a3933aa7 100644 --- a/lib/pages/add_page/widgets/category_selector.dart +++ b/lib/pages/add_page/widgets/category_selector.dart @@ -32,7 +32,7 @@ class _CategorySelectorState extends ConsumerState { final transactionType = ref.watch(transactionTypeProvider); final categoryType = ref.watch(transactionToCategoryProvider(transactionType)); - final categoriesList = ref.watch(categoriesByTypeProvider(categoryType)); + final categoriesList = ref.watch(categoriesProvider(userCategoriesFilter)); return Container( color: Theme.of(context).colorScheme.primaryContainer, @@ -70,41 +70,53 @@ class _CategorySelectorState extends ConsumerState { height: 74, width: double.infinity, child: categoriesList.when( - data: (categories) => ListView.builder( - itemCount: categories.length, // to prevent range error - scrollDirection: Axis.horizontal, - shrinkWrap: true, - itemBuilder: (context, i) { - CategoryTransaction category = categories[i]; - return GestureDetector( - onTap: () => _selectCategory(context, category), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Sizes.lg), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - RoundedIcon( - icon: iconList[category.symbol], - backgroundColor: - categoryColorListTheme[category.color], - ), - Text( - category.name, - style: Theme.of(context) - .textTheme - .labelLarge! - .copyWith( - color: Theme.of(context) - .colorScheme - .primary), - ), - ], + data: (categories) { + final availableCategories = categories + .where((category) => + category.type == categoryType && + !category.deleted) + .toList(); + return ListView.builder( + itemCount: availableCategories.length, + scrollDirection: Axis.horizontal, + shrinkWrap: true, + itemBuilder: (context, i) { + CategoryTransaction category = + availableCategories[i]; + return GestureDetector( + onTap: () => { + ref.read(categoryProvider.notifier).state = + category, + Navigator.of(context).pop(), + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RoundedIcon( + icon: iconList[category.symbol], + backgroundColor: categoryColorListTheme[ + category.color], + ), + Text( + category.name, + style: Theme.of(context) + .textTheme + .labelLarge! + .copyWith( + color: Theme.of(context) + .colorScheme + .primary), + ), + ], + ), ), - ), - ); - }, - ), + ); + }, + ); + }, loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Text('Error: $err'), @@ -121,33 +133,46 @@ class _CategorySelectorState extends ConsumerState { ), ), categoriesList.when( - data: (categories) => Container( - color: Theme.of(context).colorScheme.surface, - child: ListView.separated( - itemCount: categories.length, - scrollDirection: Axis.vertical, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - separatorBuilder: (context, index) => - const Divider(height: 1, color: grey1), - itemBuilder: (context, i) { - CategoryTransaction category = categories[i]; - return ListTile( - onTap: () => _selectCategory(context, category), - leading: RoundedIcon( - icon: iconList[category.symbol], - backgroundColor: - categoryColorListTheme[category.color], - ), - title: Text(category.name), - trailing: - ref.watch(categoryProvider)?.id == category.id - ? Icon(Icons.check) - : null, - ); - }, - ), - ), + data: (categories) { + final availableCategories = categories + .where((category) => + category.type == categoryType && + !category.deleted) + .toList(); + + return Container( + color: Theme.of(context).colorScheme.surface, + child: ListView.separated( + itemCount: availableCategories.length, + scrollDirection: Axis.vertical, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + separatorBuilder: (context, index) => + const Divider(height: 1, color: grey1), + itemBuilder: (context, i) { + CategoryTransaction category = + availableCategories[i]; + return ListTile( + onTap: () => { + ref.read(categoryProvider.notifier).state = + category, + Navigator.of(context).pop(), + }, + leading: RoundedIcon( + icon: iconList[category.symbol], + backgroundColor: + categoryColorListTheme[category.color], + ), + title: Text(category.name), + trailing: + ref.watch(categoryProvider)?.id == category.id + ? Icon(Icons.check) + : null, + ); + }, + ), + ); + }, loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Text('Error: $err'), diff --git a/lib/pages/categories/add_category.dart b/lib/pages/categories/add_category.dart index d624669a..299b0072 100644 --- a/lib/pages/categories/add_category.dart +++ b/lib/pages/categories/add_category.dart @@ -7,6 +7,8 @@ import '../../providers/categories_provider.dart'; import '../../providers/transactions_provider.dart'; import '../../ui/device.dart'; import '../../ui/extensions.dart'; +import '../../ui/widgets/alert_dialog.dart'; +import 'widgets/delete_category_dialog.dart'; class AddCategory extends ConsumerStatefulWidget { final bool hideIncome; @@ -48,6 +50,27 @@ class _AddCategoryState extends ConsumerState { super.dispose(); } + bool _isDuplicateCategory() { + final existingCategoriesAsync = + ref.read(categoriesProvider(userCategoriesFilter)); + final selectedCategory = ref.read(selectedCategoryProvider); + + if (existingCategoriesAsync is AsyncData) { + final existingCategories = existingCategoriesAsync.value; + + return existingCategories!.any((category) { + if (selectedCategory != null && category.id == selectedCategory.id) { + return false; + } + return category.name == nameController.text && + category.type == categoryType && + category.symbol == categoryIcon && + category.color == categoryColor; + }); + } + return false; + } + @override Widget build(BuildContext context) { final selectedCategory = ref.watch(selectedCategoryProvider); @@ -131,11 +154,8 @@ class _AddCategoryState extends ConsumerState { underline: const SizedBox(), isExpanded: true, items: (widget.hideIncome - ? [ - CategoryTransactionType.expense - ] // Only show 'expense' if true - : CategoryTransactionType - .values) // Otherwise, show all values + ? [CategoryTransactionType.expense] + : CategoryTransactionType.values) .map((CategoryTransactionType type) { return DropdownMenuItem( value: type, @@ -381,14 +401,8 @@ class _AddCategoryState extends ConsumerState { width: double.infinity, padding: const EdgeInsets.all(Sizes.lg), child: TextButton.icon( - onPressed: () => ref - .read(categoriesProvider.notifier) - .removeCategory(selectedCategory.id!) - .whenComplete(() { - if (context.mounted) { - Navigator.of(context).pop(); - } - }), + onPressed: () => showDeleteCategoryDialog( + context, ref, selectedCategory), style: TextButton.styleFrom( side: const BorderSide(color: red, width: 1), ), @@ -432,31 +446,39 @@ class _AddCategoryState extends ConsumerState { ), child: ElevatedButton( onPressed: () async { - if (nameController.text.isNotEmpty) { - if (selectedCategory != null) { - await ref - .read(categoriesProvider.notifier) - .updateCategory( - name: nameController.text, - type: categoryType, - icon: categoryIcon, - color: categoryColor, - ); - } else { - await ref.read(categoriesProvider.notifier).addCategory( - name: nameController.text, - type: categoryType, - icon: categoryIcon, - color: categoryColor, - ); - } - ref.invalidate(selectedCategoryProvider); - ref.invalidate(categoryMapProvider); - // Result from the .pop is used in lib\pages\planning_page\manage_budget_page.dart. - // - // If the category has been created correctly, result is true. - if (context.mounted) Navigator.of(context).pop(true); + if (nameController.text.isEmpty) { + showWarningDialog(context, "Category name cannot be empty"); + return; + } + + if (_isDuplicateCategory()) { + showErrorDialog( + context, "An identical category already exists"); + return; + } + + if (selectedCategory != null) { + await ref + .read(categoriesProvider(userCategoriesFilter).notifier) + .updateCategory( + name: nameController.text, + type: categoryType, + icon: categoryIcon, + color: categoryColor, + ); + } else { + await ref + .read(categoriesProvider(userCategoriesFilter).notifier) + .addCategory( + name: nameController.text, + type: categoryType, + icon: categoryIcon, + color: categoryColor, + ); } + ref.invalidate(selectedCategoryProvider); + ref.invalidate(categoryMapProvider); + if (context.mounted) Navigator.of(context).pop(); }, child: Text( "${selectedCategory == null ? "CREATE" : "UPDATE"} CATEGORY", diff --git a/lib/pages/categories/category_list.dart b/lib/pages/categories/category_list.dart index 74bbfe99..2beafc63 100644 --- a/lib/pages/categories/category_list.dart +++ b/lib/pages/categories/category_list.dart @@ -18,7 +18,7 @@ class CategoryList extends ConsumerStatefulWidget { class _CategoryListState extends ConsumerState { @override Widget build(BuildContext context) { - final categorysList = ref.watch(categoriesProvider); + final categorysList = ref.watch(categoriesProvider(userCategoriesFilter)); ref.listen(selectedCategoryProvider, (_, __) {}); return Scaffold( appBar: AppBar( diff --git a/lib/pages/categories/widgets/delete_category_dialog.dart b/lib/pages/categories/widgets/delete_category_dialog.dart new file mode 100644 index 00000000..274775c7 --- /dev/null +++ b/lib/pages/categories/widgets/delete_category_dialog.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../model/category_transaction.dart'; +import '../../../providers/budgets_provider.dart'; +import '../../../providers/categories_provider.dart'; +import '../../../providers/dashboard_provider.dart'; +import '../../../providers/statistics_provider.dart'; +import '../../../providers/transactions_provider.dart'; + +Future showDeleteCategoryDialog( + BuildContext context, WidgetRef ref, selectedCategory) async { + void backToCategoryList() { + if (context.mounted) { + Navigator.of(context) + .popUntil((route) => route.settings.name == '/category-list'); + } + } + + void invalidateProviders() { + ref.invalidate(categoriesProvider(userCategoriesFilter)); + ref.invalidate(categoryByIdProvider); + ref.invalidate(transactionsProvider); + ref.invalidate(budgetsProvider); + ref.invalidate(dashboardProvider); + ref.invalidate(lastTransactionsProvider); + ref.invalidate(statisticsProvider); + } + + return showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + content: const SingleChildScrollView( + child: ListBody( + children: [ + Text( + "Mark as deleted: Category remains available for existing transitions but cannot be used for new ones.\n", + ), + Text( + "Delete: All transitions using this category will be automatically changed to 'Uncategorized'.\n", + ), + Text( + "Both options will delete budgets with the specified category.", + ), + ], + ), + ), + actions: [ + TextButton( + child: Text( + "Mark as deleted", + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + onPressed: () async { + await ref + .read(categoriesProvider(userCategoriesFilter).notifier) + .markAsDeleted(selectedCategory.id); + invalidateProviders(); + backToCategoryList(); + }), + TextButton( + child: Text( + "Delete", + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + onPressed: () async { + await ref + .read(categoriesProvider(userCategoriesFilter).notifier) + .removeCategory(selectedCategory.id!); + invalidateProviders(); + backToCategoryList(); + }), + ], + ); + }, + ); +} diff --git a/lib/pages/onboarding_page/widgets/budget_setup.dart b/lib/pages/onboarding_page/widgets/budget_setup.dart index 97b97deb..85e84ea1 100644 --- a/lib/pages/onboarding_page/widgets/budget_setup.dart +++ b/lib/pages/onboarding_page/widgets/budget_setup.dart @@ -2,6 +2,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../model/category_transaction.dart'; import '../../../ui/device.dart'; import '../../categories/add_category.dart'; import '/constants/constants.dart'; @@ -33,7 +34,7 @@ class _BudgetSetupState extends ConsumerState { totalBudget = budgetsList?.fold( 0, (total, budget) => total + budget.amountLimit) ?? 0; - final categoriesGrid = ref.watch(categoriesProvider); + final categoriesGrid = ref.watch(categoriesProvider(userCategoriesFilter)); return Scaffold( backgroundColor: blue7, body: SafeArea( diff --git a/lib/pages/planning_page/manage_budget_page.dart b/lib/pages/planning_page/manage_budget_page.dart index e06cc338..c728736c 100644 --- a/lib/pages/planning_page/manage_budget_page.dart +++ b/lib/pages/planning_page/manage_budget_page.dart @@ -23,7 +23,9 @@ class _ManageBudgetPageState extends ConsumerState { List deletedBudgets = []; void _loadCategories() async { - categories = await ref.read(categoriesProvider.notifier).getCategories(); + categories = await ref + .read(categoriesProvider(userCategoriesFilter).notifier) + .getCategories(); categories.removeWhere( (element) => element.type == CategoryTransactionType.income); budgets = await ref.read(budgetsProvider.notifier).getBudgets(); diff --git a/lib/pages/planning_page/widget/budget_card.dart b/lib/pages/planning_page/widget/budget_card.dart index 3d31427e..9b4f33cd 100644 --- a/lib/pages/planning_page/widget/budget_card.dart +++ b/lib/pages/planning_page/widget/budget_card.dart @@ -5,6 +5,7 @@ import '../../../ui/widgets/default_container.dart'; import '../../../model/budget.dart'; import '../../../model/transaction.dart'; import '../../../providers/budgets_provider.dart'; +import '../../../providers/categories_provider.dart'; import '../../../providers/currency_provider.dart'; import '../../../providers/transactions_provider.dart'; import '../../../ui/assets.dart'; @@ -65,6 +66,9 @@ class _BudgetCardState extends ConsumerState { .fold(0.0, (sum, t) => sum + t.amount) .toStringAsFixed(2)); Budget budget = budgets.elementAt(index); + final budgetCategory = ref + .watch(categoryByIdProvider(budget.idCategory)) + .value; return Column( children: [ Row( diff --git a/lib/pages/planning_page/widget/budget_pie_chart.dart b/lib/pages/planning_page/widget/budget_pie_chart.dart index c0f35a6d..75552796 100644 --- a/lib/pages/planning_page/widget/budget_pie_chart.dart +++ b/lib/pages/planning_page/widget/budget_pie_chart.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../constants/constants.dart'; import '../../../model/budget.dart'; +import '../../../providers/categories_provider.dart'; import '../../../providers/currency_provider.dart'; import '../../../ui/device.dart'; @@ -51,11 +52,12 @@ class BudgetPieChartState extends ConsumerState { List showingSections() { return List.generate(widget.budgets.length, (i) { final Budget budget = widget.budgets.elementAt(i); - + final budgetCategory = + ref.watch(categoryByIdProvider(budget.idCategory)).value; double value = (budget.amountLimit / totalBudget) * 100; return PieChartSectionData( - color: categoryColorList[i], + color: categoryColorList[budgetCategory?.color ?? 1], value: value, title: "", radius: 20, diff --git a/lib/pages/planning_page/widget/recurring_payment_card.dart b/lib/pages/planning_page/widget/recurring_payment_card.dart index d37d4c24..955ac640 100644 --- a/lib/pages/planning_page/widget/recurring_payment_card.dart +++ b/lib/pages/planning_page/widget/recurring_payment_card.dart @@ -2,6 +2,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../constants/constants.dart'; +import '../../../model/category_transaction.dart'; import '../../../ui/extensions.dart'; import '../../../ui/widgets/rounded_icon.dart'; import '../../../model/recurring_transaction.dart'; @@ -37,7 +38,7 @@ class RecurringPaymentCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final categories = ref.watch(categoriesProvider).value; + final categories = ref.watch(categoriesProvider(allCategoriesFilter)).value; final accounts = ref.watch(accountsProvider).value; final isDarkMode = ref.watch(appThemeStateNotifier).isDarkModeEnabled; final currencyState = ref.watch(currencyStateNotifier); @@ -69,6 +70,7 @@ class RecurringPaymentCard extends ConsumerWidget { backgroundColor: categoryColorList[category.color], padding: const EdgeInsets.all(Sizes.sm), size: 25, + deleted: category.deleted, ), const SizedBox(width: Sizes.sm), Expanded( diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index d17ca351..5a095018 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; import '../constants/style.dart'; +import '../model/category_transaction.dart'; import '../ui/widgets/alert_dialog.dart'; import '../ui/widgets/default_card.dart'; import '../database/sossoldi_database.dart'; @@ -263,7 +264,7 @@ class _SettingsPageState extends ConsumerState { onPressed: () async { await SossoldiDatabase.instance.resetDatabase(); ref.refresh(accountsProvider); - ref.refresh(categoriesProvider); + ref.refresh(categoriesProvider(userCategoriesFilter)); ref.refresh(transactionsProvider); ref.refresh(budgetsProvider); @@ -278,7 +279,7 @@ class _SettingsPageState extends ConsumerState { await SossoldiDatabase.instance.clearDatabase(); await SossoldiDatabase.instance.fillDemoData(); ref.refresh(accountsProvider); - ref.refresh(categoriesProvider); + ref.refresh(categoriesProvider(userCategoriesFilter)); ref.refresh(transactionsProvider); ref.refresh(budgetsProvider); ref.refresh(dashboardProvider); diff --git a/lib/pages/transactions_page/widgets/categories_tab.dart b/lib/pages/transactions_page/widgets/categories_tab.dart index 7ee2e893..ec6ec6dc 100644 --- a/lib/pages/transactions_page/widgets/categories_tab.dart +++ b/lib/pages/transactions_page/widgets/categories_tab.dart @@ -23,7 +23,7 @@ class CategoriesTab extends ConsumerStatefulWidget { class _CategoriesTabState extends ConsumerState { @override Widget build(BuildContext context) { - final categories = ref.watch(categoriesProvider); + final categories = ref.watch(categoriesProvider(allCategoriesFilter)); final transactions = ref.watch(transactionsProvider); final transactionType = ref.watch(selectedTransactionTypeProvider); diff --git a/lib/providers/budgets_provider.dart b/lib/providers/budgets_provider.dart index babd3a85..e5882ff5 100644 --- a/lib/providers/budgets_provider.dart +++ b/lib/providers/budgets_provider.dart @@ -57,3 +57,12 @@ final budgetsProvider = AsyncNotifierProvider>(() { return AsyncBudgetsNotifier(); }); + +final deleteBudgetsByCategoryProvider = + Provider Function(int)>((ref) { + return (int categoryId) async { + await BudgetMethods().deleteByCategory(categoryId); + + await ref.read(budgetsProvider.notifier).refreshBudgets(); + }; +}); diff --git a/lib/providers/categories_provider.dart b/lib/providers/categories_provider.dart index 0d0c161b..f7428b23 100644 --- a/lib/providers/categories_provider.dart +++ b/lib/providers/categories_provider.dart @@ -1,7 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../model/category_transaction.dart'; +import '../model/recurring_transaction.dart'; import '../model/transaction.dart'; +import 'budgets_provider.dart'; import 'transactions_provider.dart'; final categoryTransactionTypeList = Provider>( @@ -16,14 +18,17 @@ final categoryTypeProvider = StateProvider( final selectedCategoryIndexProvider = StateProvider.autoDispose((ref) => -1); -class AsyncCategoriesNotifier extends AsyncNotifier> { +class AsyncCategoriesNotifier + extends FamilyAsyncNotifier, CategoryFilter> { @override - Future> build() async { - return _getCategories(); + Future> build(CategoryFilter filter) async { + return _getCategories(filter); } - Future> _getCategories() async { - final categories = await CategoryTransactionMethods().selectAll(); + Future> _getCategories( + CategoryFilter filter) async { + final categories = + await CategoryTransactionMethods().selectCategories(filter); return categories; } @@ -38,13 +43,14 @@ class AsyncCategoriesNotifier extends AsyncNotifier> { symbol: icon, type: type, color: color, + deleted: false, ); state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { await CategoryTransactionMethods().insert(category); ref.invalidate(categoriesByTypeProvider(category.type)); - return _getCategories(); + return _getCategories(arg); }); } @@ -64,28 +70,104 @@ class AsyncCategoriesNotifier extends AsyncNotifier> { state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { await CategoryTransactionMethods().updateItem(category); - return _getCategories(); + return _getCategories(arg); }); } + Future markAsDeleted(int categoryId) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + await ref.read(deleteBudgetsByCategoryProvider)(categoryId); + await CategoryTransactionMethods().markAsDeleted(categoryId); + return _getCategories(arg); + }); + } + + final reassignTransactionsProvider = + Provider Function(int, CategoryTransactionType)>((ref) { + return (int categoryId, CategoryTransactionType categoryType) async { + final defaultCategoryId = + categoryType == CategoryTransactionType.income ? 0 : 1; + + final transactions = await TransactionMethods().selectAll(); + final affectedTransactions = + transactions.where((t) => t.idCategory == categoryId).toList(); + + for (var transaction in affectedTransactions) { + final updatedTransaction = + transaction.copy(idCategory: defaultCategoryId); + await TransactionMethods().updateItem(updatedTransaction); + } + + ref.invalidate(transactionsProvider); + }; + }); + + final reassignRecurringTransactionsProvider = + Provider Function(int, CategoryTransactionType)>((ref) { + return (int categoryId, CategoryTransactionType categoryType) async { + final defaultCategoryId = + categoryType == CategoryTransactionType.income ? 0 : 1; + + final recurringTransactions = + await RecurringTransactionMethods().selectAll(); + final affectedRecurringTransactions = recurringTransactions + .where((t) => t.idCategory == categoryId) + .toList(); + + for (var recurringTransaction in affectedRecurringTransactions) { + final updatedTransaction = + recurringTransaction.copy(idCategory: defaultCategoryId); + await RecurringTransactionMethods().updateItem(updatedTransaction); + } + + ref.invalidate(transactionsProvider); + }; + }); + + //final reassignBudgetsProvider = + // Provider Function(int, CategoryTransactionType)>((ref) { + // return (int categoryId, CategoryTransactionType categoryType) async { + // final defaultCategoryId = 1; +// + // final budgets = + // await BudgetMethods().selectAll(); + // final affectedBudgets = budgets + // .where((t) => t.idCategory == categoryId) + // .toList(); +// + // for (var budget in affectedBudgets) { + // final updatedBudget = + // budget.copy(idCategory: defaultCategoryId); + // await BudgetMethods().updateItem(updatedBudget); + // } +// + // ref.invalidate(budgetsProvider); + // }; + //}); + Future removeCategory(int categoryId) async { + final category = await CategoryTransactionMethods().selectById(categoryId); + state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { + await ref.read(reassignTransactionsProvider)(categoryId, category.type); + await ref.read(reassignRecurringTransactionsProvider)( + categoryId, category.type); + await ref.read(deleteBudgetsByCategoryProvider)(categoryId); + await CategoryTransactionMethods().deleteById(categoryId); - return _getCategories(); + return _getCategories(arg); }); } Future> getCategories() async { - return _getCategories(); + return _getCategories(arg); } } -final categoriesProvider = - AsyncNotifierProvider>( - () { - return AsyncCategoriesNotifier(); -}); +final categoriesProvider = AsyncNotifierProviderFamily, CategoryFilter>(() => AsyncCategoriesNotifier()); final categoriesByTypeProvider = FutureProvider.family, CategoryTransactionType?>( @@ -98,6 +180,12 @@ final categoriesByTypeProvider = return categories; }); +final categoryByIdProvider = + FutureProvider.family((ref, id) async { + final category = await CategoryTransactionMethods().selectById(id); + return category; +}); + final categoryMapProvider = FutureProvider>((ref) async { final categoryType = ref.watch(categoryTypeProvider); diff --git a/lib/ui/widgets/rounded_icon.dart b/lib/ui/widgets/rounded_icon.dart index 498a056d..61aef146 100644 --- a/lib/ui/widgets/rounded_icon.dart +++ b/lib/ui/widgets/rounded_icon.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; import '../../constants/style.dart'; -import '../device.dart'; class RoundedIcon extends StatelessWidget { const RoundedIcon({ this.icon, this.backgroundColor, this.size = 24, - this.padding = const EdgeInsets.all(Sizes.md), + this.padding = const EdgeInsets.all(10.0), + this.deleted = false, + this.onDelete, super.key, }); @@ -16,22 +17,46 @@ class RoundedIcon extends StatelessWidget { final Color? backgroundColor; final double? size; final EdgeInsets? padding; + final bool deleted; + final VoidCallback? onDelete; @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: backgroundColor, - ), - padding: padding, - child: icon != null - ? Icon( - icon, - size: size, - color: white, - ) - : const SizedBox(), + return Stack( + children: [ + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: backgroundColor, + ), + padding: padding, + child: icon != null + ? Icon( + icon, + size: size, + color: white, + ) + : const SizedBox(), + ), + if (deleted) + Positioned( + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: category0, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + child: const Icon( + Icons.dangerous, + size: 12, + color: Colors.white, + ), + ), + ), + ], ); } } diff --git a/lib/ui/widgets/transactions_list.dart b/lib/ui/widgets/transactions_list.dart index 8636c2f6..ee9af343 100644 --- a/lib/ui/widgets/transactions_list.dart +++ b/lib/ui/widgets/transactions_list.dart @@ -5,6 +5,7 @@ import 'package:intl/intl.dart'; import '../../constants/constants.dart'; import '../../constants/style.dart'; import '../../model/transaction.dart'; +import '../../providers/categories_provider.dart'; import '../../providers/currency_provider.dart'; import '../../providers/transactions_provider.dart'; import '../device.dart'; @@ -128,6 +129,10 @@ class TransactionTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final currencyState = ref.watch(currencyStateNotifier); + + final category = + ref.watch(categoryByIdProvider(transaction.idCategory!)).value; + return Material( child: ListTile( visualDensity: VisualDensity.compact, @@ -155,6 +160,7 @@ class TransactionTile extends ConsumerWidget { : Theme.of(context).colorScheme.secondary, size: 25, padding: const EdgeInsets.all(Sizes.sm), + deleted: category != null ? category.deleted : false, ), title: Text( (transaction.note?.isEmpty ?? true) diff --git a/test/model/category_transaction_test.dart b/test/model/category_transaction_test.dart index e4c12b2c..78022ca3 100644 --- a/test/model/category_transaction_test.dart +++ b/test/model/category_transaction_test.dart @@ -11,6 +11,7 @@ void main() { type: CategoryTransactionType.expense, symbol: "symbol", color: 0, + deleted: false, createdAt: DateTime.utc(2022), updatedAt: DateTime.utc(2022)); @@ -21,6 +22,7 @@ void main() { assert(cCopy.type == c.type); assert(cCopy.symbol == c.symbol); assert(cCopy.color == c.color); + assert(cCopy.deleted == c.deleted); assert(cCopy.createdAt == c.createdAt); assert(cCopy.updatedAt == c.updatedAt); }); @@ -53,13 +55,13 @@ void main() { test("Test toJson Category Transaction", () { CategoryTransaction c = const CategoryTransaction( - id: 2, - name: "name", - type: CategoryTransactionType.expense, - symbol: "symbol", - color: 0, - note: "note", - ); + id: 2, + name: "name", + type: CategoryTransactionType.expense, + symbol: "symbol", + color: 0, + note: "note", + deleted: false); Map json = c.toJson(); @@ -69,5 +71,6 @@ void main() { assert(c.symbol == json[CategoryTransactionFields.symbol]); assert(c.color == json[CategoryTransactionFields.color]); assert(c.note == json[CategoryTransactionFields.note]); + assert((c.deleted ? 1 : 0) == json[CategoryTransactionFields.deleted]); }); }