Build a Subscription Manager with Flutter

Build a multi-page Flutter Web app that tracks subscriptions and costs.

Introduction

30 Second Summary

Between streaming services, cloud tools, productivity apps, and gaming subscriptions, it is surprisingly easy to lose track of what you are paying for each month. A single dashboard that shows your total spend, upcoming renewals, and spending breakdown by category would save real money.

In this project, you will build a personal subscription manager as a Flutter Web app running in your browser. You will create a multi-page app with a dashboard, subscription list, and add/edit form that persists your data locally.

What You'll Build

A fully functional subscription tracker built with Dart and Flutter Web that demonstrates production-ready patterns including routing, state management, persistence, and data visualization.

By the end of this project, you'll have:

  • A typed Dart data model with JSON serialization, enum-based categories, and monthly cost calculation.
  • Three routed pages (Dashboard, Subscription List, and Add/Edit form) connected with go_router and a persistent navigation bar.
  • A pie chart showing spending by category, summary cards, budget tracking with color-coded warnings, and local persistence so your data survives browser refreshes.
  • Secret Mission: a monthly bar chart showing your cost breakdown over the last 6 months.

Are there any prerequisites?

You need basic programming experience in any language (JavaScript, Python, or C++ all work). You also need a Linux machine with Chromium and VS Code already installed.

No prior Dart or Flutter experience is needed. This project teaches both from scratch.

Set Up the Flutter Project

Your Flutter SDK is installed and ready to go. Now it's time to create the actual project that will become your subscription manager.

In this step, you'll scaffold a new Flutter web project, add the packages it needs for routing, charts, and local storage, and confirm everything runs in Chromium.

In this step, get ready to:
  • Scaffold a new Flutter web project.
  • Install the required dependencies.
  • Verify the app runs in Chromium.
Scaffold the Flutter web project

Every Flutter project starts with a scaffold command that generates the folder structure, a starter app, and configuration files. You'll create yours on the Desktop so it's easy to find.

  • In your terminal, move to the Desktop by running this command:
cd ~/Desktop
  • Scaffold your Flutter project by running:
flutter create sub_manager

You'll see Flutter generating files in the terminal output, ending with the message All done!

  • Move into the new project folder by running:
cd sub_manager
  • Confirm the project files were created by running:
ls

You should see folders like lib/, web/, test/, and files like pubspec.yaml in the output.

Don't see the project files?

Make sure you ran cd sub_manager after the create command. The ls command shows the contents of your current directory.

If flutter create failed, check that your Flutter SDK is on your PATH by running flutter --version. Help me troubleshoot why flutter create failed.

  • Now open this project folder in VS Code. In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to Desktop, select the sub_manager folder, and click Open.
  • Open the integrated terminal by pressing Ctrl+` (the backtick key, usually above Tab).

Why open the folder in VS Code?

Opening the project folder gives VS Code access to all your files in the Explorer sidebar. The integrated terminal also starts in the project root, so every command you run targets the right directory automatically.

Install the project dependencies

Your subscription manager needs four packages beyond the default Flutter SDK. You'll declare them in the project's configuration file and let Flutter download them.

  • In the VS Code Explorer sidebar, click pubspec.yaml to open it.
  • Select all the existing content with Ctrl+A, then replace it with the following:
name: sub_manager
description: A subscription management app built with Flutter Web.
publish_to: 'none'

environment:
  sdk: ^3.12.0

dependencies:
  flutter:
    sdk: flutter
  go_router: ^17.3.0
  fl_chart: ^1.2.0
  shared_preferences: ^2.5.5
  intl: ^0.20.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

What do these packages do?

  • go_router handles multi-page navigation with URL-based routing.
  • fl_chart provides interactive pie charts and bar charts for spending visualizations.
  • shared_preferences stores subscription data in the browser's LocalStorage so it survives page refreshes.
  • intl formats dates and currencies into human-readable strings.
  • Save pubspec.yaml with Ctrl+S.
  • Install the dependencies by running this command in the integrated terminal:
flutter pub get

You should see Flutter resolving packages and a final Got dependencies! message confirming everything downloaded.

Seeing dependency resolution errors?

Double-check that your pubspec.yaml matches the code above exactly. YAML is whitespace-sensitive, so make sure each indentation level uses exactly two spaces (not tabs).

If you see version conflicts, confirm your Flutter version is 3.44.0 by running flutter --version. Help me fix my pubspec.yaml dependency errors.

✔️ Awesome, I've got everything!

Great. Double check you've saved pubspec.yaml and move on to the next substep.

ⓧ I'd like to double check the full code

name: sub_manager
description: A subscription management app built with Flutter Web.
publish_to: 'none'

environment:
  sdk: ^3.12.0

dependencies:
  flutter:
    sdk: flutter
  go_router: ^17.3.0
  fl_chart: ^1.2.0
  shared_preferences: ^2.5.5
  intl: ^0.20.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true
Verify the app runs in Chromium

Before you start building features, confirm that the scaffolded app launches correctly in the browser. This catches any environment issues early.

  • Launch the app in Chromium by running:
flutter run -d chrome

Chromium opens with the default Flutter counter app. You should see a blue app bar, a counter showing 0, and a floating action button with a + icon.

What is the counter app?

Every new Flutter project comes with a sample counter app. It proves the build toolchain, web server, and browser connection all work. You'll replace this code with your subscription manager in the next steps.

  • Once you've confirmed the app loads, stop the development server by pressing q in the terminal.

Chromium not opening?

Make sure Chromium is installed on your system. Flutter looks for chromium-browser or google-chrome on your PATH.

If you see a message about web not being supported, run flutter doctor and check that the Chrome row shows a checkmark. Help me fix my Flutter web setup so it launches in Chromium.

Your project is scaffolded, dependencies are installed, and the app runs in the browser. Next up, you'll build the data model that represents a subscription and the service that stores them locally.

Build the Data Model and Persistence Service

Your Flutter environment is ready and your project is running in Chromium. Now it's time to define what a subscription actually looks like in code and how the app remembers it.

Every app that manages data needs two things: a data model that defines the shape of each record, and a persistence layer that saves data so it survives a page refresh. In this step, you'll build both from scratch using Dart classes and shared_preferences for local storage.

In this step, get ready to:
  • Create a Subscription data model with typed fields and computed properties.
  • Build a persistence service that stores subscriptions in local browser storage.
  • Test that subscriptions survive a browser refresh.
Create the Subscription data model

A data model is a class that defines the exact shape of a record in your app. Your Subscription model will enforce types on every field, so the compiler catches mistakes before your app even runs.

  • Create the folders for your model and service files by running this command in your VS Code terminal:
mkdir -p lib/models lib/services
  • Confirm the folders exist by running:
ls lib/

You should see models and services listed alongside the existing main.dart.

  • Create a new file called subscription.dart inside the lib/models/ folder by running:
touch lib/models/subscription.dart
  • Open lib/models/subscription.dart in VS Code by clicking on it in the Explorer sidebar.
  • Add the import and enum definitions at the top of the file by pasting this code:
import 'dart:convert';

// Defines how often a subscription bills
enum BillingCycle { weekly, monthly, yearly }

// Groups subscriptions for the pie chart breakdown
enum SubscriptionCategory { entertainment, productivity, cloud, music, gaming, other }

class Subscription {
  final String id;
  final String name;
  final double cost;
  final BillingCycle billingCycle;
  final SubscriptionCategory category;
  final DateTime renewalDate;

  Subscription({
    required this.id,
    required this.name,
    required this.cost,
    required this.billingCycle,
    required this.category,
    required this.renewalDate,
  });
}

What does this code do?

You've defined two enums and a class with six typed fields. Enums restrict billing cycles and categories to specific allowed values, so you can never accidentally save an invalid string.

Every field uses the final keyword, making Subscription instances immutable. The required keyword on each constructor parameter means Dart will throw a compile error if you forget one.

  • Add the monthlyCost computed property below the constructor's closing }); by pasting this code:
  // Normalizes any billing cycle to a monthly amount
  double get monthlyCost {
    switch (billingCycle) {
      case BillingCycle.weekly:
        return cost * 4.33;
      case BillingCycle.monthly:
        return cost;
      case BillingCycle.yearly:
        return cost / 12;
    }
  }

What does this code do?

This getter converts any subscription's cost to a consistent monthly amount. A $120/year Netflix subscription becomes $10/month, while a $5/week service becomes $21.65/month. The dashboard will use this to calculate your total monthly spend.

  • Add the renewal date calculation getters below monthlyCost by pasting this code:
  // Calculates how many days remain until the next billing date
  int get daysUntilRenewal {
    final now = DateTime.now();
    var nextRenewal = renewalDate;
    while (nextRenewal.isBefore(now)) {
      switch (billingCycle) {
        case BillingCycle.weekly:
          nextRenewal = nextRenewal.add(const Duration(days: 7));
          break;
        case BillingCycle.monthly:
          nextRenewal = DateTime(nextRenewal.year, nextRenewal.month + 1, nextRenewal.day);
          break;
        case BillingCycle.yearly:
          nextRenewal = DateTime(nextRenewal.year + 1, nextRenewal.month, nextRenewal.day);
          break;
      }
    }
    return nextRenewal.difference(now).inDays;
  }

  // Returns the actual DateTime of the next renewal
  DateTime get nextRenewalDate {
    final now = DateTime.now();
    var nextRenewal = renewalDate;
    while (nextRenewal.isBefore(now)) {
      switch (billingCycle) {
        case BillingCycle.weekly:
          nextRenewal = nextRenewal.add(const Duration(days: 7));
          break;
        case BillingCycle.monthly:
          nextRenewal = DateTime(nextRenewal.year, nextRenewal.month + 1, nextRenewal.day);
          break;
        case BillingCycle.yearly:
          nextRenewal = DateTime(nextRenewal.year + 1, nextRenewal.month, nextRenewal.day);
          break;
      }
    }
    return nextRenewal;
  }

What does this code do?

Both getters use a while loop to fast-forward the original renewal date until it lands in the future. This handles subscriptions that were created months ago. For example, if you added Netflix on January 15 and today is June 14, the loop advances month by month until it finds July 15.

The daysUntilRenewal getter returns the day count (used for urgency alerts), while nextRenewalDate returns the full DateTime (used for display formatting).

  • Add the serialization and utility methods below nextRenewalDate by pasting this code:
  // Converts the subscription to a JSON-compatible map for storage
  Map<String, dynamic> toJson() => {
        'id': id,
        'name': name,
        'cost': cost,
        'billingCycle': billingCycle.name,
        'category': category.name,
        'renewalDate': renewalDate.toIso8601String(),
      };

  // Reconstructs a Subscription from a stored JSON map
  factory Subscription.fromJson(Map<String, dynamic> json) => Subscription(
        id: json['id'] as String,
        name: json['name'] as String,
        cost: (json['cost'] as num).toDouble(),
        billingCycle: BillingCycle.values.byName(json['billingCycle'] as String),
        category: SubscriptionCategory.values.byName(json['category'] as String),
        renewalDate: DateTime.parse(json['renewalDate'] as String),
      );

  // Creates a copy with optional field overrides (for editing)
  Subscription copyWith({
    String? id,
    String? name,
    double? cost,
    BillingCycle? billingCycle,
    SubscriptionCategory? category,
    DateTime? renewalDate,
  }) =>
      Subscription(
        id: id ?? this.id,
        name: name ?? this.name,
        cost: cost ?? this.cost,
        billingCycle: billingCycle ?? this.billingCycle,
        category: category ?? this.category,
        renewalDate: renewalDate ?? this.renewalDate,
      );
}

What does this code do?

These three methods handle conversion and copying:

  • toJson() converts a Subscription into a Map that can be stored as a JSON string. Enums are saved using their .name property (e.g. "monthly").
  • fromJson() reconstructs a Subscription from a stored Map. It uses values.byName() to convert stored strings back into enum values.
  • copyWith() creates a new Subscription with some fields changed. Since the class is immutable, editing means creating a modified copy.
  • Save lib/models/subscription.dart.

Seeing red squiggles in VS Code?

Make sure the closing } of the class is at the very end of the file. Each getter and method must sit inside the Subscription class body.

Check that you have no duplicate closing braces. The file should have exactly one } that closes the class at the bottom.

Help me debug my subscription.dart errors

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import 'dart:convert';

enum BillingCycle { weekly, monthly, yearly }

enum SubscriptionCategory { entertainment, productivity, cloud, music, gaming, other }

class Subscription {
  final String id;
  final String name;
  final double cost;
  final BillingCycle billingCycle;
  final SubscriptionCategory category;
  final DateTime renewalDate;

  Subscription({
    required this.id,
    required this.name,
    required this.cost,
    required this.billingCycle,
    required this.category,
    required this.renewalDate,
  });

  double get monthlyCost {
    switch (billingCycle) {
      case BillingCycle.weekly:
        return cost * 4.33;
      case BillingCycle.monthly:
        return cost;
      case BillingCycle.yearly:
        return cost / 12;
    }
  }

  int get daysUntilRenewal {
    final now = DateTime.now();
    var nextRenewal = renewalDate;
    while (nextRenewal.isBefore(now)) {
      switch (billingCycle) {
        case BillingCycle.weekly:
          nextRenewal = nextRenewal.add(const Duration(days: 7));
          break;
        case BillingCycle.monthly:
          nextRenewal = DateTime(nextRenewal.year, nextRenewal.month + 1, nextRenewal.day);
          break;
        case BillingCycle.yearly:
          nextRenewal = DateTime(nextRenewal.year + 1, nextRenewal.month, nextRenewal.day);
          break;
      }
    }
    return nextRenewal.difference(now).inDays;
  }

  DateTime get nextRenewalDate {
    final now = DateTime.now();
    var nextRenewal = renewalDate;
    while (nextRenewal.isBefore(now)) {
      switch (billingCycle) {
        case BillingCycle.weekly:
          nextRenewal = nextRenewal.add(const Duration(days: 7));
          break;
        case BillingCycle.monthly:
          nextRenewal = DateTime(nextRenewal.year, nextRenewal.month + 1, nextRenewal.day);
          break;
        case BillingCycle.yearly:
          nextRenewal = DateTime(nextRenewal.year + 1, nextRenewal.month, nextRenewal.day);
          break;
      }
    }
    return nextRenewal;
  }

  Map<String, dynamic> toJson() => {
        'id': id,
        'name': name,
        'cost': cost,
        'billingCycle': billingCycle.name,
        'category': category.name,
        'renewalDate': renewalDate.toIso8601String(),
      };

  factory Subscription.fromJson(Map<String, dynamic> json) => Subscription(
        id: json['id'] as String,
        name: json['name'] as String,
        cost: (json['cost'] as num).toDouble(),
        billingCycle: BillingCycle.values.byName(json['billingCycle'] as String),
        category: SubscriptionCategory.values.byName(json['category'] as String),
        renewalDate: DateTime.parse(json['renewalDate'] as String),
      );

  Subscription copyWith({
    String? id,
    String? name,
    double? cost,
    BillingCycle? billingCycle,
    SubscriptionCategory? category,
    DateTime? renewalDate,
  }) =>
      Subscription(
        id: id ?? this.id,
        name: name ?? this.name,
        cost: cost ?? this.cost,
        billingCycle: billingCycle ?? this.billingCycle,
        category: category ?? this.category,
        renewalDate: renewalDate ?? this.renewalDate,
      );
}
Build the SubscriptionService for local persistence

Your data model defines the shape of a subscription, but it has no way to save itself. You need a service class that handles reading from and writing to local storage.

Why SharedPreferencesWithCache?

The shared_preferences package stores key-value pairs in the browser's LocalStorage on web. The legacy SharedPreferences API is being deprecated. New Flutter projects should use SharedPreferencesWithCache, which loads all data once at startup and then reads synchronously from its in-memory cache.

This means after initialization, calling loadSubscriptions() is instant with no async delay.

  • Create the service file by running:
touch lib/services/subscription_service.dart
  • Open lib/services/subscription_service.dart in VS Code.
  • Add the imports, class shell, and initialization method by pasting this code:
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/subscription.dart';

class SubscriptionService {
  // Storage keys for LocalStorage
  static const _subscriptionsKey = 'subscriptions';
  static const _budgetKey = 'monthly_budget';

  // The cached preferences instance
  late SharedPreferencesWithCache _prefs;

  // Must be called once before using the service
  Future<void> init() async {
    _prefs = await SharedPreferencesWithCache.create(
      cacheOptions: const SharedPreferencesWithCacheOptions(),
    );
  }
}

What does this code do?

  • _subscriptionsKey and _budgetKey are the string keys under which data lives in LocalStorage.
  • late SharedPreferencesWithCache _prefs declares the cache instance. The late keyword tells Dart it will be assigned before first use.
  • init() creates the cache and loads all existing LocalStorage data into memory. You call this once at app startup.
  • Add the core load and save methods inside the class, below the init() method's closing }:
  // Reads all subscriptions from LocalStorage
  List<Subscription> loadSubscriptions() {
    final jsonString = _prefs.getString(_subscriptionsKey);
    if (jsonString == null) return [];
    final List<dynamic> jsonList = jsonDecode(jsonString) as List<dynamic>;
    return jsonList
        .map((item) => Subscription.fromJson(item as Map<String, dynamic>))
        .toList();
  }

  // Writes the full subscription list to LocalStorage as JSON
  Future<void> saveSubscriptions(List<Subscription> subscriptions) async {
    final jsonString = jsonEncode(subscriptions.map((s) => s.toJson()).toList());
    await _prefs.setString(_subscriptionsKey, jsonString);
  }

What does this code do?

loadSubscriptions() reads the stored JSON string, decodes it into a list of Maps, and converts each Map back into a Subscription using fromJson(). If nothing is stored yet, it returns an empty list.

saveSubscriptions() does the reverse. It converts each Subscription to a Map, encodes the full list as a JSON string, and writes it to LocalStorage.

  • Add the convenience CRUD methods below saveSubscriptions:
  // Appends a new subscription and persists the full list
  Future<void> addSubscription(Subscription subscription) async {
    final subscriptions = loadSubscriptions();
    subscriptions.add(subscription);
    await saveSubscriptions(subscriptions);
  }

  // Replaces a subscription by matching ID
  Future<void> updateSubscription(Subscription subscription) async {
    final subscriptions = loadSubscriptions();
    final index = subscriptions.indexWhere((s) => s.id == subscription.id);
    if (index != -1) {
      subscriptions[index] = subscription;
      await saveSubscriptions(subscriptions);
    }
  }

  // Removes a subscription by ID
  Future<void> deleteSubscription(String id) async {
    final subscriptions = loadSubscriptions();
    subscriptions.removeWhere((s) => s.id == id);
    await saveSubscriptions(subscriptions);
  }

What does this code do?

These three methods build on the core load/save pair. Each one loads the current list, modifies it, and saves the updated version. This is a simple pattern that works well for small datasets stored in LocalStorage.

  • Add the budget helper methods below deleteSubscription, just before the class's closing }:
  // Returns the user's monthly budget cap (defaults to $100)
  double getMonthlyBudget() {
    return _prefs.getDouble(_budgetKey) ?? 100.0;
  }

  // Saves a new budget cap
  Future<void> setMonthlyBudget(double budget) async {
    await _prefs.setDouble(_budgetKey, budget);
  }

What does this code do?

These two methods manage the budget cap that the dashboard will compare against your total monthly spend. The default is $100 if the user hasn't set one yet. The ?? operator returns 100.0 when getDouble finds nothing stored.

  • Save lib/services/subscription_service.dart.

You should see no red squiggles in the file. The Flutter extension validates imports and types in real time.

Seeing an error on SharedPreferencesWithCache?

Make sure your import statement says import 'package:shared_preferences/shared_preferences.dart'; exactly. The SharedPreferencesWithCache class lives in the same import as the legacy API.

If VS Code still shows an error, make sure you ran flutter pub get in the previous step. The shared_preferences package must be downloaded before Dart can resolve its classes.

Help me fix my SharedPreferencesWithCache import error

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/subscription.dart';

class SubscriptionService {
  static const _subscriptionsKey = 'subscriptions';
  static const _budgetKey = 'monthly_budget';

  late SharedPreferencesWithCache _prefs;

  Future<void> init() async {
    _prefs = await SharedPreferencesWithCache.create(
      cacheOptions: const SharedPreferencesWithCacheOptions(),
    );
  }

  List<Subscription> loadSubscriptions() {
    final jsonString = _prefs.getString(_subscriptionsKey);
    if (jsonString == null) return [];
    final List<dynamic> jsonList = jsonDecode(jsonString) as List<dynamic>;
    return jsonList
        .map((item) => Subscription.fromJson(item as Map<String, dynamic>))
        .toList();
  }

  Future<void> saveSubscriptions(List<Subscription> subscriptions) async {
    final jsonString = jsonEncode(subscriptions.map((s) => s.toJson()).toList());
    await _prefs.setString(_subscriptionsKey, jsonString);
  }

  Future<void> addSubscription(Subscription subscription) async {
    final subscriptions = loadSubscriptions();
    subscriptions.add(subscription);
    await saveSubscriptions(subscriptions);
  }

  Future<void> updateSubscription(Subscription subscription) async {
    final subscriptions = loadSubscriptions();
    final index = subscriptions.indexWhere((s) => s.id == subscription.id);
    if (index != -1) {
      subscriptions[index] = subscription;
      await saveSubscriptions(subscriptions);
    }
  }

  Future<void> deleteSubscription(String id) async {
    final subscriptions = loadSubscriptions();
    subscriptions.removeWhere((s) => s.id == id);
    await saveSubscriptions(subscriptions);
  }

  double getMonthlyBudget() {
    return _prefs.getDouble(_budgetKey) ?? 100.0;
  }

  Future<void> setMonthlyBudget(double budget) async {
    await _prefs.setDouble(_budgetKey, budget);
  }
}
Test persistence with a sample subscription

Your model and service are written, but you haven't proven they work together yet. The real test is whether data survives a full page refresh in Chromium.

  • Open lib/main.dart in VS Code.
  • Replace the entire contents of the file with this temporary test code:
import 'package:flutter/material.dart';
import 'services/subscription_service.dart';
import 'models/subscription.dart';

// Global service instance
final subscriptionService = SubscriptionService();

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await subscriptionService.init();

  // Load existing subscriptions to check persistence
  final existing = subscriptionService.loadSubscriptions();
  print('Loaded ${existing.length} subscription(s) from storage');

  // Add a test subscription only if none exist yet
  if (existing.isEmpty) {
    await subscriptionService.addSubscription(
      Subscription(
        id: '1',
        name: 'Netflix',
        cost: 15.99,
        billingCycle: BillingCycle.monthly,
        category: SubscriptionCategory.entertainment,
        renewalDate: DateTime(2025, 7, 1),
      ),
    );
    print('Added test subscription: Netflix');
  } else {
    for (final sub in existing) {
      print('Found: ${sub.name} - \$${sub.monthlyCost.toStringAsFixed(2)}/mo');
    }
  }

  runApp(const MaterialApp(home: Scaffold(body: Center(child: Text('Check console')))));
}

What does this code do?

This temporary test initializes the service, checks if any subscriptions are already saved, and adds a sample Netflix subscription if the list is empty. On the second load (after a refresh), it finds the saved subscription and prints its details.

The WidgetsFlutterBinding.ensureInitialized() call is required before using any async plugin like shared_preferences. Without it, the app crashes on startup.

  • Save lib/main.dart.
  • Run the app in Chromium by running:
flutter run -d chrome
  • Look at the terminal output in VS Code (the debug console where Flutter prints). You should see:
Loaded 0 subscription(s) from storage
Added test subscription: Netflix
  • Refresh the Chromium page by pressing Ctrl+Shift+R (hard refresh) or by pressing r in the terminal where Flutter is running.
  • Check the terminal output again. You should now see:
Loaded 1 subscription(s) from storage
Found: Netflix - $15.99/mo

The subscription persisted across the refresh. Your data model serializes correctly and the service reads and writes to LocalStorage.

Not seeing the subscription after refresh?

Make sure you used a hard refresh (Ctrl+Shift+R), not a hot reload. Hot reload preserves in-memory state but doesn't test persistence from scratch.

If you see 0 subscriptions every time, check that toJson() and fromJson() in your Subscription class use matching key names. A typo in one means the other can't deserialize the data.

Help me debug my subscription persistence issue

Your data layer is solid. You have a typed model that enforces valid subscriptions and a service that persists them across sessions. Next up, you'll build the routing and navigation shell so users can move between pages.

Set Up Multi-Page Routing and App Shell

Your data model and persistence layer are ready. Now you need a way to navigate between different screens in your app.

A subscription manager needs at least three views: a dashboard for summaries, a list of all subscriptions, and a form to add or edit entries. In this step, you will configure URL-based routing with go_router and build a persistent navigation bar that stays visible across all pages.

In this step, get ready to:
  • Configure go_router with four routes for your app pages.
  • Build an AppShell widget with a persistent bottom navigation bar.
  • Create placeholder pages and verify navigation works in Chromium.
Create placeholder pages for each route

Before building the router, you need placeholder widgets for each page. The router imports these files, so they must exist first.

  • In the VS Code Explorer sidebar, right-click the lib folder inside sub_manager and select New Folder.
  • Name the folder pages.
  • Right-click the new pages folder and select New File.
  • Name it dashboard_page.dart and paste the following code:
import 'package:flutter/material.dart';

// Placeholder page - full implementation comes in a later step
class DashboardPage extends StatefulWidget {
  const DashboardPage({super.key});

  @override
  State<DashboardPage> createState() => _DashboardPageState();
}

class _DashboardPageState extends State<DashboardPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Dashboard')),
      body: const Center(child: Text('Dashboard Page')),
    );
  }
}

What does this code do?

This creates a minimal StatefulWidget that displays a centered label. You will replace this placeholder with the full dashboard implementation in Step 5.

  • Create a second file in the pages folder called subscriptions_page.dart with this code:
import 'package:flutter/material.dart';

// Placeholder page - full implementation comes in a later step
class SubscriptionsPage extends StatefulWidget {
  const SubscriptionsPage({super.key});

  @override
  State<SubscriptionsPage> createState() => _SubscriptionsPageState();
}

class _SubscriptionsPageState extends State<SubscriptionsPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Subscriptions')),
      body: const Center(child: Text('Subscriptions Page')),
    );
  }
}
  • Create a third file in the pages folder called add_edit_page.dart with this code:
import 'package:flutter/material.dart';

// Placeholder page - full implementation comes in a later step
class AddEditPage extends StatefulWidget {
  final String? subscriptionId;

  const AddEditPage({super.key, this.subscriptionId});

  @override
  State<AddEditPage> createState() => _AddEditPageState();
}

class _AddEditPageState extends State<AddEditPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.subscriptionId != null
            ? 'Edit Subscription'
            : 'Add Subscription'),
      ),
      body: const Center(child: Text('Add/Edit Form')),
    );
  }
}

Why does AddEditPage have a subscriptionId parameter?

The same page handles both adding and editing. When subscriptionId is null, the page shows an empty form for adding. When it contains an ID, the page loads that subscription for editing.

  • Save all three files.
  • Confirm the files exist by checking the Explorer sidebar. You should see lib/pages/dashboard_page.dart, lib/pages/subscriptions_page.dart, and lib/pages/add_edit_page.dart listed.
Configure go_router with your app routes

The router maps URL paths to page widgets. Each path in your app gets a GoRoute entry that tells Flutter which widget to render.

  • Right-click the lib folder and select New File.
  • Name it router.dart and paste the following code:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'pages/dashboard_page.dart';
import 'pages/subscriptions_page.dart';
import 'pages/add_edit_page.dart';
import 'widgets/app_shell.dart';

final GoRouter router = GoRouter(
  routes: [
    ShellRoute(
      builder: (context, state, child) => AppShell(child: child),
      routes: [
        GoRoute(
          path: '/',
          builder: (context, state) => const DashboardPage(),
        ),
        GoRoute(
          path: '/subscriptions',
          builder: (context, state) => const SubscriptionsPage(),
        ),
        GoRoute(
          path: '/subscriptions/add',
          builder: (context, state) => const AddEditPage(),
        ),
        GoRoute(
          path: '/subscriptions/edit/:id',
          builder: (context, state) {
            final id = state.pathParameters['id']!;
            return AddEditPage(subscriptionId: id);
          },
        ),
      ],
    ),
  ],
);

What does this code do?

The GoRouter defines all the URL paths your app supports. The ShellRoute wraps every child route with an AppShell widget (the navigation bar you will create next). This means the nav bar stays visible on every page without rebuilding it.

The edit route uses :id as a path parameter. When a user navigates to /subscriptions/edit/abc123, go_router extracts abc123 from state.pathParameters['id'] and passes it to the page.

Why go_router instead of Navigator 2 directly?

go_router is the Flutter team's official routing package. It abstracts the complexity of Navigator 2 into a simple, declarative URL-based API. This is the industry standard approach for multi-page Flutter apps with clean URLs.

  • Save router.dart.

Seeing import errors in router.dart?

The import 'widgets/app_shell.dart' line shows an error because that file does not exist yet. This is expected. The error disappears once you create the file in the next substep.

If you see errors on the page imports, double check your pages folder is directly inside lib and the filenames match exactly.

Help me fix import errors in my router.dart file.

Build the app shell with a navigation bar

The app shell is the persistent frame that wraps every page. It contains a bottom navigation bar that lets users switch between Dashboard and Subscriptions without losing context.

  • Right-click the lib folder and create a new folder called widgets.
  • Inside widgets, create a new file called app_shell.dart and paste this code:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class AppShell extends StatelessWidget {
  final Widget child;

  const AppShell({super.key, required this.child});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: child,
      bottomNavigationBar: NavigationBar(
        selectedIndex: _calculateSelectedIndex(context),
        onDestinationSelected: (index) => _onItemTapped(index, context),
        destinations: const [
          NavigationDestination(icon: Icon(Icons.dashboard), label: 'Dashboard'),
          NavigationDestination(icon: Icon(Icons.list), label: 'Subscriptions'),
        ],
      ),
    );
  }

  int _calculateSelectedIndex(BuildContext context) {
    final location = GoRouterState.of(context).uri.toString();
    if (location.startsWith('/subscriptions')) return 1;
    return 0;
  }

  void _onItemTapped(int index, BuildContext context) {
    switch (index) {
      case 0:
        context.go('/');
        break;
      case 1:
        context.go('/subscriptions');
        break;
    }
  }
}

What does this code do?

  • The child parameter receives whatever page the router currently matches. This widget wraps it with a Scaffold and a bottom NavigationBar.
  • The _calculateSelectedIndex method reads the current URL from GoRouterState and highlights the correct tab. Any URL starting with /subscriptions highlights the Subscriptions tab.
  • The _onItemTapped method uses context.go() to navigate to the root of each section when a tab is tapped.
  • Save app_shell.dart.
  • Return to router.dart. The import error on widgets/app_shell.dart should now be resolved.

Still seeing import errors?

Make sure the widgets folder is directly inside lib (not inside pages or any other subfolder). The file path must be lib/widgets/app_shell.dart.

Help me fix my app_shell.dart import path.

Update main.dart and verify navigation

The final piece is connecting the router to your app. You need to replace the default MaterialApp with MaterialApp.router so go_router handles all navigation.

  • Open lib/main.dart in the VS Code Explorer sidebar.
  • Replace the entire contents of the file with this code:
import 'package:flutter/material.dart';
import 'router.dart';
import 'services/subscription_service.dart';

final subscriptionService = SubscriptionService();

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await subscriptionService.init();
  runApp(const SubManagerApp());
}

class SubManagerApp extends StatelessWidget {
  const SubManagerApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'SubManager',
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
      ),
      routerConfig: router,
    );
  }
}

What does this code do?

  • The subscriptionService is created as a top-level global so any page can access it without passing it through constructors.
  • The WidgetsFlutterBinding.ensureInitialized() call is required before using async in main(). It sets up Flutter's binding with the platform before any async work starts.
  • The MaterialApp.router constructor tells Flutter to delegate all routing to the GoRouter instance you defined in router.dart.
  • The colorSchemeSeed: Colors.indigo with useMaterial3: true generates a full Material 3 color palette from a single seed color.
  • Save main.dart.
  • Run the app in Chromium by running this command in the terminal:
flutter run -d chrome
  • Once Chromium opens, you should see the Dashboard page with a bottom navigation bar showing two tabs: Dashboard and Subscriptions.
  • Tap the Subscriptions tab. The page content changes to show "Subscriptions Page" and the tab highlight moves.
  • Tap Dashboard to switch back. The navigation bar stays visible across both pages.

App not loading or showing a blank screen?

Check your terminal output for compilation errors. The most common issues are mismatched import paths or a missing file.

Make sure your folder structure looks like this: lib/main.dart, lib/router.dart, lib/widgets/app_shell.dart, lib/pages/dashboard_page.dart, lib/pages/subscriptions_page.dart, lib/pages/add_edit_page.dart.

Help me debug my Flutter routing setup.

✔️ Awesome, I've got everything!

Great. Double check you have saved all five files before moving on.

ⓧ I'd like to double check the full code

Here is the full lib/main.dart:

import 'package:flutter/material.dart';
import 'router.dart';
import 'services/subscription_service.dart';

final subscriptionService = SubscriptionService();

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await subscriptionService.init();
  runApp(const SubManagerApp());
}

class SubManagerApp extends StatelessWidget {
  const SubManagerApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'SubManager',
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
      ),
      routerConfig: router,
    );
  }
}

Here is the full lib/router.dart:

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'pages/dashboard_page.dart';
import 'pages/subscriptions_page.dart';
import 'pages/add_edit_page.dart';
import 'widgets/app_shell.dart';

final GoRouter router = GoRouter(
  routes: [
    ShellRoute(
      builder: (context, state, child) => AppShell(child: child),
      routes: [
        GoRoute(
          path: '/',
          builder: (context, state) => const DashboardPage(),
        ),
        GoRoute(
          path: '/subscriptions',
          builder: (context, state) => const SubscriptionsPage(),
        ),
        GoRoute(
          path: '/subscriptions/add',
          builder: (context, state) => const AddEditPage(),
        ),
        GoRoute(
          path: '/subscriptions/edit/:id',
          builder: (context, state) {
            final id = state.pathParameters['id']!;
            return AddEditPage(subscriptionId: id);
          },
        ),
      ],
    ),
  ],
);

Here is the full lib/widgets/app_shell.dart:

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class AppShell extends StatelessWidget {
  final Widget child;

  const AppShell({super.key, required this.child});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: child,
      bottomNavigationBar: NavigationBar(
        selectedIndex: _calculateSelectedIndex(context),
        onDestinationSelected: (index) => _onItemTapped(index, context),
        destinations: const [
          NavigationDestination(icon: Icon(Icons.dashboard), label: 'Dashboard'),
          NavigationDestination(icon: Icon(Icons.list), label: 'Subscriptions'),
        ],
      ),
    );
  }

  int _calculateSelectedIndex(BuildContext context) {
    final location = GoRouterState.of(context).uri.toString();
    if (location.startsWith('/subscriptions')) return 1;
    return 0;
  }

  void _onItemTapped(int index, BuildContext context) {
    switch (index) {
      case 0:
        context.go('/');
        break;
      case 1:
        context.go('/subscriptions');
        break;
    }
  }
}

Your app now has a full routing skeleton with a persistent navigation shell. Next up, you will replace those placeholder pages with a real subscription list and an add/edit form with validation.

Build the Subscription List and Add/Edit Form

Your app has routes and navigation, but every page is still a placeholder. The core value of a subscription manager comes from being able to see all your subscriptions at a glance and quickly add or modify them.

In this step, you will build two real pages: a scrollable subscription list that shows each subscription's cost, renewal date, and category, and a form page that lets you create, edit, and delete subscriptions. By the end, you will have full CRUD operations wired to your persistence service.

In this step, get ready to:
  • Build the Subscriptions page with a ListView showing each subscription's details.
  • Build the Add/Edit form with text fields, dropdowns, a date picker, and validation.
  • Wire the form to SubscriptionService and verify CRUD operations persist after a browser refresh.
Build the Subscriptions page with a ListView

The Subscriptions page is where users browse everything they are paying for. Each subscription needs to show its name, monthly cost, next renewal date, and category at a glance.

  • In VS Code, open lib/pages/subscriptions_page.dart from the Explorer sidebar.
  • Replace the entire placeholder content with the following code:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../models/subscription.dart';
import '../services/subscription_service.dart';
import '../main.dart';

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

  @override
  State<SubscriptionsPage> createState() => _SubscriptionsPageState();
}

class _SubscriptionsPageState extends State<SubscriptionsPage> {
  List<Subscription> _subscriptions = [];

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

  // Reads all subscriptions from persistent storage into state
  void _loadSubscriptions() {
    setState(() {
      _subscriptions = subscriptionService.loadSubscriptions();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Subscriptions')),
      // Floating button navigates to the add form
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          await context.push('/subscriptions/add');
          _loadSubscriptions();
        },
        child: const Icon(Icons.add),
      ),
      body: _subscriptions.isEmpty
          ? const Center(child: Text('No subscriptions yet. Tap + to add one.'))
          : ListView.builder(
              padding: const EdgeInsets.all(16),
              itemCount: _subscriptions.length,
              itemBuilder: (context, index) {
                final sub = _subscriptions[index];
                return Card(
                  child: ListTile(
                    title: Text(sub.name),
                    subtitle: Text(
                      // Shows billing cycle and next renewal formatted as readable date
                      '${sub.billingCycle.name} - Next: ${DateFormat.yMMMd().format(sub.nextRenewalDate)}',
                    ),
                    trailing: Text(
                      // Displays normalized monthly cost
                      '\$${sub.monthlyCost.toStringAsFixed(2)}/mo',
                      style: Theme.of(context).textTheme.titleMedium,
                    ),
                    leading: Chip(label: Text(sub.category.name)),
                    // Tapping a subscription navigates to edit page
                    onTap: () async {
                      await context.push('/subscriptions/edit/${sub.id}');
                      _loadSubscriptions();
                    },
                  ),
                );
              },
            ),
    );
  }
}

What does this code do?

This page is a StatefulWidget because the subscription list can change when you add or edit entries. The _loadSubscriptions method calls setState to trigger a rebuild whenever data changes.

The FloatingActionButton uses context.push (not context.go) so the add page stacks on top and the learner returns here via context.pop. After the push completes, _loadSubscriptions reloads fresh data.

Each list item uses DateFormat.yMMMd() from the intl package to render dates like "Jun 14, 2026" instead of raw ISO strings.

  • Save subscriptions_page.dart.
  • Switch to your running Chromium window. Hot reload should trigger automatically. Navigate to the **Subscriptions** tab using the bottom navigation bar.
  • You should see the message "No subscriptions yet. Tap + to add one." centered on the page, plus a floating "+" button in the bottom right corner.

Don't see the empty-state message?

Make sure you saved the file. If hot reload did not trigger, press r in the terminal where flutter run is running.

Check that your import path says import '../main.dart'; so the global subscriptionService is accessible.

help me debug my Subscriptions page not rendering

Build the Add/Edit form page

The form page handles both adding new subscriptions and editing existing ones. It uses Flutter's built-in form validation to catch mistakes before saving.

  • Open lib/pages/add_edit_page.dart in VS Code.
  • Replace the entire placeholder content with the following code:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../models/subscription.dart';
import '../services/subscription_service.dart';
import '../main.dart';

class AddEditPage extends StatefulWidget {
  final String? subscriptionId;

  const AddEditPage({super.key, this.subscriptionId});

  @override
  State<AddEditPage> createState() => _AddEditPageState();
}

class _AddEditPageState extends State<AddEditPage> {
  final _formKey = GlobalKey<FormState>();
  final _nameController = TextEditingController();
  final _costController = TextEditingController();
  BillingCycle _billingCycle = BillingCycle.monthly;
  SubscriptionCategory _category = SubscriptionCategory.entertainment;
  DateTime _renewalDate = DateTime.now();
  // Computed property: true when a subscriptionId was passed (edit mode)
  bool get _isEditing => widget.subscriptionId != null;

  @override
  void initState() {
    super.initState();
    // If editing, pre-fill the form fields from the existing subscription
    if (_isEditing) {
      final subscriptions = subscriptionService.loadSubscriptions();
      final sub = subscriptions.firstWhere((s) => s.id == widget.subscriptionId);
      _nameController.text = sub.name;
      _costController.text = sub.cost.toString();
      _billingCycle = sub.billingCycle;
      _category = sub.category;
      _renewalDate = sub.renewalDate;
    }
  }

  @override
  void dispose() {
    _nameController.dispose();
    _costController.dispose();
    super.dispose();
  }

  // Opens a Material date picker dialog
  Future<void> _pickDate() async {
    final picked = await showDatePicker(
      context: context,
      initialDate: _renewalDate,
      firstDate: DateTime(2020),
      lastDate: DateTime(2030),
    );
    if (picked != null) {
      setState(() => _renewalDate = picked);
    }
  }

  // Validates the form, builds a Subscription, and saves it
  Future<void> _save() async {
    if (!_formKey.currentState!.validate()) return;
    final subscription = Subscription(
      id: widget.subscriptionId ?? DateTime.now().millisecondsSinceEpoch.toString(),
      name: _nameController.text.trim(),
      cost: double.parse(_costController.text.trim()),
      billingCycle: _billingCycle,
      category: _category,
      renewalDate: _renewalDate,
    );
    if (_isEditing) {
      await subscriptionService.updateSubscription(subscription);
    } else {
      await subscriptionService.addSubscription(subscription);
    }
    if (mounted) context.pop();
  }

  // Deletes the subscription and navigates back
  Future<void> _delete() async {
    await subscriptionService.deleteSubscription(widget.subscriptionId!);
    if (mounted) context.pop();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(_isEditing ? 'Edit Subscription' : 'Add Subscription'),
        actions: [
          // Only show delete button when editing an existing subscription
          if (_isEditing)
            IconButton(
              icon: const Icon(Icons.delete),
              onPressed: _delete,
            ),
        ],
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Form(
          key: _formKey,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              TextFormField(
                controller: _nameController,
                decoration: const InputDecoration(labelText: 'Name'),
                validator: (value) =>
                    value == null || value.trim().isEmpty ? 'Name is required' : null,
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _costController,
                decoration: const InputDecoration(labelText: 'Cost', prefixText: '\$ '),
                keyboardType: TextInputType.number,
                validator: (value) {
                  if (value == null || value.trim().isEmpty) return 'Cost is required';
                  if (double.tryParse(value.trim()) == null) return 'Enter a valid number';
                  return null;
                },
              ),
              const SizedBox(height: 16),
              DropdownButtonFormField<BillingCycle>(
                value: _billingCycle,
                decoration: const InputDecoration(labelText: 'Billing Cycle'),
                items: BillingCycle.values
                    .map((c) => DropdownMenuItem(value: c, child: Text(c.name)))
                    .toList(),
                onChanged: (value) => setState(() => _billingCycle = value!),
              ),
              const SizedBox(height: 16),
              DropdownButtonFormField<SubscriptionCategory>(
                value: _category,
                decoration: const InputDecoration(labelText: 'Category'),
                items: SubscriptionCategory.values
                    .map((c) => DropdownMenuItem(value: c, child: Text(c.name)))
                    .toList(),
                onChanged: (value) => setState(() => _category = value!),
              ),
              const SizedBox(height: 16),
              ListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Renewal Date'),
                subtitle: Text(DateFormat.yMMMd().format(_renewalDate)),
                trailing: const Icon(Icons.calendar_today),
                onTap: _pickDate,
              ),
              const SizedBox(height: 24),
              FilledButton(
                onPressed: _save,
                child: Text(_isEditing ? 'Update' : 'Add Subscription'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

What does this code do?

  • _isEditing checks whether a subscriptionId was passed. If yes, initState loads the existing subscription data into the form fields.
  • TextFormField widgets for name and cost each have a validator function. The form won't save unless all validators pass.
  • DropdownButtonFormField maps each enum value (BillingCycle and SubscriptionCategory) into a selectable menu item.
  • _save builds a Subscription object from the form values and calls either addSubscription or updateSubscription on the service, then pops back to the list page.
  • The delete icon only appears when editing. It calls deleteSubscription and navigates back.
  • Save add_edit_page.dart.
  • In Chromium, navigate to the **Subscriptions** tab and tap the "+" button.
  • You should see the "Add Subscription" form with fields for Name, Cost, Billing Cycle dropdown, Category dropdown, a Renewal Date row, and an "Add Subscription" button at the bottom.

Seeing a red error screen instead of the form?

Check your terminal for the exact error. A common cause is a missing import. Make sure you have import '../main.dart'; and import 'package:intl/intl.dart'; at the top of the file.

If you see a type error mentioning subscriptionId, confirm that AddEditPage declares it as final String? subscriptionId; (nullable).

help me fix the error on my AddEditPage form

Wire up CRUD operations and verify persistence

The form is already wired to SubscriptionService in the code you just pasted. Now you will test all three operations (add, edit, delete) and confirm data survives a full page refresh.

  • In Chromium, tap the "+" button on the Subscriptions page.
  • Enter Netflix in the **Name** field.
  • Enter 15.99 in the **Cost** field.
  • Leave **Billing Cycle** as monthly and **Category** as entertainment.
  • Tap the **Renewal Date** row to open the date picker. Select any upcoming date and tap **OK**.
  • Tap **Add Subscription**.

You should be taken back to the Subscriptions list. The list now shows your Netflix entry with "entertainment" as a chip, the monthly cost as "$15.99/mo", and the formatted renewal date.

  • Tap the Netflix card to open the edit form. Verify that all fields are pre-filled with the values you entered.
  • Change the cost to 22.99 and tap **Update**.
  • Confirm the list now shows "$22.99/mo" for Netflix.
  • Refresh your browser with **Ctrl+Shift+R** (full reload). Navigate back to the **Subscriptions** tab.
  • Verify that Netflix still appears with the updated $22.99/mo cost. This confirms your data persisted in LocalStorage.

Why does the data survive a refresh?

The shared_preferences package stores data in the browser's LocalStorage when running on web. LocalStorage persists until you explicitly clear it or delete browser data. Your SubscriptionService writes the full subscription list as a JSON string every time you add, update, or delete.

  • Test deletion by tapping Netflix, then tapping the trash icon in the app bar. The list should return to the "No subscriptions yet" empty state.

Subscription disappears without saving changes?

If tapping a subscription navigates to an empty "Add Subscription" form instead of "Edit Subscription" with pre-filled data, check that your router in lib/router.dart passes the id parameter: AddEditPage(subscriptionId: id).

If data does not persist after a full page refresh, check that subscriptionService.init() is called in main.dart before runApp.

help me debug my subscription CRUD not persisting

✔️ Awesome, I've got everything!

Great. Double check that both files are saved and that your CRUD operations (add, edit, delete) all work correctly before moving on.

ⓧ I'd like to double check the full code

Here is the complete lib/pages/subscriptions_page.dart:

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../models/subscription.dart';
import '../services/subscription_service.dart';
import '../main.dart';

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

  @override
  State<SubscriptionsPage> createState() => _SubscriptionsPageState();
}

class _SubscriptionsPageState extends State<SubscriptionsPage> {
  List<Subscription> _subscriptions = [];

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

  void _loadSubscriptions() {
    setState(() {
      _subscriptions = subscriptionService.loadSubscriptions();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Subscriptions')),
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          await context.push('/subscriptions/add');
          _loadSubscriptions();
        },
        child: const Icon(Icons.add),
      ),
      body: _subscriptions.isEmpty
          ? const Center(child: Text('No subscriptions yet. Tap + to add one.'))
          : ListView.builder(
              padding: const EdgeInsets.all(16),
              itemCount: _subscriptions.length,
              itemBuilder: (context, index) {
                final sub = _subscriptions[index];
                return Card(
                  child: ListTile(
                    title: Text(sub.name),
                    subtitle: Text(
                      '${sub.billingCycle.name} - Next: ${DateFormat.yMMMd().format(sub.nextRenewalDate)}',
                    ),
                    trailing: Text(
                      '\$${sub.monthlyCost.toStringAsFixed(2)}/mo',
                      style: Theme.of(context).textTheme.titleMedium,
                    ),
                    leading: Chip(label: Text(sub.category.name)),
                    onTap: () async {
                      await context.push('/subscriptions/edit/${sub.id}');
                      _loadSubscriptions();
                    },
                  ),
                );
              },
            ),
    );
  }
}

And here is the complete lib/pages/add_edit_page.dart:

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../models/subscription.dart';
import '../services/subscription_service.dart';
import '../main.dart';

class AddEditPage extends StatefulWidget {
  final String? subscriptionId;

  const AddEditPage({super.key, this.subscriptionId});

  @override
  State<AddEditPage> createState() => _AddEditPageState();
}

class _AddEditPageState extends State<AddEditPage> {
  final _formKey = GlobalKey<FormState>();
  final _nameController = TextEditingController();
  final _costController = TextEditingController();
  BillingCycle _billingCycle = BillingCycle.monthly;
  SubscriptionCategory _category = SubscriptionCategory.entertainment;
  DateTime _renewalDate = DateTime.now();
  bool get _isEditing => widget.subscriptionId != null;

  @override
  void initState() {
    super.initState();
    if (_isEditing) {
      final subscriptions = subscriptionService.loadSubscriptions();
      final sub = subscriptions.firstWhere((s) => s.id == widget.subscriptionId);
      _nameController.text = sub.name;
      _costController.text = sub.cost.toString();
      _billingCycle = sub.billingCycle;
      _category = sub.category;
      _renewalDate = sub.renewalDate;
    }
  }

  @override
  void dispose() {
    _nameController.dispose();
    _costController.dispose();
    super.dispose();
  }

  Future<void> _pickDate() async {
    final picked = await showDatePicker(
      context: context,
      initialDate: _renewalDate,
      firstDate: DateTime(2020),
      lastDate: DateTime(2030),
    );
    if (picked != null) {
      setState(() => _renewalDate = picked);
    }
  }

  Future<void> _save() async {
    if (!_formKey.currentState!.validate()) return;
    final subscription = Subscription(
      id: widget.subscriptionId ?? DateTime.now().millisecondsSinceEpoch.toString(),
      name: _nameController.text.trim(),
      cost: double.parse(_costController.text.trim()),
      billingCycle: _billingCycle,
      category: _category,
      renewalDate: _renewalDate,
    );
    if (_isEditing) {
      await subscriptionService.updateSubscription(subscription);
    } else {
      await subscriptionService.addSubscription(subscription);
    }
    if (mounted) context.pop();
  }

  Future<void> _delete() async {
    await subscriptionService.deleteSubscription(widget.subscriptionId!);
    if (mounted) context.pop();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(_isEditing ? 'Edit Subscription' : 'Add Subscription'),
        actions: [
          if (_isEditing)
            IconButton(
              icon: const Icon(Icons.delete),
              onPressed: _delete,
            ),
        ],
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Form(
          key: _formKey,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              TextFormField(
                controller: _nameController,
                decoration: const InputDecoration(labelText: 'Name'),
                validator: (value) =>
                    value == null || value.trim().isEmpty ? 'Name is required' : null,
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _costController,
                decoration: const InputDecoration(labelText: 'Cost', prefixText: '\$ '),
                keyboardType: TextInputType.number,
                validator: (value) {
                  if (value == null || value.trim().isEmpty) return 'Cost is required';
                  if (double.tryParse(value.trim()) == null) return 'Enter a valid number';
                  return null;
                },
              ),
              const SizedBox(height: 16),
              DropdownButtonFormField<BillingCycle>(
                value: _billingCycle,
                decoration: const InputDecoration(labelText: 'Billing Cycle'),
                items: BillingCycle.values
                    .map((c) => DropdownMenuItem(value: c, child: Text(c.name)))
                    .toList(),
                onChanged: (value) => setState(() => _billingCycle = value!),
              ),
              const SizedBox(height: 16),
              DropdownButtonFormField<SubscriptionCategory>(
                value: _category,
                decoration: const InputDecoration(labelText: 'Category'),
                items: SubscriptionCategory.values
                    .map((c) => DropdownMenuItem(value: c, child: Text(c.name)))
                    .toList(),
                onChanged: (value) => setState(() => _category = value!),
              ),
              const SizedBox(height: 16),
              ListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Renewal Date'),
                subtitle: Text(DateFormat.yMMMd().format(_renewalDate)),
                trailing: const Icon(Icons.calendar_today),
                onTap: _pickDate,
              ),
              const SizedBox(height: 24),
              FilledButton(
                onPressed: _save,
                child: Text(_isEditing ? 'Update' : 'Add Subscription'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Your app now has a fully functional subscription list with add, edit, and delete capabilities. Next up, you will build the Dashboard with spending visualizations and budget tracking to give meaning to all that data.

Build the Dashboard with Visualizations and Budget Tracking

Your subscription list and form are working. You can add, edit, and delete subscriptions with data persisting across page refreshes.

Now you need a way to see the big picture. How much are you spending each month? Which categories eat the most budget? Which subscriptions are renewing soon? The Dashboard page will answer all of these questions with summary cards, a pie chart, a budget progress bar, and an urgent renewals list.

In this step, get ready to:
  • Build summary cards showing total monthly spend, active count, and next renewal.
  • Add a pie chart displaying spending breakdown by category.
  • Add budget tracking with color-coded progress and an urgent renewals list.
Build summary cards at the top

The dashboard needs data before it can display anything. You will set up a StatefulWidget that loads subscriptions from the service, then computes totals, category breakdowns, and upcoming renewals.

  • In VS Code, open lib/pages/dashboard_page.dart (the placeholder file from the previous step).
  • Select all the existing placeholder code and replace it with the following data layer and summary cards:
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../models/subscription.dart';
import '../services/subscription_service.dart';
import '../main.dart';

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

  @override
  State<DashboardPage> createState() => _DashboardPageState();
}

class _DashboardPageState extends State<DashboardPage> {
  List<Subscription> _subscriptions = [];
  double _budget = 100.0;

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

  // Load subscriptions and budget from persistent storage
  void _loadData() {
    setState(() {
      _subscriptions = subscriptionService.loadSubscriptions();
      _budget = subscriptionService.getMonthlyBudget();
    });
  }

  // Calculate total monthly cost across all subscriptions
  double get _totalMonthlyCost =>
      _subscriptions.fold(0.0, (sum, s) => sum + s.monthlyCost);

  // Find the subscription with the nearest renewal date
  Subscription? get _nextRenewal {
    if (_subscriptions.isEmpty) return null;
    return _subscriptions.reduce(
        (a, b) => a.daysUntilRenewal <= b.daysUntilRenewal ? a : b);
  }

  // Filter subscriptions renewing within 7 days
  List<Subscription> get _urgentRenewals =>
      _subscriptions.where((s) => s.daysUntilRenewal <= 7).toList();

  // Group monthly cost by category for the pie chart
  Map<SubscriptionCategory, double> get _categorySpend {
    final map = <SubscriptionCategory, double>{};
    for (final sub in _subscriptions) {
      map[sub.category] = (map[sub.category] ?? 0) + sub.monthlyCost;
    }
    return map;
  }

  // Assign a distinct color to each category
  Color _categoryColor(SubscriptionCategory category) {
    switch (category) {
      case SubscriptionCategory.entertainment:
        return Colors.purple;
      case SubscriptionCategory.productivity:
        return Colors.blue;
      case SubscriptionCategory.cloud:
        return Colors.teal;
      case SubscriptionCategory.music:
        return Colors.orange;
      case SubscriptionCategory.gaming:
        return Colors.red;
      case SubscriptionCategory.other:
        return Colors.grey;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Dashboard')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Summary cards row
            Row(
              children: [
                _SummaryCard(
                  title: 'Monthly Spend',
                  value: '\$${_totalMonthlyCost.toStringAsFixed(2)}',
                  icon: Icons.attach_money,
                ),
                const SizedBox(width: 12),
                _SummaryCard(
                  title: 'Active',
                  value: '${_subscriptions.length}',
                  icon: Icons.subscriptions,
                ),
                const SizedBox(width: 12),
                _SummaryCard(
                  title: 'Next Renewal',
                  value: _nextRenewal != null
                      ? '${_nextRenewal!.daysUntilRenewal}d'
                      : 'N/A',
                  subtitle: _nextRenewal?.name,
                  icon: Icons.calendar_today,
                ),
              ],
            ),
            const SizedBox(height: 24),
          ],
        ),
      ),
    );
  }
}

// Reusable card widget for the summary row
class _SummaryCard extends StatelessWidget {
  final String title;
  final String value;
  final String? subtitle;
  final IconData icon;

  const _SummaryCard({
    required this.title,
    required this.value,
    this.subtitle,
    required this.icon,
  });

  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: Card(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Icon(icon, size: 24, color: Theme.of(context).primaryColor),
              const SizedBox(height: 8),
              Text(title, style: Theme.of(context).textTheme.bodySmall),
              Text(value, style: Theme.of(context).textTheme.headlineSmall),
              if (subtitle != null)
                Text(subtitle!,
                    style: Theme.of(context).textTheme.bodySmall,
                    overflow: TextOverflow.ellipsis),
            ],
          ),
        ),
      ),
    );
  }
}

What does this code do?

The state layer loads subscription data from SubscriptionService and pre-computes everything the UI needs.

  • _totalMonthlyCost uses fold to sum the normalized monthly cost of every subscription (weekly costs get multiplied by 4.33, yearly divided by 12).
  • _nextRenewal finds the subscription closest to its renewal date using reduce.
  • _categorySpend groups spending into a Map keyed by category, which feeds the pie chart later.
  • _SummaryCard is a private reusable widget that wraps each metric in an Expanded Card so the three cards share the row evenly.
  • Save dashboard_page.dart.
  • Switch to your terminal running flutter run -d chrome and press r to hot restart.
  • Tap the Dashboard tab in the bottom navigation. You should see three cards in a row showing $0.00 monthly spend, 0 active subscriptions, and N/A for next renewal.

Cards not appearing?

  • Make sure you replaced ALL the placeholder code. If any old class declaration remains, Dart will throw a duplicate class error.
  • Check the import paths match your folder structure exactly. The file must be at lib/pages/dashboard_page.dart.

Why are my summary cards not showing on the Dashboard?

Add a pie chart showing spending by category

The fl_chart package provides a PieChart widget that turns your category spend map into a color-coded donut. Each slice represents one category's share of your monthly spending.

  • In dashboard_page.dart, find this line inside the Column's children list:
            const SizedBox(height: 24),
          ],
  • Replace that const SizedBox(height: 24), and closing ], with the following pie chart section:
            const SizedBox(height: 24),
            if (_categorySpend.isNotEmpty) ...[
              Text('Spending by Category',
                  style: Theme.of(context).textTheme.titleMedium),
              const SizedBox(height: 12),
              SizedBox(
                height: 200,
                child: PieChart(
                  PieChartData(
                    sections: _categorySpend.entries.map((entry) {
                      return PieChartSectionData(
                        value: entry.value,
                        title: '\$${entry.value.toStringAsFixed(0)}',
                        color: _categoryColor(entry.key),
                        radius: 60,
                        titleStyle: const TextStyle(
                            fontSize: 12, color: Colors.white),
                      );
                    }).toList(),
                    sectionsSpace: 2,
                  ),
                ),
              ),
              const SizedBox(height: 8),
              Wrap(
                spacing: 12,
                children: _categorySpend.keys.map((cat) {
                  return Row(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      Container(
                          width: 12,
                          height: 12,
                          color: _categoryColor(cat)),
                      const SizedBox(width: 4),
                      Text(cat.name),
                    ],
                  );
                }).toList(),
              ),
            ],
            const SizedBox(height: 24),
          ],

What does this code do?

PieChartData takes a list of PieChartSectionData objects. Each section maps to one entry in your _categorySpend map.

  • value sets the slice size (the dollar amount for that category).
  • title displays the dollar value directly on the slice.
  • sectionsSpace: 2 adds a thin gap between slices for visual clarity.
  • The Wrap widget below the chart acts as a legend, showing colored squares next to each category name.
  • Save dashboard_page.dart and hot restart with r.
  • Navigate to Subscriptions and add a test subscription (for example, Netflix at $15.99 monthly in the entertainment category). Then tap Dashboard.
  • You should see the pie chart appear with one colored slice and a legend label below it.

Pie chart not showing?

  • The pie chart only renders when _categorySpend.isNotEmpty is true. Make sure you added at least one subscription first.
  • If you see a red overflow error, check that the SizedBox wrapping the PieChart has height: 200.

Help me fix my fl_chart PieChart not rendering.

Add budget tracking and urgent renewals

A progress bar lets you see at a glance whether your subscriptions are within your monthly budget. The color shifts from green to amber to red as you approach and exceed the limit.

  • In the build method, find this line at the very top (before return Scaffold):
  @override
  Widget build(BuildContext context) {
    return Scaffold(
  • Add the budget ratio and color variables between the opening brace and return Scaffold, so it looks like this:
  @override
  Widget build(BuildContext context) {
    // Calculate how much of the budget is used
    final budgetRatio = _budget > 0 ? _totalMonthlyCost / _budget : 0.0;
    // Color shifts based on usage thresholds
    final budgetColor = budgetRatio > 0.9
        ? Colors.red
        : budgetRatio > 0.75
            ? Colors.amber
            : Colors.green;

    return Scaffold(

What are the color thresholds?

  • Green: spending is under 75% of budget.
  • Amber: spending is between 75% and 90%.
  • Red: spending exceeds 90% of budget.

The budgetRatio is clamped to 1.0 in the progress bar so it never overflows visually, even if you exceed 100%.

  • Next, find the const SizedBox(height: 24), that sits immediately after the closing ), of the Row (the summary cards row). Replace that single SizedBox line with the full budget section:
            const SizedBox(height: 24),
            Text('Budget', style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 8),
            LinearProgressIndicator(
              value: budgetRatio.clamp(0.0, 1.0),
              color: budgetColor,
              backgroundColor: Colors.grey.shade200,
              minHeight: 12,
              borderRadius: BorderRadius.circular(6),
            ),
            const SizedBox(height: 4),
            Text(
              '\$${_totalMonthlyCost.toStringAsFixed(2)} / \$${_budget.toStringAsFixed(2)}',
            ),
            const SizedBox(height: 24),

What does this code do?

LinearProgressIndicator draws a horizontal bar showing the ratio of current spend to budget. The .clamp(0.0, 1.0) call prevents the bar from exceeding its bounds if you go over budget. The text below shows the exact dollar amounts.

  • Now find the last const SizedBox(height: 24), at the bottom of the Column (the one after the pie chart's closing ],). Add the urgent renewals section directly below it, before the final ], that closes the Column's children:
            if (_urgentRenewals.isNotEmpty) ...[
              Text('Renewing Soon',
                  style: Theme.of(context).textTheme.titleMedium),
              const SizedBox(height: 8),
              ..._urgentRenewals.map((s) => Card(
                    color: Colors.orange.shade50,
                    child: ListTile(
                      leading: const Icon(Icons.warning, color: Colors.orange),
                      title: Text(s.name),
                      subtitle: Text(
                          'Renews in ${s.daysUntilRenewal} day${s.daysUntilRenewal == 1 ? '' : 's'} - ${DateFormat.yMMMd().format(s.nextRenewalDate)}'),
                      trailing: Text('\$${s.cost.toStringAsFixed(2)}'),
                    ),
                  )),
            ],

What does this code do?

The _urgentRenewals getter filters for subscriptions renewing within 7 days. Each urgent subscription renders as an orange-tinted Card with a warning icon, the subscription name, days remaining, formatted renewal date, and the cost.

The if (_urgentRenewals.isNotEmpty) guard ensures the section only appears when there are upcoming renewals.

  • Save dashboard_page.dart and hot restart with r.
  • On the Dashboard, you should now see the budget progress bar (green, showing $15.99 / $100.00 if you added the Netflix test subscription). If your test subscription renews within 7 days, an orange card appears in the Renewing Soon section.

Seeing a red screen or layout error?

  • Make sure the budgetRatio and budgetColor variables are declared inside the build method, before return Scaffold. Placing them outside causes a compile error.
  • Check that each if (...) ...[ block has a matching closing ],. Missing brackets cause the entire Column to fail.

Help me debug my Flutter dashboard layout error.

✔️ Awesome, I've got everything!

Great. Double check you have saved dashboard_page.dart and that the app is running without errors in Chromium.

ⓧ I'd like to double check the full code

Here is the complete lib/pages/dashboard_page.dart file:

import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../models/subscription.dart';
import '../services/subscription_service.dart';
import '../main.dart';

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

  @override
  State<DashboardPage> createState() => _DashboardPageState();
}

class _DashboardPageState extends State<DashboardPage> {
  List<Subscription> _subscriptions = [];
  double _budget = 100.0;

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

  void _loadData() {
    setState(() {
      _subscriptions = subscriptionService.loadSubscriptions();
      _budget = subscriptionService.getMonthlyBudget();
    });
  }

  double get _totalMonthlyCost =>
      _subscriptions.fold(0.0, (sum, s) => sum + s.monthlyCost);

  Subscription? get _nextRenewal {
    if (_subscriptions.isEmpty) return null;
    return _subscriptions.reduce(
        (a, b) => a.daysUntilRenewal <= b.daysUntilRenewal ? a : b);
  }

  List<Subscription> get _urgentRenewals =>
      _subscriptions.where((s) => s.daysUntilRenewal <= 7).toList();

  Map<SubscriptionCategory, double> get _categorySpend {
    final map = <SubscriptionCategory, double>{};
    for (final sub in _subscriptions) {
      map[sub.category] = (map[sub.category] ?? 0) + sub.monthlyCost;
    }
    return map;
  }

  Color _categoryColor(SubscriptionCategory category) {
    switch (category) {
      case SubscriptionCategory.entertainment:
        return Colors.purple;
      case SubscriptionCategory.productivity:
        return Colors.blue;
      case SubscriptionCategory.cloud:
        return Colors.teal;
      case SubscriptionCategory.music:
        return Colors.orange;
      case SubscriptionCategory.gaming:
        return Colors.red;
      case SubscriptionCategory.other:
        return Colors.grey;
    }
  }

  @override
  Widget build(BuildContext context) {
    final budgetRatio = _budget > 0 ? _totalMonthlyCost / _budget : 0.0;
    final budgetColor = budgetRatio > 0.9
        ? Colors.red
        : budgetRatio > 0.75
            ? Colors.amber
            : Colors.green;

    return Scaffold(
      appBar: AppBar(title: const Text('Dashboard')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                _SummaryCard(
                  title: 'Monthly Spend',
                  value: '\$${_totalMonthlyCost.toStringAsFixed(2)}',
                  icon: Icons.attach_money,
                ),
                const SizedBox(width: 12),
                _SummaryCard(
                  title: 'Active',
                  value: '${_subscriptions.length}',
                  icon: Icons.subscriptions,
                ),
                const SizedBox(width: 12),
                _SummaryCard(
                  title: 'Next Renewal',
                  value: _nextRenewal != null
                      ? '${_nextRenewal!.daysUntilRenewal}d'
                      : 'N/A',
                  subtitle: _nextRenewal?.name,
                  icon: Icons.calendar_today,
                ),
              ],
            ),
            const SizedBox(height: 24),
            Text('Budget', style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 8),
            LinearProgressIndicator(
              value: budgetRatio.clamp(0.0, 1.0),
              color: budgetColor,
              backgroundColor: Colors.grey.shade200,
              minHeight: 12,
              borderRadius: BorderRadius.circular(6),
            ),
            const SizedBox(height: 4),
            Text(
              '\$${_totalMonthlyCost.toStringAsFixed(2)} / \$${_budget.toStringAsFixed(2)}',
            ),
            const SizedBox(height: 24),
            if (_categorySpend.isNotEmpty) ...[
              Text('Spending by Category',
                  style: Theme.of(context).textTheme.titleMedium),
              const SizedBox(height: 12),
              SizedBox(
                height: 200,
                child: PieChart(
                  PieChartData(
                    sections: _categorySpend.entries.map((entry) {
                      return PieChartSectionData(
                        value: entry.value,
                        title: '\$${entry.value.toStringAsFixed(0)}',
                        color: _categoryColor(entry.key),
                        radius: 60,
                        titleStyle: const TextStyle(
                            fontSize: 12, color: Colors.white),
                      );
                    }).toList(),
                    sectionsSpace: 2,
                  ),
                ),
              ),
              const SizedBox(height: 8),
              Wrap(
                spacing: 12,
                children: _categorySpend.keys.map((cat) {
                  return Row(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      Container(
                          width: 12,
                          height: 12,
                          color: _categoryColor(cat)),
                      const SizedBox(width: 4),
                      Text(cat.name),
                    ],
                  );
                }).toList(),
              ),
            ],
            const SizedBox(height: 24),
            if (_urgentRenewals.isNotEmpty) ...[
              Text('Renewing Soon',
                  style: Theme.of(context).textTheme.titleMedium),
              const SizedBox(height: 8),
              ..._urgentRenewals.map((s) => Card(
                    color: Colors.orange.shade50,
                    child: ListTile(
                      leading: const Icon(Icons.warning, color: Colors.orange),
                      title: Text(s.name),
                      subtitle: Text(
                          'Renews in ${s.daysUntilRenewal} day${s.daysUntilRenewal == 1 ? '' : 's'} - ${DateFormat.yMMMd().format(s.nextRenewalDate)}'),
                      trailing: Text('\$${s.cost.toStringAsFixed(2)}'),
                    ),
                  )),
            ],
          ],
        ),
      ),
    );
  }
}

class _SummaryCard extends StatelessWidget {
  final String title;
  final String value;
  final String? subtitle;
  final IconData icon;

  const _SummaryCard({
    required this.title,
    required this.value,
    this.subtitle,
    required this.icon,
  });

  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: Card(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Icon(icon, size: 24, color: Theme.of(context).primaryColor),
              const SizedBox(height: 8),
              Text(title, style: Theme.of(context).textTheme.bodySmall),
              Text(value, style: Theme.of(context).textTheme.headlineSmall),
              if (subtitle != null)
                Text(subtitle!,
                    style: Theme.of(context).textTheme.bodySmall,
                    overflow: TextOverflow.ellipsis),
            ],
          ),
        ),
      ),
    );
  }
}

Your subscription manager is fully functional. You have a data model, persistent storage, CRUD operations, multi-page routing, interactive visualizations, and budget tracking all running in Chromium. Try refreshing the browser to confirm your subscriptions survive the reload.

Secret mission

Monthly Cost Bar Chart

Your dashboard shows what you're spending right now, but what about trends over time? In this bonus challenge, you'll add a bar chart that visualizes your monthly spend across the last 6 months. You'll need to figure out which subscriptions were active in each historical month and handle tricky edge cases like yearly billing.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The sub_manager project folder on your Desktop.
  • Chromium LocalStorage data for localhost (where your subscription data is saved).
  • Flutter SDK installed via snap.

Keep everything running

No action needed. Choose this if you are still actively building or plan to add more features soon.

  • Your subscription data lives in Chromium's LocalStorage. It persists until you clear browser data.
  • Your sub_manager folder stays on your Desktop, ready to open in VS Code.
  • Flutter SDK remains installed and available for future projects.

Pause - I'll come back to this later

Shut down any running processes to free up system memory. Your files and data stay intact.

  • If you still have flutter run -d chrome running in your terminal, press q to quit the dev server.
  • Close the Chromium tab that was serving your app.
  • Your LocalStorage data and project folder remain untouched for next time.

Delete - I don't want to use this again

Remove all project resources and start fresh. This cannot be undone.

  • Delete your project folder by running this command in the terminal:
rm -rf ~/Desktop/sub_manager
  • Clear your LocalStorage data in Chromium:
  • Open Chromium and press F12 to open DevTools.
  • Click the Application tab.
  • In the left sidebar, expand Local storage and click on http://localhost.
  • Click the Clear All icon (the circle with a line) above the key-value table.
  • Optionally, remove the Flutter SDK by running:
sudo snap remove flutter

This removes Flutter and the bundled Dart SDK from your system. Only do this if you do not plan to use Flutter for other projects.

Nice Work!

Nice Work!

Nice work! You just built a complete subscription management app with Flutter Web from scratch.

You've learned how to:

  • Built a multi-page Flutter Web application with typed Dart data models, JSON serialization, and null safety.
  • Implemented URL-based routing with go_router including parameterized routes, and persisted application state across browser sessions with shared_preferences.
  • Created interactive data visualizations with fl_chart (pie chart and summary cards) plus a budget tracker with color-coded progress indicators and renewal alerts.
  • Secret Mission: Added a historical monthly cost bar chart with per-month spend calculation for the last 6 months.

Ready to quiz yourself?