From 68138c58874ecf548b816d87704fa198ce409b60 Mon Sep 17 00:00:00 2001 From: Guilherme Bufolo Date: Sun, 8 Dec 2024 01:44:32 +0100 Subject: [PATCH] feat: Allow adding and removing shopping lists more easily --- .../shoppinglist/shoppinglist_controller.py | 11 ++- kitchenowl/lib/cubits/shoppinglist_cubit.dart | 49 ++++++++++- kitchenowl/lib/l10n/app_en.arb | 2 + kitchenowl/lib/models/household.dart | 6 +- .../pages/household_page/shoppinglist.dart | 88 ++++++++++++++----- .../household_settings_shoppinglist_page.dart | 58 ++++++------ kitchenowl/lib/services/api/shoppinglist.dart | 8 ++ kitchenowl/lib/widgets/choice_scroll.dart | 4 +- .../shopping_list_choice_chip.dart | 51 ++++++----- 9 files changed, 205 insertions(+), 72 deletions(-) diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index ffba37aec..ce38410e6 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -103,7 +103,16 @@ def updateShoppinglist(args, id): shoppinglist.name = args["name"] shoppinglist.save() - return jsonify(shoppinglist.obj_to_dict()) + shoppinglist_dict = shoppinglist.obj_to_dict(); + socketio.emit( + "shoppinglist:update", + { + "shoppinglist": shoppinglist_dict + }, + to=shoppinglist.household_id + ) + + return jsonify(shoppinglist_dict) @shoppinglist.route("/", methods=["DELETE"]) diff --git a/kitchenowl/lib/cubits/shoppinglist_cubit.dart b/kitchenowl/lib/cubits/shoppinglist_cubit.dart index dd77776b7..c4ca39a38 100644 --- a/kitchenowl/lib/cubits/shoppinglist_cubit.dart +++ b/kitchenowl/lib/cubits/shoppinglist_cubit.dart @@ -9,11 +9,12 @@ import 'package:kitchenowl/models/shoppinglist.dart'; import 'package:kitchenowl/services/api/api_service.dart'; import 'package:kitchenowl/services/storage/storage.dart'; import 'package:kitchenowl/services/transactions/category.dart'; +import 'package:kitchenowl/services/transactions/household.dart'; import 'package:kitchenowl/services/transactions/shoppinglist.dart'; import 'package:kitchenowl/services/transaction_handler.dart'; class ShoppinglistCubit extends Cubit { - final Household household; + Household household; Future? _refreshThread; String? _refreshCurrentQuery; @@ -35,6 +36,7 @@ class ShoppinglistCubit extends Cubit { refresh(); ApiService.getInstance().onShoppinglistAdd(onShoppinglistAdd); ApiService.getInstance().onShoppinglistDelete(onShoppinglistDelete); + ApiService.getInstance().onShoppinglistUpdate(onShoppinglistUpdate); ApiService.getInstance().onShoppinglistItemAdd(onShoppinglistItemAdd); ApiService.getInstance().onShoppinglistItemRemove(onShoppinglistItemRemove); } @@ -43,6 +45,7 @@ class ShoppinglistCubit extends Cubit { Future close() async { ApiService.getInstance().offShoppinglistAdd(onShoppinglistAdd); ApiService.getInstance().offShoppinglistDelete(onShoppinglistDelete); + ApiService.getInstance().offShoppinglistUpdate(onShoppinglistUpdate); ApiService.getInstance().offShoppinglistItemAdd(onShoppinglistItemAdd); ApiService.getInstance() .offShoppinglistItemRemove(onShoppinglistItemRemove); @@ -57,6 +60,23 @@ class ShoppinglistCubit extends Cubit { refresh(); } + void onShoppinglistUpdate(dynamic data) { + refresh(); + } + + Future updateHousehold() async { + final maybeHousehold = + await TransactionHandler.getInstance().runTransaction( + TransactionHouseholdGet( + household: household, + ), + ); + + if (maybeHousehold != null) { + household = maybeHousehold; + } + } + void onShoppinglistItemAdd(dynamic data) { final item = ShoppinglistItem.fromJson(data["item"]); TransactionHandler.getInstance().runTransaction( @@ -272,6 +292,22 @@ class ShoppinglistCubit extends Cubit { emit(state.copyWith(sorting: sorting)); } + Future addShoppingList(String name) async { + final res = await ApiService.getInstance() + .addShoppingList(household, ShoppingList(name: name)); + refresh(); + + return res; + } + + Future deleteShoppingList(ShoppingList shoppingList) async { + if (household.defaultShoppingList == shoppingList) return false; + final res = await ApiService.getInstance().deleteShoppingList(shoppingList); + refresh(); + + return res; + } + void setShoppingList(ShoppingList shoppingList) { if (shoppingList.id != null) { PreferenceStorage.getInstance().writeInt( @@ -351,6 +387,7 @@ class ShoppinglistCubit extends Cubit { shoppingList!.items.firstWhereOrNull((item) => item.id == e.id)) .nonNulls .toList(), + defaultShoppingList: household.defaultShoppingList, ); if (state is LoadingShoppinglistCubitState) { @@ -371,9 +408,11 @@ class ShoppinglistCubit extends Cubit { sorting: state.sorting, categories: state.categories, selectedListItems: state.selectedListItems, + defaultShoppingList: state.defaultShoppingList, )); } + await updateHousehold(); final shoppingLists = await fetchShoppingLists(false); int? selectedShoppinglistId = state.selectedShoppinglistId; @@ -436,6 +475,7 @@ class ShoppinglistCubit extends Cubit { shoppinglist?.items.firstWhereOrNull((item) => item.id == e.id)) .nonNulls .toList(), + defaultShoppingList: household.defaultShoppingList, ); } else { resState = ShoppinglistCubitState( @@ -448,6 +488,7 @@ class ShoppinglistCubit extends Cubit { shoppinglist?.items.firstWhereOrNull((item) => item.id == e.id)) .nonNulls .toList(), + defaultShoppingList: household.defaultShoppingList, ); } if (query == _refreshCurrentQuery) { @@ -487,6 +528,7 @@ class ShoppinglistCubitState extends Equatable { final List categories; final ShoppinglistSorting sorting; final List selectedListItems; + final ShoppingList? defaultShoppingList; final ShoppingList? _selectedShoppinglist; const ShoppinglistCubitState._({ @@ -495,6 +537,7 @@ class ShoppinglistCubitState extends Equatable { this.sorting = ShoppinglistSorting.alphabetical, this.selectedListItems = const [], this.selectedShoppinglistId = null, + required this.defaultShoppingList, }) : this._selectedShoppinglist = null; ShoppinglistCubitState({ @@ -503,6 +546,7 @@ class ShoppinglistCubitState extends Equatable { this.categories = const [], this.sorting = ShoppinglistSorting.alphabetical, this.selectedListItems = const [], + required this.defaultShoppingList, }) : _selectedShoppinglist = shoppinglists[selectedShoppinglistId]; ShoppingList? get selectedShoppinglist => _selectedShoppinglist; @@ -521,6 +565,7 @@ class ShoppinglistCubitState extends Equatable { categories: categories ?? this.categories, sorting: sorting ?? this.sorting, selectedListItems: selectedListItems ?? this.selectedListItems, + defaultShoppingList: this.defaultShoppingList, ); @override @@ -540,6 +585,7 @@ class LoadingShoppinglistCubitState extends ShoppinglistCubitState { super.shoppinglists, super.categories, super.selectedListItems, + super.defaultShoppingList, }) : super._(); @override @@ -574,6 +620,7 @@ class SearchShoppinglistCubitState extends ShoppinglistCubitState { this.query = "", this.result = const [], super.selectedListItems, + super.defaultShoppingList, }); @override diff --git a/kitchenowl/lib/l10n/app_en.arb b/kitchenowl/lib/l10n/app_en.arb index cfcf0fb9f..cf8e20e7c 100644 --- a/kitchenowl/lib/l10n/app_en.arb +++ b/kitchenowl/lib/l10n/app_en.arb @@ -299,6 +299,7 @@ "@setupTitle": {}, "@share": {}, "@shoppingList": {}, + "@shoppingListAddTooltip": {}, "@shoppingListContainsEntries": { "placeholders": { "entriesCount": { @@ -600,6 +601,7 @@ "setupTitle": "Hey there! Ready to shop?", "share": "Share", "shoppingList": "Shopping list", + "shoppingListAddTooltip": "Add a shopping list", "shoppingListContainsEntries": "{entriesCount, plural, =0{The shopping list contains no items.} =1{The shopping list contains 1 item.} other{The shopping list contains {entriesCount} items.}}", "shoppingListDelete": "Delete shopping list", "shoppingListDeleteConfirmation": "Are you sure you want to delete {shoppingList}?", diff --git a/kitchenowl/lib/models/household.dart b/kitchenowl/lib/models/household.dart index 024f007be..32d634952 100644 --- a/kitchenowl/lib/models/household.dart +++ b/kitchenowl/lib/models/household.dart @@ -43,7 +43,7 @@ class Household extends Model { member = List.from(map['member'].map((e) => Member.fromJson(e))); } - return Household( + final household = Household( id: map['id'], name: map['name'], image: map['photo'], @@ -57,6 +57,8 @@ class Household extends Model { ? ShoppingList.fromJson(map['default_shopping_list']) : null, ); + + return household; } Household copyWith({ @@ -66,6 +68,7 @@ class Household extends Model { bool? featurePlanner, bool? featureExpenses, List? viewOrdering, + ShoppingList? defaultShoppingList }) => Household( id: id, @@ -76,6 +79,7 @@ class Household extends Model { featurePlanner: featurePlanner ?? this.featurePlanner, featureExpenses: featureExpenses ?? this.featureExpenses, viewOrdering: viewOrdering ?? this.viewOrdering, + defaultShoppingList: defaultShoppingList ?? this.defaultShoppingList ); @override diff --git a/kitchenowl/lib/pages/household_page/shoppinglist.dart b/kitchenowl/lib/pages/household_page/shoppinglist.dart index 4a3e6f474..b6f4e64f9 100644 --- a/kitchenowl/lib/pages/household_page/shoppinglist.dart +++ b/kitchenowl/lib/pages/household_page/shoppinglist.dart @@ -7,6 +7,7 @@ import 'package:kitchenowl/cubits/shoppinglist_cubit.dart'; import 'package:kitchenowl/enums/shoppinglist_sorting.dart'; import 'package:kitchenowl/models/item.dart'; import 'package:kitchenowl/kitchenowl.dart'; +import 'package:kitchenowl/pages/settings_household/household_settings_shoppinglist_page.dart'; import 'package:kitchenowl/widgets/choice_scroll.dart'; import 'package:kitchenowl/widgets/shopping_list/shopping_list_choice_chip.dart'; import 'package:kitchenowl/widgets/shopping_list/sliver_shopinglist_item_view.dart'; @@ -120,29 +121,70 @@ class _ShoppinglistPageState extends State { headerSliverBuilder: (context, innerBoxIsScrolled) => [ SliverToBoxAdapter( child: LeftRightWrap( - left: (state.shoppinglists.length < 2) - ? const SizedBox() - : ChoiceScroll( - children: state.shoppinglists.values - .sorted((a, b) => b.items.length - .compareTo(a.items.length)) - .map( - (shoppinglist) => - ShoppingListChoiceChip( - shoppingList: shoppinglist, - selected: shoppinglist.id == - state.selectedShoppinglistId, - onSelected: (bool selected) { - if (selected) { - cubit.setShoppingList( - shoppinglist, - ); - } - }, - ), - ) - .toList(), - ), + left: Row( + children: [ + IconButton( + tooltip: AppLocalizations.of(context)! + .shoppingListAddTooltip, + onPressed: () async { + final res = + await HouseholdSettingsShoppinglistPage + .getShoppingListName(context, + state.shoppinglists.values); + if (res != null) { + BlocProvider.of( + context) + .addShoppingList(res); + } + }, + icon: Icon(Icons.add, + color: Theme.of(context) + .colorScheme + .primary)), + (state.shoppinglists.length < 2) + ? const SizedBox() + : ChoiceScroll( + paddingLeft: 0, + children: state.shoppinglists.values + .sorted((a, b) => + a.name.compareTo(b.name)) + .map( + (shoppinglist) => + ShoppingListChoiceChip( + canDelete: state + .defaultShoppingList != + shoppinglist, + shoppingList: + shoppinglist, + selected: shoppinglist + .id == + state + .selectedShoppinglistId, + onSelected: + (bool selected) { + if (selected) { + cubit + .setShoppingList( + shoppinglist, + ); + } + }, + onDeleted: () async { + final doIt = + await HouseholdSettingsShoppinglistPage + .confirmDeleteShoppingList( + context, + shoppinglist); + if (doIt) { + cubit.deleteShoppingList( + shoppinglist); + } + }), + ) + .toList(), + ), + ], + ), right: Padding( padding: const EdgeInsets.only(right: 16, bottom: 6), diff --git a/kitchenowl/lib/pages/settings_household/household_settings_shoppinglist_page.dart b/kitchenowl/lib/pages/settings_household/household_settings_shoppinglist_page.dart index c320c4950..53f081246 100644 --- a/kitchenowl/lib/pages/settings_household/household_settings_shoppinglist_page.dart +++ b/kitchenowl/lib/pages/settings_household/household_settings_shoppinglist_page.dart @@ -38,37 +38,45 @@ class HouseholdSettingsShoppinglistPage extends StatelessWidget { )); } + static Future getShoppingListName( + BuildContext context, Iterable shoppingLists) async { + return await showDialog( + context: context, + builder: (BuildContext context) { + return TextDialog( + title: AppLocalizations.of(context)!.addShoppingList, + doneText: AppLocalizations.of(context)!.add, + hintText: AppLocalizations.of(context)!.name, + isInputValid: (s) => + s.isNotEmpty && + !shoppingLists.any((shoppingList) => shoppingList.name == s), + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( body: CustomScrollView( slivers: [ SliverAppBar( - title: Text(AppLocalizations.of(context)!.shoppingLists), - actions: [ - IconButton( - icon: const Icon(Icons.add), - tooltip: AppLocalizations.of(context)!.addShoppingList, - onPressed: () async { - final res = await showDialog( - context: context, - builder: (BuildContext context) { - return TextDialog( - title: AppLocalizations.of(context)!.addShoppingList, - doneText: AppLocalizations.of(context)!.add, - hintText: AppLocalizations.of(context)!.name, - isInputValid: (s) => s.isNotEmpty, - ); - }, - ); - if (res != null) { - BlocProvider.of(context) - .addShoppingList(res); - } - }, - ), - ], - ), + title: Text(AppLocalizations.of(context)!.shoppingLists), + actions: [ + BlocBuilder( + bloc: BlocProvider.of(context), + builder: (context, state) => IconButton( + icon: const Icon(Icons.add), + tooltip: AppLocalizations.of(context)!.addShoppingList, + onPressed: () async { + final res = await getShoppingListName( + context, state.shoppingLists); + if (res != null) { + BlocProvider.of(context) + .addShoppingList(res); + } + })) + ]), SliverCrossAxisConstrained( maxCrossAxisExtent: 600, child: BlocBuilder( diff --git a/kitchenowl/lib/services/api/shoppinglist.dart b/kitchenowl/lib/services/api/shoppinglist.dart index d61fa6c3f..9c230ad9a 100644 --- a/kitchenowl/lib/services/api/shoppinglist.dart +++ b/kitchenowl/lib/services/api/shoppinglist.dart @@ -154,6 +154,14 @@ extension ShoppinglistApi on ApiService { socket.off("shoppinglist:delete", handler); } + void onShoppinglistUpdate(dynamic Function(dynamic) handler){ + socket.on("shoppinglist:update", handler); + } + + void offShoppinglistUpdate(dynamic Function(dynamic) handler){ + socket.off("shoppinglist:update", handler); + } + void onShoppinglistItemAdd(dynamic Function(dynamic) handler) { socket.on("shoppinglist_item:add", handler); } diff --git a/kitchenowl/lib/widgets/choice_scroll.dart b/kitchenowl/lib/widgets/choice_scroll.dart index e52e75dc7..a1652f320 100644 --- a/kitchenowl/lib/widgets/choice_scroll.dart +++ b/kitchenowl/lib/widgets/choice_scroll.dart @@ -6,6 +6,7 @@ class ChoiceScroll extends StatefulWidget { final IconData? icon; final void Function()? onCollapse; final List? actions; + final double paddingLeft; const ChoiceScroll({ super.key, @@ -14,6 +15,7 @@ class ChoiceScroll extends StatefulWidget { this.icon, this.onCollapse, this.actions, + this.paddingLeft = 12, }); @override @@ -33,7 +35,7 @@ class _ChoiceScrollState extends State { Widget build(BuildContext context) { Widget child = Row( children: [ - const SizedBox(width: 12), + SizedBox(width: widget.paddingLeft), if (widget.actions != null) ...widget.actions!, if (widget.collapsable) IconButton( diff --git a/kitchenowl/lib/widgets/shopping_list/shopping_list_choice_chip.dart b/kitchenowl/lib/widgets/shopping_list/shopping_list_choice_chip.dart index e4673a66d..0a6486d68 100644 --- a/kitchenowl/lib/widgets/shopping_list/shopping_list_choice_chip.dart +++ b/kitchenowl/lib/widgets/shopping_list/shopping_list_choice_chip.dart @@ -4,14 +4,17 @@ import 'package:kitchenowl/models/shoppinglist.dart'; class ShoppingListChoiceChip extends StatelessWidget { final ShoppingList shoppingList; final bool selected; + final bool canDelete; final void Function(bool)? onSelected; + final void Function()? onDeleted; - const ShoppingListChoiceChip({ - super.key, - required this.shoppingList, - this.selected = false, - this.onSelected, - }); + const ShoppingListChoiceChip( + {super.key, + required this.shoppingList, + this.canDelete = true, + this.selected = false, + this.onSelected, + this.onDeleted}); @override Widget build(BuildContext context) { @@ -19,22 +22,30 @@ class ShoppingListChoiceChip extends StatelessWidget { padding: const EdgeInsets.symmetric( horizontal: 4, ), - child: ChoiceChip( - showCheckmark: false, - label: Text( - shoppingList.name + - (shoppingList.items.isNotEmpty - ? " (${shoppingList.items.length})" - : ""), - style: TextStyle( - color: selected ? Theme.of(context).colorScheme.onPrimary : null, + child: Row(children: [ + ChoiceChip( + showCheckmark: false, + label: Text( + shoppingList.name + + (shoppingList.items.isNotEmpty + ? " (${shoppingList.items.length})" + : ""), + style: TextStyle( + color: selected ? Theme.of(context).colorScheme.onPrimary : null, + ), ), + selected: selected, + elevation: shoppingList.items.isNotEmpty ? 2 : 0, + selectedColor: Theme.of(context).colorScheme.secondary, + onSelected: onSelected, ), - selected: selected, - elevation: shoppingList.items.isNotEmpty ? 2 : 0, - selectedColor: Theme.of(context).colorScheme.secondary, - onSelected: onSelected, - ), + if (selected && canDelete) + IconButton( + icon: Icon(Icons.delete, color: Colors.redAccent), + onPressed: onDeleted, + padding: EdgeInsets.zero, + constraints: const BoxConstraints()) + ]), ); } }