# How to Create Custom Analytics in FlutterFlow

- Tool: FlutterFlow
- Difficulty: Beginner
- Time required: 35-45 min
- Compatibility: FlutterFlow Pro+ (fl_chart Custom Widget and Cloud Functions required)
- Last updated: March 2026

## TL;DR

Custom analytics in FlutterFlow means building your own dashboard — not relying on third-party analytics tools. Use Firestore to store pre-aggregated metrics (daily totals, not raw event documents), fl_chart Custom Widgets for LineChart, BarChart, and PieChart visualizations, ChoiceChips for date range filters, and a Cloud Function to compute aggregates on a schedule so the dashboard loads instantly.

## Building an Analytics Dashboard That Scales

Many FlutterFlow developers make the mistake of loading raw event documents (page views, purchases, sign-ups) and aggregating them in the app on every dashboard load. With 50,000+ events, this becomes impossibly slow and expensive. The professional approach is to pre-aggregate: a Cloud Function runs nightly and writes daily totals to a small 'metrics' collection. The dashboard reads only the tiny metrics documents — never the raw events. This makes dashboards load in under one second regardless of event volume.

## Before you start

- A FlutterFlow project with Firestore connected and existing event or transaction data
- Firebase project on the Blaze plan (Cloud Functions required for pre-aggregation)
- fl_chart package familiarity is helpful but not required
- Basic understanding of FlutterFlow Custom Widgets and Backend Queries

## Step-by-step guide

### 1. Design the Pre-Aggregated Metrics Firestore Schema

Instead of querying thousands of raw event documents, store daily totals in a 'metrics' collection. Each document has a date-based ID (e.g., '2026-03-29') and fields for each metric: 'totalOrders' (Number), 'totalRevenue' (Number), 'newUsers' (Number), 'pageViews' (Number), 'conversionRate' (Number). Create a second collection 'metrics_summary' with a single 'current' document holding rolling totals: 'last7DaysRevenue', 'last30DaysRevenue', 'totalUsers', 'activeUsersToday'. In FlutterFlow's Firestore schema editor, add both collections and their fields. Your dashboard pages will only ever read from these two tiny collections — never from the raw events.

**Expected result:** The Firestore schema shows 'metrics' (daily docs) and 'metrics_summary' (rolling totals) collections. The Firebase Console shows sample data in these collections.

### 2. Build KPI Cards Bound to Firestore Metrics

In FlutterFlow, create a KPI card Component. It should have a title Text widget, a value Text widget, a change percentage Text widget (e.g., '+12% vs last week'), and a colored icon. Define Component Parameters: 'title' (String), 'value' (String), 'changePercent' (double), and 'changePositive' (boolean). Bind the change text's color to a conditional: if changePositive is true, use green; if false, use red. On your analytics dashboard page, add a Backend Query → Firestore Collection → 'metrics_summary', query type: Single Document, document ID: 'current'. Drag four KPI card Component instances onto a 2x2 grid and bind each card's parameters to fields from the query result.

**Expected result:** Four KPI cards on the dashboard show live values from Firestore's metrics_summary document. Editing the document in Firebase Console updates the cards within 1-2 seconds.

### 3. Create the LineChart Custom Widget with fl_chart

Add the 'fl_chart' package in Settings → Pubspec Dependencies. Create a Custom Widget named 'RevenueLineChart'. Define parameters: 'dataPoints' (List of double, the daily revenue values), 'labels' (List of String, the day labels), 'lineColor' (Color). Inside the widget, build a LineChart from fl_chart using LineChartData with a single LineChartBarData. Map the dataPoints list to FlSpot objects (index as x, value as y). Configure titlesData with bottom titles from the labels list and left titles showing formatted currency values. Style with gridData, borderData, and lineTouchData for interactive tooltips. On the dashboard page, add a Backend Query that fetches the last 7 or 30 days of daily metrics documents (ordered by document ID descending), extract the totalRevenue values into a list, and pass to the chart widget.

```
// Custom Widget: RevenueLineChart
// Parameters: dataPoints (List<double>), labels (List<String>), lineColor (Color)
// Package: fl_chart

class RevenueLineChart extends StatelessWidget {
  final List<double> dataPoints;
  final List<String> labels;
  final Color lineColor;
  final double height;

  const RevenueLineChart({
    Key? key,
    required this.dataPoints,
    required this.labels,
    required this.lineColor,
    this.height = 200.0,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    if (dataPoints.isEmpty) {
      return SizedBox(
        height: height,
        child: const Center(child: Text('No data available')),
      );
    }

    final spots = dataPoints
        .asMap()
        .entries
        .map((e) => FlSpot(e.key.toDouble(), e.value))
        .toList();

    final maxY = dataPoints.reduce((a, b) => a > b ? a : b) * 1.2;

    return SizedBox(
      height: height,
      child: LineChart(
        LineChartData(
          gridData: FlGridData(
            show: true,
            drawVerticalLine: false,
            getDrawingHorizontalLine: (value) => FlLine(
              color: Colors.grey.withOpacity(0.2),
              strokeWidth: 1,
            ),
          ),
          titlesData: FlTitlesData(
            leftTitles: AxisTitles(
              sideTitles: SideTitles(
                showTitles: true,
                reservedSize: 50,
                getTitlesWidget: (value, meta) => Text(
                  '\$${value.toStringAsFixed(0)}',
                  style: const TextStyle(fontSize: 10),
                ),
              ),
            ),
            bottomTitles: AxisTitles(
              sideTitles: SideTitles(
                showTitles: true,
                reservedSize: 22,
                getTitlesWidget: (value, meta) {
                  final idx = value.toInt();
                  if (idx < 0 || idx >= labels.length) return const SizedBox();
                  return Text(labels[idx],
                      style: const TextStyle(fontSize: 10));
                },
              ),
            ),
            topTitles: const AxisTitles(
                sideTitles: SideTitles(showTitles: false)),
            rightTitles: const AxisTitles(
                sideTitles: SideTitles(showTitles: false)),
          ),
          borderData: FlBorderData(show: false),
          minX: 0,
          maxX: (dataPoints.length - 1).toDouble(),
          minY: 0,
          maxY: maxY,
          lineBarsData: [
            LineChartBarData(
              spots: spots,
              isCurved: true,
              color: lineColor,
              barWidth: 2.5,
              dotData: const FlDotData(show: false),
              belowBarData: BarAreaData(
                show: true,
                color: lineColor.withOpacity(0.1),
              ),
            ),
          ],
          lineTouchData: LineTouchData(
            touchTooltipData: LineTouchTooltipData(
              getTooltipItems: (spots) => spots
                  .map((s) => LineTooltipItem(
                        '\$${s.y.toStringAsFixed(2)}',
                        const TextStyle(
                            color: Colors.white,
                            fontWeight: FontWeight.bold),
                      ))
                  .toList(),
            ),
          ),
        ),
      ),
    );
  }
}
```

**Expected result:** The dashboard shows a smooth line chart with daily revenue values. Tapping a data point shows a tooltip with the exact value.

### 4. Add Date Range Filter with ChoiceChips

A static dashboard with fixed data is not useful. Add a date range filter row at the top of the dashboard. In FlutterFlow, drag a Row widget. Inside, add three ChoiceChip widgets (or use a row of styled Button widgets): '7 Days', '30 Days', '90 Days'. Create a Page State variable 'selectedRange' (String, initial value '7'). Wire each chip's On Select action to Update Page State → set selectedRange to '7', '30', or '90'. Add a Condition to each chip's styling: if selectedRange equals this chip's value, show the active/selected style (filled background, white text); otherwise show the inactive style (outline only). Now wire your Backend Query on the metrics collection to a filter: document ID >= dateSubtract(today, selectedRange days). The chart and KPI cards update automatically when selectedRange changes.

**Expected result:** Tapping '30 Days' updates the Backend Query filter, and the chart animates to show 30 data points. The active chip has a filled style. KPI values update to reflect the selected range.

### 5. Deploy the Pre-Aggregation Cloud Function

This Cloud Function runs nightly (or on demand) to compute aggregate totals from your raw event documents and write them to the 'metrics' daily documents and 'metrics_summary' current document. It groups raw events by date, sums revenue and counts orders, and updates only the records that need updating. This runs once per day server-side — so no matter how many events you have, the dashboard always reads from the tiny pre-computed documents. Deploy via Firebase CLI. To trigger it manually during development: in Firebase Console → Functions → trigger manually. To update the summary stats (last 7/30 days), the function reads from the already-computed daily docs instead of raw events — extremely fast.

```
// Firebase Cloud Function: aggregateMetrics
// Scheduled: daily at midnight
// exports.aggregateMetrics = functions.pubsub
//   .schedule('0 0 * * *').onRun(async () => { ... })

const functions = require('firebase-functions');
const admin = require('firebase-admin');

exports.aggregateMetrics = functions.pubsub
  .schedule('0 0 * * *')
  .onRun(async () => {
    const db = admin.firestore();
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    const dateStr = today.toISOString().split('T')[0];

    // Aggregate raw events for today
    const eventsSnap = await db.collection('events')
      .where('createdAt', '>=', today)
      .get();

    let totalOrders = 0, totalRevenue = 0, newUsers = 0;
    eventsSnap.forEach(doc => {
      const d = doc.data();
      if (d.type === 'order_completed') {
        totalOrders++;
        totalRevenue += d.amount || 0;
      }
      if (d.type === 'user_signup') newUsers++;
    });

    // Write daily metrics document
    await db.collection('metrics').doc(dateStr).set({
      totalOrders, totalRevenue, newUsers,
      date: dateStr,
      updatedAt: admin.firestore.FieldValue.serverTimestamp(),
    }, { merge: true });

    // Update rolling summary
    const last30 = await db.collection('metrics')
      .orderBy('__name__', 'desc').limit(30).get();
    let last30Revenue = 0;
    last30.forEach(d => { last30Revenue += d.data().totalRevenue || 0; });

    await db.collection('metrics_summary').doc('current').set({
      last30DaysRevenue: last30Revenue,
      updatedAt: admin.firestore.FieldValue.serverTimestamp(),
    }, { merge: true });

    return null;
  });
```

**Expected result:** The Cloud Function runs daily and updates 'metrics' daily documents and 'metrics_summary' current document. Dashboard load time drops to under 1 second.

## Complete code example

File: `analytics_dashboard.dart`

```dart
// ============================================================
// FlutterFlow Custom Analytics — KPI Card Component
// ============================================================
// This is a reusable KPI card widget used on the dashboard
// Parameters: title, value, changePercent, isPositiveChange

class KpiCard extends StatelessWidget {
  final String title;
  final String value;
  final double changePercent;
  final bool isPositiveChange;
  final IconData icon;
  final Color accentColor;

  const KpiCard({
    Key? key,
    required this.title,
    required this.value,
    required this.changePercent,
    required this.isPositiveChange,
    required this.icon,
    required this.accentColor,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final changeColor = isPositiveChange ? Colors.green : Colors.red;
    final changePrefix = isPositiveChange ? '+' : '';

    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.06),
            blurRadius: 10,
            offset: const Offset(0, 2),
          ),
        ],
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text(title,
                  style: const TextStyle(
                      fontSize: 13,
                      color: Colors.grey,
                      fontWeight: FontWeight.w500)),
              Icon(icon, color: accentColor, size: 20),
            ],
          ),
          const SizedBox(height: 8),
          Text(value,
              style: const TextStyle(
                  fontSize: 24, fontWeight: FontWeight.bold)),
          const SizedBox(height: 4),
          Row(
            children: [
              Icon(
                isPositiveChange
                    ? Icons.trending_up
                    : Icons.trending_down,
                color: changeColor,
                size: 14,
              ),
              const SizedBox(width: 4),
              Text(
                '$changePrefix${changePercent.toStringAsFixed(1)}% vs last period',
                style: TextStyle(fontSize: 11, color: changeColor),
              ),
            ],
          ),
        ],
      ),
    );
  }
}
```

## Common mistakes

- **Running real-time aggregate queries on raw event documents (counting 50K+ docs on each dashboard load)** — Firestore charges per document read. A COUNT query on a collection with 50,000 documents reads 50,000 documents and bills for all of them — every time the dashboard page loads. With 100 users opening the dashboard 5 times a day, that is 25 million Firestore reads per day, potentially costing hundreds of dollars. Fix: Pre-aggregate using a nightly Cloud Function that writes daily totals to a 'metrics' collection. The dashboard only reads the tiny pre-computed metric documents — typically 7-30 documents for a date range query. Dashboard load time drops from 5-10 seconds to under 500 milliseconds.
- **Creating a new Backend Query for each KPI card instead of one shared query for the page** — If your dashboard has 8 KPI cards each with their own Firestore query, you make 8 separate Firestore reads on every page load. Each Firestore listener also creates a persistent WebSocket connection, multiplying connection overhead. Fix: Set one Backend Query on the page root widget that fetches the metrics_summary document. Pass its fields down to all KPI card Components via Component Parameters. One query, eight cards.
- **Passing empty lists to fl_chart without null checks** — fl_chart throws a RangeError if you pass an empty list as dataPoints or spots. When the Firestore Backend Query returns before data is available, the list is empty, crashing the chart widget and showing a red error box on the canvas. Fix: Add a null/empty check in the Custom Widget: if (dataPoints.isEmpty) { return a placeholder widget } before constructing the chart. Also add a loading state check on the page: show a loading shimmer while the Backend Query is still fetching.
- **Using the same date range filter ChoiceChip logic to also reload the Firestore backend query** — FlutterFlow's visual Backend Query filters are set at design time, not dynamically at runtime from Page State variables. Simply updating a Page State variable does not automatically change an active Firestore query filter. Fix: Use a Custom Action to re-query Firestore when the filter changes, passing the dynamic start date as a parameter. Or use Conditional Widgets: show different Backend Query widgets for each date range, and show/hide them based on selectedRange Page State.

## Best practices

- Pre-aggregate all metrics server-side — never compute totals by reading raw event documents in the client app.
- Use date-keyed document IDs (YYYY-MM-DD) in the metrics collection so date range queries become simple string comparisons.
- Show loading skeletons (shimmer placeholders) while Backend Queries are fetching — a blank dashboard looks broken.
- Limit date range options to reasonable periods (7/30/90 days) — all-time queries on large datasets will always be slow.
- Cache the last fetched metrics in App State so the dashboard shows data immediately on revisit while a fresh query runs in the background.
- Add refresh pull-to-refresh on the dashboard page so users can manually trigger a data refresh.
- Use fl_chart's animation duration parameter to animate charts when data changes — smooth transitions make dashboards feel more polished and data-driven.
- For multi-tenant apps (each business sees their own data), include a tenantId field in all metrics documents and filter all queries by it. Consider using RapidDev for help architecting multi-tenant analytics schemas on Firestore.

## Frequently asked questions

### Do I need Firebase Blaze plan for a custom analytics dashboard?

Only if you want pre-aggregation Cloud Functions, which require Blaze. The dashboard UI and Firestore reads work on the Spark (free) plan. However, without Cloud Functions, you would need to aggregate in the client app (slow, expensive), so Blaze is strongly recommended for any analytics dashboard with meaningful data volume.

### Can I use Google Analytics (GA4) instead of building custom analytics?

For standard user behavior tracking (screen views, events, conversions), GA4 is much easier and free. Custom analytics in Firestore is the right choice when you need business-specific metrics (revenue per product, orders per region, subscription churn rate) that GA4 does not provide out of the box, or when you want to display analytics to your own users inside the app.

### How do I update the dashboard without refreshing the page?

Use a Firestore real-time stream (Backend Query with Single Time Query disabled) on the metrics_summary document. FlutterFlow's real-time queries automatically re-render widgets when Firestore data changes. When the Cloud Function updates the metrics, the dashboard updates automatically within 1-2 seconds without any user action.

### How do I make the fl_chart widget responsive to different screen sizes?

Wrap the Custom Widget in a Container with no fixed width and use LayoutBuilder inside the widget to get the available width. Pass the width as a parameter to the chart so it can calculate correct axis spacing. Alternatively, use MediaQuery.of(context).size.width inside the widget to get screen dimensions.

### Can I export the dashboard data to CSV from FlutterFlow?

Yes, via a Custom Action. The action reads the metrics documents, formats them as CSV rows, and uses the 'share_plus' package to share or 'path_provider' + 'dart:io' to save the CSV file locally. You can then trigger the share sheet to let users export to their preferred app.

### What is the maximum number of data points fl_chart can render smoothly?

fl_chart renders up to around 500 data points smoothly on modern devices. Beyond 500, animation and touch performance degrade noticeably. For 365-day annual charts, consider rendering weekly aggregates (52 points) rather than daily points (365 points) for better performance.

---

Source: https://www.rapidevelopers.com/flutterflow-tutorials/how-can-i-create-custom-analytics-in-flutterflow
© RapidDev — https://www.rapidevelopers.com/flutterflow-tutorials/how-can-i-create-custom-analytics-in-flutterflow
