Skip to content

Custom Chart Component

This example builds a custom chart component with entity system integration. You will create a ChartFieldProvider for LMS entities, a custom data source that fetches from the entity API, and a custom renderer that wraps fl_chart.

What We Are Building

A Trainer Performance chart that:

  • Fetches trainer data from the LMS entity system
  • Maps entity fields to chart dimensions using ChartFieldProvider
  • Renders a grouped bar chart comparing trainer ratings across categories
  • Supports custom colors and interactive tooltips

Step 1: ChartFieldProvider for LMS Entities

Implement ChartFieldProvider to provide field suggestions in the config panel:

dart
class LmsChartFieldProvider implements ChartFieldProvider {
  @override
  String get displayName => 'LMS Entities';

  @override
  String get description => 'Field suggestions for LMS entity types';

  @override
  List<String> getFieldsForEntity(String entityType) {
    return switch (entityType) {
      'trainers' => [
        'name',
        'department',
        'rating',
        'coursesCount',
        'studentsCount',
        'completionRate',
        'averageScore',
      ],
      'courses' => [
        'name',
        'level',
        'status',
        'instructor',
        'durationMinutes',
        'enrollmentCount',
        'completionRate',
      ],
      'enrollments' => [
        'studentName',
        'courseName',
        'enrollDate',
        'completionDate',
        'status',
        'score',
        'level',
      ],
      _ => [],
    };
  }

  @override
  List<String> getNumericFieldsForEntity(String entityType) {
    return switch (entityType) {
      'trainers' => ['rating', 'coursesCount', 'studentsCount', 'completionRate', 'averageScore'],
      'courses' => ['durationMinutes', 'enrollmentCount', 'completionRate'],
      'enrollments' => ['score'],
      _ => [],
    };
  }
}

Step 2: Custom Data Source for Entity API

Create a data source that fetches from the entity system API:

dart
class LmsEntityChartDataSource implements ChartDataSource {
  final String baseUrl;

  LmsEntityChartDataSource({required this.baseUrl});

  @override
  String get displayName => 'LMS Entity API';

  @override
  String get description => 'Fetch chart data from LMS entity endpoints';

  @override
  Future<List<DataPoint>> fetchData({
    required ChartFieldMapping mapping,
    Map<String, dynamic>? filters,
    String? entityType,
  }) async {
    if (entityType == null || entityType.isEmpty) {
      throw ArgumentError(
        'LmsEntityChartDataSource: entityType is required. '
        'Specify the entity type (e.g., "trainers", "courses").',
      );
    }

    // Build the entity API URL
    final endpoint = '$baseUrl/api/$entityType';
    final queryParams = <String, String>{};

    // Apply filters
    if (filters != null) {
      for (final entry in filters.entries) {
        if (entry.value != null) {
          queryParams[entry.key] = entry.value.toString();
        }
      }
    }

    final uri = Uri.parse(endpoint).replace(queryParameters: queryParams);
    final response = await http.get(uri);

    if (response.statusCode != 200) {
      throw Exception(
        'LmsEntityChartDataSource: HTTP ${response.statusCode} from $endpoint',
      );
    }

    final jsonData = jsonDecode(response.body);
    final items = _extractItems(jsonData);

    return items.map((item) => DataPoint(
      label: _extractField(item, mapping.labelField)?.toString(),
      value: _extractNumeric(item, mapping.valueField),
      series: mapping.seriesField != null
        ? _extractField(item, mapping.seriesField!)?.toString()
        : null,
    )).toList();
  }

  List<Map<String, dynamic>> _extractItems(dynamic json) {
    if (json is List) {
      return json.cast<Map<String, dynamic>>();
    }
    if (json is Map) {
      // Try common response wrappers
      for (final key in ['data', 'items', 'results']) {
        if (json[key] is List) {
          return (json[key] as List).cast<Map<String, dynamic>>();
        }
      }
    }
    throw ArgumentError('Cannot extract data array from response');
  }

  dynamic _extractField(Map<String, dynamic> item, String fieldPath) {
    final parts = fieldPath.split('.');
    dynamic current = item;
    for (final part in parts) {
      if (current is Map) {
        current = current[part];
      } else {
        return null;
      }
    }
    return current;
  }

  num? _extractNumeric(Map<String, dynamic> item, String fieldPath) {
    final value = _extractField(item, fieldPath);
    if (value is num) return value;
    if (value is String) return num.tryParse(value);
    return null;
  }
}

Step 3: Custom Chart Renderer

Build a custom renderer that wraps fl_chart for trainer performance visualization:

dart
import 'package:fl_chart/fl_chart.dart';

class TrainerPerformanceRenderer extends BaseChartRenderer {
  @override
  Widget build(
    BuildContext context,
    ChartData chartData, {
    String? title,
    double? height,
    bool showLegend = true,
    bool showGrid = true,
    bool showTooltips = true,
    bool enableAnimation = true,
    Map<String, Color>? seriesColors,
  }) {
    final theme = Theme.of(context);
    final series = getUniqueSeries(chartData);
    final labels = chartData.data
        .map((d) => d.label ?? '')
        .toSet()
        .toList();

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        if (title != null)
          Padding(
            padding: const EdgeInsets.only(bottom: 12),
            child: Text(title, style: theme.textTheme.titleMedium),
          ),

        if (showLegend && series.isNotEmpty)
          Padding(
            padding: const EdgeInsets.only(bottom: 12),
            child: buildLegend(context, series, seriesColors),
          ),

        Expanded(
          child: BarChart(
            BarChartData(
              alignment: BarChartAlignment.spaceAround,
              maxY: _maxValue(chartData) * 1.2,
              barTouchData: BarTouchData(
                enabled: showTooltips,
                touchTooltipData: BarTouchTooltipData(
                  getTooltipItem: (group, groupIndex, rod, rodIndex) {
                    final seriesName = series.isNotEmpty
                        ? series[rodIndex]
                        : '';
                    return BarTooltipItem(
                      '$seriesName\n${rod.toY.toStringAsFixed(1)}',
                      theme.textTheme.bodySmall!,
                    );
                  },
                ),
              ),
              titlesData: FlTitlesData(
                bottomTitles: AxisTitles(
                  sideTitles: SideTitles(
                    showTitles: true,
                    getTitlesWidget: (value, meta) {
                      final index = value.toInt();
                      if (index < labels.length) {
                        return Padding(
                          padding: const EdgeInsets.only(top: 8),
                          child: Text(
                            labels[index],
                            style: theme.textTheme.bodySmall,
                            overflow: TextOverflow.ellipsis,
                          ),
                        );
                      }
                      return const SizedBox.shrink();
                    },
                  ),
                ),
                leftTitles: AxisTitles(
                  sideTitles: SideTitles(
                    showTitles: showGrid,
                    reservedSize: 40,
                  ),
                ),
                topTitles: const AxisTitles(
                  sideTitles: SideTitles(showTitles: false),
                ),
                rightTitles: const AxisTitles(
                  sideTitles: SideTitles(showTitles: false),
                ),
              ),
              gridData: FlGridData(show: showGrid),
              borderData: FlBorderData(show: false),
              barGroups: _buildBarGroups(
                chartData, labels, series, seriesColors,
              ),
            ),
            duration: enableAnimation
                ? const Duration(milliseconds: 300)
                : Duration.zero,
          ),
        ),
      ],
    );
  }

  List<BarChartGroupData> _buildBarGroups(
    ChartData chartData,
    List<String> labels,
    List<String> series,
    Map<String, Color>? seriesColors,
  ) {
    return labels.asMap().entries.map((entry) {
      final labelIndex = entry.key;
      final label = entry.value;

      final rods = series.asMap().entries.map((seriesEntry) {
        final seriesIndex = seriesEntry.key;
        final seriesName = seriesEntry.value;
        final point = chartData.data.firstWhere(
          (d) => d.label == label && d.series == seriesName,
          orElse: () => DataPoint(label: label, value: 0, series: seriesName),
        );

        return BarChartRodData(
          toY: (point.value ?? 0).toDouble(),
          color: getColorForSeries(seriesIndex, seriesColors, seriesName),
          width: 16,
          borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
        );
      }).toList();

      return BarChartGroupData(
        x: labelIndex,
        barRods: rods,
        barsSpace: 4,
      );
    }).toList();
  }

  double _maxValue(ChartData chartData) {
    return chartData.data
        .map((d) => (d.value ?? 0).toDouble())
        .reduce((a, b) => a > b ? a : b);
  }
}

Step 4: Register the Custom Renderer

dart
// Replace the built-in grouped bar renderer, or register alongside it
final factory = ChartRendererFactory();
// Option A: Use with the existing groupedBar type
factory.replace(ChartType.groupedBar, TrainerPerformanceRenderer());

// Option B: Keep the built-in and use the custom renderer directly
// (no registration needed -- just call build() directly)

Step 5: Build the ComponentDescriptor

dart
final trainerPerformanceChart = ComponentDescriptor(
  schemaType: 'lms.trainer_performance',
  componentKey: 'lms_trainer_performance',
  displayName: 'Trainer Performance',
  description: 'Grouped bar chart comparing trainer ratings across categories',
  icon: Icons.person,
  category: lmsCategory,
  defaultSize: const Size(6, 3),
  minCellHeight: 300,
  properties: () => (PropertyCollectionBuilder()
    ..group('data', 'Data Source')
    ..string('entityType', 'Entity Type', defaultValue: 'trainers')
    ..string('labelField', 'Label Field', defaultValue: 'name')
    ..string('valueField', 'Value Field', defaultValue: 'rating')
    ..string('seriesField', 'Series Field', defaultValue: 'department')
    ..group('filters', 'Filters')
    ..string('department', 'Department Filter', defaultValue: '')
    ..integer('limit', 'Max Trainers', defaultValue: 10)
    ..group('appearance', 'Appearance')
    ..string('title', 'Chart Title', defaultValue: 'Trainer Performance')
    ..boolean('showLegend', 'Show Legend', defaultValue: true)
    ..boolean('showGrid', 'Show Grid', defaultValue: true)
    ..string('seriesColors', 'Series Colors (JSON)',
      defaultValue: '',
      help: 'e.g., {"Engineering": "#2196F3", "Design": "#4CAF50"}',
    )
  ).buildCollection(),
  render: (context, props) => _TrainerPerformanceChartView(properties: props),
);

Step 6: Build the Render Widget

dart
class _TrainerPerformanceChartView extends StatefulWidget {
  final PropertyCollection properties;
  const _TrainerPerformanceChartView({required this.properties});

  @override
  State<_TrainerPerformanceChartView> createState() =>
      _TrainerPerformanceChartViewState();
}

class _TrainerPerformanceChartViewState
    extends State<_TrainerPerformanceChartView> {
  List<DataPoint>? _data;
  bool _loading = true;
  String? _error;

  @override
  void initState() {
    super.initState();
    _fetchData();
  }

  Future<void> _fetchData() async {
    try {
      final props = widget.properties;
      final entityType = props.stringValue('entityType') ?? 'trainers';
      final labelField = props.stringValue('labelField') ?? 'name';
      final valueField = props.stringValue('valueField') ?? 'rating';
      final seriesField = props.stringValue('seriesField');
      final department = props.stringValue('department');
      final limit = props.intValue('limit') ?? 10;

      final dataSource = LmsEntityChartDataSource(
        baseUrl: 'https://api.lms.example.com',
      );

      final data = await dataSource.fetchData(
        mapping: ChartFieldMapping(
          labelField: labelField,
          valueField: valueField,
          seriesField: seriesField?.isNotEmpty == true ? seriesField : null,
        ),
        entityType: entityType,
        filters: {
          if (department != null && department.isNotEmpty)
            'department': department,
          'limit': limit,
        },
      );

      if (mounted) setState(() { _data = data; _loading = false; });
    } catch (e) {
      if (mounted) setState(() { _error = '$e'; _loading = false; });
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_loading) return const Center(child: CircularProgressIndicator());
    if (_error != null) return Center(child: Text('Error: $_error'));

    final props = widget.properties;
    final title = props.stringValue('title');
    final showLegend = props.boolValue('showLegend') ?? true;
    final showGrid = props.boolValue('showGrid') ?? true;
    final colorsJson = props.stringValue('seriesColors');

    final chartData = ChartData(type: ChartType.groupedBar, data: _data!);
    final renderer = TrainerPerformanceRenderer();

    Map<String, Color>? seriesColors;
    if (colorsJson != null && colorsJson.isNotEmpty) {
      try {
        seriesColors = renderer.parseSeriesColors(colorsJson);
      } catch (_) {
        // Fall back to default palette
      }
    }

    return renderer.build(
      context,
      chartData,
      title: title,
      showLegend: showLegend,
      showGrid: showGrid,
      seriesColors: seriesColors,
    );
  }
}

Step 7: Register and Use

dart
final registry = DashboardEditorRegistry([
  DashboardDescriptor(
    name: 'LMS',
    description: 'Learning Management System dashboards',
    components: [
      trainerPerformanceChart,
      // ... other LMS components
    ],
  ),
);

// Use in a layout
final trainerRow = DashboardRow(
  id: 'row-trainers',
  order: 3,
  title: 'Trainer Analytics',
  height: 350,
  columns: [
    DashboardColumn.withComponent('col-perf',
      ComponentInstance.withDefaults(
        id: 's-trainer-perf',
        componentKey: 'lms_trainer_performance',
        registry: registry,
        configOverrides: {
          'title': 'Trainer Ratings by Department',
          'labelField': 'name',
          'valueField': 'rating',
          'seriesField': 'department',
          'limit': 8,
        },
      ),
      flex: 6,
    ),
  ],
);

Summary

This example demonstrated:

  1. ChartFieldProvider -- Providing field suggestions based on entity schema
  2. Custom ChartDataSource -- Fetching data from the entity API
  3. Custom BaseChartRenderer -- Building a renderer with fl_chart
  4. ComponentDescriptor -- Wrapping everything in a declarative component
  5. Entity integration -- Connecting dashboard components to the entity system

Next Steps