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:
- ChartFieldProvider -- Providing field suggestions based on entity schema
- Custom ChartDataSource -- Fetching data from the entity API
- Custom BaseChartRenderer -- Building a renderer with
fl_chart - ComponentDescriptor -- Wrapping everything in a declarative component
- Entity integration -- Connecting dashboard components to the entity system
Next Steps
- LMS Dashboard Example -- See all LMS components together
- Chart System -- Understand the built-in renderers
- API Reference: Components -- Complete component API