Build an analytics dashboard Component in FlutterFlow with KPI cards showing total users, revenue, and active sessions. Each card displays an icon, metric value, label, and a color-coded percentage change indicator — green arrow up or red arrow down. A trend line chart built with fl_chart visualizes data over time. All data comes from a pre-aggregated analytics_daily Firestore collection populated by a scheduled Cloud Function, so queries stay fast and cheap. A ChoiceChips date range filter lets users toggle between Today, Week, and Month views.
KPI dashboard with metric cards, trend chart, and date filtering
Most analytics dashboards in FlutterFlow fail because they try to aggregate raw event data in real-time — expensive Firestore reads, slow rendering, and unpredictable costs. This tutorial takes the production approach: a scheduled Cloud Function pre-computes daily totals into an analytics_daily collection. The FlutterFlow UI reads these lightweight summary documents. You will build reusable KPI metric card Components that accept parameters for label, current value, and previous value, then calculate and display percentage change with a green or red arrow. A custom fl_chart line chart shows the trend. A ChoiceChips filter switches between Today, Week, and Month date ranges, re-querying both the cards and the chart.
Prerequisites
- A FlutterFlow project with Firebase/Firestore connected
- Firebase Blaze plan enabled (Cloud Functions require Blaze)
- FlutterFlow Pro plan for Custom Widgets and Custom Functions
- Basic understanding of Backend Queries and Component Parameters
Step-by-step guide
Create the analytics_daily Firestore collection and Cloud Function aggregation pattern
Create the analytics_daily Firestore collection and Cloud Function aggregation pattern
In Firestore, create a collection called analytics_daily. Each document represents one day's aggregated metrics. Document ID format: YYYY-MM-DD (e.g., 2026-03-29). Fields: date (Timestamp), totalUsers (Integer — unique users that day), newUsers (Integer — first-time users), revenue (Double — total revenue in dollars), activeSessions (Integer — sessions with at least one interaction). Manually create 7-14 sample documents with realistic data for development. For production, deploy a scheduled Cloud Function (exports.aggregateDaily = functions.pubsub.schedule('every 24 hours').onRun()) that queries your raw events collection, computes the five metrics, and writes one analytics_daily document. This pattern keeps your dashboard queries to a single document read per day instead of scanning thousands of raw events.
Expected result: The analytics_daily collection exists in Firestore with sample documents. Each document has date, totalUsers, newUsers, revenue, and activeSessions fields with realistic test data.
Build the KPI metric card Component with percentage change indicator
Build the KPI metric card Component with percentage change indicator
Create a new Component called KPIMetricCard. Add four Component Parameters: label (String), currentValue (Double), previousValue (Double), and iconName (String). Layout: outer Container with 16px padding, white background, 12px borderRadius, and a subtle boxShadow. Inside, a Column with crossAxisAlignment start. First child: an Icon widget bound to iconName parameter (use Conditional Value to map strings to Icons — 'users' to Icons.people, 'revenue' to Icons.attach_money, 'sessions' to Icons.trending_up). Second child: a Text widget showing currentValue formatted appropriately (use Custom Function formatMetric that returns '1.2K' for thousands, '$12,450' for revenue). Set style to headlineLarge, fontWeight bold. Third child: a Text widget showing the label parameter in bodyMedium, secondary color. Fourth child: a Row with an Icon (Icons.arrow_upward in green or Icons.arrow_downward in red — Conditional Value based on whether percentage is positive) and a Text widget showing the percentage. Create a Custom Function called calcPercentChange with parameters current (Double) and previous (Double) that returns ((current - previous) / previous * 100).toStringAsFixed(1). Use Conditional Styles: green (#4CAF50) text when positive, red (#F44336) when negative.
1// Custom Function: calcPercentChange2// Return Type: String3// Parameters: current (Double), previous (Double)45String calcPercentChange(double current, double previous) {6 if (previous == 0) return '0.0';7 final change = ((current - previous) / previous) * 100;8 return change.toStringAsFixed(1);9}1011// Custom Function: formatMetric12// Return Type: String13// Parameters: value (Double), prefix (String, optional)1415String formatMetric(double value, {String prefix = ''}) {16 if (value >= 1000000) {17 return '$prefix${(value / 1000000).toStringAsFixed(1)}M';18 } else if (value >= 1000) {19 return '$prefix${(value / 1000).toStringAsFixed(1)}K';20 }21 return '$prefix${value.toStringAsFixed(0)}';22}Expected result: A reusable KPIMetricCard Component that accepts label, currentValue, previousValue, and iconName. It displays the formatted value, label, and a green up-arrow or red down-arrow with percentage change.
Place KPI cards in a 2-column GridView on the dashboard page
Place KPI cards in a 2-column GridView on the dashboard page
Create a new page called AnalyticsDashboard. Add a Backend Query at the page level: query analytics_daily, ordered by date descending, limit 2 (today + yesterday for comparison). Bind the query result to Page State variable analyticsData. Below the AppBar, add a Padding (16px all sides) wrapping a GridView with crossAxisCount: 2, crossAxisSpacing: 12, mainAxisSpacing: 12, and childAspectRatio: 1.4. Place four KPIMetricCard Components inside the GridView. Bind each card's parameters: first card — label: 'Total Users', currentValue: analyticsData[0].totalUsers, previousValue: analyticsData[1].totalUsers, iconName: 'users'. Second card — label: 'Revenue', currentValue: analyticsData[0].revenue, previousValue: analyticsData[1].revenue, iconName: 'revenue'. Third card — label: 'New Users', currentValue: analyticsData[0].newUsers, previousValue: analyticsData[1].newUsers, iconName: 'users'. Fourth card — label: 'Sessions', currentValue: analyticsData[0].activeSessions, previousValue: analyticsData[1].activeSessions, iconName: 'sessions'.
Expected result: The dashboard page shows four KPI cards in a 2-column grid. Each card displays a metric from today's analytics_daily document with percentage change compared to yesterday.
Build a trend line chart Custom Widget using fl_chart LineChart
Build a trend line chart Custom Widget using fl_chart LineChart
Add fl_chart to your project dependencies (pubspec.yaml via Custom Code settings: fl_chart: ^0.68.0). Create a Custom Widget called TrendLineChart with parameters: dataPoints (List<JSON> — each item has 'x' as double index and 'y' as double value), lineColor (Color, default blue), and chartHeight (Double, default 200). In the widget build method, return a Container with a FIXED height set to chartHeight (critical — fl_chart crashes without a bounded height). Inside, place a LineChart widget. Configure LineChartData: gridData with show: false, titlesData showing only bottom axis labels (dates) and left axis values, borderData with show: false. Create one LineChartBarData with spots mapped from dataPoints as FlSpot(item['x'], item['y']), isCurved: true, color: lineColor, barWidth: 3, dotData: FlDotData(show: false), belowBarData: BarAreaData(show: true, color: lineColor.withOpacity(0.1)). Enable touch tooltips: lineTouchData with LineTouchTooltipData showing the y value on hover. On the dashboard page, add a Backend Query for analytics_daily ordered by date ascending for the selected range, map results to the dataPoints format using a Custom Function, and pass them to TrendLineChart.
1// Custom Widget: TrendLineChart2import 'package:fl_chart/fl_chart.dart';34class TrendLineChart extends StatelessWidget {5 final List<dynamic> dataPoints;6 final Color lineColor;7 final double chartHeight;89 const TrendLineChart({10 super.key,11 required this.dataPoints,12 this.lineColor = const Color(0xFF2196F3),13 this.chartHeight = 200,14 });1516 @override17 Widget build(BuildContext context) {18 final spots = dataPoints.asMap().entries.map((e) {19 return FlSpot(e.key.toDouble(), (e.value['y'] as num).toDouble());20 }).toList();2122 return SizedBox(23 height: chartHeight,24 child: LineChart(25 LineChartData(26 gridData: const FlGridData(show: false),27 titlesData: const FlTitlesData(28 topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),29 rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),30 bottomTitles: AxisTitles(31 sideTitles: SideTitles(showTitles: true, reservedSize: 30),32 ),33 leftTitles: AxisTitles(34 sideTitles: SideTitles(showTitles: true, reservedSize: 40),35 ),36 ),37 borderData: FlBorderData(show: false),38 lineTouchData: LineTouchData(39 touchTooltipData: LineTouchTooltipData(40 getTooltipItems: (spots) => spots.map((s) {41 return LineTooltipItem(42 s.y.toStringAsFixed(0),43 const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),44 );45 }).toList(),46 ),47 ),48 lineBarsData: [49 LineChartBarData(50 spots: spots,51 isCurved: true,52 color: lineColor,53 barWidth: 3,54 dotData: const FlDotData(show: false),55 belowBarData: BarAreaData(56 show: true,57 color: lineColor.withOpacity(0.1),58 ),59 ),60 ],61 ),62 ),63 );64 }65}Expected result: A smooth curved line chart renders on the dashboard below the KPI cards, showing the trend of a selected metric over the date range. Touch tooltips display exact values.
Add date range filter with ChoiceChips that re-queries KPI cards and chart
Add date range filter with ChoiceChips that re-queries KPI cards and chart
Above the KPI GridView, add a Row with a ChoiceChips widget. Options: 'Today', '7 Days', '30 Days'. Bind the selected value to a Page State variable called selectedRange (String, default '7 Days'). On the ChoiceChips On Changed action: first, update Page State selectedRange to the new value. Then use a Custom Action or conditional logic to compute the start date: 'Today' = start of today, '7 Days' = 7 days ago, '30 Days' = 30 days ago. Update a Page State variable filterStartDate (DateTime). Both the KPI card Backend Query and the chart Backend Query should use filterStartDate as the where clause: date >= filterStartDate. For KPI comparison: query the two most recent documents within the range for the card values, and query the document just before the range start for the previousValue. When selectedRange changes, both queries automatically re-execute because they reference the Page State variable. The chart query fetches all documents within the range ordered by date ascending.
Expected result: Tapping a ChoiceChip (Today, 7 Days, 30 Days) immediately refreshes both the KPI metric cards and the trend line chart with data from the selected date range.
Complete working example
1Firestore Data Model:2└── analytics_daily/{YYYY-MM-DD}3 ├── date: Timestamp (2026-03-29)4 ├── totalUsers: Integer (1523)5 ├── newUsers: Integer (87)6 ├── revenue: Double (12450.00)7 └── activeSessions: Integer (892)89Cloud Function (scheduled daily):10 exports.aggregateDaily = functions.pubsub11 .schedule('every 24 hours').onRun(async () => {12 const today = new Date(); today.setHours(0,0,0,0);13 const tomorrow = new Date(today); tomorrow.setDate(today.getDate()+1);14 const events = await db.collection('raw_events')15 .where('timestamp','>=',today).where('timestamp','<',tomorrow).get();16 const uniqueUsers = new Set(events.docs.map(d => d.data().userId)).size;17 const newUsers = /* count first-seen users */;18 const revenue = events.docs.reduce((s,d) => s + (d.data().amount||0), 0);19 const sessions = events.docs.filter(d => d.data().type==='session').length;20 await db.collection('analytics_daily').doc(formatDate(today)).set({21 date: admin.firestore.Timestamp.fromDate(today),22 totalUsers: uniqueUsers, newUsers, revenue, activeSessions: sessions23 });24 });2526Custom Functions:27 calcPercentChange(current, previous) → String percentage28 formatMetric(value, prefix) → String formatted number2930Dashboard Page:31├── AppBar (title: "Analytics")32├── Padding (16px)33│ ├── ChoiceChips [Today | 7 Days | 30 Days]34│ │ └── On Changed → Update Page State: selectedRange, filterStartDate35│ ├── SizedBox (h: 16)36│ ├── GridView (crossAxisCount: 2, spacing: 12)37│ │ ├── KPIMetricCard (label: Total Users, icon: people)38│ │ ├── KPIMetricCard (label: Revenue, icon: attach_money)39│ │ ├── KPIMetricCard (label: New Users, icon: person_add)40│ │ └── KPIMetricCard (label: Sessions, icon: trending_up)41│ ├── SizedBox (h: 24)42│ └── TrendLineChart (Custom Widget, fl_chart)43│ └── SizedBox height: 200 (REQUIRED — fl_chart needs bounded height)44└── Backend Queries:45 ├── KPI Query: analytics_daily, orderBy date DESC, limit 2, where date >= filterStartDate46 └── Chart Query: analytics_daily, orderBy date ASC, where date >= filterStartDate4748KPIMetricCard Component:49├── Parameters: label (String), currentValue (Double), previousValue (Double), iconName (String)50└── Container (white, rounded, shadow)51 └── Column52 ├── Icon (mapped from iconName)53 ├── Text (formatMetric(currentValue), headlineLarge, bold)54 ├── Text (label, bodyMedium, secondary)55 └── Row56 ├── Icon (arrow_upward green | arrow_downward red)57 └── Text (calcPercentChange(current, previous) + '%', green | red)Common mistakes when creating a Custom Analytics Widget for Your FlutterFlow App
Why it's a problem: Running aggregation queries across raw event data in real-time
How to avoid: Pre-aggregate with a scheduled Cloud Function that writes one analytics_daily document per day. Dashboard queries read 7-30 small documents instead of thousands of raw events.
Why it's a problem: Not setting a fixed height on the fl_chart container
How to avoid: Always wrap the LineChart Custom Widget in a SizedBox or Container with an explicit height value (e.g., 200). Never rely on Expanded or flexible sizing alone for the chart container.
Why it's a problem: Showing stale KPI data after changing the date filter
How to avoid: Ensure both the chart Backend Query and the KPI card Backend Query use filterStartDate in their where clause. When the Page State variable changes, both queries re-execute automatically.
Best practices
- Pre-aggregate analytics data with Cloud Functions instead of computing metrics from raw events at query time
- Use Component Parameters for all dynamic values in KPIMetricCard so it is fully reusable across different metrics
- Always set a fixed height on fl_chart containers — use SizedBox with an explicit height, never unbounded constraints
- Format large numbers with K/M suffixes (1,523 → '1.5K') using a Custom Function for readability on small cards
- Cache the previous period value alongside the current period in your query to avoid a separate Firestore read for comparison data
- Use Conditional Styles (green/red) for percentage change indicators rather than separate widgets to keep the widget tree clean
- Test the dashboard with empty analytics_daily data and add an empty state Component so new apps show a helpful message instead of a blank screen
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Design a Firestore data model for a pre-aggregated analytics dashboard. I need a collection called analytics_daily where each document has date, totalUsers, newUsers, revenue, and activeSessions. Give me a scheduled Cloud Function that aggregates raw event data into this collection daily, and a Dart function that calculates percentage change between two values.
Create a dashboard page with a 2-column GridView containing four KPI metric cards. Each card should show an icon, a large number, a label, and a percentage change with a green up-arrow or red down-arrow. Below the grid, add a line chart showing a trend over 7 days. Add ChoiceChips at the top for Today, 7 Days, and 30 Days date filtering.
Frequently asked questions
How often should the Cloud Function aggregate analytics data?
For most apps, once daily (scheduled at midnight UTC) is sufficient. If your dashboard needs fresher data, run the function hourly. Avoid running more frequently than hourly — the cost savings of pre-aggregation diminish if you re-aggregate constantly. Store a lastAggregated timestamp in each document so the dashboard can show data freshness.
Can I use Supabase instead of Firestore for the analytics backend?
Yes, and SQL-based aggregation is actually easier for analytics. Use a Supabase SQL view or database function with GROUP BY date to compute daily totals. Query the view directly from FlutterFlow with a Supabase Backend Query. You skip the Cloud Function entirely because PostgreSQL handles aggregation natively.
How do I calculate percentage change between periods?
Create a Custom Function with parameters current (Double) and previous (Double). Formula: ((current - previous) / previous) * 100. Handle the edge case where previous is zero to avoid division by zero — return 0 or 'N/A'. Display the result with toStringAsFixed(1) for one decimal place (e.g., '+12.3%').
Why does my fl_chart LineChart show a blank space or crash?
fl_chart requires a bounded height constraint. If the chart is inside a Column or ListView without a fixed-height wrapper, Flutter cannot determine the chart dimensions and either renders nothing or throws a RenderBox layout error. Wrap your TrendLineChart Custom Widget in a SizedBox with an explicit height (e.g., 200).
How do I add more metrics to the dashboard later?
Add new fields to the analytics_daily documents (e.g., churnRate, avgSessionDuration) and update the Cloud Function to compute them. Then drop another KPIMetricCard Component into the GridView with the new field bindings. Because the card is parameterized, no new Component is needed — just a new instance with different parameters.
Can RapidDev help build a production analytics dashboard?
Yes. A production analytics dashboard often requires custom Cloud Functions for multi-source aggregation, real-time streaming for live metrics, role-based access for team dashboards, and export functionality. RapidDev can architect the full data pipeline and FlutterFlow UI beyond what the visual builder handles alone.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation