This is the flagship project. A working habit tracker with Flutter, Supabase, and a RevenueCat paywall that gates unlimited habits behind a subscription. If the Notes App was "ingredients" and the Chat App was "a recipe", this one is the whole meal.

I chose a habit tracker because I build and ship one (Ritualz). The pattern is small enough to teach in one sitting and general enough that most of what you learn applies to any indie app you might ship next: a data model, a repeating UI rhythm, a free tier, and a paywall. Swap the domain for todos, expenses, meals, workouts — the shape stays the same.

What we're building

Three screens and one gate:

  • Sign in screen — reused from the Notes App pattern
  • Home screen — a list of habits, each showing today's status and a 7-day streak indicator
  • New habit sheet — a bottom sheet with a name field and a color picker
  • Paywall gate — if the user is not pro and already has 5 habits, adding a new one presents the paywall

The data model is small on purpose. Two tables: habits and habit_completions. Every completion is a row. Streaks are computed from completions at read time — cheaper than storing a streak counter that has to be updated on every tap.

The data model

SQL editorsql
-- Habits: name + color + owner.
create table habits (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users not null default auth.uid(),
name text not null,
color text not null default '#F25C54',
created_at timestamptz not null default now()
);

-- Completions: one row per habit per day the user marked it done.
-- Unique constraint means the same habit can only be marked once per day.
create table habit_completions (
id uuid primary key default gen_random_uuid(),
habit_id uuid references habits on delete cascade not null,
user_id uuid references auth.users not null default auth.uid(),
day date not null default current_date,
unique (habit_id, day)
);

-- RLS — scope everything to the current user.
alter table habits enable row level security;
alter table habit_completions enable row level security;

create policy "habits: owner reads" on habits
for select using (auth.uid() = user_id);
create policy "habits: owner writes" on habits
for all using (auth.uid() = user_id);

create policy "completions: owner reads" on habit_completions
for select using (auth.uid() = user_id);
create policy "completions: owner writes" on habit_completions
for all using (auth.uid() = user_id);

Turn on realtime replication for both tables (Database → Replication). We'll drive the UI with streams.

The Habit model and repository

lib/habit.dartdart
class Habit {
final String id;
final String name;
final String color;
final Set<DateTime> completedDays;

Habit({
  required this.id,
  required this.name,
  required this.color,
  required this.completedDays,
});

/// Was this habit marked done on the given day?
bool isDoneOn(DateTime day) {
  final d = DateTime(day.year, day.month, day.day);
  return completedDays.contains(d);
}

/// Number of consecutive days up to (and including) today where this
/// habit was marked done. Reads are cheap because completedDays is a Set.
int currentStreak() {
  var streak = 0;
  var day = _today();
  while (completedDays.contains(day)) {
    streak += 1;
    day = day.subtract(const Duration(days: 1));
  }
  return streak;
}

static DateTime _today() {
  final now = DateTime.now();
  return DateTime(now.year, now.month, now.day);
}
}
lib/habits_repository.dartdart
import 'package:supabase_flutter/supabase_flutter.dart';
import 'habit.dart';

class HabitsRepository {
HabitsRepository(this._supabase);
final SupabaseClient _supabase;

/// Stream of all habits with their completions joined in.
/// Fires on any habit or completion change via realtime.
Stream<List<Habit>> watchAll() async* {
  final stream = _supabase
      .from('habits')
      .stream(primaryKey: ['id'])
      .order('created_at', ascending: true);

  await for (final rows in stream) {
    if (rows.isEmpty) {
      yield [];
      continue;
    }
    // Load completions in a single query, keyed by habit_id.
    final completions = await _supabase
        .from('habit_completions')
        .select('habit_id, day');
    final byHabit = <String, Set<DateTime>>{};
    for (final c in completions) {
      final habitId = c['habit_id'] as String;
      final day = DateTime.parse(c['day'] as String);
      (byHabit[habitId] ??= <DateTime>{}).add(day);
    }
    yield rows
        .map((r) => Habit(
              id: r['id'] as String,
              name: r['name'] as String,
              color: r['color'] as String,
              completedDays: byHabit[r['id']] ?? <DateTime>{},
            ))
        .toList();
  }
}

Future<void> create({required String name, required String color}) async {
  await _supabase.from('habits').insert({
    'name': name,
    'color': color,
  });
}

Future<void> toggleToday(Habit habit) async {
  final today = DateTime.now();
  final day = DateTime(today.year, today.month, today.day);
  if (habit.isDoneOn(day)) {
    await _supabase
        .from('habit_completions')
        .delete()
        .eq('habit_id', habit.id)
        .eq('day', _iso(day));
  } else {
    await _supabase.from('habit_completions').insert({
      'habit_id': habit.id,
      'day': _iso(day),
    });
  }
}

Future<void> delete(String habitId) async {
  await _supabase.from('habits').delete().eq('id', habitId);
}

String _iso(DateTime d) =>
    '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
}

The home screen

A list of habits, each showing today's status as a big tap target and the current streak as a chip. Long-press to delete. Plus button in the app bar to add a new habit.

lib/home_screen.dartdart
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'habit.dart';
import 'habits_repository.dart';
import 'new_habit_sheet.dart';
import 'pro.dart';
import 'paywall_screen.dart';

const freeHabitLimit = 5;

class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});

@override
State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
late final HabitsRepository _repo;

@override
void initState() {
  super.initState();
  _repo = HabitsRepository(Supabase.instance.client);
}

Future<void> _onAddPressed(List<Habit> currentHabits) async {
  // If the user is already at the free-tier limit, check entitlement.
  if (currentHabits.length >= freeHabitLimit) {
    final pro = await isPro();
    if (!pro && mounted) {
      await Navigator.of(context).push(
        MaterialPageRoute(builder: (_) => const PaywallScreen()),
      );
      // If the user didn't convert, abort the add flow.
      final nowPro = await isPro();
      if (!nowPro) return;
    }
  }
  if (!mounted) return;
  await showModalBottomSheet(
    context: context,
    isScrollControlled: true,
    builder: (_) => NewHabitSheet(
      onCreate: (name, color) async {
        await _repo.create(name: name, color: color);
      },
    ),
  );
}

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('Habits'),
      actions: [
        IconButton(
          icon: const Icon(Icons.logout),
          onPressed: () => Supabase.instance.client.auth.signOut(),
        ),
      ],
    ),
    body: StreamBuilder<List<Habit>>(
      stream: _repo.watchAll(),
      builder: (context, snapshot) {
        if (!snapshot.hasData) {
          return const Center(child: CircularProgressIndicator());
        }
        final habits = snapshot.data!;
        return Column(
          children: [
            Expanded(
              child: habits.isEmpty
                  ? const Center(
                      child: Text('No habits yet. Tap + to start one.'),
                    )
                  : ListView.separated(
                      padding: const EdgeInsets.all(16),
                      itemCount: habits.length,
                      separatorBuilder: (_, __) =>
                          const SizedBox(height: 10),
                      itemBuilder: (_, i) => _HabitTile(
                        habit: habits[i],
                        onToggle: () => _repo.toggleToday(habits[i]),
                        onDelete: () => _repo.delete(habits[i].id),
                      ),
                    ),
            ),
            SafeArea(
              top: false,
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: FilledButton.icon(
                  onPressed: () => _onAddPressed(habits),
                  icon: const Icon(Icons.add),
                  label: Text(
                    habits.length >= freeHabitLimit
                        ? 'Add habit (Pro)'
                        : 'Add habit',
                  ),
                ),
              ),
            ),
          ],
        );
      },
    ),
  );
}
}

class _HabitTile extends StatelessWidget {
const _HabitTile({
  required this.habit,
  required this.onToggle,
  required this.onDelete,
});
final Habit habit;
final VoidCallback onToggle;
final VoidCallback onDelete;

@override
Widget build(BuildContext context) {
  final color = Color(int.parse(habit.color.substring(1), radix: 16) | 0xFF000000);
  final done = habit.isDoneOn(DateTime.now());
  final streak = habit.currentStreak();

  return GestureDetector(
    onTap: onToggle,
    onLongPress: onDelete,
    child: Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: done ? color.withValues(alpha: 0.18) : null,
        borderRadius: BorderRadius.circular(14),
        border: Border.all(
          color: done ? color : Theme.of(context).dividerColor,
        ),
      ),
      child: Row(
        children: [
          Container(
            width: 12,
            height: 12,
            decoration: BoxDecoration(
              color: color,
              shape: BoxShape.circle,
            ),
          ),
          const SizedBox(width: 14),
          Expanded(
            child: Text(
              habit.name,
              style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
            ),
          ),
          if (streak > 0) ...[
            const Icon(Icons.local_fire_department, size: 16),
            const SizedBox(width: 4),
            Text('$streak'),
            const SizedBox(width: 12),
          ],
          Icon(
            done ? Icons.check_circle : Icons.circle_outlined,
            color: done ? color : null,
          ),
        ],
      ),
    ),
  );
}
}

The paywall gate

This is the pattern that makes the whole project worth it. Two tiny files: pro.dart (a helper that tells you if the user is pro) and paywall_screen.dart (the screen shown when they're not). The interesting part is how the home screen calls these.

lib/pro.dartdart
import 'package:purchases_flutter/purchases_flutter.dart';

/// Single source of truth for "is this user pro?".
/// Call from anywhere; all gating logic in the app reads through this.
Future<bool> isPro() async {
try {
  final info = await Purchases.getCustomerInfo();
  return info.entitlements.active['pro'] != null;
} catch (_) {
  // If RevenueCat is unreachable (offline, first launch before init),
  // fall through as "not pro". A real app might cache the last known
  // value in Hive so offline users keep access.
  return false;
}
}
lib/paywall_screen.dartdart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:purchases_flutter/purchases_flutter.dart';

class PaywallScreen extends StatefulWidget {
const PaywallScreen({super.key});

@override
State<PaywallScreen> createState() => _PaywallScreenState();
}

class _PaywallScreenState extends State<PaywallScreen> {
Offering? _offering;
bool _loading = true;
bool _purchasing = false;

@override
void initState() {
  super.initState();
  _fetch();
}

Future<void> _fetch() async {
  try {
    final offerings = await Purchases.getOfferings();
    setState(() {
      _offering = offerings.current;
      _loading = false;
    });
  } catch (_) {
    setState(() => _loading = false);
  }
}

Future<void> _buy(Package package) async {
  setState(() => _purchasing = true);
  try {
    final result = await Purchases.purchasePackage(package);
    final isPro = result.entitlements.active['pro'] != null;
    if (isPro && mounted) Navigator.of(context).pop(true);
  } on PlatformException catch (e) {
    final code = PurchasesErrorHelper.getErrorCode(e);
    if (code != PurchasesErrorCode.purchaseCancelledError && mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(e.message ?? 'Purchase failed')),
      );
    }
  } finally {
    if (mounted) setState(() => _purchasing = false);
  }
}

Future<void> _restore() async {
  final info = await Purchases.restorePurchases();
  final isPro = info.entitlements.active['pro'] != null;
  if (!mounted) return;
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(content: Text(isPro ? 'Restored.' : 'Nothing to restore.')),
  );
  if (isPro) Navigator.of(context).pop(true);
}

@override
Widget build(BuildContext context) {
  final packages = _offering?.availablePackages ?? [];
  return Scaffold(
    appBar: AppBar(title: const Text('Go Pro')),
    body: _loading
        ? const Center(child: CircularProgressIndicator())
        : Padding(
            padding: const EdgeInsets.all(24),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                const Text(
                  'Unlimited habits',
                  style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700),
                ),
                const SizedBox(height: 8),
                const Text(
                  'Track as many habits as you want. Free users are capped at 5.',
                ),
                const SizedBox(height: 24),
                ...packages.map((p) => Padding(
                      padding: const EdgeInsets.only(bottom: 12),
                      child: FilledButton(
                        onPressed: _purchasing ? null : () => _buy(p),
                        child: Text(
                          '${p.storeProduct.priceString} / ${p.packageType.name}',
                        ),
                      ),
                    )),
                const Spacer(),
                TextButton(
                  onPressed: _restore,
                  child: const Text('Restore purchases'),
                ),
              ],
            ),
          ),
  );
}
}

How the gate actually works

Look at _onAddPressed in the home screen. This is the whole paywall pattern in one function:

  1. Check the current count. If the user has fewer than 5 habits, the add sheet opens without any paywall check. Pro users get infinite habits, free users get up to 5 for free.
  2. At the free limit, check the entitlement. Calls isPro(), which reads from RevenueCat. If already pro, skip the paywall entirely.
  3. If not pro, show the paywall. Await its result.
  4. Check entitlement again after the paywall. If the user converted, continue to the add sheet. If not, abort.

That's the entire pattern. Swap "habits" for "notes", "workouts", "saved searches", "exports" — it works identically for anything with a countable resource.

Wiring RevenueCat in main.dart

The minimum to make the paywall work:

lib/main.dartdart
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

const supabaseUrl = 'https://YOUR-PROJECT.supabase.co';
const supabaseAnonKey = 'YOUR-ANON-KEY';
const revenueCatIosKey = 'appl_YOUR_IOS_KEY';
const revenueCatAndroidKey = 'goog_YOUR_ANDROID_KEY';

Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey);

await Purchases.configure(
  PurchasesConfiguration(
    Platform.isIOS ? revenueCatIosKey : revenueCatAndroidKey,
  ),
);

runApp(const HabitApp());
}

That's it. Now the habit tracker knows about the user, their habits, their streaks, and whether they're pro.

What I intentionally left out

A real production version of this app would also need:

  • A streak grace period — one missed day shouldn't reset a 30-day streak. Add a per-habit "allowed_misses_per_week" setting and compute the streak accordingly.
  • Notifications — daily reminders are the highest-impact feature for a habit tracker. This is the one piece you can't build with Supabase alone; you need Firebase Cloud Messaging or a similar service.
  • Offline support — the app is online-only as written. Wrap the repository in a Hive cache and reconcile when connectivity returns. See the local storage guide.
  • A weekly summary view — surface the last 7 days as a visual grid.
  • Supabase edge functions to identify the RevenueCat user — link the RC app user ID to the Supabase user ID via Purchases.logIn(supabaseUserId) so a server can verify entitlements directly.

If you've built this whole thing end-to-end, you now have a working template that covers 80% of what indie mobile apps need. Swap the domain and the name — the architecture is the same.