DEV Community

kanta13jp1
kanta13jp1

Posted on

AI Usage Quota Dashboard in Flutter + Supabase: Real-Time Token Tracking Across Models

AI Usage Quota Dashboard in Flutter + Supabase: Real-Time Token Tracking Across Models

The Problem

You're using multiple AI providers — Claude, Gemini, OpenAI. You have no idea how many tokens you've consumed today. Until you hit a limit and everything stops.

The fix: log token usage to a Supabase table after every AI call, and visualize it in a Flutter dashboard.


Schema: ai_quota_usage

CREATE TABLE ai_quota_usage (
  id           uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id      uuid        REFERENCES auth.users(id) ON DELETE CASCADE,
  model        text        NOT NULL,
  -- e.g. 'claude-sonnet-4-6', 'gemini-2.0-flash', 'gpt-4o'
  input_tokens  int        NOT NULL DEFAULT 0,
  output_tokens int        NOT NULL DEFAULT 0,
  total_tokens  int        GENERATED ALWAYS AS (input_tokens + output_tokens) STORED,
  feature       text,
  -- e.g. 'ai-university', 'daily-judgment', 'horse-racing'
  created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX ON ai_quota_usage (user_id, created_at DESC);
CREATE INDEX ON ai_quota_usage (model, created_at DESC);
Enter fullscreen mode Exit fullscreen mode

total_tokens is a GENERATED ALWAYS AS column — the DB computes it, not your app. Index on (user_id, created_at DESC) makes daily range queries fast.


Recording Usage: Fire-and-Forget

After every AI call, record usage without blocking the response:

// In your Edge Function, after getting the AI response:
const usage = aiResponse.usage;

// Fire-and-forget — don't await, don't crash if it fails
supabase.from('ai_quota_usage').insert({
  model: 'claude-sonnet-4-6',
  input_tokens: usage.input_tokens,
  output_tokens: usage.output_tokens,
  feature: 'ai-university',
}).then(() => {}).catch(() => {});
Enter fullscreen mode Exit fullscreen mode

This keeps the AI response latency clean. If the logging fails (network blip, DB hiccup), the AI call still succeeds.


Flutter: Dashboard Page

class _QuotaDashboardPageState extends State<QuotaDashboardPage> {
  List<Map<String, dynamic>> _modelStats = [];
  int _totalTokensToday = 0;
  bool _loading = true;

  Future<void> _loadQuota() async {
    final today = DateTime.now().toIso8601String().substring(0, 10);

    final response = await Supabase.instance.client
        .from('ai_quota_usage')
        .select('model, input_tokens, output_tokens')
        .gte('created_at', today);

    // Aggregate client-side (small dataset, < 1000 rows/day)
    final stats = <String, Map<String, int>>{};
    for (final row in (response as List)) {
      final model = row['model'] as String;
      stats[model] ??= {'input': 0, 'output': 0};
      stats[model]!['input'] =
          stats[model]!['input']! + (row['input_tokens'] as int);
      stats[model]!['output'] =
          stats[model]!['output']! + (row['output_tokens'] as int);
    }

    setState(() {
      _modelStats = stats.entries
          .map((e) => {
                'model': e.key,
                'input': e.value['input'],
                'output': e.value['output'],
                'total': e.value['input']! + e.value['output']!,
              })
          .toList()
        ..sort((a, b) => (b['total'] as int).compareTo(a['total'] as int));
      _totalTokensToday =
          _modelStats.fold(0, (sum, s) => sum + (s['total'] as int));
      _loading = false;
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Flutter: Usage Bar Widget

Widget _buildModelRow(Map<String, dynamic> stat) {
  final colorScheme = Theme.of(context).colorScheme;
  final total = stat['total'] as int;
  final fraction = _totalTokensToday > 0 ? total / _totalTokensToday : 0.0;

  return Padding(
    padding: const EdgeInsets.symmetric(vertical: 6),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          children: [
            Expanded(
              child: Text(
                stat['model'] as String,
                style: const TextStyle(
                  fontSize: 13,
                  fontWeight: FontWeight.w500,
                ),
              ),
            ),
            Text(
              '${_formatTokens(total)} tokens',
              style: TextStyle(
                fontSize: 12,
                color: colorScheme.onSurfaceVariant,
              ),
            ),
          ],
        ),
        const SizedBox(height: 4),
        ClipRRect(
          borderRadius: BorderRadius.circular(4),
          child: LinearProgressIndicator(
            value: fraction,
            minHeight: 8,
            backgroundColor: colorScheme.surfaceContainerHighest,
            valueColor: AlwaysStoppedAnimation(colorScheme.primary),
          ),
        ),
      ],
    ),
  );
}

String _formatTokens(int tokens) {
  if (tokens >= 1000000) return '${(tokens / 1000000).toStringAsFixed(1)}M';
  if (tokens >= 1000) return '${(tokens / 1000).toStringAsFixed(1)}K';
  return '$tokens';
}
Enter fullscreen mode Exit fullscreen mode

Client-Side Cost Estimation

Keep a model→rate map in Flutter to show estimated cost without an API key:

const Map<String, double> _costPer1kTokens = {
  'claude-sonnet-4-6': 0.003,
  'claude-haiku-4-5':  0.00025,
  'gemini-2.0-flash':  0.00015,
  'gpt-4o':            0.005,
};

double _estimateCost(String model, int tokens) {
  final rate = _costPer1kTokens[model] ?? 0.001;
  return tokens / 1000 * rate;
}
Enter fullscreen mode Exit fullscreen mode

This is an approximation — actual billing depends on which API endpoints you're hitting — but it gives a useful order-of-magnitude signal.


Why Client-Side Aggregation vs. DB Aggregate

For daily dashboards with < 1000 rows per user:

// Option A: fetch rows and aggregate client-side
final response = await supabase
    .from('ai_quota_usage')
    .select('model, input_tokens, output_tokens')
    .gte('created_at', today);
// group in Dart

// Option B: PostgreSQL aggregate query (via RPC)
final response = await supabase.rpc('get_daily_quota', params: {'day': today});
Enter fullscreen mode Exit fullscreen mode

Option A is simpler and fast enough. Option B is better if users have high-volume usage (thousands of rows/day) or if you want to add breakdowns by feature without returning all rows.


Summary

Pattern Benefit
GENERATED ALWAYS AS total_tokens DB computes totals — no app-side arithmetic
Fire-and-forget logging Zero impact on AI response latency
(user_id, created_at DESC) index Fast daily range scans
Per-model progress bars Instant visual breakdown of which model costs most
Client-side cost map Estimated cost without a billing API

Turn AI costs from invisible to manageable. On a $20/month plan, visibility is the difference between staying in budget and a surprise bill.

Try it: 自分株式会社

buildinpublic #Flutter #Supabase #Dart #AI

Top comments (0)