diff --git a/lib/models/payment.dart b/lib/models/payment.dart index 3a651b0df..f49aa53aa 100644 --- a/lib/models/payment.dart +++ b/lib/models/payment.dart @@ -10,7 +10,21 @@ enum PaymentType { @JsonValue('tpay_payment') tpayPayment, @JsonValue('wire_payment') - wirePayment, + wirePayment; + + @override + String toString() { + switch (this) { + case PaymentType.cashPayment: + return 'Cash payment'; + case PaymentType.cardPayment: + return 'Card payment'; + case PaymentType.tpayPayment: + return 'Thalia Pay'; + case PaymentType.wirePayment: + return 'Wire payment'; + } + } } @JsonSerializable(fieldRename: FieldRename.snake) diff --git a/lib/ui/screens/event_admin_screen.dart b/lib/ui/screens/event_admin_screen.dart index 53bd2b2b5..4df3550fb 100644 --- a/lib/ui/screens/event_admin_screen.dart +++ b/lib/ui/screens/event_admin_screen.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -20,6 +21,54 @@ class EventAdminScreen extends StatefulWidget { } class _EventAdminScreenState extends State { + static Filter _defaultFilter() => MultipleFilter( + [ + MapFilter( + map: { + for (PaymentType value in PaymentType.values) value: true, + null: true, + }, + title: 'Payment type', + asString: (item) => item?.toString() ?? 'Not paid', + toKey: (item) => item.payment?.type), + MapFilter( + map: { + true: true, + false: true, + }, + title: 'Precense', + asString: (item) => item ? 'Is present' : 'Is not present', + toKey: (item) => item.present), + ], + ); + + Filter _filter = _defaultFilter(); + + _SortOrder _sortOrder = _SortOrder.none; + + void _resetfilter() { + setState(() { + _filter = _defaultFilter(); + }); + } + + void _showPaymentFilter() async { + final Filter? results = await showDialog( + context: context, + builder: (BuildContext context) { + return MultiSelectPopup( + filter: _filter.clone(), + title: 'Filter registrations', + ); + }, + ); + if (results != null) { + setState(() { + _filter = results; + }); + } + } + void _showQRCode(EventAdminCubit cubit) async { showModalBottomSheet( isScrollControlled: true, @@ -80,6 +129,36 @@ class _EventAdminScreenState extends State { ); } + void _openSearch(BuildContext context) async { + final adminCubit = BlocProvider.of(context); + // TODO: check if we need this second cubit!. + final searchCubit = EventAdminCubit( + RepositoryProvider.of(context), + eventPk: widget.pk, + ); + + await showSearch( + context: context, + delegate: EventAdminSearchDelegate(searchCubit), + ); + + searchCubit.close(); + + // After the search dialog closes, refresh the results, + // since the search screen may have changed stuff through + // its own EventAdminCubit, that do not show up in the cubit + // for the EventAdminScreen until a refresh. + adminCubit.loadRegistrations(); + } + + Widget _resetfilterMessage() { + return ErrorCenter([ + const Text('No results that match the filter', + textAlign: TextAlign.center), + TextButton(onPressed: _resetfilter, child: const Text('Reset filter')) + ]); + } + @override Widget build(BuildContext context) { return BlocProvider( @@ -96,31 +175,6 @@ class _EventAdminScreenState extends State { appBar: ThaliaAppBar( title: const Text('REGISTRATIONS'), collapsingActions: [ - IconAppbarAction( - 'SEARCH', - Icons.search, - () async { - final adminCubit = - BlocProvider.of(context); - final searchCubit = EventAdminCubit( - RepositoryProvider.of(context), - eventPk: widget.pk, - ); - - await showSearch( - context: context, - delegate: EventAdminSearchDelegate(searchCubit), - ); - - searchCubit.close(); - - // After the search dialog closes, refresh the results, - // since the search screen may have changed stuff through - // its own EventAdminCubit, that do not show up in the cubit - // for the EventAdminScreen until a refresh. - adminCubit.loadRegistrations(); - }, - ), IconAppbarAction( 'QR Code', Icons.qr_code, @@ -128,6 +182,23 @@ class _EventAdminScreenState extends State { _showQRCode(BlocProvider.of(context)), tooltip: 'Show presence QR code', ), + IconAppbarAction( + 'SEARCH', + Icons.search, + () => _openSearch(context), + ), + SortButton<_SortOrder>( + _SortOrder.values.map((e) => e.asSortItem()).toList(), + (p0) => setState(() { + _sortOrder = p0 ?? _SortOrder.none; + }), + ), + IconAppbarAction( + 'Filter', + Icons.filter_alt_rounded, + _showPaymentFilter, + tooltip: 'Filter registrations', + ), ], bottom: TabBar( indicatorColor: Theme.of(context).colorScheme.primary, @@ -151,52 +222,83 @@ class _EventAdminScreenState extends State { } else if (state.isLoading) { return const Center(child: CircularProgressIndicator()); } else { - return TabBarView(children: [ - if (state.queuedMessage != null) - ErrorCenter(state.queuedMessage!) - else - Scrollbar( - child: ListView.separated( - key: const PageStorageKey('event-admin'), - itemBuilder: (context, index) => - _QueuedRegistrationTile( - registration: state.queuedRegistrations[index], + List filteredRegistrations = state + .registrations + .where(_filter.passes) + .sorted(_sortOrder.compare) + .toList(); + List filteredCancels = state + .cancelledRegistrations + .where(_filter.passes) + .sorted(_sortOrder.compare) + .toList(); + List filteredQueue = state + .queuedRegistrations + .where(_filter.passes) + .sorted(_sortOrder.compare) + .toList(); + return TabBarView( + children: [ + if (state.queuedMessage != null) + ErrorCenter([ + Text(state.queuedMessage!, + textAlign: TextAlign.center) + ]) + else if (filteredQueue.isEmpty) + _resetfilterMessage() + else + Scrollbar( + child: ListView.separated( + key: const PageStorageKey('event-admin'), + itemBuilder: (context, index) => + _QueuedRegistrationTile( + registration: filteredQueue[index], + ), + separatorBuilder: (_, __) => const Divider(), + itemCount: filteredQueue.length, ), - separatorBuilder: (_, __) => const Divider(), - itemCount: state.queuedRegistrations.length, ), - ), - if (state.message != null) - ErrorCenter(state.message!) - else - Scrollbar( - child: ListView.separated( - key: const PageStorageKey('event-admin'), - itemBuilder: (context, index) => - _RegistrationTile( - registration: state.registrations[index], - requiresPayment: state.event!.paymentIsRequired, + if (state.message != null) + ErrorCenter([ + Text(state.message!, textAlign: TextAlign.center) + ]) + else if (filteredRegistrations.isEmpty) + _resetfilterMessage() + else + Scrollbar( + child: ListView.separated( + key: const PageStorageKey('event-admin'), + itemBuilder: (context, index) => + _RegistrationTile( + registration: filteredRegistrations[index], + requiresPayment: + state.event!.paymentIsRequired, + ), + separatorBuilder: (_, __) => const Divider(), + itemCount: filteredRegistrations.length, ), - separatorBuilder: (_, __) => const Divider(), - itemCount: state.registrations.length, ), - ), - if (state.cancelledMessage != null) - ErrorCenter(state.cancelledMessage!) - else - Scrollbar( - child: ListView.separated( - key: const PageStorageKey('event-admin'), - itemBuilder: (context, index) => - _CancelledRegistrationTile( - registration: - state.cancelledRegistrations[index], + if (state.cancelledMessage != null) + ErrorCenter([ + Text(state.cancelledMessage!, + textAlign: TextAlign.center) + ]) + else if (filteredCancels.isEmpty) + _resetfilterMessage() + else + Scrollbar( + child: ListView.separated( + key: const PageStorageKey('event-admin'), + itemBuilder: (context, index) => + _CancelledRegistrationTile( + registration: filteredCancels[index], + ), + separatorBuilder: (_, __) => const Divider(), + itemCount: filteredCancels.length, ), - separatorBuilder: (_, __) => const Divider(), - itemCount: state.cancelledRegistrations.length, ), - ), - ]); + ], + ); } }, ), @@ -510,3 +612,69 @@ class EventAdminSearchDelegate extends SearchDelegate { ); } } + +enum _SortOrder { + none(text: 'None', icon: Icons.cancel, compare: equal), + payedUp(text: 'Paid', icon: Icons.keyboard_arrow_up, compare: cmpPaid), + payedDown(text: 'Paid', icon: Icons.keyboard_arrow_down, compare: cmpPaid_2), + presentUp( + text: 'Present', icon: Icons.keyboard_arrow_up, compare: cmpPresent), + presentDown( + text: 'Present', icon: Icons.keyboard_arrow_down, compare: cmpPresent_2), + nameUp(text: 'Name', icon: Icons.keyboard_arrow_up, compare: cmpName), + nameDown(text: 'Name', icon: Icons.keyboard_arrow_down, compare: cmpName_2); + + final String text; + final IconData? icon; + final int Function(AdminEventRegistration, AdminEventRegistration) compare; + + const _SortOrder({required this.text, this.icon, required this.compare}); + + SortItem<_SortOrder> asSortItem() { + return SortItem(this, text, icon); + } + + static int equal(AdminEventRegistration e1, AdminEventRegistration e2) { + return 0; + } + + static int cmpPaid(AdminEventRegistration e1, AdminEventRegistration e2) { + if (e1.isPaid) { + return -1; + } + if (e2.isPaid) { + return 1; + } + return 0; + } + + static int cmpPaid_2(AdminEventRegistration e1, AdminEventRegistration e2) => + -cmpPaid(e1, e2); + + static int cmpPresent(AdminEventRegistration e1, AdminEventRegistration e2) { + if (e1.present) { + return -1; + } + if (e2.present) { + return 1; + } + return 0; + } + + static int cmpPresent_2( + AdminEventRegistration e1, AdminEventRegistration e2) => + -cmpPresent(e1, e2); + + static int cmpName(AdminEventRegistration e1, AdminEventRegistration e2) { + if (e1.name == null) { + return -1; + } + if (e2.name == null) { + return 1; + } + return e1.name!.compareTo(e2.name!); + } + + static int cmpName_2(AdminEventRegistration e1, AdminEventRegistration e2) => + -cmpName(e1, e2); +} diff --git a/lib/ui/screens/food_admin_screen.dart b/lib/ui/screens/food_admin_screen.dart index 2cbe7b653..009b1d502 100644 --- a/lib/ui/screens/food_admin_screen.dart +++ b/lib/ui/screens/food_admin_screen.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -17,6 +18,65 @@ class FoodAdminScreen extends StatefulWidget { } class _FoodAdminScreenState extends State { + Filter _filter = MultipleFilter( + [ + MapFilter( + map: { + for (PaymentType value in PaymentType.values) value: true, + null: true, + }, + title: 'Payment type', + asString: (item) => item?.toString() ?? 'Not paid', + toKey: (item) => item.payment?.type), + ], + ); + + _SortOrder _sortOrder = _SortOrder.none; + + void _updateSortOrder(_SortOrder? order) { + setState(() { + _sortOrder = order ?? _SortOrder.none; + }); + } + + void _showPaymentFilter() async { + final Filter? results = await showDialog( + context: context, + builder: (BuildContext context) { + return MultiSelectPopup( + filter: _filter.clone(), + title: 'Filter registrations', + ); + }, + ); + if (results != null) { + setState(() { + _filter = results; + }); + } + } + + void _opensearch(BuildContext context) async { + final adminCubit = BlocProvider.of(context); + final searchCubit = FoodAdminCubit( + RepositoryProvider.of(context), + foodEventPk: widget.pk, + ); + + await showSearch( + context: context, + delegate: FoodAdminSearchDelegate(searchCubit), + ); + + searchCubit.close(); + + // After the search dialog closes, refresh the results, + // since the search screen may have changed stuff through + // its own FoodAdminCubit, that do not show up in the cubit + // for the FoodAdminScreen until a refresh. + adminCubit.load(); + } + @override Widget build(BuildContext context) { return BlocProvider( @@ -33,27 +93,17 @@ class _FoodAdminScreenState extends State { IconAppbarAction( 'SEACH', Icons.search, - () async { - final adminCubit = BlocProvider.of(context); - final searchCubit = FoodAdminCubit( - RepositoryProvider.of(context), - foodEventPk: widget.pk, - ); - - await showSearch( - context: context, - delegate: FoodAdminSearchDelegate(searchCubit), - ); - - searchCubit.close(); - - // After the search dialog closes, refresh the results, - // since the search screen may have changed stuff through - // its own FoodAdminCubit, that do not show up in the cubit - // for the FoodAdminScreen until a refresh. - adminCubit.load(); - }, - ) + () => _opensearch(context), + ), + SortButton<_SortOrder>( + _SortOrder.values.map((e) => e.asSortItem()).toList(), + _updateSortOrder, + ), + IconAppbarAction( + 'FILTER', + Icons.filter_alt_rounded, + _showPaymentFilter, + ), ], ), body: RefreshIndicator( @@ -62,20 +112,27 @@ class _FoodAdminScreenState extends State { }, child: BlocBuilder( builder: (context, state) { - if (state is ErrorState) { - return ErrorScrollView(state.message!); - } else if (state is LoadingState) { - return const Center(child: CircularProgressIndicator()); - } else { - return Scrollbar( + switch (state) { + case (ErrorState estate): + return ErrorScrollView(estate.message); + case (LoadingState _): + return const Center(child: CircularProgressIndicator()); + case (ResultState> rstate): + List filtered = rstate.result + .where(_filter.passes) + .sorted(_sortOrder.compare) + .toList(); + + return Scrollbar( child: ListView.separated( - key: const PageStorageKey('food-admin'), - itemBuilder: (context, index) => _OrderTile( - order: state.result![index], - ), - separatorBuilder: (_, __) => const Divider(), - itemCount: state.result!.length, - )); + key: const PageStorageKey('food-admin'), + itemBuilder: (context, index) => _OrderTile( + order: filtered[index], + ), + separatorBuilder: (_, __) => const Divider(), + itemCount: filtered.length, + ), + ); } }, ), @@ -249,19 +306,20 @@ class FoodAdminSearchDelegate extends SearchDelegate { value: _adminCubit..search(query), child: BlocBuilder( builder: (context, state) { - if (state is ErrorState) { - return ErrorScrollView(state.message!); - } else if (state is LoadingState) { - return const SizedBox.shrink(); - } else { - return ListView.separated( - key: const PageStorageKey('food-admin-search'), - itemBuilder: (context, index) => _OrderTile( - order: state.result![index], - ), - separatorBuilder: (_, __) => const Divider(), - itemCount: state.result!.length, - ); + switch (state) { + case (ErrorState state): + return ErrorScrollView(state.message); + case (LoadingState _): + return const SizedBox.shrink(); + case (ResultState> rstate): + return ListView.separated( + key: const PageStorageKey('food-admin-search'), + itemBuilder: (context, index) => _OrderTile( + order: rstate.result[index], + ), + separatorBuilder: (_, __) => const Divider(), + itemCount: rstate.result.length, + ); } }, ), @@ -293,3 +351,62 @@ class FoodAdminSearchDelegate extends SearchDelegate { ); } } + +enum _SortOrder { + none(text: 'None', icon: Icons.cancel, compare: equal), + payedUp(text: 'Paid', icon: Icons.keyboard_arrow_up, compare: cmpPaid), + payedDown(text: 'Paid', icon: Icons.keyboard_arrow_down, compare: cmpPaid_2), + nameUp(text: 'Name', icon: Icons.keyboard_arrow_up, compare: cmpName), + nameDown(text: 'Name', icon: Icons.keyboard_arrow_down, compare: cmpName_2), + productUp( + text: 'Product', icon: Icons.keyboard_arrow_up, compare: cmpProduct), + productDown( + text: 'Product', icon: Icons.keyboard_arrow_down, compare: cmpProduct_2); + + final String text; + final IconData? icon; + final int Function(AdminFoodOrder, AdminFoodOrder) compare; + + const _SortOrder({required this.text, this.icon, required this.compare}); + + SortItem<_SortOrder> asSortItem() { + return SortItem(this, text, icon); + } + + static int equal(AdminFoodOrder e1, AdminFoodOrder e2) { + return 0; + } + + static int cmpPaid(AdminFoodOrder e1, AdminFoodOrder e2) { + if (e1.isPaid) { + return -1; + } + if (e2.isPaid) { + return 1; + } + return 0; + } + + static int cmpPaid_2(AdminFoodOrder e1, AdminFoodOrder e2) => + -cmpPaid(e1, e2); + + static int cmpName(AdminFoodOrder e1, AdminFoodOrder e2) { + if (e1.name == null) { + return -1; + } + if (e2.name == null) { + return 1; + } + return e1.name!.compareTo(e2.name!); + } + + static int cmpName_2(AdminFoodOrder e1, AdminFoodOrder e2) => + -cmpName(e1, e2); + + static int cmpProduct(AdminFoodOrder e1, AdminFoodOrder e2) { + return e1.product.name.compareTo(e2.product.name); + } + + static int cmpProduct_2(AdminFoodOrder e1, AdminFoodOrder e2) => + -cmpName(e1, e2); +} diff --git a/lib/ui/screens/profile_screen.dart b/lib/ui/screens/profile_screen.dart index e7164d7aa..60e0d9114 100644 --- a/lib/ui/screens/profile_screen.dart +++ b/lib/ui/screens/profile_screen.dart @@ -479,7 +479,7 @@ class _ProfileScreenState extends State { slivers: [ _makeAppBar(), SliverFillRemaining( - child: ErrorCenter(state.message!), + child: ErrorCenter.fromMessage(state.message!), ), ], ); diff --git a/lib/ui/screens/registration_screen.dart b/lib/ui/screens/registration_screen.dart index c1f49363e..79190fb38 100644 --- a/lib/ui/screens/registration_screen.dart +++ b/lib/ui/screens/registration_screen.dart @@ -51,7 +51,7 @@ class _RegistrationScreenState extends State { title: const Text('REGISTRATION'), leading: const CloseButton(), ), - body: ErrorCenter(state.message!), + body: ErrorCenter.fromMessage(state.message!), ); } else if (state is LoadingState) { return Scaffold( diff --git a/lib/ui/widgets.dart b/lib/ui/widgets.dart index cb1d32929..5306819cd 100644 --- a/lib/ui/widgets.dart +++ b/lib/ui/widgets.dart @@ -13,3 +13,5 @@ export 'widgets/push_notification_overlay.dart'; export 'widgets/sales_order_dialog.dart'; export 'widgets/tpay_button.dart'; export 'widgets/group_tile.dart'; +export 'widgets/sortbutton.dart'; +export 'widgets/filter_popup.dart'; diff --git a/lib/ui/widgets/app_bar.dart b/lib/ui/widgets/app_bar.dart index df261b4c5..d6877a38f 100644 --- a/lib/ui/widgets/app_bar.dart +++ b/lib/ui/widgets/app_bar.dart @@ -5,7 +5,6 @@ import 'package:reaxit/ui/theme.dart'; abstract class AppbarAction { Widget asIcon(BuildContext _); Widget asMenuItem(BuildContext context, Function() callback); - void ontap(); const AppbarAction(); } @@ -35,9 +34,7 @@ class IconAppbarAction extends AppbarAction { return MenuItemButton( style: ButtonStyle( textStyle: MaterialStateTextStyle.resolveWith( - (states) => Theme.of(context).textTheme.labelLarge!), - foregroundColor: - MaterialStateColor.resolveWith((states) => Colors.white)), + (states) => Theme.of(context).textTheme.labelLarge!)), onPressed: () { onpressed(); callback(); @@ -46,9 +43,6 @@ class IconAppbarAction extends AppbarAction { child: Text(text), ); } - - @override - void ontap() => onpressed(); } class _IconAction extends StatelessWidget { diff --git a/lib/ui/widgets/error_center.dart b/lib/ui/widgets/error_center.dart index f82db3239..81fdee18c 100644 --- a/lib/ui/widgets/error_center.dart +++ b/lib/ui/widgets/error_center.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; class ErrorCenter extends StatelessWidget { - final String message; + final List children; - const ErrorCenter(this.message, {Key? key}) : super(key: key); + const ErrorCenter(this.children, {Key? key}) : super(key: key); + ErrorCenter.fromMessage(String message, {Key? key}) + : children = [Text(message, textAlign: TextAlign.center)], + super(key: key); @override Widget build(BuildContext context) { @@ -20,7 +23,7 @@ class ErrorCenter extends StatelessWidget { fit: BoxFit.fitHeight, ), ), - Text(message, textAlign: TextAlign.center), + ...children, ], ), ), diff --git a/lib/ui/widgets/filter_popup.dart b/lib/ui/widgets/filter_popup.dart new file mode 100644 index 000000000..6a5d8e572 --- /dev/null +++ b/lib/ui/widgets/filter_popup.dart @@ -0,0 +1,164 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +// Multi Select widget +// This widget is reusable +class MultiSelectPopup extends StatelessWidget { + final Filter filter; + final String title; + const MultiSelectPopup({Key? key, required this.filter, required this.title}) + : super(key: key); + + // this function is called when the Cancel button is pressed + void _cancel(BuildContext context) { + Navigator.pop(context); + } + + // this function is called when the Submit button is tapped + void _submit(BuildContext context) { + Navigator.pop(context, filter); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(title), + content: FilterWidget( + filter: filter, + ), + actions: [ + TextButton( + onPressed: () => _cancel(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => _submit(context), + child: const Text('Submit'), + ), + ], + ); + } +} + +class FilterWidget extends StatefulWidget { + final Filter filter; + const FilterWidget({Key? key, required this.filter}) : super(key: key); + + @override + State createState() => _FilterWidgetState(); +} + +class _FilterWidgetState extends State { + Filter? _filter; + + @override + void initState() { + super.initState(); + _filter ??= widget.filter; + } + + @override + Widget build(BuildContext context) { + Filter filter = _filter!; + List types = filter.getFilters(); + return SingleChildScrollView( + child: ListBody( + children: types + .map( + (type) => + [Text(type.title), const Divider()].followedBy(type.items.map( + // ignore: unnecessary_cast + (item) => CheckboxListTile( + value: item.value, + title: Text(item.title), + controlAffinity: ListTileControlAffinity.leading, + onChanged: (isSet) => setState(() => item.onChanged(isSet)), + ), + )), + ) + .flattened + .toList(), + ), + ); + } +} + +abstract class Filter { + List getFilters(); + bool passes(E _); + Filter clone(); +} + +class MultipleFilter implements Filter { + final List filters; + + const MultipleFilter(this.filters); + + @override + List getFilters() { + return filters.map((e) => e.getFilters()).flattened.toList(); + } + + @override + bool passes(E item) { + return filters.map((e) => e.passes(item)).reduce((a, b) => a && b); + } + + @override + MultipleFilter clone() { + return MultipleFilter(filters.map((e) => e.clone()).toList()); + } +} + +class MapFilter implements Filter { + final String title; + final Map map; + final String Function(K) asString; + final K Function(E) toKey; + + const MapFilter( + {required this.map, + required this.title, + required this.asString, + required this.toKey}); + + @override + List getFilters() { + return [ + FilterType( + title: title, + items: map.keys + .map((item) => FilterItem( + title: asString(item), + value: map[item]!, + onChanged: (isChecked) => map[item] = isChecked!)) + .toList()), + ]; + } + + @override + bool passes(E item) { + return map[toKey(item)] ?? false; + } + + @override + MapFilter clone() { + return MapFilter( + map: Map.from(map), title: title, asString: asString, toKey: toKey); + } +} + +class FilterItem { + final String title; + final bool value; + final Function(bool?) onChanged; + + const FilterItem( + {required this.title, required this.value, required this.onChanged}); +} + +class FilterType { + final String title; + final List items; + const FilterType({required this.items, required this.title}); +} diff --git a/lib/ui/widgets/sortbutton.dart b/lib/ui/widgets/sortbutton.dart new file mode 100644 index 000000000..6d96c2960 --- /dev/null +++ b/lib/ui/widgets/sortbutton.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import 'app_bar.dart'; + +class SortItem { + final T value; + final String text; + final IconData? icon; + + const SortItem(this.value, this.text, this.icon); +} + +class SortButton extends StatelessWidget implements AppbarAction { + final void Function(T?) callback; + final List> items; + + const SortButton(this.items, this.callback); + + Widget _build(BuildContext context, bool issub) { + MenuController controller = MenuController(); + // IconButton + + return MenuAnchor( + alignmentOffset: const Offset(0, -1), + controller: controller, + menuChildren: items + .map((item) => MenuItemButton( + child: Row( + children: [ + Text(item.text), + if (item.icon != null) Icon(item.icon!) + ], + ), + onPressed: () => callback(item.value), + )) + .toList(), + child: issub + ? MenuItemButton( + closeOnActivate: false, + style: ButtonStyle( + textStyle: MaterialStateTextStyle.resolveWith( + (states) => Theme.of(context).textTheme.labelLarge!)), + onPressed: controller.open, + leadingIcon: const Icon(Icons.sort), + child: const Text('Sort'), + ) + : IconButton( + onPressed: controller.open, icon: const Icon(Icons.sort)), + ); + } + + @override + Widget build(BuildContext context) => asIcon(context); + + @override + Widget asIcon(BuildContext context) => _build(context, false); + + @override + Widget asMenuItem(BuildContext context, _) => _build(context, true); +}