Custom Components
This guide walks through building three custom dashboard components step by step. For each component, you will: (a) define the properties factory, (b) create the ComponentDescriptor, (c) build the render widget, (d) build the config panel, and (e) register it.
Component 1: Stat Card
A stat card shows a single number with a label, trend indicator, and icon. This is the most common dashboard component.
1a. Define Properties
PropertyCollection buildStatCardProperties() {
return (PropertyCollectionBuilder()
..group('content', 'Content')
..string('title', 'Title',
required: true,
defaultValue: 'Metric',
)
..integer('value', 'Value', defaultValue: 0)
..string('unit', 'Unit', defaultValue: '')
..string('trend', 'Trend',
defaultValue: '+0%',
help: 'e.g., +12%, -3%, +5',
)
..group('appearance', 'Appearance')
..enumeration('trendDirection', 'Trend Direction',
values: ['up', 'down', 'neutral'],
defaultValue: 'up',
)
..string('iconName', 'Icon Name',
defaultValue: 'analytics',
help: 'Material icon name',
)
..string('accentColor', 'Accent Color',
defaultValue: '#2196F3',
help: 'Hex color code',
)
).buildCollection();
}1b. Create ComponentDescriptor
const lmsCategory = ComponentCategory(
id: 'lms',
displayName: 'LMS',
icon: Icons.school,
sortOrder: 10,
);
final statCardDescriptor = ComponentDescriptor(
schemaType: 'lms.stat_card',
componentKey: 'lms_stat_card',
displayName: 'Stat Card',
description: 'Displays a single metric with trend indicator',
icon: Icons.analytics,
category: lmsCategory,
defaultSize: const Size(2, 2),
minSize: const Size(1, 1),
minCellHeight: 100,
properties: buildStatCardProperties,
render: (context, props) => StatCardView(properties: props),
configPanel: (context, props) => StatCardConfigPanel(properties: props),
thumbnail: (context) => const Icon(Icons.analytics, size: 32),
);1c. Build the Render Widget
class StatCardView extends StatelessWidget {
final PropertyCollection properties;
const StatCardView({super.key, required this.properties});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final title = properties.stringValue('title') ?? 'Metric';
final value = properties.intValue('value') ?? 0;
final unit = properties.stringValue('unit') ?? '';
final trend = properties.stringValue('trend') ?? '';
final direction = properties.stringValue('trendDirection') ?? 'up';
final trendColor = switch (direction) {
'up' => Colors.green,
'down' => Colors.red,
_ => theme.colorScheme.onSurfaceVariant,
};
final trendIcon = switch (direction) {
'up' => Icons.trending_up,
'down' => Icons.trending_down,
_ => Icons.trending_flat,
};
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'$value',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (unit.isNotEmpty) ...[
const SizedBox(width: 4),
Text(unit, style: theme.textTheme.titleSmall),
],
],
),
const SizedBox(height: 4),
if (trend.isNotEmpty)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(trendIcon, size: 16, color: trendColor),
const SizedBox(width: 4),
Text(
trend,
style: theme.textTheme.bodySmall?.copyWith(
color: trendColor,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
),
);
}
}1d. Build the Config Panel (Optional)
If you skip this step, the system auto-generates a config panel from the property groups. For a custom panel:
class StatCardConfigPanel extends StatelessWidget {
final PropertyCollection properties;
const StatCardConfigPanel({super.key, required this.properties});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
// The PropertyCollection's built-in editors handle the UI.
// You can also build fully custom editors.
TabbedPropertyCollectionEditor(
collection: properties,
disposeCollection: false,
),
],
);
}
}1e. Register
final registry = DashboardEditorRegistry([
DashboardDescriptor(
name: 'LMS',
components: [statCardDescriptor],
),
);Component 2: KPI Widget
A KPI widget shows a progress bar comparing an actual value against a target.
2a. Define Properties
PropertyCollection buildKpiProperties() {
return (PropertyCollectionBuilder()
..group('data', 'Data')
..string('label', 'Label',
required: true,
defaultValue: 'Completion Rate',
)
..double_('target', 'Target Value', defaultValue: 100)
..double_('actual', 'Actual Value', defaultValue: 0)
..string('unit', 'Unit', defaultValue: '%')
..group('appearance', 'Appearance')
..string('color', 'Bar Color',
defaultValue: '#2196F3',
help: 'Hex color code for the progress bar',
)
..string('backgroundColor', 'Background Color',
defaultValue: '#E0E0E0',
)
..boolean('showPercentage', 'Show Percentage', defaultValue: true)
).buildCollection();
}2b. Create ComponentDescriptor
final kpiWidgetDescriptor = ComponentDescriptor(
schemaType: 'lms.kpi_widget',
componentKey: 'lms_kpi_widget',
displayName: 'KPI Progress',
description: 'Progress bar comparing actual vs target',
icon: Icons.speed,
category: lmsCategory,
defaultSize: const Size(2, 2),
minCellHeight: 120,
properties: buildKpiProperties,
render: (context, props) => KpiWidgetView(properties: props),
);2c. Build the Render Widget
class KpiWidgetView extends StatelessWidget {
final PropertyCollection properties;
const KpiWidgetView({super.key, required this.properties});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final label = properties.stringValue('label') ?? 'KPI';
final target = properties.doubleValue('target') ?? 100;
final actual = properties.doubleValue('actual') ?? 0;
final unit = properties.stringValue('unit') ?? '';
final showPercentage = properties.boolValue('showPercentage') ?? true;
final progress = target > 0 ? (actual / target).clamp(0.0, 1.0) : 0.0;
final percentage = (progress * 100).round();
// Parse color from hex string
final colorHex = properties.stringValue('color') ?? '#2196F3';
final barColor = Color(int.parse('FF${colorHex.replaceFirst('#', '')}', radix: 16));
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: theme.textTheme.bodyMedium),
Text(
'${actual.toStringAsFixed(0)}$unit / ${target.toStringAsFixed(0)}$unit',
style: theme.textTheme.bodySmall,
),
],
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
minHeight: 8,
backgroundColor: theme.colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation(barColor),
),
),
if (showPercentage) ...[
const SizedBox(height: 8),
Text(
'$percentage% of target',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
);
}
}Component 3: Enrollment Chart
A chart component that fetches enrollment data from an API and renders it using the chart system.
3a. Define Properties
PropertyCollection buildEnrollmentChartProperties() {
return (PropertyCollectionBuilder()
..group('data', 'Data Source')
..string('endpoint', 'API Endpoint',
required: true,
defaultValue: '/api/enrollments/by-course',
)
..string('dataArrayPath', 'Data Path',
defaultValue: 'data',
help: 'JSON path to the data array (e.g., data.enrollments)',
)
..string('labelField', 'Label Field', defaultValue: 'courseName')
..string('valueField', 'Value Field', defaultValue: 'enrollmentCount')
..string('seriesField', 'Series Field',
defaultValue: '',
help: 'Leave empty for single-series charts',
)
..group('chart', 'Chart Settings')
..enumeration('chartType', 'Chart Type',
values: ['bar', 'groupedBar', 'stackedBar', 'line', 'area'],
defaultValue: 'bar',
)
..boolean('showLegend', 'Show Legend', defaultValue: true)
..boolean('showGrid', 'Show Grid', defaultValue: true)
..string('title', 'Chart Title', defaultValue: 'Enrollments')
..group('refresh', 'Auto-Refresh')
..boolean('enablePolling', 'Enable Auto-Refresh', defaultValue: false)
..integer('pollingInterval', 'Interval (seconds)', defaultValue: 30)
).buildCollection();
}3b. Create ComponentDescriptor
final enrollmentChartDescriptor = ComponentDescriptor(
schemaType: 'lms.enrollment_chart',
componentKey: 'lms_enrollment_chart',
displayName: 'Enrollment Chart',
description: 'Chart showing enrollments per course with data source binding',
icon: Icons.bar_chart,
category: lmsCategory,
defaultSize: const Size(4, 3),
minCellHeight: 250,
properties: buildEnrollmentChartProperties,
render: (context, props) => EnrollmentChartView(properties: props),
);3c. Build the Render Widget
class EnrollmentChartView extends StatefulWidget {
final PropertyCollection properties;
const EnrollmentChartView({super.key, required this.properties});
@override
State<EnrollmentChartView> createState() => _EnrollmentChartViewState();
}
class _EnrollmentChartViewState extends State<EnrollmentChartView> {
List<DataPoint>? _data;
String? _error;
bool _loading = true;
@override
void initState() {
super.initState();
_fetchData();
}
Future<void> _fetchData() async {
try {
setState(() { _loading = true; _error = null; });
final props = widget.properties;
final endpoint = props.stringValue('endpoint') ?? '';
final dataArrayPath = props.stringValue('dataArrayPath');
final labelField = props.stringValue('labelField') ?? 'name';
final valueField = props.stringValue('valueField') ?? 'value';
final seriesField = props.stringValue('seriesField');
final dataSource = ApiChartDataSource();
final data = await dataSource.fetchData(
mapping: ChartFieldMapping(
labelField: labelField,
valueField: valueField,
seriesField: seriesField?.isNotEmpty == true ? seriesField : null,
),
filters: {
'endpoint': endpoint,
if (dataArrayPath != null && dataArrayPath.isNotEmpty)
'dataArrayPath': dataArrayPath,
},
);
setState(() { _data = data; _loading = false; });
} catch (e) {
setState(() { _error = e.toString(); _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 chartTypeStr = props.stringValue('chartType') ?? 'bar';
final showLegend = props.boolValue('showLegend') ?? true;
final showGrid = props.boolValue('showGrid') ?? true;
final title = props.stringValue('title') ?? '';
final chartType = ChartType.fromString(chartTypeStr);
final chartData = ChartData(type: chartType, data: _data!);
final factory = ChartRendererFactory();
final renderer = factory.createRenderer(chartData);
return renderer.build(
context,
chartData,
title: title.isNotEmpty ? title : null,
showLegend: showLegend,
showGrid: showGrid,
);
}
}3d. Build the Config Panel
For the enrollment chart, the auto-generated config panel works well since properties are organized into clear groups (Data Source, Chart Settings, Auto-Refresh). No custom config panel is needed.
3e. Register All Components
final registry = DashboardEditorRegistry([
DashboardDescriptor(
name: 'LMS',
description: 'Learning Management System dashboards',
tags: ['education', 'analytics'],
components: [
statCardDescriptor,
kpiWidgetDescriptor,
enrollmentChartDescriptor,
],
),
);Summary
For each custom component, the pattern is:
- Properties factory -- Use
PropertyCollectionBuilderto define typed, validated properties with groups, defaults, and help text. - ComponentDescriptor -- Create an instance with schema type, key, display metadata, the properties factory, and the render callback.
- Render widget -- A Flutter widget that reads from
PropertyCollectionand builds the UI. - Config panel (optional) -- A custom editor widget, or rely on the auto-generated
TabbedPropertyCollectionEditor. - Registration -- Wrap in a
DashboardDescriptorand register withDashboardEditorRegistry.
Next Steps
- Data Binding -- Connect components to API data
- Extensions -- Build custom extensions
- LMS Dashboard Example -- See all components in action