Skip to content

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

dart
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

dart
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

dart
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:

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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:

  1. Properties factory -- Use PropertyCollectionBuilder to define typed, validated properties with groups, defaults, and help text.
  2. ComponentDescriptor -- Create an instance with schema type, key, display metadata, the properties factory, and the render callback.
  3. Render widget -- A Flutter widget that reads from PropertyCollection and builds the UI.
  4. Config panel (optional) -- A custom editor widget, or rely on the auto-generated TabbedPropertyCollectionEditor.
  5. Registration -- Wrap in a DashboardDescriptor and register with DashboardEditorRegistry.

Next Steps