├── .pubignore ├── test └── paged_datatable_test.dart ├── gen_locales.sh ├── analysis_options.yaml ├── example ├── web │ ├── favicon.png │ ├── manifest.json │ └── index.html ├── macos │ ├── Runner │ │ ├── Configs │ │ │ ├── Debug.xcconfig │ │ │ ├── Release.xcconfig │ │ │ ├── Warnings.xcconfig │ │ │ └── AppInfo.xcconfig │ │ ├── Assets.xcassets │ │ │ └── AppIcon.appiconset │ │ │ │ ├── app_icon_16.png │ │ │ │ ├── app_icon_32.png │ │ │ │ ├── app_icon_64.png │ │ │ │ ├── app_icon_1024.png │ │ │ │ ├── app_icon_128.png │ │ │ │ ├── app_icon_256.png │ │ │ │ ├── app_icon_512.png │ │ │ │ └── Contents.json │ │ ├── AppDelegate.swift │ │ ├── Release.entitlements │ │ ├── DebugProfile.entitlements │ │ ├── MainFlutterWindow.swift │ │ └── Info.plist │ ├── .gitignore │ ├── Flutter │ │ ├── Flutter-Debug.xcconfig │ │ ├── Flutter-Release.xcconfig │ │ └── GeneratedPluginRegistrant.swift │ ├── Runner.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ ├── Runner.xcodeproj │ │ ├── project.xcworkspace │ │ │ └── xcshareddata │ │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── Runner.xcscheme │ ├── RunnerTests │ │ └── RunnerTests.swift │ ├── Podfile.lock │ └── Podfile ├── pubspec.yaml ├── .gitignore ├── .metadata ├── lib │ ├── post.dart │ └── main.dart └── pubspec.lock ├── resources ├── screenshot1.png ├── screenshot2.png ├── screenshot3.png ├── screenshot4.png └── screenshot5.png ├── lib ├── paged_datatable.dart ├── src │ ├── filter_state.dart │ ├── utils.dart │ ├── configuration.dart │ ├── sort_model.dart │ ├── column_format.dart │ ├── table_controller_notifier.dart │ ├── footer.dart │ ├── filter_model.dart │ ├── column_size.dart │ ├── table_view_rows.dart │ ├── double_list_rows.dart │ ├── filter_widgets.dart │ ├── row.dart │ ├── theme.dart │ ├── header.dart │ ├── filter.dart │ ├── footer_widgets.dart │ ├── column.dart │ ├── paged_datatable.dart │ ├── filter_bar.dart │ ├── linked_scroll_controller.dart │ └── controller.dart └── l10n │ ├── intl_en.arb │ ├── intl_es.arb │ ├── intl_it.arb │ ├── intl_de.arb │ └── generated │ ├── intl │ ├── messages_en.dart │ ├── messages_all.dart │ ├── messages_es.dart │ ├── messages_it.dart │ └── messages_de.dart │ └── l10n.dart ├── .metadata ├── .github └── workflows │ └── publish.yml ├── .gitignore ├── pubspec.yaml ├── LICENSE ├── CHANGELOG.md └── README.md /.pubignore: -------------------------------------------------------------------------------- 1 | resources 2 | gen_locales 3 | example/macos -------------------------------------------------------------------------------- /test/paged_datatable_test.dart: -------------------------------------------------------------------------------- 1 | void main() {} 2 | -------------------------------------------------------------------------------- /gen_locales.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | dart run intl_utils:generate -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:flutter_lints/flutter.yaml 2 | -------------------------------------------------------------------------------- /example/web/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasweigenast/paged-datatable/HEAD/example/web/favicon.png -------------------------------------------------------------------------------- /resources/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasweigenast/paged-datatable/HEAD/resources/screenshot1.png -------------------------------------------------------------------------------- /resources/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasweigenast/paged-datatable/HEAD/resources/screenshot2.png -------------------------------------------------------------------------------- /resources/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasweigenast/paged-datatable/HEAD/resources/screenshot3.png -------------------------------------------------------------------------------- /resources/screenshot4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasweigenast/paged-datatable/HEAD/resources/screenshot4.png -------------------------------------------------------------------------------- /resources/screenshot5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasweigenast/paged-datatable/HEAD/resources/screenshot5.png -------------------------------------------------------------------------------- /example/macos/Runner/Configs/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../../Flutter/Flutter-Debug.xcconfig" 2 | #include "Warnings.xcconfig" 3 | -------------------------------------------------------------------------------- /example/macos/Runner/Configs/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../../Flutter/Flutter-Release.xcconfig" 2 | #include "Warnings.xcconfig" 3 | -------------------------------------------------------------------------------- /example/macos/.gitignore: -------------------------------------------------------------------------------- 1 | # Flutter-related 2 | **/Flutter/ephemeral/ 3 | **/Pods/ 4 | 5 | # Xcode-related 6 | **/dgph 7 | **/xcuserdata/ 8 | -------------------------------------------------------------------------------- /example/macos/Flutter/Flutter-Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "ephemeral/Flutter-Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /example/macos/Flutter/Flutter-Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "ephemeral/Flutter-Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasweigenast/paged-datatable/HEAD/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasweigenast/paged-datatable/HEAD/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasweigenast/paged-datatable/HEAD/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasweigenast/paged-datatable/HEAD/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasweigenast/paged-datatable/HEAD/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasweigenast/paged-datatable/HEAD/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasweigenast/paged-datatable/HEAD/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png -------------------------------------------------------------------------------- /example/macos/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import FlutterMacOS 3 | 4 | @NSApplicationMain 5 | class AppDelegate: FlutterAppDelegate { 6 | override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 7 | return true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /example/macos/Runner/Release.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /lib/paged_datatable.dart: -------------------------------------------------------------------------------- 1 | library paged_datatable; 2 | 3 | export './l10n/generated/l10n.dart'; 4 | export './src/paged_datatable.dart'; 5 | export './src/column_size.dart'; 6 | export './src/column_format.dart'; 7 | export './src/configuration.dart'; 8 | export './src/footer.dart'; 9 | export './src/theme.dart'; 10 | -------------------------------------------------------------------------------- /example/macos/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 400608f101bcfb21db99ac4d5df763a80c279337 8 | channel: beta 9 | 10 | project_type: package 11 | -------------------------------------------------------------------------------- /example/macos/Flutter/GeneratedPluginRegistrant.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | import FlutterMacOS 6 | import Foundation 7 | 8 | import path_provider_foundation 9 | 10 | func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 11 | PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) 12 | } 13 | -------------------------------------------------------------------------------- /lib/src/filter_state.dart: -------------------------------------------------------------------------------- 1 | part of 'paged_datatable.dart'; 2 | 3 | /// Represents the state of a [TableFilter]. 4 | final class FilterState { 5 | final TableFilter _filter; 6 | T? value; 7 | 8 | FilterState._(this._filter) 9 | : value = _filter 10 | .initialValue; // Set the initial value to the filter's initial value. 11 | } 12 | -------------------------------------------------------------------------------- /example/macos/RunnerTests/RunnerTests.swift: -------------------------------------------------------------------------------- 1 | import FlutterMacOS 2 | import Cocoa 3 | import XCTest 4 | 5 | class RunnerTests: XCTestCase { 6 | 7 | func testExample() { 8 | // If you add code to the Runner application, consider adding tests here. 9 | // See https://developer.apple.com/documentation/xctest for more information about using XCTest. 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /lib/src/utils.dart: -------------------------------------------------------------------------------- 1 | bool listEquals(List? list1, List? list2) { 2 | if (identical(list1, list2)) return true; 3 | if (list1 == null || list2 == null) return false; 4 | final length = list1.length; 5 | if (length != list2.length) return false; 6 | for (var i = 0; i < length; i++) { 7 | if (list1[i] != list2[i]) return false; 8 | } 9 | return true; 10 | } 11 | 12 | const kEmptyString = ""; 13 | -------------------------------------------------------------------------------- /example/macos/Runner/DebugProfile.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | com.apple.security.network.server 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /lib/src/configuration.dart: -------------------------------------------------------------------------------- 1 | /// A set of properties used to configure a [PagedDataTable] 2 | final class PagedDataTableConfiguration { 3 | /// A flag that indicates if the table should copy the list of items returned 4 | /// by a fetch callback. 5 | /// 6 | /// This is useful when you don't want to accidentally modify the returned list. 7 | final bool copyItems; 8 | 9 | const PagedDataTableConfiguration({this.copyItems = false}); 10 | } 11 | -------------------------------------------------------------------------------- /example/macos/Runner/MainFlutterWindow.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import FlutterMacOS 3 | 4 | class MainFlutterWindow: NSWindow { 5 | override func awakeFromNib() { 6 | let flutterViewController = FlutterViewController() 7 | let windowFrame = self.frame 8 | self.contentViewController = flutterViewController 9 | self.setFrame(windowFrame, display: true) 10 | 11 | RegisterGeneratedPlugins(registry: flutterViewController) 12 | 13 | super.awakeFromNib() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to pub.dev 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+*' 7 | 8 | jobs: 9 | publish: 10 | permissions: 11 | id-token: write 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: dart-lang/setup-dart@v1 16 | - name: Install dependencies 17 | run: flutter pub get 18 | - name: Format code 19 | run: dart format . 20 | - name: Publish 21 | run: dart pub publish --force 22 | -------------------------------------------------------------------------------- /example/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: paged_datatable_example 2 | description: A new Flutter project. 3 | 4 | publish_to: "none" 5 | version: 1.0.0+1 6 | 7 | environment: 8 | sdk: "^3.0.0" 9 | 10 | dependencies: 11 | paged_datatable: 12 | path: ../ 13 | faker: any 14 | darq: any 15 | intl: any 16 | equatable: any 17 | google_fonts: any 18 | flutter: 19 | sdk: flutter 20 | flutter_localizations: 21 | sdk: flutter 22 | 23 | dev_dependencies: 24 | flutter_test: 25 | sdk: flutter 26 | flutter_lints: ^1.0.0 27 | 28 | flutter: 29 | uses-material-design: true 30 | -------------------------------------------------------------------------------- /lib/src/sort_model.dart: -------------------------------------------------------------------------------- 1 | part of 'paged_datatable.dart'; 2 | 3 | /// SortModel indicates the current field the table is using to sort values. 4 | final class SortModel { 5 | final String fieldName; 6 | final bool descending; 7 | 8 | const SortModel._({required this.fieldName, required this.descending}); 9 | 10 | @override 11 | int get hashCode => Object.hash(fieldName, descending); 12 | 13 | @override 14 | bool operator ==(Object other) => 15 | identical(other, this) || 16 | (other is SortModel && 17 | other.fieldName == fieldName && 18 | other.descending == descending); 19 | } 20 | -------------------------------------------------------------------------------- /example/macos/Runner/Configs/Warnings.xcconfig: -------------------------------------------------------------------------------- 1 | WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings 2 | GCC_WARN_UNDECLARED_SELECTOR = YES 3 | CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES 4 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE 5 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES 6 | CLANG_WARN_PRAGMA_PACK = YES 7 | CLANG_WARN_STRICT_PROTOTYPES = YES 8 | CLANG_WARN_COMMA = YES 9 | GCC_WARN_STRICT_SELECTOR_MATCH = YES 10 | CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES 11 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES 12 | GCC_WARN_SHADOW = YES 13 | CLANG_WARN_UNREACHABLE_CODE = YES 14 | -------------------------------------------------------------------------------- /example/macos/Runner/Configs/AppInfo.xcconfig: -------------------------------------------------------------------------------- 1 | // Application-level settings for the Runner target. 2 | // 3 | // This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the 4 | // future. If not, the values below would default to using the project name when this becomes a 5 | // 'flutter create' template. 6 | 7 | // The application's name. By default this is also the title of the Flutter window. 8 | PRODUCT_NAME = example 9 | 10 | // The application's bundle identifier 11 | PRODUCT_BUNDLE_IDENTIFIER = com.example.example 12 | 13 | // The copyright displayed in application information 14 | PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved. 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | .fvm/flutter_sdk 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. 26 | /pubspec.lock 27 | **/doc/api/ 28 | .dart_tool/ 29 | .packages 30 | .fvm/ 31 | .vscode/ 32 | build/ 33 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: paged_datatable 2 | description: A brand new way of creating paginated DataTables in Flutter with sorting and filters 3 | version: 2.1.1 4 | homepage: https://github.com/tomasweigenast/paged-datatable 5 | 6 | environment: 7 | sdk: "^3.0.0" 8 | 9 | dependencies: 10 | flutter: 11 | sdk: flutter 12 | flutter_localizations: 13 | sdk: flutter 14 | intl: ^0.19.0 15 | 16 | dev_dependencies: 17 | build_runner: ^2.4.8 18 | flutter_lints: ^4.0.0 19 | intl_utils: ^2.8.5 20 | 21 | flutter: 22 | generate: true 23 | 24 | flutter_intl: 25 | enabled: true 26 | class_name: PagedDataTableLocalization 27 | main_locale: en 28 | arb_dir: lib/l10n 29 | output_dir: lib/l10n/generated 30 | use_deferred_loading: false 31 | -------------------------------------------------------------------------------- /example/macos/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - FlutterMacOS (1.0.0) 3 | - path_provider_foundation (0.0.1): 4 | - Flutter 5 | - FlutterMacOS 6 | 7 | DEPENDENCIES: 8 | - FlutterMacOS (from `Flutter/ephemeral`) 9 | - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) 10 | 11 | EXTERNAL SOURCES: 12 | FlutterMacOS: 13 | :path: Flutter/ephemeral 14 | path_provider_foundation: 15 | :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin 16 | 17 | SPEC CHECKSUMS: 18 | FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 19 | path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 20 | 21 | PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 22 | 23 | COCOAPODS: 1.15.2 24 | -------------------------------------------------------------------------------- /lib/src/column_format.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | abstract interface class ColumnFormat { 4 | const ColumnFormat(); 5 | 6 | Widget transform(Widget cell); 7 | } 8 | 9 | /// Applies a numeric format to the column. That is, the cell content is aligned to the right. 10 | class NumericColumnFormat implements ColumnFormat { 11 | const NumericColumnFormat(); 12 | 13 | @override 14 | Widget transform(Widget cell) => 15 | Align(alignment: Alignment.centerRight, child: cell); 16 | } 17 | 18 | /// Applies [alignment] to the cell content. 19 | class AlignColumnFormat implements ColumnFormat { 20 | final AlignmentGeometry alignment; 21 | 22 | const AlignColumnFormat({required this.alignment}); 23 | 24 | @override 25 | Widget transform(Widget cell) => Align(alignment: alignment, child: cell); 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/table_controller_notifier.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:paged_datatable/paged_datatable.dart'; 3 | 4 | final class TableControllerProvider, T> 5 | extends InheritedWidget { 6 | final PagedDataTableController controller; 7 | 8 | const TableControllerProvider( 9 | {required this.controller, required super.child, super.key}); 10 | 11 | @override 12 | bool updateShouldNotify(covariant InheritedWidget oldWidget) => 13 | false; //controller != (oldWidget as TableControllerProvider).controller; 14 | 15 | static PagedDataTableController of, T>( 16 | BuildContext context) => 17 | context 18 | .dependOnInheritedWidgetOfExactType>()! 19 | .controller; 20 | } 21 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | **/ios/Flutter/.last_build_id 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | 34 | # Web related 35 | 36 | # Symbolication related 37 | app.*.symbols 38 | 39 | # Obfuscation related 40 | app.*.map.json 41 | 42 | # Android Studio will place build artifacts here 43 | /android/app/debug 44 | /android/app/profile 45 | /android/app/release 46 | -------------------------------------------------------------------------------- /example/web/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "short_name": "example", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#0175C2", 7 | "theme_color": "#0175C2", 8 | "description": "A new Flutter project.", 9 | "orientation": "portrait-primary", 10 | "prefer_related_applications": false, 11 | "icons": [ 12 | { 13 | "src": "icons/Icon-192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "icons/Icon-512.png", 19 | "sizes": "512x512", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "icons/Icon-maskable-192.png", 24 | "sizes": "192x192", 25 | "type": "image/png", 26 | "purpose": "maskable" 27 | }, 28 | { 29 | "src": "icons/Icon-maskable-512.png", 30 | "sizes": "512x512", 31 | "type": "image/png", 32 | "purpose": "maskable" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /example/.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "5dcb86f68f239346676ceb1ed1ea385bd215fba1" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 5dcb86f68f239346676ceb1ed1ea385bd215fba1 17 | base_revision: 5dcb86f68f239346676ceb1ed1ea385bd215fba1 18 | - platform: web 19 | create_revision: 5dcb86f68f239346676ceb1ed1ea385bd215fba1 20 | base_revision: 5dcb86f68f239346676ceb1ed1ea385bd215fba1 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tomás Weigenast 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/l10n/intl_en.arb: -------------------------------------------------------------------------------- 1 | { 2 | "showFilterMenuTooltip": "Filter", 3 | "filterByTitle": "Filter by", 4 | "applyFilterButtonText": "Apply", 5 | "cancelFilteringButtonText": "Cancel", 6 | "removeAllFiltersButtonText": "Remove", 7 | "editableColumnSaveChangesButtonText": "Save changes", 8 | "editableColumnCancelButtonText": "Cancel", 9 | "removeFilterButtonText": "Remove this filter", 10 | "refreshText": "Refresh", 11 | "refreshedAtText": "Last refreshed at {time}", 12 | "@refreshedAtText": { 13 | "placeholders": { 14 | "time": {} 15 | } 16 | }, 17 | "rowsPerPageText": "Rows per page", 18 | "pageIndicatorText": "Page {currentPage}", 19 | "@pageIndicatorText": { 20 | "placeholders": { 21 | "currentPage": {} 22 | } 23 | }, 24 | "totalElementsText": "Showing {totalElements} elements", 25 | "@totalElementsText": { 26 | "placeholders": { 27 | "totalElements": {} 28 | } 29 | }, 30 | "nextPageButtonText": "Next page", 31 | "previousPageButtonText": "Previous page", 32 | "noItemsFoundText": "No items found" 33 | } -------------------------------------------------------------------------------- /lib/l10n/intl_es.arb: -------------------------------------------------------------------------------- 1 | { 2 | "showFilterMenuTooltip": "Filtrar", 3 | "filterByTitle": "Filtrar por", 4 | "applyFilterButtonText": "Aplicar", 5 | "cancelFilteringButtonText": "Cancelar", 6 | "removeAllFiltersButtonText": "Borrar", 7 | "removeFilterButtonText": "Quitar este filtro", 8 | "refreshText": "Actualizar", 9 | "editableColumnSaveChangesButtonText": "Guardar cambios", 10 | "editableColumnCancelButtonText": "Cancelar", 11 | "refreshedAtText": "Ultima actualización {time}", 12 | "@refreshedAtText": { 13 | "placeholders": { 14 | "time": {} 15 | } 16 | }, 17 | "rowsPerPageText": "Filas por página", 18 | "pageIndicatorText": "Página {currentPage}", 19 | "@pageIndicatorText": { 20 | "placeholders": { 21 | "currentPage": {} 22 | } 23 | }, 24 | "totalElementsText": "Mostrando {totalElements} elementos", 25 | "@totalElementsText": { 26 | "placeholders": { 27 | "totalElements": {} 28 | } 29 | }, 30 | "nextPageButtonText": "Siguiente", 31 | "previousPageButtonText": "Anterior", 32 | "noItemsFoundText": "No se han encontrado elementos" 33 | } -------------------------------------------------------------------------------- /lib/l10n/intl_it.arb: -------------------------------------------------------------------------------- 1 | { 2 | "showFilterMenuTooltip": "Filtro", 3 | "filterByTitle": "Filtra per", 4 | "applyFilterButtonText": "Applica", 5 | "cancelFilteringButtonText": "Annulla", 6 | "removeAllFiltersButtonText": "Rimuovi", 7 | "editableColumnSaveChangesButtonText": "Salva", 8 | "editableColumnCancelButtonText": "Annulla", 9 | "removeFilterButtonText": "Rimuovi filtro", 10 | "refreshText": "Aggiorna", 11 | "refreshedAtText": "Ultimo aggiornamento alle {time}", 12 | "@refreshedAtText": { 13 | "placeholders": { 14 | "time": {} 15 | } 16 | }, 17 | "rowsPerPageText": "Righe per pagina", 18 | "pageIndicatorText": "Pagina {currentPage}", 19 | "@pageIndicatorText": { 20 | "placeholders": { 21 | "currentPage": {} 22 | } 23 | }, 24 | "totalElementsText": "Visualizzazione di {totalElements} elementi", 25 | "@totalElementsText": { 26 | "placeholders": { 27 | "totalElements": {} 28 | } 29 | }, 30 | "nextPageButtonText": "Pagina successiva", 31 | "previousPageButtonText": "Pagina precedente", 32 | "noItemsFoundText": "Nessun elemento trovato" 33 | } -------------------------------------------------------------------------------- /example/macos/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleVersion 22 | $(FLUTTER_BUILD_NUMBER) 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSHumanReadableCopyright 26 | $(PRODUCT_COPYRIGHT) 27 | NSMainNibFile 28 | MainMenu 29 | NSPrincipalClass 30 | NSApplication 31 | 32 | 33 | -------------------------------------------------------------------------------- /lib/l10n/intl_de.arb: -------------------------------------------------------------------------------- 1 | { 2 | "showFilterMenuTooltip": "Filter", 3 | "filterByTitle": "Filtern nach", 4 | "applyFilterButtonText": "Anwenden", 5 | "cancelFilteringButtonText": "Abbrechen", 6 | "removeAllFiltersButtonText": "Entfernen", 7 | "removeFilterButtonText": "Diesen Filter entfernen", 8 | "refreshText": "Aktualisieren", 9 | "editableColumnSaveChangesButtonText": "Änderungen speichern", 10 | "editableColumnCancelButtonText": "Abbrechen", 11 | "refreshedAtText": "Zuletzt aktualisiert um {time}", 12 | "@refreshedAtText": { 13 | "placeholders": { 14 | "time": {} 15 | } 16 | }, 17 | "rowsPerPageText": "Zeilen pro Seite", 18 | "pageIndicatorText": "Seite {currentPage}", 19 | "@pageIndicatorText": { 20 | "placeholders": { 21 | "currentPage": {} 22 | } 23 | }, 24 | "totalElementsText": "Zeigt {totalElements} Elemente an", 25 | "@totalElementsText": { 26 | "placeholders": { 27 | "totalElements": {} 28 | } 29 | }, 30 | "nextPageButtonText": "Nächste Seite", 31 | "previousPageButtonText": "Vorherige Seite", 32 | "noItemsFoundText": "Keine Elemente gefunden" 33 | } -------------------------------------------------------------------------------- /lib/src/footer.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:paged_datatable/paged_datatable.dart'; 3 | 4 | /// The default footer renderer for [PagedDataTable]. 5 | /// 6 | /// It renders the [RefreshTable], [PageSizeSelector], [CurrentPage] and [NavigationButtons] widgets. 7 | class DefaultFooter, T> extends StatelessWidget { 8 | /// An additional widget to render at the left of the footer 9 | final Widget? child; 10 | 11 | const DefaultFooter({this.child, super.key}); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return Row( 16 | children: [ 17 | if (child != null) Expanded(child: child!) else const Spacer(), 18 | RefreshTable(), 19 | const VerticalDivider( 20 | color: Color(0xFFD6D6D6), width: 3, indent: 10, endIndent: 10), 21 | PageSizeSelector(), 22 | const VerticalDivider( 23 | color: Color(0xFFD6D6D6), width: 3, indent: 10, endIndent: 10), 24 | CurrentPage(), 25 | const VerticalDivider( 26 | color: Color(0xFFD6D6D6), width: 3, indent: 10, endIndent: 10), 27 | NavigationButtons(), 28 | ], 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /example/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | example 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /lib/src/filter_model.dart: -------------------------------------------------------------------------------- 1 | part of 'paged_datatable.dart'; 2 | 3 | /// Represents the filter model of the table. 4 | /// 5 | /// Calling [Map] conventional methods should be sufficient to try to get values for filters as this class 6 | /// extends Map and because value is dynamic, you can access it like so: 7 | /// 8 | /// ```dart 9 | /// final String? myFilterValue = filterModel["filterId"]; 10 | /// ``` 11 | /// 12 | /// If the filter does not have any value set, this won't throw an error as it will return null and, 13 | /// if there is a value, it is in fact an String, so the conversion is made by Dart automatically. 14 | /// 15 | /// Obviously if filter's value is not an String an error will be thrown. 16 | final class FilterModel extends UnmodifiableMapBase { 17 | final Map _inner; 18 | 19 | FilterModel._(this._inner); 20 | 21 | @override 22 | operator [](Object? key) => _inner[key]; 23 | 24 | @override 25 | Iterable get keys => _inner.keys; 26 | 27 | /// Tries to get the value of [filterId] and converts it to [T]. 28 | /// 29 | /// If the filter does not have a value set, [orElse] will get called if not null, otherwise an 30 | /// error will be thrown. 31 | T valueAs(String filterId, {T Function()? orElse}) { 32 | final value = _inner[filterId]; 33 | if (value == null) { 34 | if (orElse != null) return orElse(); 35 | throw StateError("Value for filter $filterId was not found."); 36 | } 37 | 38 | return value as T; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /example/macos/Podfile: -------------------------------------------------------------------------------- 1 | platform :osx, '10.14' 2 | 3 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 4 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 5 | 6 | project 'Runner', { 7 | 'Debug' => :debug, 8 | 'Profile' => :release, 9 | 'Release' => :release, 10 | } 11 | 12 | def flutter_root 13 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) 14 | unless File.exist?(generated_xcode_build_settings_path) 15 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" 16 | end 17 | 18 | File.foreach(generated_xcode_build_settings_path) do |line| 19 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 20 | return matches[1].strip if matches 21 | end 22 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" 23 | end 24 | 25 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 26 | 27 | flutter_macos_podfile_setup 28 | 29 | target 'Runner' do 30 | use_frameworks! 31 | use_modular_headers! 32 | 33 | flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) 34 | target 'RunnerTests' do 35 | inherit! :search_paths 36 | end 37 | end 38 | 39 | post_install do |installer| 40 | installer.pods_project.targets.each do |target| 41 | flutter_additional_macos_build_settings(target) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "app_icon_16.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "app_icon_32.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "app_icon_32.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "app_icon_64.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "app_icon_128.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "app_icon_256.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "app_icon_256.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "app_icon_512.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "app_icon_512.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "app_icon_1024.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/l10n/generated/intl/messages_en.dart: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT. This is code generated via package:intl/generate_localized.dart 2 | // This is a library that provides messages for a en locale. All the 3 | // messages from the main program should be duplicated here with the same 4 | // function name. 5 | 6 | // Ignore issues from commonly used lints in this file. 7 | // ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new 8 | // ignore_for_file:prefer_single_quotes,comment_references, directives_ordering 9 | // ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases 10 | // ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes 11 | // ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes 12 | 13 | import 'package:intl/intl.dart'; 14 | import 'package:intl/message_lookup_by_library.dart'; 15 | 16 | final messages = new MessageLookup(); 17 | 18 | typedef String MessageIfAbsent(String messageStr, List args); 19 | 20 | class MessageLookup extends MessageLookupByLibrary { 21 | String get localeName => 'en'; 22 | 23 | static String m0(currentPage) => "Page ${currentPage}"; 24 | 25 | static String m1(time) => "Last refreshed at ${time}"; 26 | 27 | static String m2(totalElements) => "Showing ${totalElements} elements"; 28 | 29 | final messages = _notInlinedMessages(_notInlinedMessages); 30 | static Map _notInlinedMessages(_) => { 31 | "applyFilterButtonText": MessageLookupByLibrary.simpleMessage("Apply"), 32 | "cancelFilteringButtonText": 33 | MessageLookupByLibrary.simpleMessage("Cancel"), 34 | "editableColumnCancelButtonText": 35 | MessageLookupByLibrary.simpleMessage("Cancel"), 36 | "editableColumnSaveChangesButtonText": 37 | MessageLookupByLibrary.simpleMessage("Save changes"), 38 | "filterByTitle": MessageLookupByLibrary.simpleMessage("Filter by"), 39 | "nextPageButtonText": MessageLookupByLibrary.simpleMessage("Next page"), 40 | "noItemsFoundText": 41 | MessageLookupByLibrary.simpleMessage("No items found"), 42 | "pageIndicatorText": m0, 43 | "previousPageButtonText": 44 | MessageLookupByLibrary.simpleMessage("Previous page"), 45 | "refreshText": MessageLookupByLibrary.simpleMessage("Refresh"), 46 | "refreshedAtText": m1, 47 | "removeAllFiltersButtonText": 48 | MessageLookupByLibrary.simpleMessage("Remove"), 49 | "removeFilterButtonText": 50 | MessageLookupByLibrary.simpleMessage("Remove this filter"), 51 | "rowsPerPageText": 52 | MessageLookupByLibrary.simpleMessage("Rows per page"), 53 | "showFilterMenuTooltip": MessageLookupByLibrary.simpleMessage("Filter"), 54 | "totalElementsText": m2 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /lib/l10n/generated/intl/messages_all.dart: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT. This is code generated via package:intl/generate_localized.dart 2 | // This is a library that looks up messages for specific locales by 3 | // delegating to the appropriate library. 4 | 5 | // Ignore issues from commonly used lints in this file. 6 | // ignore_for_file:implementation_imports, file_names, unnecessary_new 7 | // ignore_for_file:unnecessary_brace_in_string_interps, directives_ordering 8 | // ignore_for_file:argument_type_not_assignable, invalid_assignment 9 | // ignore_for_file:prefer_single_quotes, prefer_generic_function_type_aliases 10 | // ignore_for_file:comment_references 11 | 12 | import 'dart:async'; 13 | 14 | import 'package:flutter/foundation.dart'; 15 | import 'package:intl/intl.dart'; 16 | import 'package:intl/message_lookup_by_library.dart'; 17 | import 'package:intl/src/intl_helpers.dart'; 18 | 19 | import 'messages_de.dart' as messages_de; 20 | import 'messages_en.dart' as messages_en; 21 | import 'messages_es.dart' as messages_es; 22 | import 'messages_it.dart' as messages_it; 23 | 24 | typedef Future LibraryLoader(); 25 | Map _deferredLibraries = { 26 | 'de': () => new SynchronousFuture(null), 27 | 'en': () => new SynchronousFuture(null), 28 | 'es': () => new SynchronousFuture(null), 29 | 'it': () => new SynchronousFuture(null), 30 | }; 31 | 32 | MessageLookupByLibrary? _findExact(String localeName) { 33 | switch (localeName) { 34 | case 'de': 35 | return messages_de.messages; 36 | case 'en': 37 | return messages_en.messages; 38 | case 'es': 39 | return messages_es.messages; 40 | case 'it': 41 | return messages_it.messages; 42 | default: 43 | return null; 44 | } 45 | } 46 | 47 | /// User programs should call this before using [localeName] for messages. 48 | Future initializeMessages(String localeName) { 49 | var availableLocale = Intl.verifiedLocale( 50 | localeName, (locale) => _deferredLibraries[locale] != null, 51 | onFailure: (_) => null); 52 | if (availableLocale == null) { 53 | return new SynchronousFuture(false); 54 | } 55 | var lib = _deferredLibraries[availableLocale]; 56 | lib == null ? new SynchronousFuture(false) : lib(); 57 | initializeInternalMessageLookup(() => new CompositeMessageLookup()); 58 | messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor); 59 | return new SynchronousFuture(true); 60 | } 61 | 62 | bool _messagesExistFor(String locale) { 63 | try { 64 | return _findExact(locale) != null; 65 | } catch (e) { 66 | return false; 67 | } 68 | } 69 | 70 | MessageLookupByLibrary? _findGeneratedMessagesFor(String locale) { 71 | var actualLocale = 72 | Intl.verifiedLocale(locale, _messagesExistFor, onFailure: (_) => null); 73 | if (actualLocale == null) return null; 74 | return _findExact(actualLocale); 75 | } 76 | -------------------------------------------------------------------------------- /lib/l10n/generated/intl/messages_es.dart: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT. This is code generated via package:intl/generate_localized.dart 2 | // This is a library that provides messages for a es locale. All the 3 | // messages from the main program should be duplicated here with the same 4 | // function name. 5 | 6 | // Ignore issues from commonly used lints in this file. 7 | // ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new 8 | // ignore_for_file:prefer_single_quotes,comment_references, directives_ordering 9 | // ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases 10 | // ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes 11 | // ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes 12 | 13 | import 'package:intl/intl.dart'; 14 | import 'package:intl/message_lookup_by_library.dart'; 15 | 16 | final messages = new MessageLookup(); 17 | 18 | typedef String MessageIfAbsent(String messageStr, List args); 19 | 20 | class MessageLookup extends MessageLookupByLibrary { 21 | String get localeName => 'es'; 22 | 23 | static String m0(currentPage) => "Página ${currentPage}"; 24 | 25 | static String m1(time) => "Ultima actualización ${time}"; 26 | 27 | static String m2(totalElements) => "Mostrando ${totalElements} elementos"; 28 | 29 | final messages = _notInlinedMessages(_notInlinedMessages); 30 | static Map _notInlinedMessages(_) => { 31 | "applyFilterButtonText": 32 | MessageLookupByLibrary.simpleMessage("Aplicar"), 33 | "cancelFilteringButtonText": 34 | MessageLookupByLibrary.simpleMessage("Cancelar"), 35 | "editableColumnCancelButtonText": 36 | MessageLookupByLibrary.simpleMessage("Cancelar"), 37 | "editableColumnSaveChangesButtonText": 38 | MessageLookupByLibrary.simpleMessage("Guardar cambios"), 39 | "filterByTitle": MessageLookupByLibrary.simpleMessage("Filtrar por"), 40 | "nextPageButtonText": MessageLookupByLibrary.simpleMessage("Siguiente"), 41 | "noItemsFoundText": MessageLookupByLibrary.simpleMessage( 42 | "No se han encontrado elementos"), 43 | "pageIndicatorText": m0, 44 | "previousPageButtonText": 45 | MessageLookupByLibrary.simpleMessage("Anterior"), 46 | "refreshText": MessageLookupByLibrary.simpleMessage("Actualizar"), 47 | "refreshedAtText": m1, 48 | "removeAllFiltersButtonText": 49 | MessageLookupByLibrary.simpleMessage("Borrar"), 50 | "removeFilterButtonText": 51 | MessageLookupByLibrary.simpleMessage("Quitar este filtro"), 52 | "rowsPerPageText": 53 | MessageLookupByLibrary.simpleMessage("Filas por página"), 54 | "showFilterMenuTooltip": 55 | MessageLookupByLibrary.simpleMessage("Filtrar"), 56 | "totalElementsText": m2 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /lib/l10n/generated/intl/messages_it.dart: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT. This is code generated via package:intl/generate_localized.dart 2 | // This is a library that provides messages for a it locale. All the 3 | // messages from the main program should be duplicated here with the same 4 | // function name. 5 | 6 | // Ignore issues from commonly used lints in this file. 7 | // ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new 8 | // ignore_for_file:prefer_single_quotes,comment_references, directives_ordering 9 | // ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases 10 | // ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes 11 | // ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes 12 | 13 | import 'package:intl/intl.dart'; 14 | import 'package:intl/message_lookup_by_library.dart'; 15 | 16 | final messages = new MessageLookup(); 17 | 18 | typedef String MessageIfAbsent(String messageStr, List args); 19 | 20 | class MessageLookup extends MessageLookupByLibrary { 21 | String get localeName => 'it'; 22 | 23 | static String m0(currentPage) => "Pagina ${currentPage}"; 24 | 25 | static String m1(time) => "Ultimo aggiornamento alle ${time}"; 26 | 27 | static String m2(totalElements) => 28 | "Visualizzazione di ${totalElements} elementi"; 29 | 30 | final messages = _notInlinedMessages(_notInlinedMessages); 31 | static Map _notInlinedMessages(_) => { 32 | "applyFilterButtonText": 33 | MessageLookupByLibrary.simpleMessage("Applica"), 34 | "cancelFilteringButtonText": 35 | MessageLookupByLibrary.simpleMessage("Annulla"), 36 | "editableColumnCancelButtonText": 37 | MessageLookupByLibrary.simpleMessage("Annulla"), 38 | "editableColumnSaveChangesButtonText": 39 | MessageLookupByLibrary.simpleMessage("Salva"), 40 | "filterByTitle": MessageLookupByLibrary.simpleMessage("Filtra per"), 41 | "nextPageButtonText": 42 | MessageLookupByLibrary.simpleMessage("Pagina successiva"), 43 | "noItemsFoundText": 44 | MessageLookupByLibrary.simpleMessage("Nessun elemento trovato"), 45 | "pageIndicatorText": m0, 46 | "previousPageButtonText": 47 | MessageLookupByLibrary.simpleMessage("Pagina precedente"), 48 | "refreshText": MessageLookupByLibrary.simpleMessage("Aggiorna"), 49 | "refreshedAtText": m1, 50 | "removeAllFiltersButtonText": 51 | MessageLookupByLibrary.simpleMessage("Rimuovi"), 52 | "removeFilterButtonText": 53 | MessageLookupByLibrary.simpleMessage("Rimuovi filtro"), 54 | "rowsPerPageText": 55 | MessageLookupByLibrary.simpleMessage("Righe per pagina"), 56 | "showFilterMenuTooltip": MessageLookupByLibrary.simpleMessage("Filtro"), 57 | "totalElementsText": m2 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /lib/l10n/generated/intl/messages_de.dart: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT. This is code generated via package:intl/generate_localized.dart 2 | // This is a library that provides messages for a de locale. All the 3 | // messages from the main program should be duplicated here with the same 4 | // function name. 5 | 6 | // Ignore issues from commonly used lints in this file. 7 | // ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new 8 | // ignore_for_file:prefer_single_quotes,comment_references, directives_ordering 9 | // ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases 10 | // ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes 11 | // ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes 12 | 13 | import 'package:intl/intl.dart'; 14 | import 'package:intl/message_lookup_by_library.dart'; 15 | 16 | final messages = new MessageLookup(); 17 | 18 | typedef String MessageIfAbsent(String messageStr, List args); 19 | 20 | class MessageLookup extends MessageLookupByLibrary { 21 | String get localeName => 'de'; 22 | 23 | static String m0(currentPage) => "Seite ${currentPage}"; 24 | 25 | static String m1(time) => "Zuletzt aktualisiert um ${time}"; 26 | 27 | static String m2(totalElements) => "Zeigt ${totalElements} Elemente an"; 28 | 29 | final messages = _notInlinedMessages(_notInlinedMessages); 30 | static Map _notInlinedMessages(_) => { 31 | "applyFilterButtonText": 32 | MessageLookupByLibrary.simpleMessage("Anwenden"), 33 | "cancelFilteringButtonText": 34 | MessageLookupByLibrary.simpleMessage("Abbrechen"), 35 | "editableColumnCancelButtonText": 36 | MessageLookupByLibrary.simpleMessage("Abbrechen"), 37 | "editableColumnSaveChangesButtonText": 38 | MessageLookupByLibrary.simpleMessage("Änderungen speichern"), 39 | "filterByTitle": MessageLookupByLibrary.simpleMessage("Filtern nach"), 40 | "nextPageButtonText": 41 | MessageLookupByLibrary.simpleMessage("Nächste Seite"), 42 | "noItemsFoundText": 43 | MessageLookupByLibrary.simpleMessage("Keine Elemente gefunden"), 44 | "pageIndicatorText": m0, 45 | "previousPageButtonText": 46 | MessageLookupByLibrary.simpleMessage("Vorherige Seite"), 47 | "refreshText": MessageLookupByLibrary.simpleMessage("Aktualisieren"), 48 | "refreshedAtText": m1, 49 | "removeAllFiltersButtonText": 50 | MessageLookupByLibrary.simpleMessage("Entfernen"), 51 | "removeFilterButtonText": 52 | MessageLookupByLibrary.simpleMessage("Diesen Filter entfernen"), 53 | "rowsPerPageText": 54 | MessageLookupByLibrary.simpleMessage("Zeilen pro Seite"), 55 | "showFilterMenuTooltip": MessageLookupByLibrary.simpleMessage("Filter"), 56 | "totalElementsText": m2 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /lib/src/column_size.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' as math; 2 | 3 | /// Indicates the size of a table column 4 | sealed class ColumnSize { 5 | const ColumnSize(); 6 | 7 | /// A flag that indicates if the column defines a fixed, constant size. 8 | bool get isFixed => false; 9 | 10 | /// Returns the size of the column as fractional units. 11 | double get fraction => 0.0; 12 | 13 | /// The function used to calculate the constraints for the column given the [availableWidth]. 14 | double calculateConstraints(double availableWidth); 15 | } 16 | 17 | /// Indicates a fixed size of a column. If the content of a cell does not fit, it will be wrapped 18 | final class FixedColumnSize extends ColumnSize { 19 | final double size; 20 | 21 | const FixedColumnSize(this.size); 22 | 23 | @override 24 | int get hashCode => size.hashCode; 25 | 26 | @override 27 | bool operator ==(Object other) => 28 | other is FixedColumnSize && other.size == size; 29 | 30 | @override 31 | bool get isFixed => true; 32 | 33 | @override 34 | double calculateConstraints(double availableWidth) => size; 35 | } 36 | 37 | /// Indicates a fraction size of a column. That is, a column that takes a fraction of the available viewport. 38 | final class FractionalColumnSize extends ColumnSize { 39 | final double _fraction; 40 | 41 | const FractionalColumnSize(double fraction) 42 | : _fraction = fraction, 43 | assert(fraction > 0, "Fraction cannot be less than or equal to zero."); 44 | 45 | @override 46 | int get hashCode => fraction.hashCode; 47 | 48 | @override 49 | bool operator ==(Object other) => 50 | other is FractionalColumnSize && other.fraction == fraction; 51 | 52 | @override 53 | double get fraction => _fraction; 54 | 55 | @override 56 | double calculateConstraints(double availableWidth) => 57 | availableWidth * fraction; 58 | } 59 | 60 | /// Indicates that a column will take the remaining space in the viewport. 61 | final class RemainingColumnSize extends ColumnSize { 62 | const RemainingColumnSize(); 63 | 64 | @override 65 | double calculateConstraints(double availableWidth) => 66 | math.max(0.0, availableWidth); 67 | } 68 | 69 | /// A column size that uses the maximum value of two provided constraints. 70 | final class MaxColumnSize extends ColumnSize { 71 | final ColumnSize a, b; 72 | 73 | const MaxColumnSize(this.a, this.b); 74 | 75 | @override 76 | bool get isFixed => a.isFixed && b.isFixed; 77 | 78 | @override 79 | double get fraction => math.max(a.fraction, b.fraction); 80 | 81 | @override 82 | int get hashCode => Object.hash(a.hashCode, b.hashCode); 83 | 84 | @override 85 | bool operator ==(Object other) => 86 | other is MaxColumnSize && other.a == a && other.b == b; 87 | 88 | @override 89 | double calculateConstraints(double availableWidth) => math.max( 90 | a.calculateConstraints(availableWidth), 91 | b.calculateConstraints(availableWidth)); 92 | } 93 | -------------------------------------------------------------------------------- /example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /lib/src/table_view_rows.dart: -------------------------------------------------------------------------------- 1 | part of 'paged_datatable.dart'; 2 | 3 | /// A row renderer that uses TableView 4 | /*class _TableViewRows, T> extends StatefulWidget { 5 | final TableController controller; 6 | final ScrollController horizontalController, verticalController; 7 | final int fixedColumnCount; 8 | final List columns; 9 | 10 | const _TableViewRows( 11 | {required this.controller, 12 | required this.horizontalController, 13 | required this.verticalController, 14 | required this.columns, 15 | required this.fixedColumnCount}); 16 | 17 | @override 18 | State<_TableViewRows> createState() => _TableViewRowsState(); 19 | } 20 | 21 | class _TableViewRowsState, T> extends State<_TableViewRows> { 22 | late PagedDataTableThemeData theme; 23 | late FixedTableSpanExtent rowSpanExtent; 24 | 25 | late _TableState tableState; 26 | 27 | @override 28 | void initState() { 29 | super.initState(); 30 | 31 | tableState = widget.controller._state; 32 | widget.controller.addListener(_onControllerChanged); 33 | } 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | theme = PagedDataTableTheme.of(context); 38 | rowSpanExtent = FixedTableSpanExtent(theme.rowHeight); 39 | 40 | return Opacity( 41 | opacity: tableState == _TableState.idle ? 1 : 0.3, 42 | child: DefaultTextStyle( 43 | style: theme.cellTextStyle, 44 | child: Scrollbar( 45 | controller: widget.verticalController, 46 | thumbVisibility: true, 47 | child: Scrollbar( 48 | controller: widget.horizontalController, 49 | thumbVisibility: true, 50 | child: TableView.builder( 51 | pinnedColumnCount: widget.fixedColumnCount, 52 | verticalDetails: ScrollableDetails.vertical(controller: widget.verticalController), 53 | horizontalDetails: ScrollableDetails.horizontal(controller: widget.horizontalController), 54 | columnCount: widget.columns.length, 55 | rowCount: widget.controller.totalItems, 56 | rowBuilder: _buildRowSpan, 57 | columnBuilder: _buildColumnSpan, 58 | cellBuilder: _buildCell, 59 | ), 60 | ), 61 | ), 62 | ), 63 | ); 64 | } 65 | 66 | TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) { 67 | final itemIndex = vicinity.row; 68 | final column = widget.columns[vicinity.column]; 69 | final item = widget.controller._currentDataset[itemIndex]; 70 | 71 | return TableViewCell( 72 | child: switch (column) { 73 | TableColumn(:final cellBuilder) => Padding( 74 | padding: theme.padding, 75 | child: column.format.transform( 76 | Padding( 77 | padding: theme.cellPadding, 78 | child: cellBuilder(context, item, itemIndex), 79 | ), 80 | ), 81 | ), 82 | _ => throw UnimplementedError() 83 | }, 84 | ); 85 | } 86 | 87 | TableSpan _buildColumnSpan(int index) { 88 | final column = widget.columns[index]; 89 | return TableSpan( 90 | extent: switch (column.size) { 91 | RemainingColumnSize() => const RemainingTableSpanExtent(), 92 | FixedColumnSize(:final size) => FixedTableSpanExtent(size), 93 | FractionalColumnSize(:final fraction) => FractionalTableSpanExtent(fraction) 94 | }, 95 | padding: const TableSpanPadding.all(0), 96 | ); 97 | } 98 | 99 | TableSpan _buildRowSpan(int index) { 100 | final TableSpanDecoration decoration = TableSpanDecoration( 101 | color: theme.cellColor?.call(index), 102 | border: const TableSpanBorder( 103 | trailing: BorderSide(width: 1, color: Color(0xFFD6D6D6)), 104 | ), 105 | ); 106 | 107 | return TableSpan( 108 | backgroundDecoration: decoration, 109 | cursor: SystemMouseCursors.click, 110 | extent: rowSpanExtent, 111 | recognizerFactories: { 112 | TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( 113 | () => TapGestureRecognizer(), 114 | (TapGestureRecognizer t) => t.onTap = () => debugPrint('Tap row $index'), 115 | ), 116 | }, 117 | ); 118 | } 119 | 120 | void _onControllerChanged() { 121 | if (tableState != widget.controller._state) { 122 | setState(() { 123 | tableState = widget.controller._state; 124 | }); 125 | } 126 | } 127 | 128 | @override 129 | void dispose() { 130 | super.dispose(); 131 | widget.controller.removeListener(_onControllerChanged); 132 | } 133 | } 134 | */ 135 | -------------------------------------------------------------------------------- /lib/src/double_list_rows.dart: -------------------------------------------------------------------------------- 1 | part of 'paged_datatable.dart'; 2 | 3 | /// A Row renderer that uses two lists for two directional scrolling 4 | class _DoubleListRows, T> extends StatefulWidget { 5 | final List columns; 6 | final ScrollController horizontalController; 7 | final int fixedColumnCount; 8 | final PagedDataTableController controller; 9 | final PagedDataTableConfiguration configuration; 10 | final List sizes; 11 | 12 | const _DoubleListRows({ 13 | required this.columns, 14 | required this.fixedColumnCount, 15 | required this.horizontalController, 16 | required this.controller, 17 | required this.configuration, 18 | required this.sizes, 19 | }); 20 | 21 | @override 22 | State createState() => _DoubleListRowsState(); 23 | } 24 | 25 | class _DoubleListRowsState, T> 26 | extends State<_DoubleListRows> { 27 | final scrollControllerGroup = LinkedScrollControllerGroup(); 28 | late final fixedController = scrollControllerGroup.addAndGet(); 29 | late final normalController = scrollControllerGroup.addAndGet(); 30 | 31 | late _TableState state; 32 | 33 | @override 34 | void initState() { 35 | super.initState(); 36 | 37 | state = widget.controller._state; 38 | widget.controller.addListener(() { 39 | setState(() {}); 40 | }); 41 | } 42 | 43 | @override 44 | Widget build(BuildContext context) { 45 | final theme = PagedDataTableTheme.of(context); 46 | 47 | return DefaultTextStyle( 48 | style: theme.cellTextStyle, 49 | child: ScrollConfiguration( 50 | behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), 51 | child: Opacity( 52 | opacity: widget.controller._state == _TableState.idle ? 1 : 0.5, 53 | child: Scrollbar( 54 | thumbVisibility: theme.verticalScrollbarVisibility, 55 | controller: normalController, 56 | child: Row( 57 | children: [ 58 | SizedBox( 59 | width: widget.sizes 60 | .take(widget.fixedColumnCount) 61 | .fold(0.0, (a, b) => a! + b), 62 | child: ListView.separated( 63 | primary: false, 64 | controller: fixedController, 65 | itemCount: widget.controller._totalItems, 66 | separatorBuilder: (_, __) => 67 | const Divider(height: 0, color: Color(0xFFD6D6D6)), 68 | itemBuilder: (context, index) => _FixedPartRow( 69 | index: index, 70 | fixedColumnCount: widget.fixedColumnCount, 71 | sizes: widget.sizes, 72 | columns: widget.columns, 73 | ), 74 | ), 75 | ), 76 | Expanded( 77 | child: Scrollbar( 78 | thumbVisibility: theme.horizontalScrollbarVisibility, 79 | controller: widget.horizontalController, 80 | child: ListView( 81 | controller: widget.horizontalController, 82 | scrollDirection: Axis.horizontal, 83 | children: [ 84 | ConstrainedBox( 85 | constraints: BoxConstraints( 86 | maxWidth: widget.sizes 87 | .skip(widget.fixedColumnCount) 88 | .fold(0.0, (a, b) => a + b)), 89 | child: ListView.separated( 90 | controller: normalController, 91 | itemCount: widget.controller._totalItems, 92 | separatorBuilder: (_, __) => const Divider( 93 | height: 0, color: Color(0xFFD6D6D6)), 94 | itemBuilder: (context, index) => 95 | _VariablePartRow( 96 | sizes: widget.sizes, 97 | index: index, 98 | fixedColumnCount: widget.fixedColumnCount, 99 | columns: widget.columns, 100 | ), 101 | ), 102 | ), 103 | ], 104 | ), 105 | ), 106 | ), 107 | ], 108 | ), 109 | ), 110 | ), 111 | ), 112 | ); 113 | } 114 | 115 | @override 116 | void dispose() { 117 | super.dispose(); 118 | 119 | normalController.dispose(); 120 | fixedController.dispose(); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /lib/src/filter_widgets.dart: -------------------------------------------------------------------------------- 1 | part of 'paged_datatable.dart'; 2 | 3 | class _DateTimePicker extends StatefulWidget { 4 | final DateTime firstDate, lastDate; 5 | final DateTime? initialDate; 6 | final DatePickerMode initialDatePickerMode; 7 | final DatePickerEntryMode initialEntryMode; 8 | final bool Function(DateTime)? selectableDayPredicate; 9 | final DateFormat dateFormat; 10 | final DateTime? value; 11 | final void Function(DateTime) onChanged; 12 | final InputDecoration inputDecoration; 13 | final String name; 14 | 15 | const _DateTimePicker({ 16 | required this.firstDate, 17 | required this.lastDate, 18 | required this.initialDate, 19 | required this.initialDatePickerMode, 20 | required this.initialEntryMode, 21 | required this.selectableDayPredicate, 22 | required this.dateFormat, 23 | required this.value, 24 | required this.onChanged, 25 | required this.inputDecoration, 26 | required this.name, 27 | }); 28 | 29 | @override 30 | State createState() => _DateTimePickerState(); 31 | } 32 | 33 | class _DateTimePickerState extends State<_DateTimePicker> { 34 | late final TextEditingController textController; 35 | 36 | @override 37 | void initState() { 38 | super.initState(); 39 | 40 | textController = TextEditingController( 41 | text: widget.value == null 42 | ? null 43 | : widget.dateFormat.format(widget.value!)); 44 | } 45 | 46 | @override 47 | Widget build(BuildContext context) { 48 | return TextFormField( 49 | readOnly: true, 50 | controller: textController, 51 | decoration: widget.inputDecoration.copyWith( 52 | labelText: widget.name, 53 | ), 54 | onTap: () async { 55 | final DateTime? result = await showDatePicker( 56 | context: context, 57 | firstDate: widget.firstDate, 58 | lastDate: widget.lastDate, 59 | initialDate: widget.initialDate, 60 | initialDatePickerMode: widget.initialDatePickerMode, 61 | currentDate: widget.value, 62 | initialEntryMode: widget.initialEntryMode, 63 | selectableDayPredicate: widget.selectableDayPredicate, 64 | ); 65 | 66 | if (result != null) { 67 | textController.text = widget.dateFormat.format(result); 68 | widget.onChanged(result); 69 | } 70 | }, 71 | ); 72 | } 73 | 74 | @override 75 | void dispose() { 76 | super.dispose(); 77 | textController.dispose(); 78 | } 79 | } 80 | 81 | class _DateRangePicker extends StatefulWidget { 82 | final DateTime firstDate, lastDate; 83 | final DateTimeRange? initialDateRange; 84 | final DatePickerMode initialDatePickerMode; 85 | final DatePickerEntryMode initialEntryMode; 86 | final String Function(DateTimeRange) formatter; 87 | final DateTimeRange? value; 88 | final void Function(DateTimeRange) onChanged; 89 | final String name; 90 | final InputDecoration inputDecoration; 91 | final TransitionBuilder? dialogBuilder; 92 | 93 | const _DateRangePicker({ 94 | required this.firstDate, 95 | required this.lastDate, 96 | required this.initialDateRange, 97 | required this.value, 98 | required this.initialDatePickerMode, 99 | required this.initialEntryMode, 100 | required this.formatter, 101 | required this.onChanged, 102 | required this.name, 103 | required this.inputDecoration, 104 | required this.dialogBuilder, 105 | }); 106 | 107 | @override 108 | State createState() => _DateRangePickerState(); 109 | } 110 | 111 | class _DateRangePickerState extends State<_DateRangePicker> { 112 | late final TextEditingController textController; 113 | 114 | @override 115 | void initState() { 116 | super.initState(); 117 | 118 | textController = TextEditingController( 119 | text: widget.value == null ? null : widget.formatter(widget.value!)); 120 | } 121 | 122 | @override 123 | Widget build(BuildContext context) { 124 | return TextFormField( 125 | readOnly: true, 126 | controller: textController, 127 | decoration: widget.inputDecoration.copyWith( 128 | labelText: widget.name, 129 | ), 130 | onTap: () async { 131 | final DateTimeRange? result = await showDateRangePicker( 132 | context: context, 133 | firstDate: widget.firstDate, 134 | lastDate: widget.lastDate, 135 | currentDate: widget.value?.start, 136 | initialEntryMode: widget.initialEntryMode, 137 | initialDateRange: widget.initialDateRange, 138 | builder: widget.dialogBuilder, 139 | ); 140 | 141 | if (result != null) { 142 | textController.text = widget.formatter(result); 143 | widget.onChanged(result); 144 | } 145 | }, 146 | ); 147 | } 148 | 149 | @override 150 | void dispose() { 151 | super.dispose(); 152 | textController.dispose(); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /lib/src/row.dart: -------------------------------------------------------------------------------- 1 | part of 'paged_datatable.dart'; 2 | 3 | abstract class _RowBuilder, T> extends StatefulWidget { 4 | final int index; 5 | 6 | const _RowBuilder({required this.index, super.key}); 7 | 8 | @override 9 | State createState() => _RowBuilderState(); 10 | 11 | List buildColumns(BuildContext context, int index, 12 | PagedDataTableController controller, PagedDataTableThemeData theme); 13 | } 14 | 15 | class _RowBuilderState, T> 16 | extends State<_RowBuilder> { 17 | late final controller = TableControllerProvider.of(context); 18 | late final theme = PagedDataTableTheme.of(context); 19 | bool selected = false; 20 | 21 | @override 22 | void didChangeDependencies() { 23 | super.didChangeDependencies(); 24 | 25 | controller.addRowChangeListener(widget.index, _onRowChanged); 26 | setState(() { 27 | selected = controller._selectedRows.contains(widget.index); 28 | }); 29 | } 30 | 31 | @override 32 | Widget build(BuildContext context) { 33 | Widget child = Row( 34 | children: 35 | widget.buildColumns(context, widget.index, controller, theme)); 36 | var color = theme.rowColor?.call(widget.index); 37 | if (selected && theme.selectedRow != null) { 38 | color = theme.selectedRow; 39 | } 40 | if (color != null) { 41 | child = DecoratedBox( 42 | decoration: BoxDecoration(color: color), 43 | child: child, 44 | ); 45 | } 46 | 47 | return SizedBox(height: theme.rowHeight, child: child); 48 | } 49 | 50 | void _onRowChanged(int index, T value) { 51 | if (mounted) { 52 | setState(() { 53 | selected = controller._selectedRows.contains(index); 54 | }); 55 | } 56 | } 57 | 58 | @override 59 | void dispose() { 60 | super.dispose(); 61 | 62 | controller.removeRowChangeListener(widget.index, _onRowChanged); 63 | } 64 | } 65 | 66 | class _FixedPartRow, T> extends _RowBuilder { 67 | final int fixedColumnCount; 68 | final List columns; 69 | final List sizes; 70 | 71 | const _FixedPartRow({ 72 | required super.index, 73 | required this.fixedColumnCount, 74 | required this.columns, 75 | required this.sizes, 76 | super.key, 77 | }); 78 | 79 | @override 80 | List buildColumns( 81 | BuildContext context, 82 | int index, 83 | PagedDataTableController controller, 84 | PagedDataTableThemeData theme) { 85 | final item = controller._currentDataset[index]; 86 | final list = []; 87 | 88 | for (int i = 0; i < fixedColumnCount; i++) { 89 | final column = columns[i]; 90 | final widget = _buildCell(context, index, item, sizes[i], theme, column); 91 | list.add(widget); 92 | } 93 | 94 | return list; 95 | } 96 | } 97 | 98 | class _VariablePartRow, T> extends _RowBuilder { 99 | final List columns; 100 | final int fixedColumnCount; 101 | final List sizes; 102 | 103 | const _VariablePartRow({ 104 | required super.index, 105 | required this.fixedColumnCount, 106 | required this.columns, 107 | required this.sizes, 108 | super.key, 109 | }); 110 | 111 | @override 112 | List buildColumns( 113 | BuildContext context, 114 | int index, 115 | PagedDataTableController controller, 116 | PagedDataTableThemeData theme) { 117 | final item = controller._currentDataset[index]; 118 | final list = []; 119 | 120 | for (int i = fixedColumnCount; i < columns.length; i++) { 121 | final column = columns[i]; 122 | final widget = _buildCell(context, index, item, sizes[i], theme, column); 123 | list.add(widget); 124 | } 125 | 126 | return list; 127 | } 128 | } 129 | 130 | Widget _buildCell(BuildContext context, int index, T value, double width, 131 | PagedDataTableThemeData theme, ReadOnlyTableColumn column) { 132 | Widget child = Container( 133 | padding: theme.cellPadding, 134 | margin: theme.padding, 135 | decoration: BoxDecoration(border: theme.cellBorderSide), 136 | child: column.format.transform(column.build(context, value, index)), 137 | ); 138 | 139 | child = SizedBox(width: width, child: child); 140 | // switch (column.size) { 141 | // case FixedColumnSize(:final size): 142 | // child = SizedBox(width: size, child: child); 143 | // availableWidth -= size; 144 | // break; 145 | // case FractionalColumnSize(:final fraction): 146 | // final size = totalWidth * fraction; 147 | // child = SizedBox(width: size, child: child); 148 | // availableWidth -= size; 149 | // break; 150 | // case RemainingColumnSize(): 151 | // child = SizedBox(width: availableWidth, child: child); 152 | // availableWidth = 0; 153 | // break; 154 | // } 155 | 156 | return child; 157 | } 158 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.1.1 2 | 3 | - Fix Date- filters not showing `name` attribute as label in the text field. 4 | - Fix FractionalColumnSize does not calculate correctly sizes. 5 | 6 | ## 2.1.0 7 | 8 | - Add Italian locale 9 | 10 | ## 2.0.1 11 | 12 | - Fix a bug in controller that does not reload dataset when applying a sort filter 13 | 14 | ## 2.0.0 15 | 16 | > First public release of paged-datatable v2 17 | 18 | - `intl` was updated to version 0.19.0 19 | 20 | ## 2.0.0-dev.4 21 | 22 | - Update documentation 23 | - Add more controller examples 24 | - Fix several issues 25 | 26 | > The next version will be a complete release. This version can be used in production. I'm waiting for feedback to publish 27 | > a release. 28 | 29 | ## 2.0.0-dev.3 30 | 31 | - Add localizations again. 32 | - Add TextField-like editable columns. 33 | - Add `ProgrammaticTableFilter`. 34 | - Add `DateTimePickerTableFilter` and `DateRangePickerTableFilter`. 35 | 36 | > Keep in mind this is an uncomplete release. Probably the next version will be a complete release. 37 | 38 | ## 2.0.0-dev.2 39 | 40 | - Add filter bar and filters. 41 | - Add controller methods to manage filters. 42 | - Removed PagedDataTableMenu. Now to display a menu, use the `filterBarChild` and a `PopupMenuButton`. 43 | - Improved the calculation of column widths. 44 | 45 | > Keep in mind this is an uncomplete release. I will continue working on this package actively. 46 | 47 | ## 2.0.0-dev.1 48 | 49 | **First prerelease of PagedDataTable v2!** 50 | 51 | - Adds Horizontal scrolling. 52 | - Fixes row selection. 53 | - Adds a better `TableController`. 54 | - Better column sorting. 55 | 56 | > Keep in mind this is an uncomplete release. Filters, intl and cell edition features are disabled. I will continue working on this package 57 | > actively. 58 | 59 | ## 1.4.4 60 | 61 | - Add support for 'de' locale 62 | - Fix initialPageSize is ignored when setup the widget 63 | 64 | ## 1.4.3 65 | 66 | - Update dependencies 67 | - Format dart code 68 | 69 | ## 1.4.2 70 | 71 | - Packages version upgrade 72 | 73 | ## 1.4.1 74 | 75 | - Improve documentation 76 | 77 | ## 1.4.0 78 | 79 | - Remove internal memory cache. 80 | - Display checkbox as the first column if the table allows row selection 81 | - Bug fixes related to theming 82 | - Add pagination methods to controller 83 | - Improve drawing performance 84 | - Better pub.dev documentation incoming 85 | 86 | ## 1.3.0 87 | 88 | - Add option to completely remove the footer. 89 | - Improve `PagedDataTableTheme` documentation. 90 | 91 | ## 1.2.0 92 | 93 | - Upgrade Flutter version 94 | - Make it compatible with Dart 3 95 | 96 | ## 1.1.4 97 | 98 | - Apply dividerColor on every divider line. 99 | - Add buttons theme 100 | 101 | ## 1.1.3 102 | 103 | - Apply dividerColor on every divider line. 104 | - Add buttons theme 105 | 106 | ## 1.1.2 107 | 108 | - Revert old styling 109 | 110 | ## 1.1.1 111 | 112 | - Fix bugs while customizing text styles 113 | 114 | ## 1.1.0 115 | 116 | - Add better customization by placing a common widget or for a specific one using PagedDataTableTheme 117 | 118 | ## 1.0.17 119 | 120 | - Add method to TableController to allow updating a row based on a predicate. 121 | 122 | ## 1.0.16 123 | 124 | - Fix wrong header alignment 125 | 126 | ## 1.0.15 127 | 128 | - Header child now can take less width. Replaced Expanded with Flexible. 129 | 130 | ## 1.0.14 131 | 132 | - Add method to get applied filters from controller 133 | 134 | ## 1.0.13 135 | 136 | - Fix small bug 137 | 138 | ## 1.0.12 139 | 140 | - Add Flexible to every row 141 | 142 | ## 1.0.11 143 | 144 | - Add option to remove the size of the rows 145 | 146 | ## 1.0.10 147 | 148 | - Fix locales 149 | - Fix refresh current dataset 150 | - Add option to remove a row without reloading the entire dataset 151 | 152 | ## 1.0.9 153 | 154 | - Remove header when no filter is added 155 | 156 | ## 1.0.8 157 | 158 | - Added `ProgrammaticTableFilter` 159 | 160 | ## 1.0.7 161 | 162 | - Fix menu overlay is not opening correctly 163 | 164 | ## 1.0.6 165 | 166 | - Fix call stack exceeded 167 | 168 | ## 1.0.5 169 | 170 | - Small bug fixes 171 | 172 | ## 1.0.4 173 | 174 | - Add refresh listener. Listen to a stream and refresh the dataset when it changes 175 | - Add custom row builder 176 | 177 | ## 1.0.3 178 | 179 | - Add option to set default filters 180 | 181 | ## 1.0.2 182 | 183 | - Fix scores 184 | 185 | ## 1.0.1 186 | 187 | - Fix homepage 188 | 189 | ## 1.0.0 190 | 191 | - Rewrite all the code from scratch 192 | 193 | ## 0.4.9 194 | 195 | - Fixes ScrollController 196 | 197 | ## 0.4.8 198 | 199 | - Fixes ScrollController 200 | 201 | ## 0.4.7 202 | 203 | - When the page changes, the scrollbar is animated to the top of the table 204 | 205 | ## 0.4.6 206 | 207 | - Fixes a bug with filters scrollbar 208 | 209 | ## 0.4.4 210 | 211 | - Fixes a timer bug. 212 | 213 | ## 0.4.0 214 | 215 | - Add a custom row builder 216 | 217 | ## 0.3.1 218 | 219 | - Add option to clear selected rows 220 | 221 | ## 0.3.0 222 | 223 | - Add refresh listener 224 | - Add separated widget to build custom filters. 225 | 226 | ## 0.2.0 227 | 228 | - Add widget to build custom filters. 229 | 230 | ## 0.1.0 231 | 232 | - Initial release. 233 | -------------------------------------------------------------------------------- /example/lib/post.dart: -------------------------------------------------------------------------------- 1 | import 'dart:collection'; 2 | 3 | import 'package:darq/darq.dart'; 4 | import 'package:faker/faker.dart'; 5 | import 'package:flutter/material.dart'; 6 | 7 | class Post { 8 | final int id; 9 | String author; 10 | String content; 11 | DateTime createdAt; 12 | bool isEnabled; 13 | int number; 14 | Gender authorGender; 15 | 16 | Post({ 17 | required this.id, 18 | required this.author, 19 | required this.content, 20 | required this.createdAt, 21 | required this.isEnabled, 22 | required this.number, 23 | required this.authorGender, 24 | }); 25 | 26 | static final Faker _faker = Faker(); 27 | factory Post.random({required int id}) { 28 | return Post( 29 | id: id, 30 | author: _faker.person.name(), 31 | content: _faker.lorem.sentences(10).join(". "), 32 | createdAt: _faker.date.dateTime(minYear: 2022, maxYear: 2023), 33 | isEnabled: _faker.randomGenerator.boolean(), 34 | number: faker.randomGenerator.integer(9999), 35 | authorGender: Gender.values[_faker.randomGenerator.integer(3)]); 36 | } 37 | 38 | @override 39 | int get hashCode => id.hashCode; 40 | 41 | @override 42 | bool operator ==(Object other) => other is Post ? other.id == id : false; 43 | 44 | @override 45 | String toString() => 46 | "Post(id: $id, author: $author, content: ${content.length > 50 ? content.substring(0, 50) + '...' : content}, createdAt: $createdAt, isEnabled: $isEnabled, number: $number, authorGender: $authorGender)"; 47 | } 48 | 49 | enum Gender { 50 | male("Male"), 51 | female("Female"), 52 | unespecified("Unspecified"); 53 | 54 | const Gender(this.name); 55 | 56 | final String name; 57 | } 58 | 59 | class PostsRepository { 60 | PostsRepository._(); 61 | 62 | static final List _backend = []; 63 | 64 | static void generate(int count) { 65 | _backend.clear(); 66 | _backend.addAll(List.generate(count, (index) => Post.random(id: index))); 67 | } 68 | 69 | static Future> getPosts( 70 | {required int pageSize, 71 | required String? pageToken, 72 | bool? status, 73 | Gender? gender, 74 | DateTimeRange? between, 75 | String? authorName, 76 | String? searchQuery, 77 | String? sortBy, 78 | bool sortDescending = false}) async { 79 | await Future.delayed(const Duration(seconds: 1)); 80 | 81 | // Decode page token 82 | int nextId = pageToken == null ? 0 : int.tryParse(pageToken) ?? 1; 83 | 84 | Iterable query = _backend; 85 | 86 | if (sortBy == null) { 87 | query = query.orderBy((element) => element.id); 88 | } else { 89 | switch (sortBy) { 90 | case "createdAt": 91 | query = sortDescending 92 | ? query.orderByDescending( 93 | (element) => element.createdAt.millisecondsSinceEpoch) 94 | : query.orderBy( 95 | (element) => element.createdAt.millisecondsSinceEpoch); 96 | break; 97 | 98 | case "number": 99 | query = sortDescending 100 | ? query.orderByDescending((element) => element.number) 101 | : query.orderBy((element) => element.number); 102 | break; 103 | 104 | case "author": 105 | query = sortDescending 106 | ? query.orderByDescending((element) => element.author) 107 | : query.orderBy((element) => element.author); 108 | break; 109 | 110 | case "authorGender": 111 | query = sortDescending 112 | ? query.orderByDescending((element) => element.authorGender.name) 113 | : query.orderBy((element) => element.authorGender.name); 114 | break; 115 | } 116 | } 117 | 118 | query = query.where((element) => element.id >= nextId); 119 | if (status != null) { 120 | query = query.where((element) => element.isEnabled == status); 121 | } 122 | 123 | if (gender != null) { 124 | query = query.where((element) => element.authorGender == gender); 125 | } 126 | 127 | if (between != null) { 128 | query = query.where((element) => 129 | between.start.isBefore(element.createdAt) && 130 | between.end.isAfter(element.createdAt)); 131 | } 132 | 133 | if (authorName != null) { 134 | query = query.where((element) => 135 | element.author.toLowerCase().contains(authorName.toLowerCase())); 136 | } 137 | 138 | if (searchQuery != null) { 139 | searchQuery = searchQuery.toLowerCase(); 140 | query = query.where((element) => 141 | element.author.toLowerCase().startsWith(searchQuery!) || 142 | element.content.toLowerCase().contains(searchQuery)); 143 | } 144 | 145 | var resultSet = query.take(pageSize + 1).toList(); 146 | String? nextPageToken; 147 | if (resultSet.length == pageSize + 1) { 148 | Post lastPost = resultSet.removeLast(); 149 | nextPageToken = lastPost.id.toString(); 150 | } 151 | 152 | return PaginatedList(items: resultSet, nextPageToken: nextPageToken); 153 | } 154 | } 155 | 156 | class PaginatedList { 157 | final Iterable _items; 158 | final String? _nextPageToken; 159 | 160 | List get items => UnmodifiableListView(_items); 161 | String? get nextPageToken => _nextPageToken; 162 | 163 | PaginatedList({required Iterable items, String? nextPageToken}) 164 | : _items = items, 165 | _nextPageToken = nextPageToken; 166 | } 167 | -------------------------------------------------------------------------------- /lib/src/theme.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | final class PagedDataTableThemeData { 4 | /// The padding of the cell. 5 | final EdgeInsetsGeometry cellPadding; 6 | 7 | /// The padding of a entire column, from other columns. 8 | final EdgeInsetsGeometry padding; 9 | 10 | /// The [BorderRadius] of the table. 11 | final BorderRadius borderRadius; 12 | 13 | /// The elevation of the table. 14 | final double elevation; 15 | 16 | /// The table's background color. 17 | final Color backgroundColor; 18 | 19 | /// The header bar's height 20 | final double headerHeight; 21 | 22 | /// The footer bar's height 23 | final double footerHeight; 24 | 25 | /// The height of each row. 26 | final double rowHeight; 27 | 28 | /// The filter bar's height. 29 | final double filterBarHeight; 30 | 31 | /// The [BoxBorder] to render for each cell. 32 | final BoxBorder cellBorderSide; 33 | 34 | /// The [TextStyle] for [Text]-like elements of a cell. 35 | final TextStyle cellTextStyle; 36 | 37 | /// The [TextStyle] for [Text]-like elements in the header. 38 | final TextStyle headerTextStyle; 39 | 40 | /// The [TextStyle] for [Text]-like elements in the footer. 41 | final TextStyle footerTextStyle; 42 | 43 | /// A function that calculates the [Color] of a row. 44 | final Color? Function(int index)? rowColor; 45 | 46 | /// The [Color] of a selected row. 47 | final Color? selectedRow; 48 | 49 | /// A flag that indicates if the vertical scrollbar should be visible. 50 | final bool verticalScrollbarVisibility; 51 | 52 | /// A flag that indicates if the horizontal scrollbar should be visible. 53 | final bool horizontalScrollbarVisibility; 54 | 55 | /// The width breakpoint that [PagedDataTable] uses to decide if will render a popup or a bottom sheet when the filter dialog is requested. 56 | final double filterDialogBreakpoint; 57 | 58 | /// The [ChipThemeData] to apply to filter chips. 59 | final ChipThemeData? chipTheme; 60 | 61 | const PagedDataTableThemeData({ 62 | this.cellPadding = 63 | const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), 64 | this.padding = const EdgeInsets.symmetric(horizontal: 16.0), 65 | this.borderRadius = const BorderRadius.all(Radius.circular(4.0)), 66 | this.elevation = 0.0, 67 | this.cellBorderSide = const Border(), 68 | this.headerHeight = 56.0, 69 | this.footerHeight = 56.0, 70 | this.filterBarHeight = 50.0, 71 | this.rowHeight = 52.0, 72 | this.selectedRow, 73 | this.cellTextStyle = 74 | const TextStyle(color: Colors.black, overflow: TextOverflow.ellipsis), 75 | this.headerTextStyle = const TextStyle( 76 | color: Colors.black, 77 | fontWeight: FontWeight.bold, 78 | overflow: TextOverflow.ellipsis), 79 | this.footerTextStyle = const TextStyle(fontSize: 14, color: Colors.black), 80 | this.rowColor, 81 | this.verticalScrollbarVisibility = true, 82 | this.horizontalScrollbarVisibility = true, 83 | this.filterDialogBreakpoint = 1000.0, 84 | this.chipTheme, 85 | this.backgroundColor = Colors.white, 86 | }); 87 | 88 | @override 89 | int get hashCode => Object.hash( 90 | cellPadding, 91 | padding, 92 | borderRadius, 93 | elevation, 94 | headerHeight, 95 | footerHeight, 96 | rowHeight, 97 | cellBorderSide, 98 | cellTextStyle, 99 | headerTextStyle, 100 | rowColor, 101 | verticalScrollbarVisibility, 102 | horizontalScrollbarVisibility, 103 | chipTheme, 104 | backgroundColor); 105 | 106 | @override 107 | bool operator ==(Object other) => 108 | identical(other, this) || 109 | (other is PagedDataTableThemeData && 110 | other.cellPadding == cellPadding && 111 | other.padding == padding && 112 | other.borderRadius == borderRadius && 113 | other.elevation == elevation && 114 | other.headerHeight == headerHeight && 115 | other.footerHeight == footerHeight && 116 | other.rowHeight == rowHeight && 117 | other.cellTextStyle == cellTextStyle && 118 | other.headerTextStyle == headerTextStyle && 119 | other.rowColor == rowColor && 120 | other.cellBorderSide == cellBorderSide && 121 | other.selectedRow == selectedRow && 122 | other.verticalScrollbarVisibility == verticalScrollbarVisibility && 123 | other.horizontalScrollbarVisibility == 124 | horizontalScrollbarVisibility && 125 | other.chipTheme == chipTheme && 126 | other.backgroundColor == backgroundColor); 127 | } 128 | 129 | final class PagedDataTableTheme extends InheritedWidget { 130 | final PagedDataTableThemeData data; 131 | 132 | const PagedDataTableTheme( 133 | {required this.data, required super.child, super.key}); 134 | 135 | @override 136 | bool updateShouldNotify(covariant InheritedWidget oldWidget) => 137 | data != (oldWidget as PagedDataTableTheme).data; 138 | 139 | /// Lookups for a [PagedDataTableTheme] widget in the widget tree, if not found, returns null. 140 | static PagedDataTableThemeData? maybeOf(BuildContext context) => 141 | context.dependOnInheritedWidgetOfExactType()?.data; 142 | 143 | /// Lookups for a [PagedDataTableTheme] widget in the widget tree, if not found, then default [PagedDataTableThemeData] is returned. 144 | static PagedDataTableThemeData of(BuildContext context) => 145 | maybeOf(context) ?? const PagedDataTableThemeData(); 146 | } 147 | -------------------------------------------------------------------------------- /lib/src/header.dart: -------------------------------------------------------------------------------- 1 | part of 'paged_datatable.dart'; 2 | 3 | final class _Header, T> extends StatefulWidget { 4 | final PagedDataTableController controller; 5 | final int fixedColumnCount; 6 | final List columns; 7 | final ScrollController horizontalController; 8 | final PagedDataTableConfiguration configuration; 9 | final List sizes; 10 | 11 | const _Header({ 12 | required this.sizes, 13 | required this.controller, 14 | required this.columns, 15 | required this.fixedColumnCount, 16 | required this.horizontalController, 17 | required this.configuration, 18 | }); 19 | 20 | @override 21 | State createState() => _HeaderState(); 22 | } 23 | 24 | final class _HeaderState, T> 25 | extends State<_Header> { 26 | late _TableState tableState; 27 | SortModel? sortModel; 28 | 29 | @override 30 | void initState() { 31 | super.initState(); 32 | 33 | sortModel = widget.controller.sortModel; 34 | tableState = widget.controller._state; 35 | widget.controller.addListener(_onControllerChanged); 36 | } 37 | 38 | @override 39 | Widget build(BuildContext context) { 40 | final theme = PagedDataTableTheme.of(context); 41 | final fixedColumns = _buildFixedColumns(context, theme); 42 | final columns = _buildColumns(context, theme); 43 | 44 | return SizedBox( 45 | height: theme.headerHeight, 46 | child: DefaultTextStyle( 47 | style: theme.headerTextStyle, 48 | child: Stack( 49 | fit: StackFit.expand, 50 | children: [ 51 | Row( 52 | children: [ 53 | if (widget.fixedColumnCount > 0) ...fixedColumns, 54 | Expanded( 55 | child: _ScrollableColumns( 56 | controller: widget.horizontalController, 57 | children: columns 58 | .map((e) => SliverToBoxAdapter(child: e)) 59 | .toList(growable: false)), 60 | ), 61 | ], 62 | ), 63 | Align( 64 | alignment: Alignment.bottomCenter, 65 | child: tableState == _TableState.fetching 66 | ? const LinearProgressIndicator() 67 | : const SizedBox.shrink(), 68 | ) 69 | ], 70 | ), 71 | ), 72 | ); 73 | } 74 | 75 | List _buildFixedColumns( 76 | BuildContext context, PagedDataTableThemeData theme) { 77 | final list = []; 78 | 79 | for (int i = 0; i < widget.fixedColumnCount; i++) { 80 | final column = widget.columns[i]; 81 | list.add(_buildColumn(context, theme, widget.sizes[i], column)); 82 | } 83 | 84 | return list; 85 | } 86 | 87 | List _buildColumns( 88 | BuildContext context, PagedDataTableThemeData theme) { 89 | final list = []; 90 | for (int i = widget.fixedColumnCount; i < widget.columns.length; i++) { 91 | final column = widget.columns[i]; 92 | list.add(_buildColumn(context, theme, widget.sizes[i], column)); 93 | } 94 | 95 | return list; 96 | } 97 | 98 | Widget _buildColumn(BuildContext context, PagedDataTableThemeData theme, 99 | double width, ReadOnlyTableColumn column) { 100 | Widget child = Container( 101 | padding: theme.cellPadding, 102 | margin: theme.padding, 103 | child: column.format.transform( 104 | Tooltip( 105 | textAlign: TextAlign.left, 106 | message: column.title is Text 107 | ? (column.title as Text).data! 108 | : column.title is RichText 109 | ? (column.title as RichText).text.toPlainText() 110 | : column.tooltip ?? "", 111 | child: column.title), 112 | ), 113 | ); 114 | 115 | if (column.sortable) { 116 | child = MouseRegion( 117 | cursor: SystemMouseCursors.click, 118 | child: GestureDetector( 119 | onTap: () { 120 | widget.controller.swipeSortModel(column.id); 121 | }, 122 | child: child, 123 | ), 124 | ); 125 | 126 | if (sortModel?.fieldName == column.id) { 127 | child = Row( 128 | children: [ 129 | Flexible(child: child), 130 | IconButton( 131 | icon: sortModel!.descending 132 | ? const Icon(Icons.arrow_downward) 133 | : const Icon(Icons.arrow_upward), 134 | onPressed: () { 135 | widget.controller.swipeSortModel(column.id); 136 | }, 137 | ) 138 | ], 139 | ); 140 | } 141 | } 142 | 143 | child = SizedBox(width: width, child: child); 144 | 145 | return child; 146 | } 147 | 148 | void _onControllerChanged() { 149 | if (widget.controller.sortModel != sortModel || 150 | widget.controller._state != tableState) { 151 | setState(() { 152 | sortModel = widget.controller.sortModel; 153 | tableState = widget.controller._state; 154 | }); 155 | } 156 | } 157 | 158 | @override 159 | void dispose() { 160 | super.dispose(); 161 | widget.controller.removeListener(_onControllerChanged); 162 | } 163 | } 164 | 165 | class _ScrollableColumns extends ScrollView { 166 | final List children; 167 | 168 | const _ScrollableColumns( 169 | {required this.children, required ScrollController controller}) 170 | : super(scrollDirection: Axis.horizontal, controller: controller); 171 | 172 | @override 173 | List buildSlivers(BuildContext context) => children; 174 | } 175 | -------------------------------------------------------------------------------- /lib/l10n/generated/l10n.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | import 'package:flutter/material.dart'; 3 | import 'package:intl/intl.dart'; 4 | import 'intl/messages_all.dart'; 5 | 6 | // ************************************************************************** 7 | // Generator: Flutter Intl IDE plugin 8 | // Made by Localizely 9 | // ************************************************************************** 10 | 11 | // ignore_for_file: non_constant_identifier_names, lines_longer_than_80_chars 12 | // ignore_for_file: join_return_with_assignment, prefer_final_in_for_each 13 | // ignore_for_file: avoid_redundant_argument_values, avoid_escaping_inner_quotes 14 | 15 | class PagedDataTableLocalization { 16 | PagedDataTableLocalization(); 17 | 18 | static PagedDataTableLocalization? _current; 19 | 20 | static PagedDataTableLocalization get current { 21 | assert(_current != null, 22 | 'No instance of PagedDataTableLocalization was loaded. Try to initialize the PagedDataTableLocalization delegate before accessing PagedDataTableLocalization.current.'); 23 | return _current!; 24 | } 25 | 26 | static const AppLocalizationDelegate delegate = AppLocalizationDelegate(); 27 | 28 | static Future load(Locale locale) { 29 | final name = (locale.countryCode?.isEmpty ?? false) 30 | ? locale.languageCode 31 | : locale.toString(); 32 | final localeName = Intl.canonicalizedLocale(name); 33 | return initializeMessages(localeName).then((_) { 34 | Intl.defaultLocale = localeName; 35 | final instance = PagedDataTableLocalization(); 36 | PagedDataTableLocalization._current = instance; 37 | 38 | return instance; 39 | }); 40 | } 41 | 42 | static PagedDataTableLocalization of(BuildContext context) { 43 | final instance = PagedDataTableLocalization.maybeOf(context); 44 | assert(instance != null, 45 | 'No instance of PagedDataTableLocalization present in the widget tree. Did you add PagedDataTableLocalization.delegate in localizationsDelegates?'); 46 | return instance!; 47 | } 48 | 49 | static PagedDataTableLocalization? maybeOf(BuildContext context) { 50 | return Localizations.of( 51 | context, PagedDataTableLocalization); 52 | } 53 | 54 | /// `Filter` 55 | String get showFilterMenuTooltip { 56 | return Intl.message( 57 | 'Filter', 58 | name: 'showFilterMenuTooltip', 59 | desc: '', 60 | args: [], 61 | ); 62 | } 63 | 64 | /// `Filter by` 65 | String get filterByTitle { 66 | return Intl.message( 67 | 'Filter by', 68 | name: 'filterByTitle', 69 | desc: '', 70 | args: [], 71 | ); 72 | } 73 | 74 | /// `Apply` 75 | String get applyFilterButtonText { 76 | return Intl.message( 77 | 'Apply', 78 | name: 'applyFilterButtonText', 79 | desc: '', 80 | args: [], 81 | ); 82 | } 83 | 84 | /// `Cancel` 85 | String get cancelFilteringButtonText { 86 | return Intl.message( 87 | 'Cancel', 88 | name: 'cancelFilteringButtonText', 89 | desc: '', 90 | args: [], 91 | ); 92 | } 93 | 94 | /// `Remove` 95 | String get removeAllFiltersButtonText { 96 | return Intl.message( 97 | 'Remove', 98 | name: 'removeAllFiltersButtonText', 99 | desc: '', 100 | args: [], 101 | ); 102 | } 103 | 104 | /// `Save changes` 105 | String get editableColumnSaveChangesButtonText { 106 | return Intl.message( 107 | 'Save changes', 108 | name: 'editableColumnSaveChangesButtonText', 109 | desc: '', 110 | args: [], 111 | ); 112 | } 113 | 114 | /// `Cancel` 115 | String get editableColumnCancelButtonText { 116 | return Intl.message( 117 | 'Cancel', 118 | name: 'editableColumnCancelButtonText', 119 | desc: '', 120 | args: [], 121 | ); 122 | } 123 | 124 | /// `Remove this filter` 125 | String get removeFilterButtonText { 126 | return Intl.message( 127 | 'Remove this filter', 128 | name: 'removeFilterButtonText', 129 | desc: '', 130 | args: [], 131 | ); 132 | } 133 | 134 | /// `Refresh` 135 | String get refreshText { 136 | return Intl.message( 137 | 'Refresh', 138 | name: 'refreshText', 139 | desc: '', 140 | args: [], 141 | ); 142 | } 143 | 144 | /// `Last refreshed at {time}` 145 | String refreshedAtText(Object time) { 146 | return Intl.message( 147 | 'Last refreshed at $time', 148 | name: 'refreshedAtText', 149 | desc: '', 150 | args: [time], 151 | ); 152 | } 153 | 154 | /// `Rows per page` 155 | String get rowsPerPageText { 156 | return Intl.message( 157 | 'Rows per page', 158 | name: 'rowsPerPageText', 159 | desc: '', 160 | args: [], 161 | ); 162 | } 163 | 164 | /// `Page {currentPage}` 165 | String pageIndicatorText(Object currentPage) { 166 | return Intl.message( 167 | 'Page $currentPage', 168 | name: 'pageIndicatorText', 169 | desc: '', 170 | args: [currentPage], 171 | ); 172 | } 173 | 174 | /// `Showing {totalElements} elements` 175 | String totalElementsText(Object totalElements) { 176 | return Intl.message( 177 | 'Showing $totalElements elements', 178 | name: 'totalElementsText', 179 | desc: '', 180 | args: [totalElements], 181 | ); 182 | } 183 | 184 | /// `Next page` 185 | String get nextPageButtonText { 186 | return Intl.message( 187 | 'Next page', 188 | name: 'nextPageButtonText', 189 | desc: '', 190 | args: [], 191 | ); 192 | } 193 | 194 | /// `Previous page` 195 | String get previousPageButtonText { 196 | return Intl.message( 197 | 'Previous page', 198 | name: 'previousPageButtonText', 199 | desc: '', 200 | args: [], 201 | ); 202 | } 203 | 204 | /// `No items found` 205 | String get noItemsFoundText { 206 | return Intl.message( 207 | 'No items found', 208 | name: 'noItemsFoundText', 209 | desc: '', 210 | args: [], 211 | ); 212 | } 213 | } 214 | 215 | class AppLocalizationDelegate 216 | extends LocalizationsDelegate { 217 | const AppLocalizationDelegate(); 218 | 219 | List get supportedLocales { 220 | return const [ 221 | Locale.fromSubtags(languageCode: 'en'), 222 | Locale.fromSubtags(languageCode: 'de'), 223 | Locale.fromSubtags(languageCode: 'es'), 224 | Locale.fromSubtags(languageCode: 'it'), 225 | ]; 226 | } 227 | 228 | @override 229 | bool isSupported(Locale locale) => _isSupported(locale); 230 | @override 231 | Future load(Locale locale) => 232 | PagedDataTableLocalization.load(locale); 233 | @override 234 | bool shouldReload(AppLocalizationDelegate old) => false; 235 | 236 | bool _isSupported(Locale locale) { 237 | for (var supportedLocale in supportedLocales) { 238 | if (supportedLocale.languageCode == locale.languageCode) { 239 | return true; 240 | } 241 | } 242 | return false; 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /lib/src/filter.dart: -------------------------------------------------------------------------------- 1 | part of 'paged_datatable.dart'; 2 | 3 | /// Represents a value selector that filters the dataset returned and displayed in the table. 4 | abstract class TableFilter { 5 | /// The name of the filter. 6 | /// 7 | /// It will appear in the filter dialog. 8 | final String name; 9 | 10 | /// The id of the filter, used to identify it when fetching data. 11 | final String id; 12 | 13 | /// Formats [T] to be displayed in the selected filter chip. 14 | final String Function(T value) chipFormatter; 15 | 16 | /// A flag that indicates if the filter is enabled or not 17 | final bool enabled; 18 | 19 | /// A flag that indicates if the filter is visible or not 20 | final bool visible; 21 | 22 | /// The initial value for the filter 23 | final T? initialValue; 24 | 25 | const TableFilter({ 26 | required this.id, 27 | required this.name, 28 | required this.chipFormatter, 29 | required this.enabled, 30 | required this.initialValue, 31 | this.visible = true, 32 | }); 33 | 34 | /// Renders the picker for the filter. 35 | Widget buildPicker(BuildContext context, FilterState state); 36 | 37 | /// Creates the state of the filter 38 | FilterState createState() => FilterState._(this); 39 | 40 | @override 41 | int get hashCode => Object.hash(name, id, enabled, initialValue); 42 | 43 | @override 44 | bool operator ==(Object other) => 45 | identical(other, this) || 46 | (other is TableFilter && 47 | other.id == id && 48 | other.name == name && 49 | other.enabled == enabled && 50 | other.initialValue == initialValue); 51 | } 52 | 53 | /// A [TableFilter] that renders a [TextFormField]. 54 | final class TextTableFilter extends TableFilter { 55 | final InputDecoration? decoration; 56 | 57 | const TextTableFilter({ 58 | this.decoration, 59 | required super.chipFormatter, 60 | required super.id, 61 | required super.name, 62 | super.initialValue, 63 | super.enabled = true, 64 | }); 65 | 66 | @override 67 | Widget buildPicker(BuildContext context, FilterState state) { 68 | return TextFormField( 69 | decoration: decoration ?? InputDecoration(labelText: name), 70 | initialValue: state.value, 71 | onSaved: (newValue) { 72 | if (newValue != null && newValue.isNotEmpty) { 73 | state.value = newValue; 74 | } 75 | }, 76 | ); 77 | } 78 | } 79 | 80 | /// A [TableFilter] that is not visible in the filter selection but can be set using the controller. 81 | final class ProgrammingTextFilter extends TableFilter { 82 | ProgrammingTextFilter({ 83 | required super.id, 84 | required super.chipFormatter, 85 | required super.initialValue, 86 | }) : super(enabled: true, visible: false, name: ""); 87 | 88 | @override 89 | Widget buildPicker(BuildContext context, FilterState state) => 90 | const SizedBox.shrink(); 91 | } 92 | 93 | /// A [TableFilter] that renders a [DropdownButtonFormField]. 94 | final class DropdownTableFilter extends TableFilter { 95 | final InputDecoration? decoration; 96 | final List> items; 97 | 98 | const DropdownTableFilter({ 99 | this.decoration, 100 | required this.items, 101 | required super.chipFormatter, 102 | required super.id, 103 | required super.name, 104 | super.initialValue, 105 | super.enabled = true, 106 | }); 107 | 108 | @override 109 | Widget buildPicker(BuildContext context, FilterState state) { 110 | return DropdownButtonFormField( 111 | items: items, 112 | value: state.value, 113 | onChanged: (newValue) {}, 114 | onSaved: (newValue) { 115 | state.value = newValue; 116 | }, 117 | decoration: decoration ?? InputDecoration(labelText: name), 118 | ); 119 | } 120 | } 121 | 122 | /// A [TableFilter] that renders a [TextField] that, when selected, opens a [DateTime] picker. 123 | final class DateTimePickerTableFilter extends TableFilter { 124 | final DateTime firstDate; 125 | final DateTime lastDate; 126 | final DateTime? initialDate; 127 | final DatePickerMode initialDatePickerMode; 128 | final DatePickerEntryMode initialEntryMode; 129 | final bool Function(DateTime)? selectableDayPredicate; 130 | final DateFormat dateFormat; 131 | final InputDecoration inputDecoration; 132 | 133 | DateTimePickerTableFilter({ 134 | required super.id, 135 | required super.name, 136 | required super.chipFormatter, 137 | required super.initialValue, 138 | required this.firstDate, 139 | required this.lastDate, 140 | required this.dateFormat, 141 | super.enabled = true, 142 | this.initialDate, 143 | this.initialDatePickerMode = DatePickerMode.day, 144 | this.initialEntryMode = DatePickerEntryMode.calendar, 145 | this.selectableDayPredicate, 146 | this.inputDecoration = const InputDecoration(), 147 | }); 148 | 149 | @override 150 | Widget buildPicker(BuildContext context, FilterState state) => 151 | _DateTimePicker( 152 | firstDate: firstDate, 153 | initialDate: initialDate, 154 | initialDatePickerMode: initialDatePickerMode, 155 | initialEntryMode: initialEntryMode, 156 | lastDate: lastDate, 157 | selectableDayPredicate: selectableDayPredicate, 158 | dateFormat: dateFormat, 159 | value: state.value, 160 | inputDecoration: inputDecoration, 161 | name: name, 162 | onChanged: (newValue) { 163 | state.value = newValue; 164 | }, 165 | ); 166 | } 167 | 168 | /// A [TableFilter] that renders a [TextField] that, when selected, opens a [DateTimeRange] picker. 169 | 170 | final class DateRangePickerTableFilter extends TableFilter { 171 | final DateTime firstDate; 172 | final DateTime lastDate; 173 | final DateTimeRange? initialDateRange; 174 | final DatePickerMode initialDatePickerMode; 175 | final DatePickerEntryMode initialEntryMode; 176 | final String Function(DateTimeRange) formatter; 177 | final InputDecoration inputDecoration; 178 | final TransitionBuilder? dialogBuilder; 179 | 180 | DateRangePickerTableFilter({ 181 | required super.id, 182 | required super.name, 183 | required super.chipFormatter, 184 | required super.initialValue, 185 | required this.firstDate, 186 | required this.lastDate, 187 | required this.formatter, 188 | super.enabled = true, 189 | this.initialDateRange, 190 | this.initialDatePickerMode = DatePickerMode.day, 191 | this.initialEntryMode = DatePickerEntryMode.calendar, 192 | this.inputDecoration = const InputDecoration(), 193 | this.dialogBuilder, 194 | }); 195 | 196 | @override 197 | Widget buildPicker(BuildContext context, FilterState state) => 198 | _DateRangePicker( 199 | firstDate: firstDate, 200 | formatter: formatter, 201 | initialDateRange: initialDateRange, 202 | initialDatePickerMode: initialDatePickerMode, 203 | initialEntryMode: initialEntryMode, 204 | lastDate: lastDate, 205 | value: state.value, 206 | inputDecoration: inputDecoration, 207 | name: name, 208 | dialogBuilder: dialogBuilder, 209 | onChanged: (newValue) { 210 | state.value = newValue; 211 | }, 212 | ); 213 | 214 | /// A convenient method to create a [TransitionBuilder] that builds the DateRangePicker dialog with a given size. 215 | /// 216 | /// This is useful on desktop platforms. 217 | static TransitionBuilder sizedDialog(double height, double width) { 218 | return (context, child) => Center( 219 | child: ConstrainedBox( 220 | constraints: const BoxConstraints( 221 | maxWidth: 400.0, 222 | maxHeight: 600.0, 223 | ), 224 | child: child, 225 | ), 226 | ); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /lib/src/footer_widgets.dart: -------------------------------------------------------------------------------- 1 | part of "paged_datatable.dart"; 2 | 3 | /// A [PagedDataTable] footer widget that renders a refresh button. 4 | class RefreshTable, T> extends StatefulWidget { 5 | const RefreshTable({super.key}); 6 | 7 | @override 8 | State createState() => _RefreshTableState(); 9 | } 10 | 11 | class _RefreshTableState, T> 12 | extends State> { 13 | late final theme = PagedDataTableTheme.of(context); 14 | late final controller = TableControllerProvider.of(context); 15 | 16 | @override 17 | void didChangeDependencies() { 18 | super.didChangeDependencies(); 19 | 20 | controller.addListener(_onChanged); 21 | } 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | final localizations = PagedDataTableLocalization.of(context); 26 | return Row( 27 | children: [ 28 | const SizedBox(width: 10), 29 | IconButton( 30 | splashRadius: 20, 31 | tooltip: localizations.refreshText, 32 | onPressed: () => controller.refresh(fromStart: false), 33 | icon: const Icon(Icons.refresh_outlined), 34 | ), 35 | const SizedBox(width: 10), 36 | ], 37 | ); 38 | } 39 | 40 | void _onChanged() { 41 | if (mounted) { 42 | setState(() {}); 43 | } 44 | } 45 | 46 | @override 47 | void dispose() { 48 | super.dispose(); 49 | controller.removeListener(_onChanged); 50 | } 51 | } 52 | 53 | /// A [PagedDataTable] footer widget that renders a dropdown used to select a page size. 54 | class PageSizeSelector, T> extends StatefulWidget { 55 | const PageSizeSelector({super.key}); 56 | 57 | @override 58 | State createState() => _PageSizeSelectorState(); 59 | } 60 | 61 | class _PageSizeSelectorState, T> 62 | extends State> { 63 | late final theme = PagedDataTableTheme.of(context); 64 | late final controller = TableControllerProvider.of(context); 65 | 66 | @override 67 | void didChangeDependencies() { 68 | super.didChangeDependencies(); 69 | 70 | controller.addListener(_onChanged); 71 | } 72 | 73 | @override 74 | Widget build(BuildContext context) { 75 | final localizations = PagedDataTableLocalization.of(context); 76 | assert(controller._pageSizes != null, 77 | "PageSizeSelector widget can be used only if the pageSizes property is set."); 78 | 79 | return Row( 80 | children: [ 81 | const SizedBox(width: 10), 82 | Text(localizations.rowsPerPageText), 83 | const SizedBox(width: 10), 84 | SizedBox( 85 | width: 100, 86 | child: DropdownButtonFormField( 87 | value: controller.pageSize, 88 | items: controller._pageSizes! 89 | .map((pageSize) => DropdownMenuItem( 90 | value: pageSize, child: Text(pageSize.toString()))) 91 | .toList(growable: false), 92 | onChanged: controller._state == _TableState.fetching 93 | ? null 94 | : (newPageSize) { 95 | if (newPageSize != null) { 96 | controller.pageSize = newPageSize; 97 | } 98 | }, 99 | style: theme.footerTextStyle.copyWith(fontSize: 14), 100 | decoration: const InputDecoration( 101 | border: OutlineInputBorder( 102 | borderSide: BorderSide(color: Color(0xFFD6D6D6))), 103 | isCollapsed: true, 104 | contentPadding: EdgeInsets.symmetric(horizontal: 6, vertical: 8), 105 | ), 106 | ), 107 | ), 108 | const SizedBox(width: 10), 109 | ], 110 | ); 111 | } 112 | 113 | void _onChanged() { 114 | if (mounted) { 115 | setState(() {}); 116 | } 117 | } 118 | 119 | @override 120 | void dispose() { 121 | super.dispose(); 122 | controller.removeListener(_onChanged); 123 | } 124 | } 125 | 126 | /// A [PagedDataTable] footer widget that renders the total items in the current resultset. 127 | class TotalItems, T> extends StatefulWidget { 128 | const TotalItems({super.key}); 129 | 130 | @override 131 | State createState() => _TotalItemsState(); 132 | } 133 | 134 | class _TotalItemsState, T> 135 | extends State> { 136 | late final theme = PagedDataTableTheme.of(context); 137 | late final controller = TableControllerProvider.of(context); 138 | 139 | @override 140 | void didChangeDependencies() { 141 | super.didChangeDependencies(); 142 | 143 | controller.addListener(_onChanged); 144 | } 145 | 146 | @override 147 | Widget build(BuildContext context) { 148 | final localizations = PagedDataTableLocalization.of(context); 149 | 150 | return Row( 151 | children: [ 152 | const SizedBox(width: 10), 153 | Text(localizations.totalElementsText(controller._totalItems)), 154 | const SizedBox(width: 10), 155 | ], 156 | ); 157 | } 158 | 159 | void _onChanged() { 160 | if (mounted) { 161 | setState(() {}); 162 | } 163 | } 164 | 165 | @override 166 | void dispose() { 167 | super.dispose(); 168 | controller.removeListener(_onChanged); 169 | } 170 | } 171 | 172 | /// A [PagedDataTable] footer widget that renders the current page. 173 | class CurrentPage, T> extends StatefulWidget { 174 | const CurrentPage({super.key}); 175 | 176 | @override 177 | State createState() => _CurrentPageState(); 178 | } 179 | 180 | class _CurrentPageState, T> 181 | extends State> { 182 | late final theme = PagedDataTableTheme.of(context); 183 | late final controller = TableControllerProvider.of(context); 184 | 185 | @override 186 | void didChangeDependencies() { 187 | super.didChangeDependencies(); 188 | 189 | controller.addListener(_onChanged); 190 | } 191 | 192 | @override 193 | Widget build(BuildContext context) { 194 | final localizations = PagedDataTableLocalization.of(context); 195 | return Row( 196 | children: [ 197 | const SizedBox(width: 10), 198 | Text(localizations.pageIndicatorText(controller._currentPageIndex + 1)), 199 | const SizedBox(width: 10), 200 | ], 201 | ); 202 | } 203 | 204 | void _onChanged() { 205 | if (mounted) { 206 | setState(() {}); 207 | } 208 | } 209 | 210 | @override 211 | void dispose() { 212 | super.dispose(); 213 | controller.removeListener(_onChanged); 214 | } 215 | } 216 | 217 | /// A [PagedDataTable] footer widget that renders navigation buttons. 218 | class NavigationButtons, T> extends StatefulWidget { 219 | const NavigationButtons({super.key}); 220 | 221 | @override 222 | State createState() => _NavigationButtonsState(); 223 | } 224 | 225 | class _NavigationButtonsState, T> 226 | extends State> { 227 | late final theme = PagedDataTableTheme.of(context); 228 | late final controller = TableControllerProvider.of(context); 229 | 230 | @override 231 | void didChangeDependencies() { 232 | super.didChangeDependencies(); 233 | 234 | controller.addListener(_onChanged); 235 | } 236 | 237 | @override 238 | Widget build(BuildContext context) { 239 | final localizations = PagedDataTableLocalization.of(context); 240 | return Row( 241 | children: [ 242 | const SizedBox(width: 10), 243 | IconButton( 244 | tooltip: localizations.previousPageButtonText, 245 | splashRadius: 20, 246 | icon: const Icon(Icons.keyboard_arrow_left_rounded), 247 | onPressed: (controller.hasPreviousPage && 248 | controller._state != _TableState.fetching) 249 | ? controller.previousPage 250 | : null, 251 | ), 252 | const SizedBox(width: 12), 253 | IconButton( 254 | tooltip: localizations.nextPageButtonText, 255 | splashRadius: 20, 256 | icon: const Icon(Icons.keyboard_arrow_right_rounded), 257 | onPressed: (controller.hasNextPage && 258 | controller._state != _TableState.fetching) 259 | ? controller.nextPage 260 | : null, 261 | ), 262 | const SizedBox(width: 10), 263 | ], 264 | ); 265 | } 266 | 267 | void _onChanged() { 268 | if (mounted) { 269 | setState(() {}); 270 | } 271 | } 272 | 273 | @override 274 | void dispose() { 275 | super.dispose(); 276 | controller.removeListener(_onChanged); 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /lib/src/column.dart: -------------------------------------------------------------------------------- 1 | part of 'paged_datatable.dart'; 2 | 3 | typedef Setter = FutureOr Function(T item, V value, int rowIndex); 4 | typedef Getter = V? Function(T item, int rowIndex); 5 | typedef CellBuilder = Widget Function( 6 | BuildContext context, T item, int rowIndex); 7 | 8 | /// [ReadOnlyTableColumn] represents a basic table column for [T] that displays read-only content. 9 | abstract class ReadOnlyTableColumn, T> { 10 | /// The title of the column. 11 | final Widget title; 12 | 13 | /// The tooltip to show in the column's header. If null, it will try to take the content of [title], if it's a Text, otherwise, it won't show a tooltip. 14 | final String? tooltip; 15 | 16 | /// The id of the column. Useful if this column can be used to sort data. 17 | final String? id; 18 | 19 | /// The size of the column 20 | final ColumnSize size; 21 | 22 | /// The column's format 23 | final ColumnFormat format; 24 | 25 | /// A flag that indicates if the column can be used as sort model. [id] must not be null. 26 | final bool sortable; 27 | 28 | const ReadOnlyTableColumn({ 29 | required this.id, 30 | required this.title, 31 | required this.size, 32 | required this.format, 33 | required this.tooltip, 34 | required this.sortable, 35 | }) : assert(sortable ? id != null : true, 36 | "When column is sortable, id must be set."); 37 | 38 | /// Builds the cell for [item] at [index]. 39 | Widget build(BuildContext context, T item, int index); 40 | 41 | @override 42 | int get hashCode => Object.hash(id, size, title, format); 43 | 44 | @override 45 | bool operator ==(Object other) => 46 | other is ReadOnlyTableColumn && 47 | other.title == title && 48 | other.id == id && 49 | other.size == size && 50 | other.format == format; 51 | } 52 | 53 | /// [EditableTableColumn] represents a basic table column for [T] that display editable content of type [V]. 54 | abstract class EditableTableColumn, T, V> 55 | extends ReadOnlyTableColumn { 56 | /// The function that is going to be called when the field is saved. It must return a boolean indicating 57 | /// if the update operation succeeded or not, being [true] for a successful operation or false otherwise. 58 | final Setter setter; 59 | 60 | /// The function that is going to retrieve the current value of [T] for this column. 61 | final Getter getter; 62 | 63 | const EditableTableColumn({ 64 | required super.id, 65 | required super.title, 66 | required super.size, 67 | required super.format, 68 | required super.tooltip, 69 | required super.sortable, 70 | required this.setter, 71 | required this.getter, 72 | }); 73 | } 74 | 75 | /// [TableColumn] is a implementation of [ReadOnlyTableColumn] that renders a cell based on [cellBuilder]. 76 | final class TableColumn, T> 77 | extends ReadOnlyTableColumn { 78 | final CellBuilder cellBuilder; 79 | 80 | const TableColumn({ 81 | required this.cellBuilder, 82 | required super.title, 83 | super.id, 84 | super.size = const FractionalColumnSize(.1), 85 | super.format = const AlignColumnFormat(alignment: Alignment.centerLeft), 86 | super.tooltip, 87 | super.sortable = false, 88 | }); 89 | 90 | @override 91 | Widget build(BuildContext context, T item, int index) => 92 | cellBuilder(context, item, index); 93 | 94 | @override 95 | int get hashCode => Object.hash(id, size, title, cellBuilder, format); 96 | 97 | @override 98 | bool operator ==(Object other) => 99 | other is TableColumn && 100 | other.cellBuilder == 101 | cellBuilder && // todo: this will always return false, fix a better way to compare those 102 | other.title == title && 103 | other.id == id && 104 | other.size == size && 105 | other.format == format; 106 | } 107 | 108 | /// [DropdownTableColumn] renders a compact [DropdownButton] that allows to modify the cell's value in place. 109 | /// 110 | /// The [DropdownButton]'s type is [V]. 111 | final class DropdownTableColumn, T, V> 112 | extends EditableTableColumn { 113 | final InputDecoration inputDecoration; 114 | final List> items; 115 | 116 | const DropdownTableColumn({ 117 | required super.title, 118 | super.id, 119 | super.size = const FractionalColumnSize(.1), 120 | super.format = const AlignColumnFormat(alignment: Alignment.centerLeft), 121 | super.tooltip, 122 | super.sortable = false, 123 | required super.setter, 124 | required super.getter, 125 | required this.items, 126 | this.inputDecoration = const InputDecoration(isDense: true), 127 | }); 128 | 129 | @override 130 | Widget build(BuildContext context, T item, int index) => _DropdownCell( 131 | getter: getter, 132 | setter: setter, 133 | index: index, 134 | item: item, 135 | items: items, 136 | inputDecoration: inputDecoration, 137 | key: ValueKey(item), 138 | ); 139 | } 140 | 141 | /// [TextTableColumn] renders a compact [TextField] that allows to modify the cell's value in place when double-clicked. 142 | final class TextTableColumn, T> 143 | extends EditableTableColumn { 144 | final InputDecoration inputDecoration; 145 | final List? inputFormatters; 146 | 147 | const TextTableColumn({ 148 | required super.title, 149 | super.id, 150 | super.size = const FractionalColumnSize(.1), 151 | super.format = const AlignColumnFormat(alignment: Alignment.centerLeft), 152 | super.tooltip, 153 | super.sortable = false, 154 | required super.setter, 155 | required super.getter, 156 | this.inputDecoration = const InputDecoration(isDense: true), 157 | this.inputFormatters, 158 | }); 159 | 160 | @override 161 | Widget build(BuildContext context, T item, int index) => _TextFieldCell( 162 | getter: getter, 163 | setter: setter, 164 | index: index, 165 | item: item, 166 | key: ValueKey(item), 167 | isDialog: false, 168 | inputDecoration: inputDecoration, 169 | inputFormatters: inputFormatters, 170 | ); 171 | } 172 | 173 | /// [LargeTextTableColumn] renders an overlay dialog with a [TextField] that allows to modify the cell's value when double-clicked. 174 | /// 175 | /// This works better for cells with large content. 176 | final class LargeTextTableColumn, T> 177 | extends EditableTableColumn { 178 | final InputDecoration inputDecoration; 179 | final List? inputFormatters; 180 | 181 | /// A flag that indicates if the cell should display a tooltip of the whole cell's content. 182 | final bool showTooltip; 183 | 184 | /// The text's validator. 185 | final FormFieldValidator? validator; 186 | 187 | /// The overlay field's label. 188 | final String fieldLabel; 189 | 190 | /// The tooltip's text style. 191 | final TextStyle tooltipStyle; 192 | 193 | /// The [BoxConstraints] where to render the tooltip. 194 | final BoxConstraints? tooltipConstraints; 195 | 196 | /// The width breakpoint that [PagedDataTable] uses to decide if will render an overlay or a bottom sheet when the field is edited. 197 | final double bottomSheetBreakpoint; 198 | 199 | const LargeTextTableColumn({ 200 | required super.title, 201 | super.id, 202 | super.size = const FractionalColumnSize(.1), 203 | super.format = const AlignColumnFormat(alignment: Alignment.centerLeft), 204 | super.tooltip, 205 | super.sortable = false, 206 | required super.setter, 207 | required super.getter, 208 | required this.fieldLabel, 209 | this.inputDecoration = 210 | const InputDecoration(isDense: true, border: OutlineInputBorder()), 211 | this.inputFormatters, 212 | this.validator, 213 | this.showTooltip = true, 214 | this.tooltipStyle = const TextStyle(color: Colors.white), 215 | this.tooltipConstraints, 216 | this.bottomSheetBreakpoint = 1000, 217 | }); 218 | 219 | @override 220 | Widget build(BuildContext context, T item, int index) => 221 | _LargeTextFieldCell( 222 | getter: getter, 223 | setter: setter, 224 | index: index, 225 | item: item, 226 | key: ValueKey(item), 227 | isDialog: false, 228 | inputDecoration: inputDecoration, 229 | inputFormatters: inputFormatters, 230 | label: fieldLabel, 231 | tooltipText: showTooltip, 232 | validator: validator, 233 | tooltipStyle: tooltipStyle, 234 | tooltipConstraints: tooltipConstraints, 235 | bottomSheetBreakpoint: bottomSheetBreakpoint, 236 | ); 237 | } 238 | 239 | /// A special [ReadOnlyTableColumn] that renders a checkbox used to select rows. 240 | final class RowSelectorColumn, T> 241 | extends ReadOnlyTableColumn { 242 | /// Creates a new [RowSelectorColumn]. 243 | RowSelectorColumn() 244 | : super( 245 | format: const AlignColumnFormat(alignment: Alignment.center), 246 | id: null, 247 | size: const FixedColumnSize(80), 248 | sortable: false, 249 | tooltip: "Select rows", 250 | title: _SelectAllRowsCheckbox(), 251 | ); 252 | 253 | @override 254 | Widget build(BuildContext context, T item, int index) { 255 | return _SelectRowCheckbox(index: index); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /lib/src/paged_datatable.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:collection'; 3 | 4 | import 'package:flutter/foundation.dart' show kDebugMode; 5 | import 'package:flutter/material.dart'; 6 | import 'package:flutter/services.dart'; 7 | import 'package:intl/intl.dart'; 8 | import 'package:paged_datatable/paged_datatable.dart'; 9 | import 'package:paged_datatable/src/linked_scroll_controller.dart'; 10 | import 'package:paged_datatable/src/table_controller_notifier.dart'; 11 | 12 | part 'column.dart'; 13 | part 'column_widgets.dart'; 14 | part 'controller.dart'; 15 | part 'double_list_rows.dart'; 16 | part 'filter.dart'; 17 | part 'filter_bar.dart'; 18 | part 'filter_model.dart'; 19 | part 'filter_state.dart'; 20 | part 'filter_widgets.dart'; 21 | part 'footer_widgets.dart'; 22 | part 'header.dart'; 23 | part 'row.dart'; 24 | part 'sort_model.dart'; 25 | part 'table_view_rows.dart'; 26 | 27 | /// [PagedDataTable] renders a table of items that is paginable. 28 | /// 29 | /// The type of element to be displayed in the table is [T] and [K] is the type of key 30 | /// used to paginate the table. 31 | final class PagedDataTable, T> extends StatefulWidget { 32 | /// An specific [PagedDataTableController] to be use in this [PagedDataTable]. 33 | final PagedDataTableController? controller; 34 | 35 | /// The list of columns to draw in the table. 36 | final List> columns; 37 | 38 | /// The initial page size of the table. 39 | /// 40 | /// If [pageSizes] is not null, this value must match any of the its values. 41 | final int initialPageSize; 42 | 43 | /// The initial page query. 44 | final K? initialPage; 45 | 46 | /// The list of page sizes availables to be selected in the footer. 47 | final List? pageSizes; 48 | 49 | /// The callback used to fetch new items. 50 | final Fetcher fetcher; 51 | 52 | /// The amount of columns to fix, starting from the left. 53 | final int fixedColumnCount; 54 | 55 | /// The configuration of this [PagedDataTable]. 56 | final PagedDataTableConfiguration configuration; 57 | 58 | /// The widget to display at the footer of the table. 59 | /// 60 | /// If null, the default footer will be displayed. 61 | final Widget? footer; 62 | 63 | /// Additional widget to add at the right of the filter bar. 64 | final Widget? filterBarChild; 65 | 66 | /// The list of filters to use. 67 | final List filters; 68 | 69 | const PagedDataTable({ 70 | required this.columns, 71 | required this.fetcher, 72 | this.initialPage, 73 | this.initialPageSize = 50, 74 | this.pageSizes = const [10, 50, 100], 75 | this.controller, 76 | this.fixedColumnCount = 0, 77 | this.configuration = const PagedDataTableConfiguration(), 78 | this.footer, 79 | this.filterBarChild, 80 | this.filters = const [], 81 | super.key, 82 | }); 83 | 84 | @override 85 | State createState() => _PagedDataTableState(); 86 | } 87 | 88 | final class _PagedDataTableState, T> 89 | extends State> { 90 | final verticalController = ScrollController(); 91 | final linkedControllers = LinkedScrollControllerGroup(); 92 | late final headerHorizontalController = linkedControllers.addAndGet(); 93 | late final horizontalController = linkedControllers.addAndGet(); 94 | late final PagedDataTableController tableController; 95 | // late FixedTableSpanExtent rowSpanExtent, headerRowSpanExtent; 96 | late PagedDataTableThemeData theme; 97 | bool selfConstructedController = false; 98 | 99 | @override 100 | void initState() { 101 | super.initState(); 102 | assert( 103 | widget.pageSizes != null 104 | ? widget.pageSizes!.contains(widget.initialPageSize) 105 | : true, 106 | "initialPageSize must be inside pageSizes. To disable this restriction, set pageSizes to null."); 107 | 108 | if (widget.controller == null) { 109 | selfConstructedController = true; 110 | tableController = PagedDataTableController(); 111 | } else { 112 | tableController = widget.controller!; 113 | } 114 | tableController._init( 115 | columns: widget.columns, 116 | pageSizes: widget.pageSizes, 117 | initialPageSize: widget.initialPageSize, 118 | fetcher: widget.fetcher, 119 | config: widget.configuration, 120 | filters: widget.filters, 121 | ); 122 | } 123 | 124 | @override 125 | void didUpdateWidget(covariant PagedDataTable oldWidget) { 126 | super.didUpdateWidget(oldWidget); 127 | 128 | if (oldWidget.columns.length != 129 | widget.columns 130 | .length /*!listEquals(oldWidget.columns, widget.columns)*/) { 131 | tableController._reset(columns: widget.columns); 132 | debugPrint("PagedDataTable<$T> changed and rebuilt."); 133 | } 134 | } 135 | 136 | @override 137 | Widget build(BuildContext context) { 138 | theme = PagedDataTableTheme.of(context); 139 | 140 | return Card( 141 | color: theme.backgroundColor, 142 | elevation: theme.elevation, 143 | shape: RoundedRectangleBorder(borderRadius: theme.borderRadius), 144 | margin: EdgeInsets.zero, 145 | child: TableControllerProvider( 146 | controller: tableController, 147 | child: LayoutBuilder( 148 | builder: (context, constraints) { 149 | final sizes = _calculateColumnWidth(constraints.maxWidth); 150 | 151 | return Column( 152 | children: [ 153 | _FilterBar(child: widget.filterBarChild), 154 | 155 | _Header( 156 | controller: tableController, 157 | configuration: widget.configuration, 158 | columns: widget.columns, 159 | sizes: sizes, 160 | fixedColumnCount: widget.fixedColumnCount, 161 | horizontalController: headerHorizontalController, 162 | ), 163 | const Divider(height: 0, color: Color(0xFFD6D6D6)), 164 | 165 | Expanded( 166 | child: _DoubleListRows( 167 | fixedColumnCount: widget.fixedColumnCount, 168 | columns: widget.columns, 169 | horizontalController: horizontalController, 170 | controller: tableController, 171 | configuration: widget.configuration, 172 | sizes: sizes, 173 | ), 174 | ), 175 | 176 | // Expanded( 177 | // child: _TableViewRows( 178 | // columns: widget.columns, 179 | // controller: tableController, 180 | // fixedColumnCount: widget.fixedColumnCount, 181 | // horizontalController: horizontalController, 182 | // verticalController: verticalController, 183 | // ), 184 | // ), 185 | const Divider(height: 0, color: Color(0xFFD6D6D6)), 186 | SizedBox( 187 | height: theme.footerHeight, 188 | child: widget.footer ?? DefaultFooter(), 189 | ), 190 | ], 191 | ); 192 | }, 193 | ), 194 | ), 195 | ); 196 | } 197 | 198 | @override 199 | void dispose() { 200 | super.dispose(); 201 | verticalController.dispose(); 202 | horizontalController.dispose(); 203 | headerHorizontalController.dispose(); 204 | 205 | if (selfConstructedController) { 206 | tableController.dispose(); 207 | } 208 | } 209 | 210 | List _calculateColumnWidth(double maxWidth) { 211 | final sizes = 212 | List.filled(widget.columns.length, 0.0, growable: false); 213 | 214 | double totalFixedWidth = 0.0; 215 | double totalFraction = 0.0; 216 | int remainingColumnCount = 0; 217 | double totalFractionalWidth = 0.0; 218 | 219 | // First pass to determine widths and types of columns 220 | for (int i = 0; i < widget.columns.length; i++) { 221 | final column = widget.columns[i]; 222 | if (column.size.isFixed) { 223 | final columnSize = column.size.calculateConstraints(maxWidth); 224 | totalFixedWidth += columnSize; 225 | } else { 226 | totalFraction += column.size.fraction; 227 | 228 | // Handle this special case 229 | if (column.size is RemainingColumnSize) { 230 | remainingColumnCount++; 231 | } 232 | } 233 | } 234 | 235 | // Ensure totalFraction is within a valid range to prevent overflow 236 | assert(totalFraction <= 1.0, 237 | "Total fraction exceeds 1.0, which means the columns will overflow."); 238 | 239 | double remainingWidth = maxWidth - 240 | totalFixedWidth; // Calculate remaining width after fixed sizes are allocated 241 | totalFractionalWidth = 242 | remainingWidth * totalFraction; // Re-calculate total fractional width 243 | remainingWidth -= 244 | totalFractionalWidth; // Adjust remaining width to exclude fractional columns' widths for RemainingColumnSize 245 | final remainingColumnWidth = remainingColumnCount > 0.0 246 | ? remainingWidth / remainingColumnCount 247 | : 0.0; 248 | 249 | // Now calculate and assign column sizes 250 | for (int i = 0; i < widget.columns.length; i++) { 251 | final column = widget.columns[i]; 252 | if (column.size.isFixed) { 253 | // Pass totalFixedWidth but the ColumnSize should don't care about it because its a fixed size. 254 | sizes[i] = column.size.calculateConstraints(totalFixedWidth); 255 | } else if (column.size is RemainingColumnSize) { 256 | sizes[i] = remainingColumnWidth; 257 | } else { 258 | sizes[i] = totalFractionalWidth * column.size.fraction / totalFraction; 259 | } 260 | } 261 | 262 | return sizes; 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /lib/src/filter_bar.dart: -------------------------------------------------------------------------------- 1 | part of 'paged_datatable.dart'; 2 | 3 | /// The filter bar is displayed before the table header 4 | class _FilterBar, T> extends StatefulWidget { 5 | final Widget? child; 6 | 7 | const _FilterBar({required this.child}); 8 | 9 | @override 10 | State createState() => _FilterBarState(); 11 | } 12 | 13 | class _FilterBarState, T> 14 | extends State<_FilterBar> { 15 | late final theme = PagedDataTableTheme.of(context); 16 | late final controller = TableControllerProvider.of(context); 17 | 18 | final chipsListController = ScrollController(); 19 | 20 | @override 21 | void didChangeDependencies() { 22 | super.didChangeDependencies(); 23 | 24 | controller.addListener(_onChanged); 25 | } 26 | 27 | @override 28 | Widget build(BuildContext context) { 29 | var localizations = PagedDataTableLocalization.of(context); 30 | 31 | Widget child = SizedBox( 32 | height: theme.filterBarHeight, 33 | child: Row( 34 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 35 | children: [ 36 | Flexible( 37 | child: Row( 38 | children: [ 39 | /* FILTER BUTTON */ 40 | if (controller._filtersState.isNotEmpty) 41 | Container( 42 | padding: theme.cellPadding, 43 | margin: theme.padding, 44 | child: Ink( 45 | child: InkWell( 46 | radius: 20, 47 | child: Tooltip( 48 | message: localizations.showFilterMenuTooltip, 49 | child: MouseRegion( 50 | cursor: controller._state == _TableState.fetching 51 | ? SystemMouseCursors.basic 52 | : SystemMouseCursors.click, 53 | child: GestureDetector( 54 | onTapDown: 55 | controller._state == _TableState.fetching 56 | ? null 57 | : (details) => 58 | _showFilterOverlay(details, context), 59 | child: const Icon(Icons.filter_list_rounded), 60 | ), 61 | ), 62 | ), 63 | ), 64 | ), 65 | ), 66 | 67 | /* SELECTED FILTERS */ 68 | Expanded( 69 | child: Scrollbar( 70 | controller: chipsListController, 71 | trackVisibility: true, 72 | child: SingleChildScrollView( 73 | controller: chipsListController, 74 | scrollDirection: Axis.horizontal, 75 | child: Row( 76 | children: controller._filtersState.values 77 | .where((element) => element.value != null) 78 | .map( 79 | (e) => Padding( 80 | padding: 81 | const EdgeInsets.symmetric(horizontal: 4.0), 82 | child: Chip( 83 | deleteIcon: const Icon( 84 | Icons.close, 85 | size: 20, 86 | ), 87 | deleteButtonTooltipMessage: 88 | "Remove filter", //localizations.removeFilterButtonText, 89 | onDeleted: () { 90 | controller.removeFilter(e._filter.id); 91 | }, 92 | label: Text((e._filter as dynamic) 93 | .chipFormatter(e.value)), 94 | ), 95 | ), 96 | ) 97 | .toList(), 98 | ), 99 | ), 100 | ), 101 | ), 102 | ], 103 | ), 104 | ), 105 | if (widget.child != null) widget.child!, 106 | ], 107 | ), 108 | ); 109 | 110 | if (theme.chipTheme != null) { 111 | child = ChipTheme( 112 | data: theme.chipTheme!, 113 | child: child, 114 | ); 115 | } 116 | 117 | return child; 118 | } 119 | 120 | Future _showFilterOverlay( 121 | TapDownDetails details, BuildContext context) { 122 | final mediaWidth = MediaQuery.of(context).size.width; 123 | final bool isBottomSheet = mediaWidth < theme.filterDialogBreakpoint; 124 | 125 | if (isBottomSheet) { 126 | return showModalBottomSheet( 127 | barrierLabel: 128 | MaterialLocalizations.of(context).modalBarrierDismissLabel, 129 | context: context, 130 | builder: (context) => _FiltersDialog( 131 | availableWidth: mediaWidth, 132 | rect: null, 133 | tableController: controller, 134 | ), 135 | ); 136 | } 137 | 138 | final RenderBox renderBox = context.findRenderObject() as RenderBox; 139 | final offset = renderBox.localToGlobal(Offset.zero); 140 | final rect = RelativeRect.fromLTRB( 141 | offset.dx + 10, offset.dy + renderBox.size.height - 10, 0, 0); 142 | 143 | return showDialog( 144 | context: context, 145 | barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, 146 | barrierDismissible: true, 147 | barrierColor: Colors.transparent, 148 | builder: (context) => _FiltersDialog( 149 | availableWidth: mediaWidth, 150 | rect: rect, 151 | tableController: controller, 152 | ), 153 | ); 154 | } 155 | 156 | void _onChanged() { 157 | setState(() {}); 158 | } 159 | 160 | @override 161 | void dispose() { 162 | super.dispose(); 163 | chipsListController.dispose(); 164 | controller.removeListener(_onChanged); 165 | } 166 | } 167 | 168 | class _FiltersDialog, T> extends StatelessWidget { 169 | final RelativeRect? rect; 170 | final PagedDataTableController tableController; 171 | final double availableWidth; 172 | 173 | const _FiltersDialog( 174 | {required this.rect, 175 | required this.availableWidth, 176 | required this.tableController}); 177 | 178 | @override 179 | Widget build(BuildContext context) { 180 | final localizations = PagedDataTableLocalization.of(context); 181 | 182 | Widget filtersList = Padding( 183 | padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8.0), 184 | child: Form( 185 | key: tableController._filtersFormKey, 186 | child: Column( 187 | crossAxisAlignment: CrossAxisAlignment.stretch, 188 | children: [ 189 | Text(localizations.filterByTitle, 190 | style: const TextStyle(fontWeight: FontWeight.bold)), 191 | const SizedBox(height: 8), 192 | ...tableController._filtersState.entries 193 | .where((element) => element.value._filter.visible) 194 | .map( 195 | (entry) => Padding( 196 | padding: const EdgeInsets.symmetric(vertical: 6), 197 | child: 198 | entry.value._filter.buildPicker(context, entry.value), 199 | ), 200 | ) 201 | ], 202 | ), 203 | ), 204 | ); 205 | 206 | final buttons = Padding( 207 | padding: const EdgeInsets.all(8), 208 | child: Row( 209 | children: [ 210 | TextButton( 211 | style: TextButton.styleFrom( 212 | foregroundColor: Theme.of(context).colorScheme.secondary, 213 | padding: 214 | const EdgeInsets.symmetric(horizontal: 30, vertical: 20)), 215 | onPressed: () { 216 | Navigator.pop(context); 217 | tableController.removeFilters(); 218 | }, 219 | child: Text(localizations.removeAllFiltersButtonText), 220 | ), 221 | const Spacer(), 222 | TextButton( 223 | style: TextButton.styleFrom( 224 | padding: 225 | const EdgeInsets.symmetric(horizontal: 30, vertical: 20)), 226 | onPressed: () { 227 | Navigator.pop(context); 228 | }, 229 | child: Text(localizations.cancelFilteringButtonText), 230 | ), 231 | const SizedBox(width: 10), 232 | FilledButton( 233 | style: FilledButton.styleFrom( 234 | padding: 235 | const EdgeInsets.symmetric(horizontal: 30, vertical: 20)), 236 | onPressed: () { 237 | // to ensure onSaved is called on filters 238 | tableController._filtersFormKey.currentState!.save(); 239 | Navigator.pop(context); 240 | tableController.applyFilters(); 241 | }, 242 | child: Text(localizations.applyFilterButtonText), 243 | ), 244 | ], 245 | ), 246 | ); 247 | 248 | if (rect == null) { 249 | filtersList = Expanded(child: filtersList); 250 | } 251 | 252 | Widget child = Material( 253 | shape: const RoundedRectangleBorder( 254 | borderRadius: BorderRadius.all(Radius.circular(28))), 255 | elevation: 0, 256 | child: Column( 257 | crossAxisAlignment: CrossAxisAlignment.stretch, 258 | children: [ 259 | filtersList, 260 | const Divider(height: 0, color: Color(0xFFD6D6D6)), 261 | buttons, 262 | ], 263 | ), 264 | ); 265 | 266 | if (rect != null) { 267 | return Stack( 268 | fit: StackFit.loose, 269 | children: [ 270 | Positioned( 271 | top: rect!.top, 272 | left: rect!.left, 273 | child: Container( 274 | width: availableWidth / 3, 275 | decoration: const BoxDecoration( 276 | color: Colors.white, 277 | boxShadow: [BoxShadow(blurRadius: 3, color: Colors.black54)], 278 | borderRadius: BorderRadius.all(Radius.circular(28)), 279 | ), 280 | child: child, 281 | ), 282 | ), 283 | ], 284 | ); 285 | } 286 | 287 | return child; 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /example/lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter/services.dart'; 6 | import 'package:flutter_localizations/flutter_localizations.dart'; 7 | import 'package:intl/intl.dart'; 8 | import 'package:paged_datatable/paged_datatable.dart'; 9 | import 'package:paged_datatable_example/post.dart'; 10 | import 'package:google_fonts/google_fonts.dart'; 11 | 12 | Future main() async { 13 | WidgetsFlutterBinding.ensureInitialized(); 14 | // await initializeDateFormatting("en"); 15 | 16 | PostsRepository.generate(500); 17 | 18 | runApp(const MyApp()); 19 | } 20 | 21 | class MyApp extends StatelessWidget { 22 | const MyApp({Key? key}) : super(key: key); 23 | 24 | @override 25 | Widget build(BuildContext context) { 26 | return MaterialApp( 27 | localizationsDelegates: const [ 28 | GlobalMaterialLocalizations.delegate, 29 | GlobalCupertinoLocalizations.delegate, 30 | GlobalWidgetsLocalizations.delegate, 31 | PagedDataTableLocalization.delegate 32 | ], 33 | supportedLocales: const [ 34 | Locale("es"), 35 | Locale("en"), 36 | Locale("de"), 37 | Locale("it"), 38 | ], 39 | locale: const Locale("en"), 40 | title: 'Flutter Demo', 41 | debugShowCheckedModeBanner: false, 42 | theme: ThemeData( 43 | useMaterial3: true, 44 | colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), 45 | textTheme: kIsWeb ? GoogleFonts.robotoTextTheme() : null, 46 | ), 47 | home: const MainView(), 48 | ); 49 | } 50 | } 51 | 52 | class MainView extends StatefulWidget { 53 | const MainView({Key? key}) : super(key: key); 54 | 55 | @override 56 | State createState() => _MainViewState(); 57 | } 58 | 59 | class _MainViewState extends State { 60 | final tableController = PagedDataTableController(); 61 | 62 | @override 63 | Widget build(BuildContext context) { 64 | return Scaffold( 65 | body: Container( 66 | color: const Color.fromARGB(255, 208, 208, 208), 67 | padding: const EdgeInsets.all(20.0), 68 | child: Column( 69 | children: [ 70 | TextButton( 71 | child: const Text("Print debug"), 72 | onPressed: () { 73 | tableController.printDebugString(); 74 | }, 75 | ), 76 | Expanded( 77 | child: PagedDataTableTheme( 78 | data: PagedDataTableThemeData( 79 | selectedRow: const Color(0xFFCE93D8), 80 | rowColor: (index) => index.isEven ? Colors.purple[50] : null, 81 | ), 82 | child: PagedDataTable( 83 | controller: tableController, 84 | initialPageSize: 100, 85 | configuration: const PagedDataTableConfiguration(), 86 | pageSizes: const [10, 20, 50, 100], 87 | fetcher: (pageSize, sortModel, filterModel, pageToken) async { 88 | final data = await PostsRepository.getPosts( 89 | pageSize: pageSize, 90 | pageToken: pageToken, 91 | sortBy: sortModel?.fieldName, 92 | sortDescending: sortModel?.descending ?? false, 93 | gender: filterModel["authorGender"], 94 | searchQuery: filterModel["content"], 95 | ); 96 | return (data.items, data.nextPageToken); 97 | }, 98 | filters: [ 99 | TextTableFilter( 100 | id: "content", 101 | chipFormatter: (value) => 'Content has "$value"', 102 | name: "Content", 103 | ), 104 | DropdownTableFilter( 105 | items: Gender.values 106 | .map((e) => 107 | DropdownMenuItem(value: e, child: Text(e.name))) 108 | .toList(growable: false), 109 | chipFormatter: (value) => 110 | 'Author is ${value.name.toLowerCase()}', 111 | id: "authorGender", 112 | name: "Author's Gender", 113 | ), 114 | DateTimePickerTableFilter( 115 | id: "1", 116 | name: "Date picker", 117 | chipFormatter: (date) => "Date is $date", 118 | initialValue: DateTime.now(), 119 | firstDate: 120 | DateTime.now().subtract(const Duration(days: 30)), 121 | lastDate: DateTime.now(), 122 | dateFormat: DateFormat.yMd(), 123 | ), 124 | DateRangePickerTableFilter( 125 | id: "2", 126 | name: "DateRange picker", 127 | chipFormatter: (date) => "Date is $date", 128 | initialValue: null, 129 | firstDate: 130 | DateTime.now().subtract(const Duration(days: 30)), 131 | lastDate: DateTime.now(), 132 | formatter: (range) => "${range.start} - ${range.end}", 133 | ), 134 | ], 135 | filterBarChild: PopupMenuButton( 136 | icon: const Icon(Icons.more_vert_outlined), 137 | itemBuilder: (context) => [ 138 | PopupMenuItem( 139 | child: const Text("Print selected rows"), 140 | onTap: () { 141 | debugPrint(tableController.selectedRows.toString()); 142 | debugPrint(tableController.selectedItems.toString()); 143 | }, 144 | ), 145 | PopupMenuItem( 146 | child: const Text("Select random row"), 147 | onTap: () { 148 | final index = 149 | Random().nextInt(tableController.totalItems); 150 | tableController.selectRow(index); 151 | }, 152 | ), 153 | PopupMenuItem( 154 | child: const Text("Select all rows"), 155 | onTap: () { 156 | tableController.selectAllRows(); 157 | }, 158 | ), 159 | PopupMenuItem( 160 | child: const Text("Unselect all rows"), 161 | onTap: () { 162 | tableController.unselectAllRows(); 163 | }, 164 | ), 165 | const PopupMenuDivider(), 166 | PopupMenuItem( 167 | child: const Text("Remove first row"), 168 | onTap: () { 169 | tableController.removeRowAt(0); 170 | }, 171 | ), 172 | PopupMenuItem( 173 | child: const Text("Remove last row"), 174 | onTap: () { 175 | tableController 176 | .removeRowAt(tableController.totalItems - 1); 177 | }, 178 | ), 179 | PopupMenuItem( 180 | child: const Text("Remove random row"), 181 | onTap: () { 182 | final index = 183 | Random().nextInt(tableController.totalItems); 184 | tableController.removeRowAt(index); 185 | }, 186 | ), 187 | ], 188 | ), 189 | fixedColumnCount: 2, 190 | columns: [ 191 | RowSelectorColumn(), 192 | TableColumn( 193 | title: const Text("Id"), 194 | cellBuilder: (context, item, index) => 195 | Text(item.id.toString()), 196 | size: const FixedColumnSize(100), 197 | ), 198 | TableColumn( 199 | title: const Text("Author"), 200 | cellBuilder: (context, item, index) => Text(item.author), 201 | sortable: true, 202 | id: "author", 203 | size: const FractionalColumnSize(.15), 204 | ), 205 | DropdownTableColumn( 206 | title: const Text("Enabled"), 207 | // cellBuilder: (context, item, index) => Text(item.isEnabled ? "Yes" : "No"), 208 | items: const >[ 209 | DropdownMenuItem(value: true, child: Text("Yes")), 210 | DropdownMenuItem(value: false, child: Text("No")), 211 | ], 212 | size: const FixedColumnSize(100), 213 | getter: (item, index) => item.isEnabled, 214 | setter: (item, newValue, index) async { 215 | await Future.delayed(const Duration(seconds: 2)); 216 | item.isEnabled = newValue; 217 | return true; 218 | }, 219 | ), 220 | TableColumn( 221 | title: const Text("Author Gender"), 222 | cellBuilder: (context, item, index) => 223 | Text(item.authorGender.name), 224 | sortable: true, 225 | id: "authorGender", 226 | size: const MaxColumnSize( 227 | FractionalColumnSize(.2), FixedColumnSize(100)), 228 | ), 229 | LargeTextTableColumn( 230 | title: const Text("Content"), 231 | size: const RemainingColumnSize(), 232 | getter: (item, index) => item.content, 233 | fieldLabel: "Content", 234 | setter: (item, newValue, index) async { 235 | await Future.delayed(const Duration(seconds: 2)); 236 | item.content = newValue; 237 | return true; 238 | }, 239 | ), 240 | TextTableColumn( 241 | title: const Text("Number"), 242 | format: const NumericColumnFormat(), 243 | // cellBuilder: (context, item, index) => Text(item.number.toString()), 244 | size: const MaxColumnSize( 245 | FixedColumnSize(100), FractionalColumnSize(.1)), 246 | getter: (item, index) => item.number.toString(), 247 | inputFormatters: [FilteringTextInputFormatter.digitsOnly], 248 | setter: (item, newValue, index) async { 249 | await Future.delayed(const Duration(seconds: 2)); 250 | item.number = int.parse(newValue); 251 | return true; 252 | }, 253 | ), 254 | ], 255 | ), 256 | ), 257 | ), 258 | ], 259 | ), 260 | ), 261 | ); 262 | } 263 | 264 | @override 265 | void dispose() { 266 | super.dispose(); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /lib/src/linked_scroll_controller.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2018 the Dart project authors. 2 | // 3 | // Use of this source code is governed by a BSD-style 4 | // license that can be found in the LICENSE file or at 5 | // https://developers.google.com/open-source/licenses/bsd 6 | 7 | /* 8 | TAKEN FROM https://github.com/google/flutter.widgets/tree/master/packages/linked_scroll_controller 9 | */ 10 | 11 | import 'package:flutter/material.dart'; 12 | import 'package:flutter/rendering.dart'; 13 | 14 | /// Sets up a collection of scroll controllers that mirror their movements to 15 | /// each other. 16 | /// 17 | /// Controllers are added and returned via [addAndGet]. The initial offset 18 | /// of the newly created controller is synced to the current offset. 19 | /// Controllers must be `dispose`d when no longer in use to prevent memory 20 | /// leaks and performance degradation. 21 | /// 22 | /// If controllers are disposed over the course of the lifetime of this 23 | /// object the corresponding scrollables should be given unique keys. 24 | /// Without the keys, Flutter may reuse a controller after it has been disposed, 25 | /// which can cause the controller offsets to fall out of sync. 26 | class LinkedScrollControllerGroup { 27 | LinkedScrollControllerGroup() { 28 | _offsetNotifier = _LinkedScrollControllerGroupOffsetNotifier(this); 29 | } 30 | 31 | final _allControllers = <_LinkedScrollController>[]; 32 | 33 | late _LinkedScrollControllerGroupOffsetNotifier _offsetNotifier; 34 | 35 | /// The current scroll offset of the group. 36 | double get offset { 37 | assert( 38 | _attachedControllers.isNotEmpty, 39 | 'LinkedScrollControllerGroup does not have any scroll controllers ' 40 | 'attached.', 41 | ); 42 | return _attachedControllers.first.offset; 43 | } 44 | 45 | /// Creates a new controller that is linked to any existing ones. 46 | ScrollController addAndGet() { 47 | final initialScrollOffset = _attachedControllers.isEmpty 48 | ? 0.0 49 | : _attachedControllers.first.position.pixels; 50 | final controller = 51 | _LinkedScrollController(this, initialScrollOffset: initialScrollOffset); 52 | _allControllers.add(controller); 53 | controller.addListener(_offsetNotifier.notifyListeners); 54 | return controller; 55 | } 56 | 57 | /// Adds a callback that will be called when the value of [offset] changes. 58 | void addOffsetChangedListener(VoidCallback onChanged) { 59 | _offsetNotifier.addListener(onChanged); 60 | } 61 | 62 | /// Removes the specified offset changed listener. 63 | void removeOffsetChangedListener(VoidCallback listener) { 64 | _offsetNotifier.removeListener(listener); 65 | } 66 | 67 | Iterable<_LinkedScrollController> get _attachedControllers => 68 | _allControllers.where((controller) => controller.hasClients); 69 | 70 | /// Animates the scroll position of all linked controllers to [offset]. 71 | Future animateTo( 72 | double offset, { 73 | required Curve curve, 74 | required Duration duration, 75 | }) async { 76 | final animations = >[]; 77 | for (final controller in _attachedControllers) { 78 | animations 79 | .add(controller.animateTo(offset, duration: duration, curve: curve)); 80 | } 81 | return Future.wait(animations).then((List _) => null); 82 | } 83 | 84 | /// Jumps the scroll position of all linked controllers to [value]. 85 | void jumpTo(double value) { 86 | for (final controller in _attachedControllers) { 87 | controller.jumpTo(value); 88 | } 89 | } 90 | 91 | /// Resets the scroll position of all linked controllers to 0. 92 | void resetScroll() { 93 | jumpTo(0.0); 94 | } 95 | } 96 | 97 | /// This class provides change notification for [LinkedScrollControllerGroup]'s 98 | /// scroll offset. 99 | /// 100 | /// This change notifier de-duplicates change events by only firing listeners 101 | /// when the scroll offset of the group has changed. 102 | class _LinkedScrollControllerGroupOffsetNotifier extends ChangeNotifier { 103 | _LinkedScrollControllerGroupOffsetNotifier(this.controllerGroup); 104 | 105 | final LinkedScrollControllerGroup controllerGroup; 106 | 107 | /// The cached offset for the group. 108 | /// 109 | /// This value will be used in determining whether to notify listeners. 110 | double? _cachedOffset; 111 | 112 | @override 113 | void notifyListeners() { 114 | final currentOffset = controllerGroup.offset; 115 | if (currentOffset != _cachedOffset) { 116 | _cachedOffset = currentOffset; 117 | super.notifyListeners(); 118 | } 119 | } 120 | } 121 | 122 | /// A scroll controller that mirrors its movements to a peer, which must also 123 | /// be a [_LinkedScrollController]. 124 | class _LinkedScrollController extends ScrollController { 125 | final LinkedScrollControllerGroup _controllers; 126 | 127 | _LinkedScrollController(this._controllers, 128 | {required super.initialScrollOffset}) 129 | : super(keepScrollOffset: false); 130 | 131 | @override 132 | void dispose() { 133 | _controllers._allControllers.remove(this); 134 | super.dispose(); 135 | } 136 | 137 | @override 138 | void attach(ScrollPosition position) { 139 | assert( 140 | position is _LinkedScrollPosition, 141 | '_LinkedScrollControllers can only be used with' 142 | ' _LinkedScrollPositions.'); 143 | final _LinkedScrollPosition linkedPosition = 144 | position as _LinkedScrollPosition; 145 | assert(linkedPosition.owner == this, 146 | '_LinkedScrollPosition cannot change controllers once created.'); 147 | super.attach(position); 148 | } 149 | 150 | @override 151 | _LinkedScrollPosition createScrollPosition(ScrollPhysics physics, 152 | ScrollContext context, ScrollPosition? oldPosition) { 153 | return _LinkedScrollPosition( 154 | this, 155 | physics: physics, 156 | context: context, 157 | initialPixels: initialScrollOffset, 158 | oldPosition: oldPosition, 159 | ); 160 | } 161 | 162 | @override 163 | double get initialScrollOffset => _controllers._attachedControllers.isEmpty 164 | ? super.initialScrollOffset 165 | : _controllers.offset; 166 | 167 | @override 168 | _LinkedScrollPosition get position => super.position as _LinkedScrollPosition; 169 | 170 | Iterable<_LinkedScrollController> get _allPeersWithClients => 171 | _controllers._attachedControllers.where((peer) => peer != this); 172 | 173 | bool get canLinkWithPeers => _allPeersWithClients.isNotEmpty; 174 | 175 | Iterable<_LinkedScrollActivity> linkWithPeers(_LinkedScrollPosition driver) { 176 | assert(canLinkWithPeers); 177 | return _allPeersWithClients 178 | .map((peer) => peer.link(driver)) 179 | .expand((e) => e); 180 | } 181 | 182 | Iterable<_LinkedScrollActivity> link(_LinkedScrollPosition driver) { 183 | assert(hasClients); 184 | final activities = <_LinkedScrollActivity>[]; 185 | for (final position in positions) { 186 | final linkedPosition = position as _LinkedScrollPosition; 187 | activities.add(linkedPosition.link(driver)); 188 | } 189 | return activities; 190 | } 191 | } 192 | 193 | // Implementation details: Whenever position.setPixels or position.forcePixels 194 | // is called on a _LinkedScrollPosition (which may happen programmatically, or 195 | // as a result of a user action), the _LinkedScrollPosition creates a 196 | // _LinkedScrollActivity for each linked position and uses it to move to or jump 197 | // to the appropriate offset. 198 | // 199 | // When a new activity begins, the set of peer activities is cleared. 200 | class _LinkedScrollPosition extends ScrollPositionWithSingleContext { 201 | _LinkedScrollPosition( 202 | this.owner, { 203 | required super.physics, 204 | required super.context, 205 | super.initialPixels = null, 206 | super.oldPosition, 207 | }); 208 | 209 | final _LinkedScrollController owner; 210 | 211 | final Set<_LinkedScrollActivity> _peerActivities = <_LinkedScrollActivity>{}; 212 | 213 | // We override hold to propagate it to all peer controllers. 214 | @override 215 | ScrollHoldController hold(VoidCallback holdCancelCallback) { 216 | for (final controller in owner._allPeersWithClients) { 217 | controller.position._holdInternal(); 218 | } 219 | return super.hold(holdCancelCallback); 220 | } 221 | 222 | // Calls hold without propagating to peers. 223 | void _holdInternal() { 224 | super.hold(() {}); 225 | } 226 | 227 | @override 228 | void beginActivity(ScrollActivity? newActivity) { 229 | if (newActivity == null) { 230 | return; 231 | } 232 | for (var activity in _peerActivities) { 233 | activity.unlink(this); 234 | } 235 | 236 | _peerActivities.clear(); 237 | 238 | super.beginActivity(newActivity); 239 | } 240 | 241 | @override 242 | double setPixels(double newPixels) { 243 | if (newPixels == pixels) { 244 | return 0.0; 245 | } 246 | updateUserScrollDirection(newPixels - pixels > 0.0 247 | ? ScrollDirection.forward 248 | : ScrollDirection.reverse); 249 | 250 | if (owner.canLinkWithPeers) { 251 | _peerActivities.addAll(owner.linkWithPeers(this)); 252 | for (var activity in _peerActivities) { 253 | activity.moveTo(newPixels); 254 | } 255 | } 256 | 257 | return setPixelsInternal(newPixels); 258 | } 259 | 260 | double setPixelsInternal(double newPixels) { 261 | return super.setPixels(newPixels); 262 | } 263 | 264 | @override 265 | void forcePixels(double value) { 266 | if (value == pixels) { 267 | return; 268 | } 269 | updateUserScrollDirection(value - pixels > 0.0 270 | ? ScrollDirection.forward 271 | : ScrollDirection.reverse); 272 | 273 | if (owner.canLinkWithPeers) { 274 | _peerActivities.addAll(owner.linkWithPeers(this)); 275 | for (var activity in _peerActivities) { 276 | activity.jumpTo(value); 277 | } 278 | } 279 | 280 | forcePixelsInternal(value); 281 | } 282 | 283 | void forcePixelsInternal(double value) { 284 | super.forcePixels(value); 285 | } 286 | 287 | _LinkedScrollActivity link(_LinkedScrollPosition driver) { 288 | if (this.activity is! _LinkedScrollActivity) { 289 | beginActivity(_LinkedScrollActivity(this)); 290 | } 291 | final _LinkedScrollActivity activity = 292 | this.activity as _LinkedScrollActivity; 293 | activity.link(driver); 294 | return activity; 295 | } 296 | 297 | void unlink(_LinkedScrollActivity activity) { 298 | _peerActivities.remove(activity); 299 | } 300 | 301 | // We override this method to make it public (overridden method is protected) 302 | @override 303 | void updateUserScrollDirection(ScrollDirection value) { 304 | super.updateUserScrollDirection(value); 305 | } 306 | 307 | @override 308 | void debugFillDescription(List description) { 309 | super.debugFillDescription(description); 310 | description.add('owner: $owner'); 311 | } 312 | } 313 | 314 | class _LinkedScrollActivity extends ScrollActivity { 315 | _LinkedScrollActivity(_LinkedScrollPosition super.delegate); 316 | 317 | @override 318 | _LinkedScrollPosition get delegate => super.delegate as _LinkedScrollPosition; 319 | 320 | final Set<_LinkedScrollPosition> drivers = <_LinkedScrollPosition>{}; 321 | 322 | void link(_LinkedScrollPosition driver) { 323 | drivers.add(driver); 324 | } 325 | 326 | void unlink(_LinkedScrollPosition driver) { 327 | drivers.remove(driver); 328 | if (drivers.isEmpty) { 329 | delegate.goIdle(); 330 | } 331 | } 332 | 333 | @override 334 | bool get shouldIgnorePointer => true; 335 | 336 | @override 337 | bool get isScrolling => true; 338 | 339 | // _LinkedScrollActivity is not self-driven but moved by calls to the [moveTo] 340 | // method. 341 | @override 342 | double get velocity => 0.0; 343 | 344 | void moveTo(double newPixels) { 345 | _updateUserScrollDirection(); 346 | delegate.setPixelsInternal(newPixels); 347 | } 348 | 349 | void jumpTo(double newPixels) { 350 | _updateUserScrollDirection(); 351 | delegate.forcePixelsInternal(newPixels); 352 | } 353 | 354 | void _updateUserScrollDirection() { 355 | assert(drivers.isNotEmpty); 356 | ScrollDirection commonDirection = drivers.first.userScrollDirection; 357 | for (var driver in drivers) { 358 | if (driver.userScrollDirection != commonDirection) { 359 | commonDirection = ScrollDirection.idle; 360 | } 361 | } 362 | delegate.updateUserScrollDirection(commonDirection); 363 | } 364 | 365 | @override 366 | void dispose() { 367 | for (var driver in drivers) { 368 | driver.unlink(this); 369 | } 370 | super.dispose(); 371 | } 372 | } 373 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PagedDataTable 2 | 3 | [![pub package](https://img.shields.io/pub/v/paged_datatable?label=pub.dev&labelColor=333940&logo=dart)](https://pub.dev/packages/paged_datatable) 4 | 5 | Completely customisable data table which supports cursor and offset pagination, filters and horizontal scrolling out-of-the-box. It's written from scratch, no dependency from Flutter's `DataTable` nor `Table`. 6 | Designed to follow Google's Material You style. 7 | 8 | ## Online demo 9 | 10 | Check it out here 11 | 12 | ## Features 13 | 14 | - **Horizontal scrolling**, allowing you to define columns wider than the viewport width. 15 | - **Fixed columns**, to scroll horizontally only a set of columns. 16 | - **Row updating on demand**, preventing you to create other views for updating fields of a class. Now you can update an object from the table directly. 17 | - **Cursor and offset pagination**, you decide how to paginate your data. 18 | - **Filtering** by date, text, number, whatever you want! 19 | - **Sorting** by predefined columns 20 | - **Page modification** using controller, to add or remove items to the current page without reloading the entire table. 21 | - **Themeable**, allowing you to change colors, fonts, text styles, and more! 22 | 23 | ## Table Of Contents 24 | 25 | - [Setup](#setup) 26 | - [Fetcher](#fetcher) 27 | - [Header](#header) 28 | - [Footer](#footer) 29 | - [Custom footer](#custom-footer) 30 | - [Columns](#columns) 31 | - [TableColumn](#tablecolumnk-t) 32 | - [EditableTableColumn](#editabletablecolumnk-t-v) 33 | - [Custom columns](#custom-columns) 34 | - [Filters](#filters) 35 | - [Custom filters](#custom-filters) 36 | - [Controller](#controller) 37 | - [Internationalization](#internationalization) 38 | - [Screenshots](#screenshots) 39 | - [Contributing](#contribute) 40 | 41 | ## Setup 42 | 43 | Everything you need is a **PagedDataTable** widget, which accepts two generic arguments, **K** and **T**, the type of object you will use as paging key and the type of object you will be showing in the table. 44 | 45 | > Keep in mind that `K` must extends `Comparable`. 46 | 47 | There are only two required parameters: `columns` and `fetcher`. 48 | 49 | ```dart 50 | PagedDataTable( 51 | fetcher: (int pageSize, SortModel? sortModel, FilterModel filterModel, String? pageToken) => ..., 52 | columns: [...], 53 | ) 54 | ``` 55 | 56 | ### Fetcher 57 | 58 | The fetcher is a function that gets called every time a page is requested. It gives you the current page size, sort and filter models and the page token that is requested. It must return 59 | a `FutureOr<(List, K?)>`, so you can convert it to a Future and do `async`-like requests or simply return the data. 60 | 61 | It expects a tuple, where the first value is the list of items and the second the next page token: 62 | 63 | ```dart 64 | PagedDataTable( 65 | fetcher: (int pageSize, SortModel? sortModel, FilterModel filterModel, String? pageToken) { 66 | final result = await FetchService.listPosts(); 67 | return (result.data, result.nextPageToken); 68 | }, 69 | columns: [...], 70 | ) 71 | ``` 72 | 73 | By default, `PagedDataTable` does not copy the returned list, so, if it is a shared list, you or the table may modify the items. If you want, `PagedDataTable` can copy the list 74 | if you specify it in the `configuration` property: 75 | 76 | > Note that `PagedDataTable` **DOES NOT** cache pages. 77 | 78 | ```dart 79 | PagedDataTable( 80 | configuration: PagedDataTableConfiguration( 81 | copyItems: true, 82 | ), 83 | ) 84 | ``` 85 | 86 | ### Header 87 | 88 | The header renders the column names, but also the FilterBar exists, which is an additional header that renders the filter picker and, optionally, you can display additional widgets aligned at the right of the bar. 89 | 90 | Just pass your widget to the `filterBarChild` property. Naturally you would want to display a `PopupMenuButton` that will act as a menu. 91 | 92 | ### Footer 93 | 94 | Using the `footer` property you can render anything. If you don't pass it, it will render the `DefaultFooter` widget, 95 | which again, if not specified, will display, aligned to the right, the following widgets: 96 | 97 | - **Refresh button**: A button that can be used to refresh the current dataset. 98 | - **Page size selector**: A dropdown that can be used to select the current page size to use, based on the `pageSizes` property. 99 | - **Current page display**: Displays the current page number. 100 | - **Navigation buttons**: will display the previous and next buttons as `IconButton`s. 101 | 102 | #### Custom footer 103 | 104 | If you want your own footer widget but reuse some of the already existing widgets, they are named: `RefreshButton`, `PageSizeSelector`, `CurrentPage` and `NavigationButtons`. 105 | 106 | ### Columns 107 | 108 | There are two types of columns in `PagedDataTable`: 109 | 110 | - **ReadOnlyTableColumn**: renders a simple widget that does not allow edition. 111 | - **EditableTableColumn**: renders a simple widget too, but this can be modified in place and modify the dataset. 112 | 113 | > `K` and `T` are the same parameters defined in the `PagedDataTable` widget. 114 | 115 | Every column type has: 116 | 117 | - **title**: the column's title. It is a widget but commonly it's a `Text` widget displaying the name. 118 | - **size**: configures the column's size. By default, it is a `FractionalColumnSize(.1)`, which means it will take 10% of the available width. You can use `FixedColumnSize`, `FractionalColumnSize`, `RemainingColumnSize` and `MaxColumnSize`. 119 | - **format**: applies a transformation to the cell's widget. You have `NumericColumnFormat`, which aligns content to the right and `AlignColumnFormat` which aligns cell's content to the `alignment` property and you can implement your own implemeting the `ColumnFormat` interface. 120 | - **sort** and **id**: both properties are used to indicate that a column can be used for sorting. The `id` is what you get in the Fetcher's `SortModel`. To sort, you click the column's header. 121 | - There are other properties that you can use to play around and modify your columns. Check out the `ReadOnlyTableColumn`'s documentation. 122 | 123 | > If you want to fix columns at the left, you can specify the amount of columns to fix using the `fixedColumnCount` property. 124 | 125 | #### TableColumn 126 | 127 | Is the default `ReadOnlyTableColumn` that renders a cell using the `cellBuilder` property. 128 | 129 | ```dart 130 | PagedDataTable( 131 | columns: [ 132 | TableColumn( 133 | title: const Text("Author"), 134 | cellBuilder: (context, item, index) => Text(item.author), 135 | ), 136 | ], 137 | ) 138 | ``` 139 | 140 | #### EditableTableColumn 141 | 142 | This abstract class provides two more properties, `getter` and `setter`. The first one is used to provide the value **V** to render and the second one 143 | is the function used to set the new value. It must return a boolean indicating if the operation succeeded or not. If is true, the cell will update its 144 | value, otherwise will keep the old one. 145 | 146 | There are three built in editable columns, which are `DropdownTableColumn` which renders a dropdown; `TextTableColumn` which renders a `Text` until double-clicked, then it renders a `TextField` used to edit the cell's content; `LargeTextTableColumn`, which is the same as `TextTableColumn` but when 147 | double-clicked, it opens an overlay, designed to edit large text cells. 148 | 149 | You can [create your own column](#custom-columns). 150 | 151 | #### Custom columns 152 | 153 | To create your own columns, simply extend `ReadOnlyTableColumn` or `EditableTableColumn` depending on your needs. 154 | 155 | For example: 156 | 157 | ```dart 158 | class MyColumnType extends ReadOnlyTableColumn { 159 | @override 160 | Widget build(BuildContext context, T item, int index) { 161 | return MyCellWidget(); 162 | } 163 | } 164 | ``` 165 | 166 | > If you want more examples, check out the implementation of the already existing column types. 167 | 168 | ## Filters 169 | 170 | `PagedDataTable` allows you to define a set of filters that you can use to interactively select them using a 171 | popup overlay or a bottom sheet if you are in a small device. 172 | 173 | To define filters, use the `filters` property: 174 | 175 | ```dart 176 | PagedDataTable( 177 | ..., 178 | filters: [ 179 | TextTableFilter( 180 | id: "content", 181 | chipFormatter: (value) => 'Content has "$value"', 182 | name: "Content", 183 | ), 184 | DropdownTableFilter( 185 | items: Gender.values 186 | .map((e) => 187 | DropdownMenuItem(value: e, child: Text(e.name))) 188 | .toList(growable: false), 189 | chipFormatter: (value) => 190 | 'Author is ${value.name.toLowerCase()}', 191 | id: "authorGender", 192 | name: "Author's Gender", 193 | ), 194 | ], 195 | ) 196 | ``` 197 | 198 | There are five built-in filter types: 199 | 200 | - `TextTableFilter`: renders a `TextField` to filter by raw text. 201 | - `DropdownTableFilter`: renders a `DropdownButton` with a set of options. 202 | - `DateTimePickerTableFilter`: renders a `TextField` that, when tapped, opens the `DateTime` picker dialog. 203 | - `DateRangePickerTableFilter`: the same as `DateTimePickerTableFilter` but selects a `DateTimeRange`. 204 | - `ProgrammingTextFilter`: a filter that does not render nothing in the filter dialog but can be set using the controller. 205 | 206 | Every filter type must define, at least, the `id`, the `name` and the `chipFormatter` properties. The first one is the identifier of the filter, used in the fetcher's `FilterMode` property. The name is the label displayed in the filter picker and the `chipFormatter` is a function that maps the actual selected value to a more user-friendly string that is displayed in the selected filter's chip. 207 | 208 | ### Custom filters 209 | 210 | To create your own filter, extend the `TableFilter` abstract class, where `T` is the type of value the filter will handle. Then, implement the `buildPicker` function, which renders the actual filter picker. 211 | 212 | The implementation of the `TextTableFilter` as an example: 213 | 214 | ```dart 215 | final class TextTableFilter extends TableFilter { 216 | final InputDecoration? decoration; 217 | 218 | const TextTableFilter({ 219 | this.decoration, 220 | required super.chipFormatter, 221 | required super.id, 222 | required super.name, 223 | super.initialValue, 224 | super.enabled = true, 225 | }); 226 | 227 | @override 228 | Widget buildPicker(BuildContext context, FilterState state) { 229 | return TextFormField( 230 | decoration: decoration ?? InputDecoration(labelText: name), 231 | initialValue: state.value, 232 | onSaved: (newValue) { 233 | if (newValue != null && newValue.isNotEmpty) { 234 | state.value = newValue; 235 | } 236 | }, 237 | ); 238 | } 239 | } 240 | ``` 241 | 242 | ## Controller 243 | 244 | If you want to control the table programatically, provide your own `PagedDataTableController` instance passing it to the `controller` property. It provides methods to interact with rows (selecting, unselecting, removing, inserting, updating), filters, sorting, pagination, and more. 245 | 246 | ## Internationalization 247 | 248 | Update your `MaterialApp` or `CupertinoApp` widget with the following: 249 | 250 | ```dart 251 | localizationsDelegates: const [ 252 | PagedDataTableLocalization.delegate 253 | ], 254 | 255 | ``` 256 | 257 | And you're done. 258 | 259 | At the moment of writing this, the supported locales are: 260 | 261 | - **es**: Spanish 262 | - **en**: English 263 | - **de**: Deutsch 264 | 265 | If you want more languages, you can [contribute](#contribute). 266 | 267 | ## Screenshots 268 | 269 | ![Screenshot 1](https://raw.githubusercontent.com/tomasweigenast/paged-datatable/d9c6b290d8effa1a5676db03720b3c866f44bb4c/resources/screenshot1.png) 270 | ![Screenshot 2](https://raw.githubusercontent.com/tomasweigenast/paged-datatable/d9c6b290d8effa1a5676db03720b3c866f44bb4c/resources/screenshot2.png) 271 | ![Screenshot 3](https://raw.githubusercontent.com/tomasweigenast/paged-datatable/d9c6b290d8effa1a5676db03720b3c866f44bb4c/resources/screenshot3.png) 272 | ![Screenshot 4](https://raw.githubusercontent.com/tomasweigenast/paged-datatable/d9c6b290d8effa1a5676db03720b3c866f44bb4c/resources/screenshot4.png) 273 | ![Screenshot 5](https://raw.githubusercontent.com/tomasweigenast/paged-datatable/d9c6b290d8effa1a5676db03720b3c866f44bb4c/resources/screenshot5.png) 274 | 275 | ## Contribute 276 | 277 | Any suggestion to improve/add is welcome, if you want to make a PR, you are welcome :) 278 | -------------------------------------------------------------------------------- /example/pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | async: 5 | dependency: transitive 6 | description: 7 | name: async 8 | sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" 9 | url: "https://pub.dev" 10 | source: hosted 11 | version: "2.11.0" 12 | boolean_selector: 13 | dependency: transitive 14 | description: 15 | name: boolean_selector 16 | sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" 17 | url: "https://pub.dev" 18 | source: hosted 19 | version: "2.1.1" 20 | characters: 21 | dependency: transitive 22 | description: 23 | name: characters 24 | sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" 25 | url: "https://pub.dev" 26 | source: hosted 27 | version: "1.3.0" 28 | clock: 29 | dependency: transitive 30 | description: 31 | name: clock 32 | sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf 33 | url: "https://pub.dev" 34 | source: hosted 35 | version: "1.1.1" 36 | collection: 37 | dependency: transitive 38 | description: 39 | name: collection 40 | sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a 41 | url: "https://pub.dev" 42 | source: hosted 43 | version: "1.18.0" 44 | crypto: 45 | dependency: transitive 46 | description: 47 | name: crypto 48 | sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab 49 | url: "https://pub.dev" 50 | source: hosted 51 | version: "3.0.3" 52 | darq: 53 | dependency: "direct main" 54 | description: 55 | name: darq 56 | sha256: eb58203f550d113df1984cf6550b94b6951e515bed98eb49d098fb35719c78b0 57 | url: "https://pub.dev" 58 | source: hosted 59 | version: "2.0.0" 60 | equatable: 61 | dependency: "direct main" 62 | description: 63 | name: equatable 64 | sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 65 | url: "https://pub.dev" 66 | source: hosted 67 | version: "2.0.5" 68 | fake_async: 69 | dependency: transitive 70 | description: 71 | name: fake_async 72 | sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" 73 | url: "https://pub.dev" 74 | source: hosted 75 | version: "1.3.1" 76 | faker: 77 | dependency: "direct main" 78 | description: 79 | name: faker 80 | sha256: "746e59f91d8b06a389e74cf76e909a05ed69c12691768e2f93557fdf29200fd0" 81 | url: "https://pub.dev" 82 | source: hosted 83 | version: "2.1.0" 84 | ffi: 85 | dependency: transitive 86 | description: 87 | name: ffi 88 | sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" 89 | url: "https://pub.dev" 90 | source: hosted 91 | version: "2.1.2" 92 | flutter: 93 | dependency: "direct main" 94 | description: flutter 95 | source: sdk 96 | version: "0.0.0" 97 | flutter_lints: 98 | dependency: "direct dev" 99 | description: 100 | name: flutter_lints 101 | sha256: b543301ad291598523947dc534aaddc5aaad597b709d2426d3a0e0d44c5cb493 102 | url: "https://pub.dev" 103 | source: hosted 104 | version: "1.0.4" 105 | flutter_localizations: 106 | dependency: "direct main" 107 | description: flutter 108 | source: sdk 109 | version: "0.0.0" 110 | flutter_test: 111 | dependency: "direct dev" 112 | description: flutter 113 | source: sdk 114 | version: "0.0.0" 115 | google_fonts: 116 | dependency: "direct main" 117 | description: 118 | name: google_fonts 119 | sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 120 | url: "https://pub.dev" 121 | source: hosted 122 | version: "6.2.1" 123 | http: 124 | dependency: transitive 125 | description: 126 | name: http 127 | sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" 128 | url: "https://pub.dev" 129 | source: hosted 130 | version: "1.2.1" 131 | http_parser: 132 | dependency: transitive 133 | description: 134 | name: http_parser 135 | sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" 136 | url: "https://pub.dev" 137 | source: hosted 138 | version: "4.0.2" 139 | intl: 140 | dependency: "direct main" 141 | description: 142 | name: intl 143 | sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf 144 | url: "https://pub.dev" 145 | source: hosted 146 | version: "0.19.0" 147 | leak_tracker: 148 | dependency: transitive 149 | description: 150 | name: leak_tracker 151 | sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" 152 | url: "https://pub.dev" 153 | source: hosted 154 | version: "10.0.4" 155 | leak_tracker_flutter_testing: 156 | dependency: transitive 157 | description: 158 | name: leak_tracker_flutter_testing 159 | sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" 160 | url: "https://pub.dev" 161 | source: hosted 162 | version: "3.0.3" 163 | leak_tracker_testing: 164 | dependency: transitive 165 | description: 166 | name: leak_tracker_testing 167 | sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" 168 | url: "https://pub.dev" 169 | source: hosted 170 | version: "3.0.1" 171 | lints: 172 | dependency: transitive 173 | description: 174 | name: lints 175 | sha256: a2c3d198cb5ea2e179926622d433331d8b58374ab8f29cdda6e863bd62fd369c 176 | url: "https://pub.dev" 177 | source: hosted 178 | version: "1.0.1" 179 | matcher: 180 | dependency: transitive 181 | description: 182 | name: matcher 183 | sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb 184 | url: "https://pub.dev" 185 | source: hosted 186 | version: "0.12.16+1" 187 | material_color_utilities: 188 | dependency: transitive 189 | description: 190 | name: material_color_utilities 191 | sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" 192 | url: "https://pub.dev" 193 | source: hosted 194 | version: "0.8.0" 195 | meta: 196 | dependency: transitive 197 | description: 198 | name: meta 199 | sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" 200 | url: "https://pub.dev" 201 | source: hosted 202 | version: "1.12.0" 203 | paged_datatable: 204 | dependency: "direct main" 205 | description: 206 | path: ".." 207 | relative: true 208 | source: path 209 | version: "2.1.0" 210 | path: 211 | dependency: transitive 212 | description: 213 | name: path 214 | sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" 215 | url: "https://pub.dev" 216 | source: hosted 217 | version: "1.9.0" 218 | path_provider: 219 | dependency: transitive 220 | description: 221 | name: path_provider 222 | sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 223 | url: "https://pub.dev" 224 | source: hosted 225 | version: "2.1.3" 226 | path_provider_android: 227 | dependency: transitive 228 | description: 229 | name: path_provider_android 230 | sha256: "9c96da072b421e98183f9ea7464898428e764bc0ce5567f27ec8693442e72514" 231 | url: "https://pub.dev" 232 | source: hosted 233 | version: "2.2.5" 234 | path_provider_foundation: 235 | dependency: transitive 236 | description: 237 | name: path_provider_foundation 238 | sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 239 | url: "https://pub.dev" 240 | source: hosted 241 | version: "2.4.0" 242 | path_provider_linux: 243 | dependency: transitive 244 | description: 245 | name: path_provider_linux 246 | sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 247 | url: "https://pub.dev" 248 | source: hosted 249 | version: "2.2.1" 250 | path_provider_platform_interface: 251 | dependency: transitive 252 | description: 253 | name: path_provider_platform_interface 254 | sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" 255 | url: "https://pub.dev" 256 | source: hosted 257 | version: "2.1.2" 258 | path_provider_windows: 259 | dependency: transitive 260 | description: 261 | name: path_provider_windows 262 | sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" 263 | url: "https://pub.dev" 264 | source: hosted 265 | version: "2.2.1" 266 | platform: 267 | dependency: transitive 268 | description: 269 | name: platform 270 | sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" 271 | url: "https://pub.dev" 272 | source: hosted 273 | version: "3.1.4" 274 | plugin_platform_interface: 275 | dependency: transitive 276 | description: 277 | name: plugin_platform_interface 278 | sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" 279 | url: "https://pub.dev" 280 | source: hosted 281 | version: "2.1.8" 282 | sky_engine: 283 | dependency: transitive 284 | description: flutter 285 | source: sdk 286 | version: "0.0.99" 287 | source_span: 288 | dependency: transitive 289 | description: 290 | name: source_span 291 | sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" 292 | url: "https://pub.dev" 293 | source: hosted 294 | version: "1.10.0" 295 | stack_trace: 296 | dependency: transitive 297 | description: 298 | name: stack_trace 299 | sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" 300 | url: "https://pub.dev" 301 | source: hosted 302 | version: "1.11.1" 303 | stream_channel: 304 | dependency: transitive 305 | description: 306 | name: stream_channel 307 | sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 308 | url: "https://pub.dev" 309 | source: hosted 310 | version: "2.1.2" 311 | string_scanner: 312 | dependency: transitive 313 | description: 314 | name: string_scanner 315 | sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" 316 | url: "https://pub.dev" 317 | source: hosted 318 | version: "1.2.0" 319 | term_glyph: 320 | dependency: transitive 321 | description: 322 | name: term_glyph 323 | sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 324 | url: "https://pub.dev" 325 | source: hosted 326 | version: "1.2.1" 327 | test_api: 328 | dependency: transitive 329 | description: 330 | name: test_api 331 | sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" 332 | url: "https://pub.dev" 333 | source: hosted 334 | version: "0.7.0" 335 | typed_data: 336 | dependency: transitive 337 | description: 338 | name: typed_data 339 | sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c 340 | url: "https://pub.dev" 341 | source: hosted 342 | version: "1.3.2" 343 | vector_math: 344 | dependency: transitive 345 | description: 346 | name: vector_math 347 | sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" 348 | url: "https://pub.dev" 349 | source: hosted 350 | version: "2.1.4" 351 | vm_service: 352 | dependency: transitive 353 | description: 354 | name: vm_service 355 | sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" 356 | url: "https://pub.dev" 357 | source: hosted 358 | version: "14.2.1" 359 | web: 360 | dependency: transitive 361 | description: 362 | name: web 363 | sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" 364 | url: "https://pub.dev" 365 | source: hosted 366 | version: "0.5.1" 367 | win32: 368 | dependency: transitive 369 | description: 370 | name: win32 371 | sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4 372 | url: "https://pub.dev" 373 | source: hosted 374 | version: "5.5.1" 375 | xdg_directories: 376 | dependency: transitive 377 | description: 378 | name: xdg_directories 379 | sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d 380 | url: "https://pub.dev" 381 | source: hosted 382 | version: "1.0.4" 383 | sdks: 384 | dart: ">=3.4.0 <4.0.0" 385 | flutter: ">=3.22.0" 386 | -------------------------------------------------------------------------------- /lib/src/controller.dart: -------------------------------------------------------------------------------- 1 | part of 'paged_datatable.dart'; 2 | 3 | typedef Fetcher, T> 4 | = FutureOr<(List resultset, K? nextPageToken)> Function(int pageSize, 5 | SortModel? sortModel, FilterModel filterModel, K? pageToken); 6 | 7 | typedef RowChangeListener, T> = void Function( 8 | int index, T item); 9 | 10 | /// [PagedDataTableController] represents the state of a [PagedDataTable] of type [T], using pagination keys of type [K]. 11 | /// 12 | /// Is recommended that [T] specifies a custom hashCode and equals method for comparison reasons. 13 | final class PagedDataTableController, T> 14 | extends ChangeNotifier { 15 | final List _currentDataset = 16 | []; // the current dataset that is being displayed 17 | final Map _filtersState = 18 | {}; // The list of filters' states 19 | final Map _paginationKeys = 20 | {}; // it's a map because on not found map will return null, list will throw 21 | final Set _selectedRows = {}; // The list of selected row indexes 22 | final GlobalKey _filtersFormKey = GlobalKey(); 23 | late final List? _pageSizes; 24 | late final Fetcher _fetcher; // The function used to fetch items 25 | final Map<_ListenerType, dynamic> _listeners = { 26 | // The list of special listeners which all are functions 27 | 28 | // Callbacks for row change. The key of the map is the row index, the value the list of listeners for the row 29 | _ListenerType.rowChange: >>{}, 30 | }; 31 | PagedDataTableConfiguration? _configuration; 32 | 33 | Object? 34 | _currentError; // If something went wrong when fetching items, this is the latest error 35 | int _totalItems = 0; // the total items in the current dataset 36 | int _currentPageSize = 0; 37 | int _currentPageIndex = 38 | 0; // The current index of the page, used to lookup token inside _paginationKeys 39 | bool _hasNextPage = 40 | false; // a flag that indicates if there are more pages after the current one 41 | SortModel? _currentSortModel; // The current sort model of the table 42 | _TableState _state = _TableState.idle; 43 | 44 | /// A flag that indicates if the dataaset has a next page 45 | bool get hasNextPage => _hasNextPage; 46 | 47 | /// A flag that indicates if the dataset has a previous page 48 | bool get hasPreviousPage => _currentPageIndex != 0; 49 | 50 | /// The current amount of items that are being displayed on the current page 51 | int get totalItems => _totalItems; 52 | 53 | /// The current page size 54 | int get pageSize => _currentPageSize; 55 | 56 | /// Sets the new page size for the table 57 | set pageSize(int pageSize) { 58 | _currentPageSize = pageSize; 59 | refresh(fromStart: true); 60 | notifyListeners(); 61 | } 62 | 63 | /// The current sort model of the table 64 | SortModel? get sortModel => _currentSortModel; 65 | 66 | /// The list of selected row indexes 67 | List get selectedRows => _selectedRows.toList(growable: false); 68 | 69 | /// The list of selected items. 70 | List get selectedItems => UnmodifiableListView( 71 | _selectedRows.map((index) => _currentDataset[index])); 72 | 73 | /// Updates the sort model and refreshes the dataset 74 | set sortModel(SortModel? sortModel) { 75 | _currentSortModel = sortModel; 76 | refresh(fromStart: true); 77 | notifyListeners(); 78 | } 79 | 80 | /// Swipes the current sort model or sets it to [columnId]. 81 | /// 82 | /// If the sort model was ascending, it gets changed to descending, and finally it gets changed to null. 83 | void swipeSortModel([String? columnId]) { 84 | if (columnId != null && _currentSortModel?.fieldName != columnId) { 85 | sortModel = SortModel._(fieldName: columnId, descending: false); 86 | return; 87 | } 88 | 89 | // Ignore if no sort model 90 | if (_currentSortModel == null) return; 91 | 92 | if (_currentSortModel!.descending) { 93 | sortModel = null; 94 | } else { 95 | sortModel = SortModel._( 96 | fieldName: _currentSortModel!.fieldName, descending: true); 97 | } 98 | } 99 | 100 | /// Advances to the next page 101 | Future nextPage() => _fetch(_currentPageIndex + 1); 102 | 103 | /// Comes back to the previous page 104 | Future previousPage() => _fetch(_currentPageIndex - 1); 105 | 106 | /// Refreshes the state of the table. 107 | /// 108 | /// If [fromStart] is true, it will fetch from the first page. Otherwise, will try to refresh 109 | /// the current page. 110 | void refresh({bool fromStart = false}) { 111 | if (fromStart) { 112 | _paginationKeys.clear(); 113 | _totalItems = 0; 114 | _fetch(); 115 | } else { 116 | _fetch(_currentPageIndex); 117 | } 118 | } 119 | 120 | /// Prints a helpful debug string. Only works in debug mode. 121 | void printDebugString() { 122 | if (kDebugMode) { 123 | final buf = StringBuffer(); 124 | buf.writeln("TableController<$T>("); 125 | buf.writeln(" CurrentPageIndex($_currentPageIndex),"); 126 | buf.writeln(" PaginationKeys(${_paginationKeys.values.join(", ")}),"); 127 | buf.writeln(" Error($_currentError)"); 128 | buf.writeln(" CurrentPageSize($_currentPageSize)"); 129 | buf.writeln(" TotalItems($_totalItems)"); 130 | buf.writeln(" State($_state)"); 131 | buf.writeln(")"); 132 | 133 | debugPrint(buf.toString()); 134 | } 135 | } 136 | 137 | /// Removes the row with [item] from the dataset. 138 | /// 139 | /// This will use item to lookup based on its hashcode, so if you don't implement a custom 140 | /// one, this may not remove anything. 141 | void removeRow(T item) { 142 | final index = _currentDataset.indexOf(item); 143 | removeRowAt(index); 144 | _notifyOnRowChanged(index); 145 | } 146 | 147 | /// Removes a row at the specified [index]. 148 | void removeRowAt(int index) { 149 | if (index >= _totalItems) { 150 | throw ArgumentError( 151 | "index cannot be greater than or equals to the total list of items.", 152 | "index"); 153 | } 154 | 155 | if (index < 0) { 156 | throw ArgumentError("index cannot be less than zero.", "index"); 157 | } 158 | 159 | _currentDataset.removeAt(index); 160 | _totalItems--; 161 | _notifyOnRowChanged(index); 162 | } 163 | 164 | /// Inserts [value] in the current dataset at the specified [index] 165 | void insertAt(int index, T value) { 166 | _currentDataset.insert(index, value); 167 | _totalItems++; 168 | _notifyOnRowChanged(index); 169 | } 170 | 171 | /// Inserts [value] at the bottom of the current dataset 172 | void insert(T value) { 173 | insertAt(_totalItems, value); 174 | _notifyOnRowChanged(_totalItems); 175 | } 176 | 177 | /// Replaces the element at [index] with [value] 178 | void replace(int index, T value) { 179 | if (index >= _totalItems) { 180 | throw ArgumentError( 181 | "Index cannot be greater than or equals to the total size of the current dataset.", 182 | "index"); 183 | } 184 | 185 | _currentDataset[index] = value; 186 | _notifyOnRowChanged(index); 187 | } 188 | 189 | /// Marks a row as selected 190 | void selectRow(int index) { 191 | _selectedRows.add(index); 192 | _notifyOnRowChanged(index); 193 | } 194 | 195 | /// Marks every row in the current resultset as selected 196 | void selectAllRows() { 197 | final iterable = Iterable.generate(_totalItems); 198 | _selectedRows.addAll(iterable); 199 | _notifyRowChangedMany(iterable); 200 | } 201 | 202 | /// Unselects every row 203 | void unselectAllRows() { 204 | final selectedRows = _selectedRows.toList(growable: false); 205 | _selectedRows.clear(); 206 | _notifyRowChangedMany(selectedRows); 207 | } 208 | 209 | /// Unselects a row if was selected before 210 | void unselectRow(int index) { 211 | _selectedRows.remove(index); 212 | _notifyOnRowChanged(index); 213 | } 214 | 215 | /// Selects or unselects a row 216 | void toggleRow(int index) { 217 | if (_selectedRows.contains(index)) { 218 | _selectedRows.remove(index); 219 | } else { 220 | _selectedRows.add(index); 221 | } 222 | _notifyOnRowChanged(index); 223 | } 224 | 225 | /// Registers a callback that gets called when the row at [index] is updated. 226 | void addRowChangeListener(int index, RowChangeListener onRowChange) { 227 | final listeners = _listeners[_ListenerType.rowChange] 228 | as Map>>; 229 | final listenersForIndex = listeners[index] ?? []; 230 | listenersForIndex.add(onRowChange); 231 | listeners[index] = listenersForIndex; 232 | } 233 | 234 | /// Unregisters a row change callback. 235 | void removeRowChangeListener( 236 | int index, RowChangeListener rowChangeListener) { 237 | final listeners = _listeners[_ListenerType.rowChange] 238 | as Map>>; 239 | final listenersForIndex = listeners[index]; 240 | if (listenersForIndex == null) return; 241 | 242 | int? toRemove; 243 | for (int i = 0; i < listenersForIndex.length; i++) { 244 | if (listenersForIndex[i] == rowChangeListener) { 245 | toRemove = i; 246 | break; 247 | } 248 | } 249 | 250 | if (toRemove != null) listenersForIndex.removeAt(toRemove); 251 | } 252 | 253 | /// Removes a filter, changing its value to null. 254 | void removeFilter(String filterId) { 255 | final filter = _filtersState[filterId]; 256 | if (filter == null) { 257 | throw ArgumentError("Filter with id $filterId not found."); 258 | } 259 | 260 | filter.value = null; 261 | notifyListeners(); 262 | _fetch(); 263 | } 264 | 265 | /// Removes all the set filters, changing their values to null. 266 | void removeFilters() { 267 | _filtersState.forEach((key, value) { 268 | value.value = null; 269 | }); 270 | notifyListeners(); 271 | _fetch(); 272 | } 273 | 274 | /// Applies the current set filters 275 | void applyFilters() { 276 | if (_filtersState.values.any((element) => element.value != null)) { 277 | notifyListeners(); 278 | _fetch(); 279 | } 280 | } 281 | 282 | /// Sets filter [filterId]'s value. 283 | void setFilter(String filterId, dynamic value) { 284 | final filterState = _filtersState[filterId]; 285 | if (filterState == null) { 286 | throw ArgumentError( 287 | "Filter with id $filterId does not exist.", "filterId"); 288 | } 289 | 290 | filterState.value = value; 291 | applyFilters(); 292 | } 293 | 294 | /// This method automatically calls notifyListeners too. 295 | void _notifyOnRowChanged(int rowIndex) { 296 | final rowChangeListeners = (_listeners[_ListenerType.rowChange] 297 | as Map>>); 298 | final listeners = rowChangeListeners[rowIndex]; 299 | try { 300 | if (listeners != null) { 301 | final item = _currentDataset[rowIndex]; 302 | 303 | for (final listener in listeners) { 304 | listener(rowIndex, item); 305 | } 306 | } 307 | } catch (_) { 308 | listeners?.clear(); 309 | rowChangeListeners.remove(rowIndex); 310 | } finally { 311 | notifyListeners(); 312 | } 313 | } 314 | 315 | /// This method automatically calls notifyListeners too. 316 | void _notifyRowChangedMany(Iterable indexes) { 317 | final listeners = (_listeners[_ListenerType.rowChange] 318 | as Map>>); 319 | for (final index in indexes) { 320 | try { 321 | final listenerGroup = listeners[index]; 322 | if (listenerGroup != null) { 323 | final value = _currentDataset[index]!; 324 | for (final listener in listenerGroup) { 325 | listener(index, value); 326 | } 327 | } 328 | } catch (_) { 329 | listeners.remove(index); 330 | } 331 | } 332 | notifyListeners(); 333 | } 334 | 335 | /// Initializes the controller filling up properties 336 | void _init({ 337 | required List columns, 338 | required List? pageSizes, 339 | required int initialPageSize, 340 | required Fetcher fetcher, 341 | required List filters, 342 | required PagedDataTableConfiguration config, 343 | }) { 344 | if (_configuration != null) return; 345 | 346 | assert(columns.isNotEmpty, "columns cannot be empty."); 347 | 348 | _currentPageSize = initialPageSize; 349 | _pageSizes = pageSizes; 350 | _configuration = config; 351 | _fetcher = fetcher; 352 | _filtersState.addEntries( 353 | filters.map((filter) => MapEntry(filter.id, filter.createState()))); 354 | 355 | // Schedule a fetch 356 | Future.microtask(_fetch); 357 | } 358 | 359 | void _reset({required List columns}) { 360 | assert(columns.isNotEmpty, "columns cannot be empty."); 361 | 362 | // Schedule a fetch 363 | Future.microtask(_fetch); 364 | } 365 | 366 | Future _fetch([int page = 0]) async { 367 | _state = _TableState.fetching; 368 | _selectedRows.clear(); 369 | notifyListeners(); 370 | 371 | try { 372 | final pageToken = _paginationKeys[page]; 373 | final filterModel = FilterModel._( 374 | _filtersState.map((key, value) => MapEntry(key, value.value))); 375 | 376 | var (items, nextPageToken) = 377 | await _fetcher(_currentPageSize, sortModel, filterModel, pageToken); 378 | _hasNextPage = nextPageToken != null; 379 | _currentPageIndex = page; 380 | if (nextPageToken != null) { 381 | _paginationKeys[page + 1] = nextPageToken; 382 | } 383 | 384 | if (_configuration!.copyItems) { 385 | items = items.toList(); 386 | } 387 | 388 | /* the following may be more efficient than clearing the list and adding items again */ 389 | 390 | _currentDataset.clear(); 391 | _currentDataset.addAll(items); 392 | // if no items, clear dataset 393 | // if (items.isEmpty) { 394 | // _currentDataset.clear(); 395 | // } 396 | 397 | // // if no items before, just add all 398 | // else if (_totalItems == 0) { 399 | // _currentDataset.addAll(items); 400 | // } 401 | 402 | // // if now more items than before, replace then add 403 | // else if (items.length > _totalItems) { 404 | // _currentDataset.replaceRange(0, _totalItems - 1, items); 405 | // } 406 | 407 | // // if now less than items than before, replace and remove 408 | // else { 409 | // _currentDataset.replaceRange(0, items.length - 1, items); 410 | // if (items.length < _totalItems) { 411 | // _currentDataset.removeRange(items.length, _totalItems - 1); 412 | // } 413 | // } 414 | 415 | _totalItems = items.length; 416 | _state = _TableState.idle; 417 | _currentError = null; 418 | notifyListeners(); 419 | } catch (err, stack) { 420 | debugPrint("An error occurred trying to fetch a page: $err"); 421 | debugPrint(stack.toString()); 422 | _state = _TableState.error; 423 | _currentError = err; 424 | _totalItems = 0; 425 | _currentDataset.clear(); 426 | notifyListeners(); 427 | } 428 | } 429 | } 430 | 431 | enum _TableState { 432 | idle, 433 | fetching, 434 | error, 435 | } 436 | 437 | enum _ListenerType { 438 | rowChange, 439 | } 440 | --------------------------------------------------------------------------------