VooDataGrid

A powerful and flexible data grid widget for Flutter with advanced features like sorting, filtering, pagination, and remote data support.

pub package style: flutter_lints

Features

  • State Management Agnostic: Works with any state management solution (Cubit, BLoC, Provider, Riverpod, GetX, etc.)
  • Type-Safe Generic Support: Full compile-time type safety with generic type parameters
  • Flexible Data Display: Display tabular data with customizable columns and rows
  • Sorting: Built-in column sorting with custom comparators
  • Advanced Filtering:
    • Multiple filter types (string, int, date, decimal)
    • Secondary filters with AND/OR logic
    • Complex compound filters
    • Legacy filter format support
    • Built-in filter UI widget
    • Filters remain visible during error states
  • Pagination: Server-side and client-side pagination support
  • Selection: Row selection with single and multi-select modes
  • Remote Data: Built-in support for fetching data from REST APIs
  • Synchronized Scrolling: Uniform horizontal scrolling between header and body
  • Performance: Optimized for large datasets with efficient rendering
  • API Standards: Support for 7 API standards (Voo, Simple REST, JSON:API, OData, MongoDB, GraphQL, Custom)
  • Field Prefix Support: Automatic field name prefixing for nested properties
  • Action Columns: Support for clickable cells and action buttons
  • Enhanced Error Handling: Comprehensive error handling with graceful fallbacks
  • Customization: Highly customizable headers, cells, and styling
  • Responsive: Adapts to different screen sizes
  • Empty State: Headers and filters remain visible with empty data for better UX

Installation

Add this to your package's pubspec.yaml file:

dependencies:
  voo_data_grid: ^0.6.0
  voo_ui_core: ^0.1.0

Then run:

flutter pub get

Usage

Three Ways to Use VooDataGrid

VooDataGrid offers three different approaches to suit your state management needs:

  1. VooDataGrid - Traditional controller-based widget (works with Provider/ChangeNotifier)
  2. StatelessVooDataGrid - Completely state-agnostic widget (works with ANY state management)
  3. VooDataGridStateController - Provider-compatible controller for existing users

The new StatelessVooDataGrid widget accepts state and callbacks directly, making it perfect for use with any state management solution:

// With Cubit
BlocBuilder<OrderGridCubit, VooDataGridState<OrderList>>(
  builder: (context, state) {
    return StatelessVooDataGrid<OrderList>(
      state: state,
      columns: columns,
      onPageChanged: (page) => context.read<OrderGridCubit>().changePage(page),
      onFilterChanged: (field, filter) => 
        context.read<OrderGridCubit>().applyFilter(field, filter),
      onSortChanged: (field, direction) => 
        context.read<OrderGridCubit>().applySort(field, direction),
      onRowTap: (order) => _showOrderDetails(order),
    );
  },
)

// With Riverpod
Consumer(
  builder: (context, ref, child) {
    final state = ref.watch(orderGridProvider);
    return StatelessVooDataGrid<OrderList>(
      state: state,
      columns: columns,
      onPageChanged: (page) => 
        ref.read(orderGridProvider.notifier).changePage(page),
      onFilterChanged: (field, filter) => 
        ref.read(orderGridProvider.notifier).applyFilter(field, filter),
      // ... other callbacks
    );
  },
)

Basic Example (Traditional Controller)

import 'package:flutter/material.dart';
import 'package:voo_data_grid/voo_data_grid.dart';
import 'package:voo_ui_core/voo_ui_core.dart';

class DataGridExample extends StatefulWidget {
  @override
  State<DataGridExample> createState() => _DataGridExampleState();
}

class _DataGridExampleState extends State<DataGridExample> {
  late VooDataGridController controller;
  
  @override
  void initState() {
    super.initState();
    controller = VooDataGridController(
      dataSource: VooLocalDataSource(data: _generateData()),
      columns: _buildColumns(),
    );
  }
  
  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }
  
  List<VooDataGridColumn> _buildColumns() {
    return [
      VooDataGridColumn(
        key: 'id',
        label: 'ID',
        width: 80,
        getValue: (item) => item['id'].toString(),
      ),
      VooDataGridColumn(
        key: 'name',
        label: 'Name',
        getValue: (item) => item['name'],
        sortable: true,
      ),
      VooDataGridColumn(
        key: 'email',
        label: 'Email',
        getValue: (item) => item['email'],
        sortable: true,
        filterable: true,
      ),
      VooDataGridColumn(
        key: 'status',
        label: 'Status',
        getValue: (item) => item['status'],
        cellBuilder: (context, item, column) {
          final status = column.getValue(item);
          final color = status == 'Active' ? Colors.green : Colors.orange;
          return Container(
            padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
            decoration: BoxDecoration(
              color: color.withOpacity(0.2),
              borderRadius: BorderRadius.circular(4),
            ),
            child: Text(status),
          );
        },
      ),
    ];
  }
  
  List<Map<String, dynamic>> _generateData() {
    return List.generate(100, (index) => {
      'id': index + 1,
      'name': 'User ${index + 1}',
      'email': 'user${index + 1}@example.com',
      'status': index % 3 == 0 ? 'Inactive' : 'Active',
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return VooDesignSystem(
      data: VooDesignSystemData.defaultSystem,
      child: Scaffold(
        appBar: AppBar(title: const Text('Data Grid Example')),
        body: VooDataGrid(
          controller: controller,
          onRowTap: (item) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('Tapped: ${item['name']}')),
            );
          },
        ),
      ),
    );
  }
}

Using Typed Objects (Generic Support)

VooDataGrid fully supports typed objects with compile-time type safety:

// Define your data model
class OrderModel {
  final int id;
  final String orderNumber;
  final String customerName;
  final double amount;
  final FileStatus? status;
  
  OrderModel({
    required this.id,
    required this.orderNumber,
    required this.customerName,
    required this.amount,
    this.status,
  });
}

class FileStatus {
  final String code;
  final String name;
  final Color color;
  
  FileStatus({required this.code, required this.name, required this.color});
}

// Create strongly typed controller
class TypedDataExample extends StatefulWidget {
  @override
  State<TypedDataExample> createState() => _TypedDataExampleState();
}

class _TypedDataExampleState extends State<TypedDataExample> {
  late VooDataGridController<OrderModel> controller;
  
  @override
  void initState() {
    super.initState();
    
    // Create typed data source
    final dataSource = TypedDataSource();
    dataSource.setLocalData([
      OrderModel(
        id: 1,
        orderNumber: 'ORD-001',
        customerName: 'John Doe',
        amount: 1500.00,
        status: FileStatus(code: 'ACTIVE', name: 'Active', color: Colors.green),
      ),
      // ... more data
    ]);
    
    controller = VooDataGridController<OrderModel>(
      dataSource: dataSource,
    );
    
    // Define columns with type-safe valueGetter
    controller.setColumns([
      VooDataColumn<OrderModel>(
        field: 'orderNumber',
        label: 'Order #',
        // No casting needed - row is already typed as OrderModel
        valueGetter: (row) => row.orderNumber,
      ),
      VooDataColumn<OrderModel>(
        field: 'customerName',
        label: 'Customer',
        valueGetter: (row) => row.customerName,
      ),
      VooDataColumn<OrderModel>(
        field: 'amount',
        label: 'Amount',
        valueGetter: (row) => row.amount,
        valueFormatter: (value) => '\$${value.toStringAsFixed(2)}',
      ),
      VooDataColumn<OrderModel>(
        field: 'status',
        label: 'Status',
        // Handle nullable nested properties safely
        valueGetter: (row) => row.status?.name,
        cellBuilder: (context, value, row) {
          final status = row.status;
          if (status == null) return const Text('N/A');
          
          return Container(
            padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
            decoration: BoxDecoration(
              color: status.color.withOpacity(0.2),
              borderRadius: BorderRadius.circular(4),
            ),
            child: Text(
              status.name,
              style: TextStyle(color: status.color),
            ),
          );
        },
      ),
    ]);
  }
  
  @override
  Widget build(BuildContext context) {
    return VooDataGrid<OrderModel>(
      controller: controller,
      onRowTap: (OrderModel order) {
        // Type-safe row access
        print('Order ${order.orderNumber} tapped');
      },
    );
  }
}

// Custom data source for typed objects
class TypedDataSource extends VooDataGridSource<OrderModel> {
  TypedDataSource() : super(mode: VooDataGridMode.local);
  
  @override
  Future<VooDataGridResponse<OrderModel>> fetchRemoteData({
    required int page,
    required int pageSize,
    required Map<String, VooDataFilter> filters,
    required List<VooColumnSort> sorts,
  }) async {
    // Implement remote fetching if needed
    return VooDataGridResponse<OrderModel>(
      rows: [],
      totalRows: 0,
      page: page,
      pageSize: pageSize,
    );
  }
}

Important Notes for Typed Objects:

  • You MUST provide a valueGetter function for each column when using typed objects
  • The grid cannot use reflection to access properties dynamically
  • All callbacks (onRowTap, cellBuilder, etc.) receive properly typed data
  • Map objects work without valueGetter (backward compatibility)

Remote Data Example

class RemoteDataExample extends StatefulWidget {
  @override
  State<RemoteDataExample> createState() => _RemoteDataExampleState();
}

class _RemoteDataExampleState extends State<RemoteDataExample> {
  late VooDataGridController controller;
  
  @override
  void initState() {
    super.initState();
    controller = VooDataGridController(
      dataSource: RemoteDataGridSource(
        apiEndpoint: 'https://api.example.com/users',
        apiStandard: ApiFilterStandard.jsonApi,
        headers: {
          'Authorization': 'Bearer YOUR_TOKEN',
        },
        httpClient: (url, requestData, headers) async {
          // Use your preferred HTTP client (dio, http, etc.)
          final response = await dio.get(url, 
            queryParameters: requestData['params'],
            options: Options(headers: headers),
          );
          return response.data;
        },
      ),
      columns: _buildColumns(),
    );
  }
  
  // ... rest of the implementation
}

With Pagination

VooDataGrid(
  controller: VooDataGridController(
    dataSource: dataSource,
    columns: columns,
    paginationConfig: VooPaginationConfig(
      enabled: true,
      pageSize: 20,
      pageSizeOptions: [10, 20, 50, 100],
    ),
  ),
)

With Filtering

List<VooDataGridColumn> _buildColumns() {
  return [
    VooDataGridColumn(
      key: 'name',
      label: 'Name',
      getValue: (item) => item['name'],
      filterable: true,
      filterType: FilterType.text,
    ),
    VooDataGridColumn(
      key: 'age',
      label: 'Age',
      getValue: (item) => item['age'].toString(),
      filterable: true,
      filterType: FilterType.number,
    ),
    VooDataGridColumn(
      key: 'created_at',
      label: 'Created',
      getValue: (item) => item['created_at'],
      filterable: true,
      filterType: FilterType.date,
    ),
    VooDataGridColumn(
      key: 'status',
      label: 'Status',
      getValue: (item) => item['status'],
      filterable: true,
      filterType: FilterType.select,
      filterOptions: ['Active', 'Inactive', 'Pending'],
    ),
  ];
}

With Row Selection

VooDataGrid(
  controller: VooDataGridController(
    dataSource: dataSource,
    columns: columns,
    selectionMode: SelectionMode.multiple,
  ),
  onSelectionChanged: (selectedItems) {
    print('Selected ${selectedItems.length} items');
  },
)

Custom Cell Rendering

VooDataGridColumn(
  key: 'avatar',
  label: 'Avatar',
  getValue: (item) => item['avatar_url'],
  cellBuilder: (context, item, column) {
    final url = column.getValue(item);
    return CircleAvatar(
      backgroundImage: NetworkImage(url),
      radius: 16,
    );
  },
)

Custom Header

VooDataGridColumn(
  key: 'actions',
  label: 'Actions',
  headerBuilder: (context, column) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(Icons.settings, size: 16),
        const SizedBox(width: 4),
        Text(column.label),
      ],
    );
  },
  cellBuilder: (context, item, column) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        IconButton(
          icon: const Icon(Icons.edit, size: 16),
          onPressed: () => _editItem(item),
        ),
        IconButton(
          icon: const Icon(Icons.delete, size: 16),
          onPressed: () => _deleteItem(item),
        ),
      ],
    );
  },
)

Advanced Filtering

Understanding the Filters Map Structure

The filters in VooDataGrid are stored as a Map<String, VooDataFilter> where:

  • Key: The field name (e.g., 'name', 'status', 'price')
  • Value: A VooDataFilter object containing the operator and value
// Example of what the filters map looks like internally:
Map<String, VooDataFilter> filters = {
  'name': VooDataFilter(
    operator: VooFilterOperator.contains,
    value: 'John',
  ),
  'status': VooDataFilter(
    operator: VooFilterOperator.equals,
    value: 'active',
  ),
  'price': VooDataFilter(
    operator: VooFilterOperator.greaterThan,
    value: 100,
  ),
};

Applying Filters Programmatically

You can apply filters directly to the data source:

// Apply a single filter
controller.dataSource.applyFilter(
  'status',
  VooDataFilter(
    operator: VooFilterOperator.equals,
    value: 'active',
  ),
);

// Apply multiple filters at once
controller.dataSource.applyFilters({
  'name': VooDataFilter(
    operator: VooFilterOperator.startsWith,
    value: 'A',
  ),
  'age': VooDataFilter(
    operator: VooFilterOperator.greaterThanOrEqual,
    value: 18,
  ),
});

// Clear a specific filter
controller.dataSource.clearFilter('name');

// Clear all filters
controller.dataSource.clearAllFilters();

Using Primary Filters

Primary filters are pre-configured filters that users can quickly apply. They work with the same filter map structure:

VooDataGrid(
  controller: controller,
  showPrimaryFilters: true,
  primaryFilters: [
    PrimaryFilter(
      label: 'Active Only',
      field: 'status',
      filter: VooDataFilter(
        operator: VooFilterOperator.equals,
        value: 'active',
      ),
    ),
    PrimaryFilter(
      label: 'High Priority',
      field: 'priority',
      filter: VooDataFilter(
        operator: VooFilterOperator.greaterThanOrEqual,
        value: 8,
      ),
    ),
    PrimaryFilter(
      label: 'Recent Items',
      field: 'createdAt',
      filter: VooDataFilter(
        operator: VooFilterOperator.greaterThan,
        value: DateTime.now().subtract(Duration(days: 7)),
      ),
    ),
  ],
  onFilterChanged: (field, filter) {
    // This callback is triggered when any filter changes
    // field: The field name (e.g., 'status')
    // filter: The VooDataFilter object or null if clearing
    print('Filter changed: $field = ${filter?.value}');
  },
);

Available Filter Operators

enum VooFilterOperator {
  equals,           // Exact match
  notEquals,        // Not equal to
  contains,         // String contains
  notContains,      // String does not contain
  startsWith,       // String starts with
  endsWith,         // String ends with
  greaterThan,      // Greater than (numbers/dates)
  greaterThanOrEqual, // Greater than or equal
  lessThan,         // Less than (numbers/dates)
  lessThanOrEqual,  // Less than or equal
  between,          // Between two values (requires List value)
  inList,           // Value in list
  notInList,        // Value not in list
  isEmpty,          // Field is empty/null
  isNotEmpty,       // Field has value
}

Complex Filter with Secondary Conditions

// Using AdvancedRemoteDataSource for complex filtering
final dataSource = AdvancedRemoteDataSource(
  apiEndpoint: '/api/orders',
  httpClient: httpClient,
  useAdvancedFilters: true,
);

// Apply complex filter programmatically
final filterRequest = AdvancedFilterRequest(
  stringFilters: [
    StringFilter(
      fieldName: 'Site.Name',
      value: 'Tech',
      operator: 'Contains',
      secondaryFilter: SecondaryFilter(
        logic: FilterLogic.and,
        value: 'Park',
        operator: 'NotContains',
      ),
    ),
  ],
  intFilters: [
    IntFilter(
      fieldName: 'Site.SiteNumber',
      value: 1006,
      operator: 'GreaterThan',
      secondaryFilter: SecondaryFilter(
        logic: FilterLogic.and,
        value: 1011,
        operator: 'LessThan',
      ),
    ),
  ],
  logic: FilterLogic.and,
  pageNumber: 1,
  pageSize: 20,
);

dataSource.setAdvancedFilterRequest(filterRequest);

Using Advanced Filter Widget

AdvancedFilterWidget(
  dataSource: dataSource,
  fields: [
    FilterFieldConfig(
      fieldName: 'Site.SiteNumber',
      displayName: 'Site Number',
      type: FilterType.int,
    ),
    FilterFieldConfig(
      fieldName: 'Client.CompanyName',
      displayName: 'Client',
      type: FilterType.string,
    ),
    FilterFieldConfig(
      fieldName: 'OrderDate',
      displayName: 'Order Date',
      type: FilterType.date,
    ),
    FilterFieldConfig(
      fieldName: 'OrderCost',
      displayName: 'Cost',
      type: FilterType.decimal,
    ),
  ],
  onFilterApplied: (request) {
    print('Applied filters: ${request.toJson()}');
  },
)

State Management Compatibility

VooDataGrid now supports any state management solution through a flexible, state-agnostic architecture. Whether you use Cubit, BLoC, Provider, Riverpod, GetX, or any other state management, VooDataGrid integrates seamlessly.

New Architecture (v0.5.9+)

The package provides three key components for state management flexibility:

  1. VooDataGridDataSource<T> - Clean interface for data fetching without forcing inheritance
  2. VooDataGridState<T> - Immutable state class with copyWith method
  3. VooDataGridStateController<T> - ChangeNotifier-based controller for Provider users

Using with Cubit/BLoC

// Repository for data fetching
class OrderRepository extends VooDataGridDataSource<Order> {
  @override
  Future<VooDataGridResponse<Order>> fetchRemoteData({
    required int page,
    required int pageSize,
    required Map<String, VooDataFilter> filters,
    required List<VooColumnSort> sorts,
  }) async {
    // Fetch from your API
    final response = await api.getOrders(page, pageSize, filters, sorts);
    return VooDataGridResponse<Order>(
      rows: response.items,
      totalRows: response.total,
      page: page,
      pageSize: pageSize,
    );
  }
}

// Cubit for state management
class OrderGridCubit extends Cubit<VooDataGridState<Order>> {
  final OrderRepository repository;
  
  OrderGridCubit({required this.repository}) 
    : super(const VooDataGridState<Order>());
  
  Future<void> loadData() async {
    emit(state.copyWith(isLoading: true));
    try {
      final response = await repository.fetchRemoteData(
        page: state.currentPage,
        pageSize: state.pageSize,
        filters: state.filters,
        sorts: state.sorts,
      );
      emit(state.copyWith(
        rows: response.rows,
        totalRows: response.totalRows,
        isLoading: false,
      ));
    } catch (e) {
      emit(state.copyWith(error: e.toString(), isLoading: false));
    }
  }
  
  void changePage(int page) {
    emit(state.copyWith(currentPage: page));
    loadData();
  }
}

Using with Provider

// Use the provided controller
final controller = VooDataGridStateController<Order>(
  dataSource: OrderRepository(),
  mode: VooDataGridMode.remote,
);

// In your widget
ChangeNotifierProvider(
  create: (_) => controller..loadData(),
  child: Consumer<VooDataGridStateController<Order>>(
    builder: (context, controller, _) {
      return VooDataGrid<Order>(
        state: controller.state,
        columns: [...],
        onPageChanged: controller.changePage,
        onFilterChanged: controller.applyFilter,
      );
    },
  ),
)

Backward Compatibility

The original VooDataGridSource with ChangeNotifier is still available and fully supported:

// Legacy approach still works
class MyDataSource extends VooDataGridSource<Map<String, dynamic>> {
  MyDataSource() : super(mode: VooDataGridMode.local);
  // ... implementation
}

For detailed migration examples with Riverpod, GetX, and other state management solutions, see MIGRATION_GUIDE.md.

API Standards Support

VooDataGrid includes integrated support for 7 different API standards through the DataGridRequestBuilder class. Each data source can have its own HTTP client with custom interceptors and authentication.

Voo API Standard

The Voo API Standard is designed for enterprise applications with typed filters:

final dataSource = RemoteDataGridSource(
  apiEndpoint: '/api/data',
  apiStandard: ApiFilterStandard.voo,
  fieldPrefix: 'Site', // Optional: prefix for nested properties
  httpClient: (url, requestData, headers) async {
    final response = await dio.post(url, data: requestData);
    return response.data;
  },
);

// Example request format for Voo API Standard:
{
  "pageNumber": 1,
  "pageSize": 20,
  "logic": "And",
  "intFilters": [
    {
      "fieldName": "Site.siteNumber",
      "value": 100,
      "operator": "GreaterThanOrEqual"
    },
    {
      "fieldName": "Site.siteNumber",
      "value": 200,
      "operator": "LessThanOrEqual"
    }
  ],
  "stringFilters": [
    {
      "fieldName": "Site.name",
      "value": "Tech",
      "operator": "Contains"
    }
  ]
}

Important Notes for Voo API Standard:

  • Number ranges are sent as two separate filters (GreaterThanOrEqual and LessThanOrEqual)
  • The "Between" operator is not supported - it's automatically converted to two filters
  • Filters are organized by type: stringFilters, intFilters, dateFilters, decimalFilters
  • Field prefix is automatically applied to all field names when set

Simple REST Standard

final dataSource = RemoteDataGridSource(
  apiEndpoint: 'https://api.example.com/products',
  apiStandard: ApiFilterStandard.simple,
  httpClient: (url, requestData, headers) async {
    // Generates: ?page=0&limit=20&status=active&age_gt=25&sort=-created_at
    final params = requestData['params'] as Map<String, String>;
    final response = await dio.get(url, queryParameters: params);
    return response.data;
  },
);

JSON:API Standard

final dataSource = RemoteDataGridSource(
  apiEndpoint: 'https://api.example.com/products',
  apiStandard: ApiFilterStandard.jsonApi,
  httpClient: (url, requestData, headers) async {
    // Generates: ?page[number]=1&page[size]=20&filter[status]=active&sort=-created_at
    final params = requestData['params'] as Map<String, String>;
    final response = await dio.get(url, queryParameters: params);
    return response.data;
  },
);

OData Standard

final dataSource = RemoteDataGridSource(
  apiEndpoint: 'https://api.example.com/odata/products',
  apiStandard: ApiFilterStandard.odata,
  httpClient: (url, requestData, headers) async {
    // Generates: ?$top=20&$skip=0&$filter=status eq 'active'&$orderby=name asc
    final params = requestData['params'] as Map<String, String>;
    final response = await dio.get(url, queryParameters: params);
    return response.data;
  },
);

MongoDB/Elasticsearch Standard

final dataSource = RemoteDataGridSource(
  apiEndpoint: 'https://api.example.com/search',
  apiStandard: ApiFilterStandard.mongodb,
  httpClient: (url, requestData, headers) async {
    // Sends POST body with MongoDB-style query
    final body = requestData['body'];
    final response = await dio.post(url, data: body);
    return response.data;
  },
);

GraphQL Standard

final dataSource = RemoteDataGridSource(
  apiEndpoint: 'https://api.example.com/graphql',
  apiStandard: ApiFilterStandard.graphql,
  httpClient: (url, requestData, headers) async {
    // Sends GraphQL query with variables
    final response = await dio.post(url, data: {
      'query': requestData['query'],
      'variables': requestData['variables'],
    });
    return response.data;
  },
);

Custom Standard (Default)

final dataSource = RemoteDataGridSource(
  apiEndpoint: 'https://api.example.com/data',
  apiStandard: ApiFilterStandard.custom,
  httpClient: (url, requestData, headers) async {
    // Uses VooDataGrid's default format
    final response = await dio.post(url, data: requestData);
    return response.data;
  },
);

Response Format

{
  "data": [...],
  "current_page": 1,
  "total": 100,
  "per_page": 20,
  "last_page": 5
}

Spring Boot Format

{
  "content": [...],
  "pageable": {
    "pageNumber": 0,
    "pageSize": 20
  },
  "totalElements": 100,
  "totalPages": 5
}

Custom Format

VooRemoteDataSource(
  url: 'https://api.example.com/data',
  apiStandard: ApiStandard.custom,
  responseParser: (response) {
    return DataGridResponse(
      data: response['items'],
      total: response['count'],
      page: response['page'],
      pageSize: response['limit'],
    );
  },
)

Controller API

final controller = VooDataGridController(...);

// Refresh data
await controller.refresh();

// Navigate pages
controller.nextPage();
controller.previousPage();
controller.goToPage(3);

// Change page size
controller.setPageSize(50);

// Sorting
controller.sortBy('name', ascending: true);
controller.clearSort();

// Filtering
controller.setFilter('name', 'John');
controller.setFilters({
  'name': 'John',
  'status': 'Active',
});
controller.clearFilter('name');
controller.clearAllFilters();

// Selection
controller.selectAll();
controller.clearSelection();
controller.toggleSelection(item);
final selected = controller.selectedItems;

// Get current state
final currentPage = controller.currentPage;
final totalPages = controller.totalPages;
final isLoading = controller.isLoading;
final hasError = controller.hasError;

Customization

Styling

VooDataGrid(
  controller: controller,
  headerStyle: const DataGridHeaderStyle(
    backgroundColor: Colors.blue,
    textStyle: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
    height: 56,
  ),
  rowStyle: DataGridRowStyle(
    height: 48,
    alternateColor: Colors.grey.shade50,
    hoverColor: Colors.blue.shade50,
  ),
  borderStyle: const DataGridBorderStyle(
    horizontal: BorderSide(color: Colors.grey),
    vertical: BorderSide(color: Colors.grey),
  ),
)

Empty State

VooDataGrid(
  controller: controller,
  emptyStateBuilder: (context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(Icons.inbox, size: 64, color: Colors.grey),
          const SizedBox(height: 16),
          Text('No data available'),
        ],
      ),
    );
  },
)

Loading State

VooDataGrid(
  controller: controller,
  loadingBuilder: (context) {
    return const Center(
      child: CircularProgressIndicator(),
    );
  },
)

Error State

VooDataGrid(
  controller: controller,
  errorBuilder: (context, error) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(Icons.error, size: 64, color: Colors.red),
          const SizedBox(height: 16),
          Text('Error: $error'),
          ElevatedButton(
            onPressed: controller.refresh,
            child: const Text('Retry'),
          ),
        ],
      ),
    );
  },
)

Error Handling

VooDataGrid provides comprehensive error handling with graceful fallbacks:

Enhanced Error Recovery

  • Filters remain visible during errors: Headers and filter row stay accessible even when data loading fails
  • Type-safe error handling: Comprehensive try-catch blocks around valueGetter functions
  • Detailed error logging: Debug mode provides detailed type information for troubleshooting

Handling Type Mismatches

// The grid gracefully handles type mismatches
VooDataColumn<OrderModel>(
  field: 'status',
  label: 'Status',
  valueGetter: (row) {
    // This is protected with error handling
    return row.status?.name;
  },
  // Fallback for null or error cases
  valueFormatter: (value) => value ?? 'N/A',
)

Debug Logging

In debug mode, detailed error information is logged:

[VooDataGrid Error] Failed to get value for column "status":
Error: type 'Null' is not a subtype of type 'String'
Row type: OrderModel
Column field: status
ValueGetter type: (OrderModel) => String?

Widget Previews\n\nVooDataGrid includes comprehensive preview widgets for testing and development:\n\n### Available Previews\n\n1. VooDataGridPreview - Large dataset demo with 200+ rows and 15+ columns\n2. VooDataGridApiStandardsPreview - Interactive API standards configuration tool\n3. VooDataGridEmptyStatePreview - Demonstrates empty state with persistent headers\n4. VooDataGridTypedObjectsPreview - Demonstrates using VooDataGrid with typed objects\n\n### Using Previews\n\ndart\nimport 'package:voo_data_grid/voo_data_grid.dart';\n\n// In your development/testing environment\nvoid main() {\n runApp(const VooDataGridApiStandardsPreview());\n}\n\n\n### Interactive API Standards Preview\n\nThe API standards preview provides:\n- Live switching between all 6 API standards\n- Real-time request format viewer\n- Copy-paste ready code examples\n- Interactive filter and sort testing\n\n## Performance Tips

  1. Use pagination for large datasets
  2. Implement virtual scrolling for very large lists
  3. Use const constructors where possible
  4. Optimize cell builders to be lightweight
  5. Cache remote data when appropriate
  6. Use proper keys for columns to optimize rebuilds

Migration Guide

From v0.5.1 to v0.5.2

Bug Fixes & Improvements:

  • Fixed VooApiStandard number range filtering (now uses GreaterThanOrEqual/LessThanOrEqual instead of Between)
  • Fixed filters disappearing when data loading fails
  • Enhanced error handling for type mismatches in valueGetter functions
  • Added comprehensive error logging in debug mode

From v0.5.0 to v0.5.1

New Features:

  • Added field prefix support for nested properties
  • Added action columns with excludeFromApi flag
  • Added onCellTap callback for clickable cells

From v0.4.0 to v0.5.0

Breaking Changes:

  • All components now use generic type parameters for type safety
  • valueGetter now has proper type signature: dynamic Function(T row)
  • No casting needed in valueGetter functions anymore
  • Compile-time type checking for all row data

From v0.2.0 to v0.3.0

Breaking Changes:

  • StandardApiRequestBuilder renamed to DataGridRequestBuilder
  • API standards now integrated directly into DataGridRequestBuilder
  • Preview files moved from /preview to /lib/preview

From voo_ui

If you're migrating from the monolithic voo_ui package:

  1. Update your dependencies:
dependencies:
  voo_data_grid: ^0.5.4
  voo_ui_core: ^0.1.0  # Required for design system
  1. Update imports:
// Old
import 'package:voo_ui/voo_ui.dart';

// New
import 'package:voo_data_grid/voo_data_grid.dart';
import 'package:voo_ui_core/voo_ui_core.dart';
  1. The API has been improved but remains similar. Main changes:
    • Controller is now required
    • Column configuration is more flexible
    • Better TypeScript support

Troubleshooting

Sorting not working with VooApiStandard

If sorting isn't being applied when clicking column headers with VooApiStandard:

  1. Ensure columns are sortable:
VooDataColumn<OrderList>(
  field: 'siteNumber',
  label: 'Site Number',
  sortable: true,  // <-- REQUIRED for sorting
  valueGetter: (row) => row.siteNumber,  // <-- REQUIRED for typed objects
),
  1. Verify API standard is set:
// In your data source:
final request = const DataGridRequestBuilder(
  standard: ApiFilterStandard.voo,  // <-- REQUIRED for VooApiStandard
  fieldPrefix: 'Site',  // Optional prefix
).buildRequest(
  page: page + 1,
  pageSize: pageSize,
  filters: filters,
  sorts: sorts,  // <-- Will be converted to sortBy and sortDescending
);
  1. Check debug output:
// Add logging to your fetchRemoteData:
debugPrint('Sorts received: $sorts');
debugPrint('Request built: $request');

If sorts are empty, verify:

  • Column has sortable: true
  • Column has valueGetter for typed objects
  • Controller is properly initialized with columns

See example/lib/voo_api_standard_example.dart for a complete working example.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

MIT License - see the LICENSE file for details