From d7824815e7d94ca9b9eeaaba2858fcfa14085e0e Mon Sep 17 00:00:00 2001 From: mikecoomber <58986130+mikecoomber@users.noreply.github.com> Date: Fri, 12 Apr 2024 15:49:49 +0100 Subject: [PATCH] feat: Pagination (#46) * pagination * dropdown pagination, docs * widget book * fixing todo * fixing disabled logic --- example/lib/home.dart | 2 + .../pages/components/pagination_example.dart | 56 +++ example/widgetbook/main.dart | 2 + .../components/pagination_widgetbook.dart | 18 + example/windows/flutter/CMakeLists.txt | 7 +- lib/src/components/pagination/pagination.dart | 359 ++++++++++++++++++ lib/zeta_flutter.dart | 1 + 7 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 example/lib/pages/components/pagination_example.dart create mode 100644 example/widgetbook/pages/components/pagination_widgetbook.dart create mode 100644 lib/src/components/pagination/pagination.dart diff --git a/example/lib/home.dart b/example/lib/home.dart index 8c6465f4..2c96e4fe 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -19,6 +19,7 @@ import 'package:zeta_example/pages/components/stepper_example.dart'; import 'package:zeta_example/pages/components/switch_example.dart'; import 'package:zeta_example/pages/components/snackbar_example.dart'; import 'package:zeta_example/pages/components/tabs_example.dart'; +import 'package:zeta_example/pages/components/pagination_example.dart'; import 'package:zeta_example/pages/theme/color_example.dart'; import 'package:zeta_example/pages/components/password_input_example.dart'; import 'package:zeta_example/pages/components/progress_example.dart'; @@ -48,6 +49,7 @@ final List components = [ Component(ChipExample.name, (context) => const ChipExample()), Component(ListItemExample.name, (context) => const ListItemExample()), Component(NavigationBarExample.name, (context) => const NavigationBarExample()), + Component(PaginationExample.name, (context) => const PaginationExample()), Component(PasswordInputExample.name, (context) => const PasswordInputExample()), Component(DropdownExample.name, (context) => const DropdownExample()), Component(ProgressExample.name, (context) => const ProgressExample()), diff --git a/example/lib/pages/components/pagination_example.dart b/example/lib/pages/components/pagination_example.dart new file mode 100644 index 00000000..df318ed5 --- /dev/null +++ b/example/lib/pages/components/pagination_example.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class PaginationExample extends StatefulWidget { + static const name = 'Pagination'; + + const PaginationExample({super.key}); + + @override + State createState() => _PaginationExampleState(); +} + +class _PaginationExampleState extends State { + int currentPage = 1; + + @override + Widget build(BuildContext context) { + return ExampleScaffold( + name: PaginationExample.name, + child: Center( + child: Padding( + padding: const EdgeInsets.all(64), + child: Column( + children: [ + Expanded( + child: Center( + child: Text( + 'Current Page: ${currentPage}', + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ), + ZetaPagination( + pages: 10, + currentPage: currentPage, + onChange: (val) => setState(() { + currentPage = val; + }), + ), + const SizedBox(height: 8), + ZetaPagination( + pages: 10, + currentPage: currentPage, + onChange: (val) => setState(() { + currentPage = val; + }), + type: ZetaPaginationType.dropdown, + ), + ], + ), + ), + ), + ); + } +} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index 0a348249..96a46c7f 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -18,6 +18,7 @@ import 'pages/components/dropdown_widgetbook.dart'; import 'pages/components/in_page_banner_widgetbook.dart'; import 'pages/components/list_item_widgetbook.dart'; import 'pages/components/navigation_bar_widgetbook.dart'; +import 'pages/components/pagination_widgetbook.dart'; import 'pages/components/password_input_widgetbook.dart'; import 'pages/components/progress_widgetbook.dart'; import 'pages/components/radio_widgetbook.dart'; @@ -87,6 +88,7 @@ class HotReload extends StatelessWidget { WidgetbookUseCase(name: 'Dial Pad', builder: (context) => dialPadUseCase(context)), WidgetbookUseCase(name: 'List Item', builder: (context) => listItemUseCase(context)), WidgetbookUseCase(name: 'Navigation Bar', builder: (context) => navigationBarUseCase(context)), + WidgetbookUseCase(name: 'Pagination', builder: (context) => paginationUseCase(context)), WidgetbookComponent( name: 'Progress', useCases: [ diff --git a/example/widgetbook/pages/components/pagination_widgetbook.dart b/example/widgetbook/pages/components/pagination_widgetbook.dart new file mode 100644 index 00000000..2d383ec4 --- /dev/null +++ b/example/widgetbook/pages/components/pagination_widgetbook.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../test/test_components.dart'; + +Widget paginationUseCase(BuildContext context) => WidgetbookTestWidget( + widget: ZetaPagination( + pages: 10, + type: context.knobs.list( + label: 'Type', + options: ZetaPaginationType.values, + labelBuilder: (value) => value.name.split('.').last.toUpperCase(), + ), + rounded: context.knobs.boolean(label: 'Rounded'), + disabled: context.knobs.boolean(label: 'Disabled'), + ), + ); diff --git a/example/windows/flutter/CMakeLists.txt b/example/windows/flutter/CMakeLists.txt index 930d2071..903f4899 100644 --- a/example/windows/flutter/CMakeLists.txt +++ b/example/windows/flutter/CMakeLists.txt @@ -10,6 +10,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -92,7 +97,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/lib/src/components/pagination/pagination.dart b/lib/src/components/pagination/pagination.dart new file mode 100644 index 00000000..274cdc64 --- /dev/null +++ b/lib/src/components/pagination/pagination.dart @@ -0,0 +1,359 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../zeta_flutter.dart'; + +const _itemHeight = ZetaSpacing.x9; +const _itemWidth = ZetaSpacing.x8; + +/// The type of a [ZetaPagination] +enum ZetaPaginationType { + /// A standard pagination with buttons for each page. + standard, + + /// A dropdown pagination. + dropdown, +} + +/// Pagination is used to switch between pages. +class ZetaPagination extends StatefulWidget { + /// Creates a new [ZetaPagination] + const ZetaPagination({ + required this.pages, + this.type = ZetaPaginationType.standard, + this.onChange, + this.currentPage = 1, + this.rounded = true, + this.disabled = false, + super.key, + }) : assert( + pages > 0, + 'Pages must be greater than zero', + ), + assert( + currentPage >= 1 && currentPage <= pages, + 'currentPage must be greater than 1 and less than the number of pages', + ); + + /// The number of pages. + final int pages; + + /// The current page. + /// + /// Defaults to 1 + final int currentPage; + + /// {@macro zeta-component-rounded} + final bool rounded; + + /// Disables the pagination. + final bool disabled; + + /// A callback executed every time the page changes. + final void Function(int value)? onChange; + + /// The type of the pagination. + /// A pagination dropdown will be enforced if there is not enough space for a standard dropdown. + /// + /// Default to [ZetaPaginationType.standard] + final ZetaPaginationType type; + + @override + State createState() => _ZetaPaginationState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(IntProperty('pages', pages)) + ..add(IntProperty('currentPage', currentPage)) + ..add(DiagnosticsProperty('rounded', rounded)) + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(ObjectFlagProperty.has('onChange', onChange)) + ..add(EnumProperty('type', type)); + } +} + +class _ZetaPaginationState extends State { + late int _currentPage; + final _paginationKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _currentPage = widget.currentPage; + } + + @override + void didUpdateWidget(covariant ZetaPagination oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentPage != widget.currentPage) { + setState(() { + _currentPage = widget.currentPage; + }); + } + } + + void _onItemPressed(int value) { + setState(() { + _currentPage = value; + }); + widget.onChange?.call(value); + } + + _PaginationItem _getNumberedPaginationItem(int value) { + return _PaginationItem( + value: value, + onPressed: () => _onItemPressed(value), + selected: _currentPage == value, + rounded: widget.rounded, + disabled: widget.disabled, + ); + } + + List get numberedPaginationItems { + if (widget.pages <= 6) { + final List items = []; + for (int i = 1; i <= widget.pages; i++) { + items.add(_getNumberedPaginationItem(i)); + } + return items; + } + + const totalCenterItems = 5; + const initialIndex = 1; + final finalIndex = widget.pages; + + final diffToEnd = finalIndex - _currentPage; + final diffToStart = _currentPage - initialIndex; + final showLeftElipsis = diffToStart > 2; + final showRightElipsis = diffToEnd > 2; + + final List items = [ + _getNumberedPaginationItem(initialIndex), + if (showLeftElipsis) const _Elipsis(), + ]; + + if (!showLeftElipsis) { + int itemCount = totalCenterItems; + + // Add items to the left of the current page + for (int i = 0; i <= diffToStart; i++) { + items.add(_getNumberedPaginationItem(i + 2)); + itemCount--; + } + // Add items to the right of the current page + for (int i = _currentPage + 2; i <= _currentPage + itemCount; i++) { + items.add(_getNumberedPaginationItem(i)); + } + } else if (!showRightElipsis) { + int itemCount = totalCenterItems; + final List newItems = []; + for (int i = finalIndex - 1; i >= _currentPage; i--) { + newItems.add(_getNumberedPaginationItem(i)); + itemCount--; + } + + for (int i = _currentPage - 1; i >= _currentPage - itemCount + 1; i--) { + newItems.add(_getNumberedPaginationItem(i)); + } + items.addAll(newItems.reversed); + } else { + for (int i = -1; i <= 1; i++) { + items.add(_getNumberedPaginationItem(_currentPage + i)); + } + } + + items.addAll([ + if (showRightElipsis) const _Elipsis(), + _getNumberedPaginationItem(finalIndex), + ]); + + return items; + } + + Widget get paginationDropdown { + final colors = Zeta.of(context).colors; + final List> items = List.generate( + widget.pages, + (i) => DropdownMenuItem( + value: i + 1, + child: Text((i + 1).toString()), + ), + ); + return Container( + height: ZetaSpacing.x10, + decoration: BoxDecoration( + border: Border.all(color: colors.borderSubtle), + borderRadius: widget.rounded ? ZetaRadius.minimal : ZetaRadius.none, + ), + // TODO(mikecoomber): Replace with Zeta Dropdown + child: DropdownButton( + items: items, + onChanged: (val) => _onItemPressed(val!), + value: _currentPage, + icon: Icon( + widget.rounded ? ZetaIcons.expand_more_round : ZetaIcons.expand_more_sharp, + ).paddingStart(ZetaSpacing.x2), + underline: const SizedBox(), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colors.textSubtle, + ), + padding: const EdgeInsets.symmetric( + horizontal: ZetaSpacing.x3, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final showDropdown = + widget.type == ZetaPaginationType.dropdown || constraints.deviceType == DeviceType.mobilePortrait; + + final List buttons = [ + if (!showDropdown) + _PaginationItem( + icon: widget.rounded ? ZetaIcons.first_page_round : ZetaIcons.first_page_sharp, + onPressed: () => _onItemPressed(1), + disabled: widget.disabled, + rounded: widget.rounded, + ), + _PaginationItem( + icon: widget.rounded ? ZetaIcons.chevron_left_round : ZetaIcons.chevron_left_sharp, + onPressed: () => _onItemPressed(max(1, _currentPage - 1)), + disabled: widget.disabled, + rounded: widget.rounded, + ), + if (!showDropdown) ...numberedPaginationItems else paginationDropdown, + _PaginationItem( + icon: widget.rounded ? ZetaIcons.chevron_right_round : ZetaIcons.chevron_right_sharp, + onPressed: () => _onItemPressed( + min(widget.pages, _currentPage + 1), + ), + disabled: widget.disabled, + rounded: widget.rounded, + ), + if (!showDropdown) + _PaginationItem( + icon: widget.rounded ? ZetaIcons.last_page_round : ZetaIcons.last_page_sharp, + onPressed: () => _onItemPressed( + widget.pages, + ), + disabled: widget.disabled, + rounded: widget.rounded, + ), + ]; + + return Row( + key: _paginationKey, + mainAxisSize: MainAxisSize.min, + children: buttons.divide(const SizedBox(width: ZetaSpacing.x2)).toList(), + ); + }, + ); + } +} + +class _PaginationItem extends StatelessWidget { + const _PaginationItem({ + required this.onPressed, + required this.rounded, + required this.disabled, + this.selected = false, + this.value, + this.icon, + }); + + final VoidCallback onPressed; + final int? value; + final IconData? icon; + final bool disabled; + final bool selected; + final bool rounded; + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + + late final Widget child; + + if (value != null) { + child = Text( + value!.toString(), + maxLines: 1, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: disabled + ? colors.textDisabled + : selected + ? colors.textInverse + : colors.textDefault, + ), + ); + } else if (icon != null) { + child = Icon( + icon, + color: disabled ? colors.iconDisabled : colors.iconDefault, + ); + } + + return ConstrainedBox( + constraints: const BoxConstraints( + minHeight: _itemHeight, + maxHeight: _itemHeight, + minWidth: _itemWidth, + ), + child: Material( + borderRadius: rounded ? ZetaRadius.minimal : ZetaRadius.none, + color: disabled + ? colors.surfaceDisabled + : selected + ? colors.cool[100] + : colors.surfacePrimary, + child: InkWell( + onTap: disabled ? null : onPressed, + borderRadius: rounded ? ZetaRadius.minimal : ZetaRadius.none, + highlightColor: selected ? colors.cool[100] : colors.surfaceSelected, + hoverColor: selected ? colors.cool[100] : colors.surfaceHovered, + enableFeedback: false, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: ZetaSpacing.x1), + decoration: BoxDecoration( + borderRadius: rounded ? ZetaRadius.minimal : ZetaRadius.none, + ), + child: child, + ), + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ObjectFlagProperty.has('onPressed', onPressed)) + ..add(IntProperty('value', value)) + ..add(DiagnosticsProperty('icon', icon)) + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(DiagnosticsProperty('selected', selected)) + ..add(DiagnosticsProperty('rounded', rounded)); + } +} + +class _Elipsis extends StatelessWidget { + const _Elipsis(); + + @override + Widget build(BuildContext context) { + return const SizedBox( + width: _itemWidth, + height: _itemHeight, + child: Center(child: Text('...')), + ); + } +} diff --git a/lib/zeta_flutter.dart b/lib/zeta_flutter.dart index 468ce203..bca4aef8 100644 --- a/lib/zeta_flutter.dart +++ b/lib/zeta_flutter.dart @@ -27,6 +27,7 @@ export 'src/components/dial_pad/dial_pad.dart'; export 'src/components/dropdown/dropdown.dart'; export 'src/components/list_item/list_item.dart'; export 'src/components/navigation bar/navigation_bar.dart'; +export 'src/components/pagination/pagination.dart'; export 'src/components/password/password_input.dart'; export 'src/components/progress/progress_bar.dart'; export 'src/components/progress/progress_circle.dart';