Analytics Dashboard
An analytics dashboard with multiple polling data sources, selectors for widget-level optimization, lenses for deep config updates, and computed aggregations. Demonstrates reactonQuery with polling, selector, lens, and computed for a data-rich UI.
Full Source
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_reacton/flutter_reacton.dart';
// --- Models ---
class SalesData {
final double revenue;
final int orders;
final int visitors;
final double conversionRate;
final List<DailySales> daily;
const SalesData({
required this.revenue,
required this.orders,
required this.visitors,
required this.conversionRate,
required this.daily,
});
}
class DailySales {
final String date;
final double revenue;
final int orders;
const DailySales({
required this.date,
required this.revenue,
required this.orders,
});
}
class SystemHealth {
final double cpuUsage;
final double memoryUsage;
final int activeConnections;
final int errorCount;
final double uptime;
const SystemHealth({
required this.cpuUsage,
required this.memoryUsage,
required this.activeConnections,
required this.errorCount,
required this.uptime,
});
}
class DashboardConfig {
final String dateRange;
final String currency;
final bool showTrends;
final RefreshSettings refresh;
const DashboardConfig({
this.dateRange = '7d',
this.currency = 'USD',
this.showTrends = true,
this.refresh = const RefreshSettings(),
});
DashboardConfig copyWith({
String? dateRange,
String? currency,
bool? showTrends,
RefreshSettings? refresh,
}) =>
DashboardConfig(
dateRange: dateRange ?? this.dateRange,
currency: currency ?? this.currency,
showTrends: showTrends ?? this.showTrends,
refresh: refresh ?? this.refresh,
);
}
class RefreshSettings {
final int salesIntervalSec;
final int healthIntervalSec;
const RefreshSettings({
this.salesIntervalSec = 30,
this.healthIntervalSec = 10,
});
RefreshSettings copyWith({
int? salesIntervalSec,
int? healthIntervalSec,
}) =>
RefreshSettings(
salesIntervalSec: salesIntervalSec ?? this.salesIntervalSec,
healthIntervalSec: healthIntervalSec ?? this.healthIntervalSec,
);
}
// --- Simulated APIs ---
final _rng = Random();
class DashboardApi {
static Future<SalesData> fetchSales(String dateRange) async {
await Future.delayed(const Duration(milliseconds: 400));
final baseRevenue = 12500.0 + _rng.nextDouble() * 500;
return SalesData(
revenue: baseRevenue,
orders: 340 + _rng.nextInt(20),
visitors: 8200 + _rng.nextInt(300),
conversionRate: 3.8 + _rng.nextDouble() * 0.5,
daily: List.generate(7, (i) => DailySales(
date: 'Day ${i + 1}',
revenue: 1500 + _rng.nextDouble() * 500,
orders: 40 + _rng.nextInt(15),
)),
);
}
static Future<SystemHealth> fetchHealth() async {
await Future.delayed(const Duration(milliseconds: 200));
return SystemHealth(
cpuUsage: 30 + _rng.nextDouble() * 40,
memoryUsage: 50 + _rng.nextDouble() * 25,
activeConnections: 120 + _rng.nextInt(50),
errorCount: _rng.nextInt(5),
uptime: 99.9 + _rng.nextDouble() * 0.09,
);
}
}
// --- Dashboard Config with Lenses ---
final configReacton = reacton(const DashboardConfig(), name: 'config');
/// Lens into the dateRange field of the config.
final dateRangeLens = lens<DashboardConfig, String>(
configReacton,
get: (config) => config.dateRange,
set: (config, dateRange) => config.copyWith(dateRange: dateRange),
name: 'config.dateRange',
);
/// Lens into the currency field.
final currencyLens = lens<DashboardConfig, String>(
configReacton,
get: (config) => config.currency,
set: (config, currency) => config.copyWith(currency: currency),
name: 'config.currency',
);
/// Lens into the showTrends flag.
final showTrendsLens = lens<DashboardConfig, bool>(
configReacton,
get: (config) => config.showTrends,
set: (config, show) => config.copyWith(showTrends: show),
name: 'config.showTrends',
);
/// Lens into the nested refresh settings.
final refreshSettingsLens = lens<DashboardConfig, RefreshSettings>(
configReacton,
get: (config) => config.refresh,
set: (config, refresh) => config.copyWith(refresh: refresh),
name: 'config.refresh',
);
/// Nested lens: sales interval within refresh settings.
final salesIntervalLens = lens<RefreshSettings, int>(
refreshSettingsLens,
get: (r) => r.salesIntervalSec,
set: (r, v) => r.copyWith(salesIntervalSec: v),
name: 'config.refresh.salesInterval',
);
// --- Query Reactons with Polling ---
final salesQuery = reactonQuery<SalesData>(
queryFn: (read) => DashboardApi.fetchSales(read(dateRangeLens)),
config: QueryConfig(
staleTime: const Duration(seconds: 15),
cacheTime: const Duration(minutes: 5),
pollInterval: const Duration(seconds: 30),
retryPolicy: RetryPolicy(maxAttempts: 3),
),
name: 'salesQuery',
);
final healthQuery = reactonQuery<SystemHealth>(
queryFn: (_) => DashboardApi.fetchHealth(),
config: QueryConfig(
staleTime: const Duration(seconds: 5),
cacheTime: const Duration(minutes: 2),
pollInterval: const Duration(seconds: 10),
),
name: 'healthQuery',
);
// --- Selectors (widget-level optimization) ---
/// Selector: extract only the revenue from the sales query.
final revenueSelector = selector<AsyncValue<SalesData>, double?>(
salesQuery,
(state) => state.when(
loading: () => null,
data: (data) => data.revenue,
error: (_, __) => null,
),
name: 'revenueSelector',
);
/// Selector: extract only the order count.
final ordersSelector = selector<AsyncValue<SalesData>, int?>(
salesQuery,
(state) => state.when(
loading: () => null,
data: (data) => data.orders,
error: (_, __) => null,
),
name: 'ordersSelector',
);
/// Selector: extract CPU usage from health.
final cpuSelector = selector<AsyncValue<SystemHealth>, double?>(
healthQuery,
(state) => state.when(
loading: () => null,
data: (data) => data.cpuUsage,
error: (_, __) => null,
),
name: 'cpuSelector',
);
/// Selector: extract memory usage from health.
final memorySelector = selector<AsyncValue<SystemHealth>, double?>(
healthQuery,
(state) => state.when(
loading: () => null,
data: (data) => data.memoryUsage,
error: (_, __) => null,
),
name: 'memorySelector',
);
// --- Computed Aggregations ---
/// Average daily revenue from the sales data.
final avgDailyRevenue = computed<double?>((read) {
final state = read(salesQuery);
return state.when(
loading: () => null,
data: (data) {
if (data.daily.isEmpty) return 0.0;
final total = data.daily.fold(0.0, (sum, d) => sum + d.revenue);
return total / data.daily.length;
},
error: (_, __) => null,
);
}, name: 'avgDailyRevenue');
/// Revenue trend: positive means growing, negative means declining.
final revenueTrend = computed<double?>((read) {
final state = read(salesQuery);
return state.when(
loading: () => null,
data: (data) {
if (data.daily.length < 2) return 0.0;
final firstHalf = data.daily.sublist(0, data.daily.length ~/ 2);
final secondHalf = data.daily.sublist(data.daily.length ~/ 2);
final firstAvg =
firstHalf.fold(0.0, (s, d) => s + d.revenue) / firstHalf.length;
final secondAvg =
secondHalf.fold(0.0, (s, d) => s + d.revenue) / secondHalf.length;
return ((secondAvg - firstAvg) / firstAvg) * 100;
},
error: (_, __) => null,
);
}, name: 'revenueTrend');
/// System health score: weighted average of CPU, memory, uptime.
final healthScore = computed<double?>((read) {
final state = read(healthQuery);
return state.when(
loading: () => null,
data: (data) {
// Lower CPU and memory usage is better; higher uptime is better
final cpuScore = (100 - data.cpuUsage) / 100;
final memScore = (100 - data.memoryUsage) / 100;
final uptimeScore = data.uptime / 100;
return (cpuScore * 0.3 + memScore * 0.3 + uptimeScore * 0.4) * 100;
},
error: (_, __) => null,
);
}, name: 'healthScore');
// --- App ---
void main() => runApp(ReactonScope(child: const DashboardApp()));
class DashboardApp extends StatelessWidget {
const DashboardApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Analytics Dashboard',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
home: const DashboardPage(),
);
}
}
class DashboardPage extends StatelessWidget {
const DashboardPage({super.key});
@override
Widget build(BuildContext context) {
final dateRange = context.watch(dateRangeLens);
final showTrends = context.watch(showTrendsLens);
return Scaffold(
appBar: AppBar(
title: const Text('Analytics'),
actions: [
// Date range selector
SegmentedButton<String>(
segments: const [
ButtonSegment(value: '7d', label: Text('7D')),
ButtonSegment(value: '30d', label: Text('30D')),
ButtonSegment(value: '90d', label: Text('90D')),
],
selected: {dateRange},
onSelectionChanged: (v) =>
context.set(dateRangeLens, v.first),
),
const SizedBox(width: 8),
// Trends toggle
IconButton(
icon: Icon(showTrends
? Icons.trending_up
: Icons.trending_flat),
tooltip: 'Toggle trends',
onPressed: () =>
context.set(showTrendsLens, !showTrends),
),
// Settings
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => _showSettings(context),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// KPI cards row
Text('Key Metrics',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
const Row(
children: [
Expanded(child: _RevenueCard()),
SizedBox(width: 12),
Expanded(child: _OrdersCard()),
],
),
const SizedBox(height: 12),
const Row(
children: [
Expanded(child: _CpuCard()),
SizedBox(width: 12),
Expanded(child: _MemoryCard()),
],
),
const SizedBox(height: 24),
// Aggregations section
Text('Aggregations',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
const _AggregationsPanel(),
const SizedBox(height: 24),
// Daily breakdown
Text('Daily Breakdown',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
const _DailyBreakdownTable(),
],
),
),
);
}
void _showSettings(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (ctx) => const _SettingsSheet(),
);
}
}
// --- KPI Cards (each uses a selector for minimal rebuilds) ---
class _RevenueCard extends StatelessWidget {
const _RevenueCard();
@override
Widget build(BuildContext context) {
final revenue = context.watch(revenueSelector);
final trend = context.watch(revenueTrend);
final showTrends = context.watch(showTrendsLens);
final currency = context.watch(currencyLens);
return _KpiCard(
title: 'Revenue',
value: revenue != null
? '$currency ${revenue.toStringAsFixed(0)}'
: 'Loading...',
trend: showTrends ? trend : null,
icon: Icons.attach_money,
color: Colors.green,
);
}
}
class _OrdersCard extends StatelessWidget {
const _OrdersCard();
@override
Widget build(BuildContext context) {
final orders = context.watch(ordersSelector);
return _KpiCard(
title: 'Orders',
value: orders?.toString() ?? 'Loading...',
icon: Icons.shopping_bag_outlined,
color: Colors.blue,
);
}
}
class _CpuCard extends StatelessWidget {
const _CpuCard();
@override
Widget build(BuildContext context) {
final cpu = context.watch(cpuSelector);
return _KpiCard(
title: 'CPU',
value: cpu != null ? '${cpu.toStringAsFixed(1)}%' : 'Loading...',
icon: Icons.memory,
color: cpu != null && cpu > 80 ? Colors.red : Colors.orange,
);
}
}
class _MemoryCard extends StatelessWidget {
const _MemoryCard();
@override
Widget build(BuildContext context) {
final memory = context.watch(memorySelector);
return _KpiCard(
title: 'Memory',
value: memory != null ? '${memory.toStringAsFixed(1)}%' : 'Loading...',
icon: Icons.storage,
color: memory != null && memory > 85 ? Colors.red : Colors.purple,
);
}
}
class _KpiCard extends StatelessWidget {
final String title;
final String value;
final double? trend;
final IconData icon;
final Color color;
const _KpiCard({
required this.title,
required this.value,
this.trend,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 8),
Text(title, style: Theme.of(context).textTheme.labelLarge),
],
),
const SizedBox(height: 12),
Text(value, style: Theme.of(context).textTheme.headlineSmall),
if (trend != null) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(
trend! >= 0 ? Icons.trending_up : Icons.trending_down,
size: 16,
color: trend! >= 0 ? Colors.green : Colors.red,
),
const SizedBox(width: 4),
Text(
'${trend! >= 0 ? '+' : ''}${trend!.toStringAsFixed(1)}%',
style: TextStyle(
color: trend! >= 0 ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
),
),
],
),
],
],
),
),
);
}
}
// --- Aggregations Panel ---
class _AggregationsPanel extends StatelessWidget {
const _AggregationsPanel();
@override
Widget build(BuildContext context) {
final avgRevenue = context.watch(avgDailyRevenue);
final score = context.watch(healthScore);
final currency = context.watch(currencyLens);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
children: [
Text('Avg Daily Revenue',
style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 4),
Text(
avgRevenue != null
? '$currency ${avgRevenue.toStringAsFixed(0)}'
: '--',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
),
const VerticalDivider(),
Expanded(
child: Column(
children: [
Text('Health Score',
style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 4),
Text(
score != null ? '${score.toStringAsFixed(1)}%' : '--',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: score != null && score > 80
? Colors.green
: Colors.orange),
),
],
),
),
],
),
),
);
}
}
// --- Daily Breakdown Table ---
class _DailyBreakdownTable extends StatelessWidget {
const _DailyBreakdownTable();
@override
Widget build(BuildContext context) {
final salesState = context.watch(salesQuery);
final currency = context.watch(currencyLens);
return salesState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Text('Error: $e'),
data: (data) => Card(
child: DataTable(
columns: const [
DataColumn(label: Text('Day')),
DataColumn(label: Text('Revenue'), numeric: true),
DataColumn(label: Text('Orders'), numeric: true),
],
rows: data.daily.map((d) => DataRow(cells: [
DataCell(Text(d.date)),
DataCell(Text('$currency ${d.revenue.toStringAsFixed(0)}')),
DataCell(Text('${d.orders}')),
])).toList(),
),
),
);
}
}
// --- Settings Sheet (lens-driven config updates) ---
class _SettingsSheet extends StatelessWidget {
const _SettingsSheet();
@override
Widget build(BuildContext context) {
final currency = context.watch(currencyLens);
final salesInterval = context.watch(salesIntervalLens);
final showTrends = context.watch(showTrendsLens);
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Dashboard Settings',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
// Currency
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Currency'),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'USD', label: Text('\$')),
ButtonSegment(value: 'EUR', label: Text('\u20AC')),
ButtonSegment(value: 'GBP', label: Text('\u00A3')),
],
selected: {currency},
onSelectionChanged: (v) =>
context.set(currencyLens, v.first),
),
],
),
const SizedBox(height: 12),
// Show trends
SwitchListTile(
title: const Text('Show trend indicators'),
value: showTrends,
onChanged: (v) => context.set(showTrendsLens, v),
contentPadding: EdgeInsets.zero,
),
// Sales poll interval
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Sales refresh (sec)'),
DropdownButton<int>(
value: salesInterval,
items: [10, 15, 30, 60]
.map((v) => DropdownMenuItem(
value: v, child: Text('${v}s')))
.toList(),
onChanged: (v) {
if (v != null) context.set(salesIntervalLens, v);
},
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('Done'),
),
),
],
),
);
}
}Walkthrough
Data Models
SalesData holds aggregate metrics and a list of DailySales records. SystemHealth captures infrastructure metrics. DashboardConfig is a deeply nested immutable config object with RefreshSettings inside it.
Lenses for Deep Config Updates
Lenses provide bidirectional access into nested structures:
final configReacton = reacton(const DashboardConfig(), name: 'config');
final dateRangeLens = lens<DashboardConfig, String>(
configReacton,
get: (config) => config.dateRange,
set: (config, dateRange) => config.copyWith(dateRange: dateRange),
name: 'config.dateRange',
);Lenses compose. The salesIntervalLens focuses through two levels of nesting:
final refreshSettingsLens = lens<DashboardConfig, RefreshSettings>(
configReacton,
get: (config) => config.refresh,
set: (config, refresh) => config.copyWith(refresh: refresh),
);
final salesIntervalLens = lens<RefreshSettings, int>(
refreshSettingsLens,
get: (r) => r.salesIntervalSec,
set: (r, v) => r.copyWith(salesIntervalSec: v),
);In the UI, updating a nested field is a single context.set call:
context.set(salesIntervalLens, 15);
// Equivalent to: config.copyWith(refresh: config.refresh.copyWith(salesIntervalSec: 15))Query Reactons with Polling
final salesQuery = reactonQuery<SalesData>(
queryFn: (read) => DashboardApi.fetchSales(read(dateRangeLens)),
config: QueryConfig(
staleTime: const Duration(seconds: 15),
cacheTime: const Duration(minutes: 5),
pollInterval: const Duration(seconds: 30),
),
name: 'salesQuery',
);pollInterval causes the query to refetch automatically on a timer. The queryFn reads dateRangeLens, so changing the date range also triggers an immediate refetch. Both the polling and the reactive dependency work together.
Selectors for Widget-Level Optimization
final revenueSelector = selector<AsyncValue<SalesData>, double?>(
salesQuery,
(state) => state.when(
loading: () => null,
data: (data) => data.revenue,
error: (_, __) => null,
),
name: 'revenueSelector',
);A selector watches a source reacton but only notifies its subscribers when the extracted sub-value changes. The _RevenueCard widget watches revenueSelector, so it only rebuilds when the revenue number changes -- not when orders, visitors, or daily data change. On a dashboard with many widgets sharing the same data source, this prevents unnecessary rebuilds.
Computed Aggregations
final avgDailyRevenue = computed<double?>((read) {
final state = read(salesQuery);
return state.when(
loading: () => null,
data: (data) {
final total = data.daily.fold(0.0, (sum, d) => sum + d.revenue);
return total / data.daily.length;
},
error: (_, __) => null,
);
}, name: 'avgDailyRevenue');The revenueTrend computed reacton compares the first and second halves of the daily data to determine whether revenue is trending up or down. The healthScore combines CPU, memory, and uptime into a weighted composite score. All three recompute automatically when their source queries poll new data.
KPI Cards with Selectors
Each card is a separate StatelessWidget that watches only its specific selector:
class _RevenueCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final revenue = context.watch(revenueSelector);
final trend = context.watch(revenueTrend);
final showTrends = context.watch(showTrendsLens);
final currency = context.watch(currencyLens);
// ...
}
}This means the revenue card rebuilds only when revenue, trend, currency, or the trends toggle changes. It does not rebuild when CPU usage or memory data arrives from the health query.
Settings Sheet with Lenses
The settings sheet reads and writes config values through lenses:
SegmentedButton<String>(
selected: {currency},
onSelectionChanged: (v) => context.set(currencyLens, v.first),
),
SwitchListTile(
value: showTrends,
onChanged: (v) => context.set(showTrendsLens, v),
),Each context.set(lens, value) call updates the nested field inside the DashboardConfig object immutably. The dashboard immediately reflects the change because widgets watch the same lenses.
Key Takeaways
- Selectors prevent unnecessary widget rebuilds -- Each KPI card watches only the specific value it displays, not the entire query response. On a data-dense dashboard, this is a significant performance win.
- Lenses simplify deeply nested config updates -- Instead of manually constructing
copyWithchains, a lens provides a singlesetcall for any depth of nesting. - Query polling keeps data fresh automatically --
pollIntervalinQueryConfigtriggers periodic refetches without manual timers or effects. - Computed aggregations derive insights from raw data -- Averages, trends, and composite scores are expressed declaratively and update automatically when source data changes.
- Lenses compose for arbitrary depth --
salesIntervalLensfocuses throughconfigReacton -> refreshSettingsLens -> salesIntervalSec, and each level is independently reusable.
What's Next
- Search with Debounce -- Query caching and debounced input
- Real-Time Chat -- Sagas for WebSocket orchestration
- Pagination -- Infinite scroll with query reactons