Files
SwiftInvoice/docs/superpowers/plans/2026-03-22-swiftinvoice-mvp.md
T

226 KiB
Raw Blame History

SwiftInvoice MVP Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a cross-platform offline-first invoicing app for freelancers using Flutter, targeting App Store submission in 9 weeks.

Architecture: Offline-first SQLite (Drift ORM) with UUID keys for future cloud sync. Unified documents table for invoices and estimates with discriminator column. Riverpod for reactive state. Feature-first folder structure. All monetary values stored as integer cents.

Tech Stack: Flutter 3.x, Dart 3.x, Drift 2.x (SQLite), Riverpod 2.x, pdf package, flutter_local_notifications, purchases_flutter (RevenueCat), Firebase Analytics

Spec reference: docs/SwiftInvoice_Implementation_Plan.md


Scope Breakdown — 4 Phases

Phase Tasks Produces
Phase 1: Foundation (Weeks 12) Tasks 17 Scaffold, DB, navigation, onboarding — app boots and persists business profile
Phase 2: Core Invoicing (Weeks 35) Tasks 814 Clients CRUD, invoice creator, dashboard — user can create clients + invoices
Phase 3: PDF & Pro Features (Weeks 67) Tasks 1521 PDF export, estimates, payments, notifications — full feature set
Phase 4: Monetization & Launch (Weeks 89) Tasks 2226 RevenueCat IAP, paywall, polish, store submission

File Structure

swift_invoice/
  lib/
    main.dart                                    # Entry point, ProviderScope
    app.dart                                     # MaterialApp.router, theme
    core/
      database/
        database.dart                            # AppDatabase class (Drift)
        database.g.dart                          # Generated (drift_dev)
        tables/
          businesses.dart                        # Businesses table definition
          clients.dart                           # Clients table definition
          documents.dart                         # Documents table definition
          line_items.dart                        # LineItems table definition
          payments.dart                          # Payments table definition
          app_settings.dart                      # AppSettings table definition
        daos/
          business_dao.dart                      # Business CRUD
          client_dao.dart                        # Client CRUD + search
          document_dao.dart                      # Document CRUD + status queries
          line_item_dao.dart                     # Line item CRUD
          payment_dao.dart                       # Payment CRUD + balance updates
          app_settings_dao.dart                  # Key-value settings
      models/
        enums.dart                               # DocumentType, DocumentStatus, PaymentMethod, DiscountType
      providers/
        database_provider.dart                   # Riverpod provider for AppDatabase
        business_provider.dart                   # Business state
        client_provider.dart                     # Client list/detail state
        document_provider.dart                   # Invoice/estimate state
        payment_provider.dart                    # Payment state
        settings_provider.dart                   # App settings state
        subscription_provider.dart               # Tier gating logic
      services/
        pdf_service.dart                         # PDF generation
        notification_service.dart                # Local notification scheduling
        subscription_service.dart                # RevenueCat wrapper
        document_calculator.dart                 # Subtotal/tax/discount/total logic
      utils/
        currency_formatter.dart                  # Cents → display string
        date_formatter.dart                      # ISO 8601 helpers
        uuid_generator.dart                      # UUID v4 wrapper
        validators.dart                          # Form field validators
      theme/
        app_theme.dart                           # Material 3 theme data
        app_colors.dart                          # Color constants
    features/
      onboarding/
        onboarding_screen.dart                   # Business profile setup form
        onboarding_notifier.dart                 # Form state + save logic
      invoices/
        invoice_dashboard_screen.dart            # Home: stats + list
        invoice_dashboard_notifier.dart          # Dashboard state
        invoice_creator_screen.dart              # Single-screen invoice form
        invoice_creator_notifier.dart            # Creator form state
        invoice_detail_screen.dart               # Read-only view + actions
        widgets/
          status_pill.dart                       # Colored status badge
          summary_card.dart                      # Stat card (Outstanding, Overdue, etc.)
          line_item_row.dart                      # Editable line item widget
      estimates/
        estimate_list_screen.dart                # Estimate tab
        estimate_creator_screen.dart             # Estimate form (reuses invoice form)
        estimate_creator_notifier.dart           # Estimate form state
      clients/
        client_list_screen.dart                  # Alphabetical client list
        client_form_screen.dart                  # Create/edit client
        client_detail_screen.dart                # Client detail + doc history
        client_notifier.dart                     # Client state
      payments/
        payment_bottom_sheet.dart                # Record payment form
        payment_notifier.dart                    # Payment state
      pdf/
        pdf_template.dart                        # PDF layout builder
        pdf_preview_screen.dart                  # Preview + share
      paywall/
        paywall_screen.dart                      # 3-plan paywall
        paywall_notifier.dart                    # Purchase flow state
      settings/
        settings_screen.dart                     # App settings
    shared/
      widgets/
        app_scaffold.dart                        # Bottom nav shell
        search_dropdown.dart                     # Searchable dropdown
        confirm_dialog.dart                      # Confirmation dialog
  test/
    core/
      database/
        daos/
          business_dao_test.dart
          client_dao_test.dart
          document_dao_test.dart
          line_item_dao_test.dart
          payment_dao_test.dart
          app_settings_dao_test.dart
      services/
        document_calculator_test.dart
        pdf_service_test.dart
        notification_service_test.dart
        subscription_service_test.dart
      utils/
        currency_formatter_test.dart
        validators_test.dart
    features/
      onboarding/
        onboarding_notifier_test.dart
      invoices/
        invoice_dashboard_notifier_test.dart
        invoice_creator_notifier_test.dart
      estimates/
        estimate_creator_notifier_test.dart
      clients/
        client_notifier_test.dart
      payments/
        payment_notifier_test.dart
      paywall/
        paywall_notifier_test.dart

Phase 1: Foundation (Weeks 12)

Task 1: Project Scaffold & Dependencies

Files:

  • Create: swift_invoice/ (Flutter project)

  • Modify: pubspec.yaml

  • Create: lib/main.dart

  • Create: lib/app.dart

  • Create: lib/core/theme/app_theme.dart

  • Create: lib/core/theme/app_colors.dart

  • Step 1: Create Flutter project

flutter create swift_invoice --org com.swiftinvoice --platforms ios,android
cd swift_invoice
  • Step 2: Add dependencies to pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  drift: ^2.22.0
  sqlite3_flutter_libs: ^0.5.0
  path_provider: ^2.1.0
  path: ^1.9.0
  flutter_riverpod: ^2.6.0
  riverpod_annotation: ^2.6.0
  uuid: ^4.5.0
  pdf: ^3.11.0
  printing: ^5.13.0
  share_plus: ^10.1.0
  flutter_local_notifications: ^18.0.0
  purchases_flutter: ^8.6.0
  firebase_core: ^3.8.0
  firebase_analytics: ^11.4.0
  go_router: ^14.6.0
  intl: ^0.19.0
  image_picker: ^1.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  drift_dev: ^2.22.0
  build_runner: ^2.4.0
  riverpod_generator: ^2.6.0
  mocktail: ^1.0.0
  flutter_lints: ^5.0.0
flutter pub get
  • Step 3: Create app theme
// lib/core/theme/app_colors.dart
import 'package:flutter/material.dart';

class AppColors {
  static const Color primary = Color(0xFF2563EB);      // Blue 600
  static const Color success = Color(0xFF16A34A);       // Green 600
  static const Color warning = Color(0xFFF59E0B);       // Amber 500
  static const Color error = Color(0xFFDC2626);         // Red 600
  static const Color neutral = Color(0xFF6B7280);       // Gray 500

  // Status pill colors
  static const Color statusDraft = Color(0xFF9CA3AF);   // Gray 400
  static const Color statusSent = Color(0xFF3B82F6);    // Blue 500
  static const Color statusPaid = Color(0xFF22C55E);    // Green 500
  static const Color statusOverdue = Color(0xFFEF4444); // Red 500
  static const Color statusPartial = Color(0xFFF97316); // Orange 500
  static const Color statusVoid = Color(0xFF6B7280);    // Gray 500
}
// lib/core/theme/app_theme.dart
import 'package:flutter/material.dart';
import 'app_colors.dart';

class AppTheme {
  static ThemeData light() {
    return ThemeData(
      useMaterial3: true,
      colorSchemeSeed: AppColors.primary,
      brightness: Brightness.light,
      inputDecorationTheme: const InputDecorationTheme(
        border: OutlineInputBorder(),
        contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      ),
      cardTheme: const CardTheme(
        elevation: 1,
        margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
      ),
    );
  }
}
  • Step 4: Create main.dart and app.dart
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const ProviderScope(child: SwiftInvoiceApp()));
}
// lib/app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/theme/app_theme.dart';

class SwiftInvoiceApp extends ConsumerWidget {
  const SwiftInvoiceApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp(
      title: 'SwiftInvoice',
      theme: AppTheme.light(),
      home: const Scaffold(
        body: Center(child: Text('SwiftInvoice')),
      ),
    );
  }
}
  • Step 5: Write smoke widget test
// test/app_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:swift_invoice/app.dart';

void main() {
  testWidgets('app renders without crashing', (tester) async {
    await tester.pumpWidget(const ProviderScope(child: SwiftInvoiceApp()));
    expect(find.text('SwiftInvoice'), findsOneWidget);
  });
}
  • Step 6: Run smoke test
flutter test test/app_test.dart -v

Expected: PASS.

  • Step 7: Verify app builds and runs
flutter run -d chrome  # or connected device

Expected: App launches showing "SwiftInvoice" centered text.

  • Step 8: Commit
git init
git add .
git commit -m "feat: scaffold Flutter project with dependencies and Material 3 theme"

Task 2: Drift Database — Table Definitions

Files:

  • Create: lib/core/models/enums.dart

  • Create: lib/core/database/tables/businesses.dart

  • Create: lib/core/database/tables/clients.dart

  • Create: lib/core/database/tables/documents.dart

  • Create: lib/core/database/tables/line_items.dart

  • Create: lib/core/database/tables/payments.dart

  • Create: lib/core/database/tables/app_settings.dart

  • Step 1: Define enums

// lib/core/models/enums.dart

enum DocumentType {
  invoice,
  estimate;

  String get value => name;

  static DocumentType fromString(String s) =>
      DocumentType.values.firstWhere((e) => e.name == s);
}

enum DocumentStatus {
  // Invoice statuses
  draft,
  sent,
  partial,
  paid,
  overdue,
  void_,

  // Estimate statuses
  accepted,
  declined,
  expired,
  converted;

  String get value => this == void_ ? 'void' : name;

  String get displayName => switch (this) {
        void_ => 'Void',
        _ => name[0].toUpperCase() + name.substring(1),
      };

  static DocumentStatus fromString(String s) {
    if (s == 'void') return DocumentStatus.void_;
    return DocumentStatus.values.firstWhere((e) => e.name == s);
  }
}

enum PaymentMethod {
  cash,
  card,
  bankTransfer,
  check,
  other;

  String get value => switch (this) {
        bankTransfer => 'bank_transfer',
        _ => name,
      };

  static PaymentMethod fromString(String s) => switch (s) {
        'bank_transfer' => PaymentMethod.bankTransfer,
        _ => PaymentMethod.values.firstWhere((e) => e.name == s),
      };
}

enum DiscountType {
  percentage,
  fixed;

  String get value => name;

  static DiscountType fromString(String s) =>
      DiscountType.values.firstWhere((e) => e.name == s);
}
  • Step 2: Define all Drift table classes
// lib/core/database/tables/businesses.dart
import 'package:drift/drift.dart';

class Businesses extends Table {
  TextColumn get id => text()();
  TextColumn get name => text()();
  TextColumn get email => text().nullable()();
  TextColumn get phone => text().nullable()();
  TextColumn get addressLine1 => text().nullable()();
  TextColumn get addressLine2 => text().nullable()();
  TextColumn get city => text().nullable()();
  TextColumn get state => text().nullable()();
  TextColumn get postalCode => text().nullable()();
  TextColumn get countryCode => text().withDefault(const Constant('US'))();
  TextColumn get taxNumber => text().nullable()();
  TextColumn get logoPath => text().nullable()();
  TextColumn get defaultCurrency => text().withDefault(const Constant('USD'))();
  IntColumn get defaultTaxRate => integer().withDefault(const Constant(0))();
  IntColumn get defaultPaymentTermsDays => integer().withDefault(const Constant(30))();
  TextColumn get invoicePrefix => text().withDefault(const Constant('INV'))();
  TextColumn get estimatePrefix => text().withDefault(const Constant('EST'))();
  IntColumn get nextInvoiceNumber => integer().withDefault(const Constant(1))();
  IntColumn get nextEstimateNumber => integer().withDefault(const Constant(1))();
  TextColumn get createdAt => text()();
  TextColumn get updatedAt => text()();

  @override
  Set<Column> get primaryKey => {id};
}
// lib/core/database/tables/clients.dart
import 'package:drift/drift.dart';

class Clients extends Table {
  TextColumn get id => text()();
  TextColumn get businessId => text().references(Businesses, #id)();
  TextColumn get name => text()();
  TextColumn get email => text().nullable()();
  TextColumn get phone => text().nullable()();
  TextColumn get addressLine1 => text().nullable()();
  TextColumn get addressLine2 => text().nullable()();
  TextColumn get city => text().nullable()();
  TextColumn get state => text().nullable()();
  TextColumn get postalCode => text().nullable()();
  TextColumn get countryCode => text().nullable()();
  TextColumn get notes => text().nullable()();
  IntColumn get outstandingBalance => integer().withDefault(const Constant(0))();
  IntColumn get isDeleted => integer().withDefault(const Constant(0))();
  TextColumn get createdAt => text()();
  TextColumn get updatedAt => text()();

  @override
  Set<Column> get primaryKey => {id};
}
// lib/core/database/tables/documents.dart
import 'package:drift/drift.dart';

class Documents extends Table {
  TextColumn get id => text()();
  TextColumn get businessId => text()();
  TextColumn get clientId => text()();
  TextColumn get documentType => text()();
  TextColumn get documentNumber => text().unique()();
  TextColumn get status => text()();
  TextColumn get issueDate => text()();
  TextColumn get dueDate => text().nullable()();
  TextColumn get currencyCode => text().withDefault(const Constant('USD'))();
  IntColumn get subtotal => integer().withDefault(const Constant(0))();
  IntColumn get taxRate => integer().withDefault(const Constant(0))();
  IntColumn get taxAmount => integer().withDefault(const Constant(0))();
  TextColumn get discountType => text().nullable()();
  IntColumn get discountValue => integer().withDefault(const Constant(0))();
  IntColumn get discountAmount => integer().withDefault(const Constant(0))();
  IntColumn get total => integer().withDefault(const Constant(0))();
  IntColumn get amountPaid => integer().withDefault(const Constant(0))();
  IntColumn get amountDue => integer().withDefault(const Constant(0))();
  TextColumn get docNotes => text().nullable()();
  TextColumn get convertedFromId => text().nullable()();
  IntColumn get isDeleted => integer().withDefault(const Constant(0))();
  TextColumn get createdAt => text()();
  TextColumn get updatedAt => text()();

  @override
  Set<Column> get primaryKey => {id};
}
// lib/core/database/tables/line_items.dart
import 'package:drift/drift.dart';

class LineItems extends Table {
  TextColumn get id => text()();
  TextColumn get documentId => text()();
  TextColumn get description => text()();
  RealColumn get quantity => real().withDefault(const Constant(1.0))();
  IntColumn get unitPrice => integer()();
  IntColumn get amount => integer()();
  IntColumn get sortOrder => integer().withDefault(const Constant(0))();
  TextColumn get createdAt => text()();
  TextColumn get updatedAt => text()();

  @override
  Set<Column> get primaryKey => {id};
}
// lib/core/database/tables/payments.dart
import 'package:drift/drift.dart';

class Payments extends Table {
  TextColumn get id => text()();
  TextColumn get documentId => text()();
  IntColumn get amount => integer()();
  TextColumn get method => text()();
  TextColumn get paidAt => text()();
  TextColumn get notes => text().nullable()();
  TextColumn get createdAt => text()();
  TextColumn get updatedAt => text()();

  @override
  Set<Column> get primaryKey => {id};
}
// lib/core/database/tables/app_settings.dart
import 'package:drift/drift.dart';

class AppSettings extends Table {
  TextColumn get key => text()();
  TextColumn get value => text()();
  TextColumn get updatedAt => text()();

  @override
  Set<Column> get primaryKey => {key};
}
  • Step 3: Commit
git add lib/core/models/enums.dart lib/core/database/tables/
git commit -m "feat: define Drift table classes and domain enums for all 6 MVP tables"

Task 3: Drift Database — AppDatabase & Code Generation

Files:

  • Create: lib/core/database/database.dart

  • Create: lib/core/providers/database_provider.dart

  • Create: lib/core/utils/uuid_generator.dart

  • Step 1: Create AppDatabase class

// lib/core/database/database.dart
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

import 'tables/businesses.dart';
import 'tables/clients.dart';
import 'tables/documents.dart';
import 'tables/line_items.dart';
import 'tables/payments.dart';
import 'tables/app_settings.dart';

part 'database.g.dart';

@DriftDatabase(tables: [
  Businesses,
  Clients,
  Documents,
  LineItems,
  Payments,
  AppSettings,
])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  AppDatabase.forTesting(super.e);

  @override
  int get schemaVersion => 1;

  @override
  MigrationStrategy get migration => MigrationStrategy(
        onCreate: (Migrator m) async {
          await m.createAll();
          // Create indexes
          await customStatement(
              'CREATE INDEX IF NOT EXISTS idx_clients_business ON clients(business_id, is_deleted)');
          await customStatement(
              'CREATE INDEX IF NOT EXISTS idx_documents_client ON documents(client_id, document_type, status)');
          await customStatement(
              'CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status, due_date)');
          await customStatement(
              'CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(document_type, is_deleted)');
          await customStatement(
              'CREATE INDEX IF NOT EXISTS idx_line_items_doc ON line_items(document_id, sort_order)');
          await customStatement(
              'CREATE INDEX IF NOT EXISTS idx_payments_doc ON payments(document_id)');
        },
      );
}

LazyDatabase _openConnection() {
  return LazyDatabase(() async {
    final dbFolder = await getApplicationDocumentsDirectory();
    final file = File(p.join(dbFolder.path, 'swift_invoice.sqlite'));
    return NativeDatabase.createInBackground(file);
  });
}
  • Step 2: Create UUID utility
// lib/core/utils/uuid_generator.dart
import 'package:uuid/uuid.dart';

const _uuid = Uuid();

String generateUuid() => _uuid.v4();
  • Step 3: Create database provider
// lib/core/providers/database_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/database.dart';

final databaseProvider = Provider<AppDatabase>((ref) {
  final db = AppDatabase();
  ref.onDispose(() => db.close());
  return db;
});
  • Step 4: Run code generation
dart run build_runner build --delete-conflicting-outputs

Expected: database.g.dart generated without errors.

  • Step 5: Write schema validation test
// test/core/database/database_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';

void main() {
  late AppDatabase db;

  setUp(() {
    db = AppDatabase.forTesting(NativeDatabase.memory());
  });

  tearDown(() => db.close());

  test('database creates all 6 tables without error', () async {
    // Verify each table is accessible by selecting from it
    expect(await db.select(db.businesses).get(), isEmpty);
    expect(await db.select(db.clients).get(), isEmpty);
    expect(await db.select(db.documents).get(), isEmpty);
    expect(await db.select(db.lineItems).get(), isEmpty);
    expect(await db.select(db.payments).get(), isEmpty);
    expect(await db.select(db.appSettings).get(), isEmpty);
  });

  test('schema version is 1', () {
    expect(db.schemaVersion, 1);
  });
}
  • Step 6: Run schema test
flutter test test/core/database/database_test.dart -v

Expected: All 2 tests PASS.

  • Step 7: Verify app still compiles
flutter build apk --debug 2>&1 | tail -5

Expected: BUILD SUCCESSFUL

  • Step 8: Commit
git add lib/core/database/database.dart lib/core/database/database.g.dart \
  lib/core/utils/uuid_generator.dart lib/core/providers/database_provider.dart \
  test/core/database/database_test.dart
git commit -m "feat: wire up AppDatabase with Drift code generation, indexes, and schema tests"

Task 4: DAOs — Business & AppSettings

Files:

  • Create: lib/core/database/daos/business_dao.dart

  • Create: lib/core/database/daos/app_settings_dao.dart

  • Create: test/core/database/daos/business_dao_test.dart

  • Create: test/core/database/daos/app_settings_dao_test.dart

  • Step 1: Write failing tests for BusinessDao

// test/core/database/daos/business_dao_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';

void main() {
  late AppDatabase db;
  late BusinessDao dao;

  setUp(() {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    dao = BusinessDao(db);
  });

  tearDown(() => db.close());

  test('createBusiness inserts and returns a business', () async {
    final biz = await dao.createBusiness(name: 'Test Co');
    expect(biz.name, 'Test Co');
    expect(biz.id, isNotEmpty);
    expect(biz.defaultCurrency, 'USD');
    expect(biz.defaultPaymentTermsDays, 30);
  });

  test('getBusiness returns the single business row', () async {
    await dao.createBusiness(name: 'Test Co');
    final biz = await dao.getBusiness();
    expect(biz, isNotNull);
    expect(biz!.name, 'Test Co');
  });

  test('getBusiness returns null when no business exists', () async {
    final biz = await dao.getBusiness();
    expect(biz, isNull);
  });

  test('updateBusiness modifies fields', () async {
    final biz = await dao.createBusiness(name: 'Old Name');
    await dao.updateBusiness(biz.id, name: 'New Name', email: 'a@b.com');
    final updated = await dao.getBusiness();
    expect(updated!.name, 'New Name');
    expect(updated.email, 'a@b.com');
  });

  test('incrementInvoiceNumber returns current and increments', () async {
    final biz = await dao.createBusiness(name: 'Co');
    final num1 = await dao.getAndIncrementInvoiceNumber(biz.id);
    expect(num1, 1);
    final num2 = await dao.getAndIncrementInvoiceNumber(biz.id);
    expect(num2, 2);
  });

  test('incrementEstimateNumber returns current and increments', () async {
    final biz = await dao.createBusiness(name: 'Co');
    final num1 = await dao.getAndIncrementEstimateNumber(biz.id);
    expect(num1, 1);
    final num2 = await dao.getAndIncrementEstimateNumber(biz.id);
    expect(num2, 2);
  });
}
  • Step 2: Run test to verify it fails
flutter test test/core/database/daos/business_dao_test.dart

Expected: FAIL — BusinessDao not found.

  • Step 3: Implement BusinessDao
// lib/core/database/daos/business_dao.dart
import 'package:drift/drift.dart';
import '../database.dart';
import '../../utils/uuid_generator.dart';

class BusinessDao {
  final AppDatabase _db;

  BusinessDao(this._db);

  Future<Business> createBusiness({
    required String name,
    String? email,
    String? phone,
    String? logoPath,
    String defaultCurrency = 'USD',
    int defaultTaxRate = 0,
    int defaultPaymentTermsDays = 30,
  }) async {
    final now = DateTime.now().toIso8601String();
    final id = generateUuid();

    await _db.into(_db.businesses).insert(BusinessesCompanion.insert(
          id: id,
          name: name,
          email: Value(email),
          phone: Value(phone),
          logoPath: Value(logoPath),
          defaultCurrency: Value(defaultCurrency),
          defaultTaxRate: Value(defaultTaxRate),
          defaultPaymentTermsDays: Value(defaultPaymentTermsDays),
          createdAt: now,
          updatedAt: now,
        ));

    return (await (_db.select(_db.businesses)
              ..where((t) => t.id.equals(id)))
            .getSingle());
  }

  Future<Business?> getBusiness() async {
    final results = await _db.select(_db.businesses).get();
    return results.isEmpty ? null : results.first;
  }

  Future<void> updateBusiness(
    String id, {
    String? name,
    String? email,
    String? phone,
    String? addressLine1,
    String? addressLine2,
    String? city,
    String? state,
    String? postalCode,
    String? countryCode,
    String? taxNumber,
    String? logoPath,
    String? defaultCurrency,
    int? defaultTaxRate,
    int? defaultPaymentTermsDays,
    String? invoicePrefix,
    String? estimatePrefix,
  }) async {
    final now = DateTime.now().toIso8601String();
    await (_db.update(_db.businesses)..where((t) => t.id.equals(id))).write(
      BusinessesCompanion(
        name: name != null ? Value(name) : const Value.absent(),
        email: email != null ? Value(email) : const Value.absent(),
        phone: phone != null ? Value(phone) : const Value.absent(),
        addressLine1:
            addressLine1 != null ? Value(addressLine1) : const Value.absent(),
        addressLine2:
            addressLine2 != null ? Value(addressLine2) : const Value.absent(),
        city: city != null ? Value(city) : const Value.absent(),
        state: state != null ? Value(state) : const Value.absent(),
        postalCode:
            postalCode != null ? Value(postalCode) : const Value.absent(),
        countryCode:
            countryCode != null ? Value(countryCode) : const Value.absent(),
        taxNumber: taxNumber != null ? Value(taxNumber) : const Value.absent(),
        logoPath: logoPath != null ? Value(logoPath) : const Value.absent(),
        defaultCurrency: defaultCurrency != null
            ? Value(defaultCurrency)
            : const Value.absent(),
        defaultTaxRate:
            defaultTaxRate != null ? Value(defaultTaxRate) : const Value.absent(),
        defaultPaymentTermsDays: defaultPaymentTermsDays != null
            ? Value(defaultPaymentTermsDays)
            : const Value.absent(),
        invoicePrefix:
            invoicePrefix != null ? Value(invoicePrefix) : const Value.absent(),
        estimatePrefix: estimatePrefix != null
            ? Value(estimatePrefix)
            : const Value.absent(),
        updatedAt: Value(now),
      ),
    );
  }

  Future<int> getAndIncrementInvoiceNumber(String businessId) async {
    final biz = await (_db.select(_db.businesses)
          ..where((t) => t.id.equals(businessId)))
        .getSingle();
    final current = biz.nextInvoiceNumber;
    await (_db.update(_db.businesses)
          ..where((t) => t.id.equals(businessId)))
        .write(BusinessesCompanion(
      nextInvoiceNumber: Value(current + 1),
      updatedAt: Value(DateTime.now().toIso8601String()),
    ));
    return current;
  }

  Future<int> getAndIncrementEstimateNumber(String businessId) async {
    final biz = await (_db.select(_db.businesses)
          ..where((t) => t.id.equals(businessId)))
        .getSingle();
    final current = biz.nextEstimateNumber;
    await (_db.update(_db.businesses)
          ..where((t) => t.id.equals(businessId)))
        .write(BusinessesCompanion(
      nextEstimateNumber: Value(current + 1),
      updatedAt: Value(DateTime.now().toIso8601String()),
    ));
    return current;
  }
}
  • Step 4: Run tests to verify they pass
flutter test test/core/database/daos/business_dao_test.dart -v

Expected: All 6 tests PASS.

  • Step 5: Write failing tests for AppSettingsDao
// test/core/database/daos/app_settings_dao_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';

void main() {
  late AppDatabase db;
  late AppSettingsDao dao;

  setUp(() {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    dao = AppSettingsDao(db);
  });

  tearDown(() => db.close());

  test('getSetting returns null for non-existent key', () async {
    expect(await dao.getSetting('foo'), isNull);
  });

  test('setSetting creates and retrieves a setting', () async {
    await dao.setSetting('theme', 'dark');
    expect(await dao.getSetting('theme'), 'dark');
  });

  test('setSetting overwrites existing value', () async {
    await dao.setSetting('theme', 'dark');
    await dao.setSetting('theme', 'light');
    expect(await dao.getSetting('theme'), 'light');
  });

  test('isOnboardingComplete returns false by default', () async {
    expect(await dao.isOnboardingComplete(), isFalse);
  });

  test('markOnboardingComplete sets flag', () async {
    await dao.markOnboardingComplete();
    expect(await dao.isOnboardingComplete(), isTrue);
  });
}
  • Step 6: Run tests to verify they fail
flutter test test/core/database/daos/app_settings_dao_test.dart

Expected: FAIL — AppSettingsDao not found.

  • Step 7: Implement AppSettingsDao
// lib/core/database/daos/app_settings_dao.dart
import 'package:drift/drift.dart';
import '../database.dart';

class AppSettingsDao {
  final AppDatabase _db;

  AppSettingsDao(this._db);

  Future<String?> getSetting(String key) async {
    final results = await (_db.select(_db.appSettings)
          ..where((t) => t.key.equals(key)))
        .get();
    return results.isEmpty ? null : results.first.value;
  }

  Future<void> setSetting(String key, String value) async {
    final now = DateTime.now().toIso8601String();
    await _db.into(_db.appSettings).insertOnConflictUpdate(
          AppSettingsCompanion.insert(
            key: key,
            value: value,
            updatedAt: now,
          ),
        );
  }

  Future<bool> isOnboardingComplete() async {
    final val = await getSetting('onboarding_complete');
    return val == 'true';
  }

  Future<void> markOnboardingComplete() async {
    await setSetting('onboarding_complete', 'true');
  }

  Future<String> getSubscriptionTier() async {
    return await getSetting('subscription_tier') ?? 'free';
  }

  Future<void> setSubscriptionTier(String tier) async {
    await setSetting('subscription_tier', tier);
  }
}
  • Step 8: Run all DAO tests
flutter test test/core/database/daos/ -v

Expected: All 11 tests PASS.

  • Step 9: Commit
git add lib/core/database/daos/business_dao.dart lib/core/database/daos/app_settings_dao.dart \
  test/core/database/daos/
git commit -m "feat: implement BusinessDao and AppSettingsDao with full test coverage"

Task 5: DAOs — Client & Document

Files:

  • Create: lib/core/database/daos/client_dao.dart

  • Create: lib/core/database/daos/document_dao.dart

  • Create: lib/core/database/daos/line_item_dao.dart

  • Create: test/core/database/daos/client_dao_test.dart

  • Create: test/core/database/daos/document_dao_test.dart

  • Create: test/core/database/daos/line_item_dao_test.dart

  • Step 1: Write failing tests for ClientDao

// test/core/database/daos/client_dao_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';

void main() {
  late AppDatabase db;
  late ClientDao dao;
  late String businessId;

  setUp(() async {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    dao = ClientDao(db);
    final biz = await BusinessDao(db).createBusiness(name: 'Test Co');
    businessId = biz.id;
  });

  tearDown(() => db.close());

  test('createClient inserts and returns client', () async {
    final client = await dao.createClient(
      businessId: businessId,
      name: 'Jane Doe',
      email: 'jane@example.com',
    );
    expect(client.name, 'Jane Doe');
    expect(client.email, 'jane@example.com');
    expect(client.outstandingBalance, 0);
  });

  test('getActiveClients returns only non-deleted clients', () async {
    await dao.createClient(businessId: businessId, name: 'Active');
    final deleted = await dao.createClient(businessId: businessId, name: 'Deleted');
    await dao.softDeleteClient(deleted.id);

    final clients = await dao.getActiveClients(businessId);
    expect(clients.length, 1);
    expect(clients.first.name, 'Active');
  });

  test('searchClients matches partial name', () async {
    await dao.createClient(businessId: businessId, name: 'Jane Doe');
    await dao.createClient(businessId: businessId, name: 'John Smith');

    final results = await dao.searchClients(businessId, 'jan');
    expect(results.length, 1);
    expect(results.first.name, 'Jane Doe');
  });

  test('getActiveClientCount counts non-deleted clients', () async {
    await dao.createClient(businessId: businessId, name: 'A');
    await dao.createClient(businessId: businessId, name: 'B');
    final c = await dao.createClient(businessId: businessId, name: 'C');
    await dao.softDeleteClient(c.id);

    expect(await dao.getActiveClientCount(businessId), 2);
  });

  test('updateOutstandingBalance sets correct value', () async {
    final client = await dao.createClient(businessId: businessId, name: 'A');
    await dao.updateOutstandingBalance(client.id, 5000);

    final updated = await dao.getClient(client.id);
    expect(updated!.outstandingBalance, 5000);
  });
}
  • Step 2: Run tests to verify they fail
flutter test test/core/database/daos/client_dao_test.dart

Expected: FAIL — ClientDao not found.

  • Step 3: Implement ClientDao
// lib/core/database/daos/client_dao.dart
import 'package:drift/drift.dart';
import '../database.dart';
import '../../utils/uuid_generator.dart';

class ClientDao {
  final AppDatabase _db;

  ClientDao(this._db);

  Future<Client> createClient({
    required String businessId,
    required String name,
    String? email,
    String? phone,
    String? addressLine1,
    String? addressLine2,
    String? city,
    String? state,
    String? postalCode,
    String? countryCode,
    String? notes,
  }) async {
    final now = DateTime.now().toIso8601String();
    final id = generateUuid();

    await _db.into(_db.clients).insert(ClientsCompanion.insert(
          id: id,
          businessId: businessId,
          name: name,
          email: Value(email),
          phone: Value(phone),
          addressLine1: Value(addressLine1),
          addressLine2: Value(addressLine2),
          city: Value(city),
          state: Value(state),
          postalCode: Value(postalCode),
          countryCode: Value(countryCode),
          notes: Value(notes),
          createdAt: now,
          updatedAt: now,
        ));

    return (_db.select(_db.clients)..where((t) => t.id.equals(id))).getSingle();
  }

  Future<Client?> getClient(String id) async {
    final results = await (_db.select(_db.clients)
          ..where((t) => t.id.equals(id)))
        .get();
    return results.isEmpty ? null : results.first;
  }

  Future<List<Client>> getActiveClients(String businessId) async {
    return (_db.select(_db.clients)
          ..where((t) =>
              t.businessId.equals(businessId) & t.isDeleted.equals(0))
          ..orderBy([(t) => OrderingTerm.asc(t.name)]))
        .get();
  }

  Future<List<Client>> searchClients(String businessId, String query) async {
    return (_db.select(_db.clients)
          ..where((t) =>
              t.businessId.equals(businessId) &
              t.isDeleted.equals(0) &
              t.name.lower().like('%${query.toLowerCase()}%'))
          ..orderBy([(t) => OrderingTerm.asc(t.name)]))
        .get();
  }

  Future<int> getActiveClientCount(String businessId) async {
    final count = _db.clients.id.count();
    final query = _db.selectOnly(_db.clients)
      ..addColumns([count])
      ..where(
          _db.clients.businessId.equals(businessId) & _db.clients.isDeleted.equals(0));
    final result = await query.getSingle();
    return result.read(count)!;
  }

  Future<void> updateClient(String id, {
    String? name,
    String? email,
    String? phone,
    String? addressLine1,
    String? addressLine2,
    String? city,
    String? state,
    String? postalCode,
    String? countryCode,
    String? notes,
  }) async {
    await (_db.update(_db.clients)..where((t) => t.id.equals(id))).write(
      ClientsCompanion(
        name: name != null ? Value(name) : const Value.absent(),
        email: email != null ? Value(email) : const Value.absent(),
        phone: phone != null ? Value(phone) : const Value.absent(),
        addressLine1: addressLine1 != null ? Value(addressLine1) : const Value.absent(),
        addressLine2: addressLine2 != null ? Value(addressLine2) : const Value.absent(),
        city: city != null ? Value(city) : const Value.absent(),
        state: state != null ? Value(state) : const Value.absent(),
        postalCode: postalCode != null ? Value(postalCode) : const Value.absent(),
        countryCode: countryCode != null ? Value(countryCode) : const Value.absent(),
        notes: notes != null ? Value(notes) : const Value.absent(),
        updatedAt: Value(DateTime.now().toIso8601String()),
      ),
    );
  }

  Future<void> softDeleteClient(String id) async {
    await (_db.update(_db.clients)..where((t) => t.id.equals(id))).write(
      ClientsCompanion(
        isDeleted: const Value(1),
        updatedAt: Value(DateTime.now().toIso8601String()),
      ),
    );
  }

  Future<void> updateOutstandingBalance(String clientId, int balanceCents) async {
    await (_db.update(_db.clients)..where((t) => t.id.equals(clientId))).write(
      ClientsCompanion(
        outstandingBalance: Value(balanceCents),
        updatedAt: Value(DateTime.now().toIso8601String()),
      ),
    );
  }
}
  • Step 4: Run client tests
flutter test test/core/database/daos/client_dao_test.dart -v

Expected: All 5 tests PASS.

  • Step 5: Write failing tests for DocumentDao
// test/core/database/daos/document_dao_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/document_dao.dart';

void main() {
  late AppDatabase db;
  late DocumentDao dao;
  late String businessId;
  late String clientId;

  setUp(() async {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    dao = DocumentDao(db);
    final biz = await BusinessDao(db).createBusiness(name: 'Co');
    businessId = biz.id;
    final client = await ClientDao(db).createClient(
      businessId: businessId,
      name: 'Client',
    );
    clientId = client.id;
  });

  tearDown(() => db.close());

  test('createDocument inserts an invoice', () async {
    final doc = await dao.createDocument(
      businessId: businessId,
      clientId: clientId,
      documentType: 'invoice',
      documentNumber: 'INV-001',
      status: 'draft',
      issueDate: '2026-03-22',
    );
    expect(doc.documentType, 'invoice');
    expect(doc.documentNumber, 'INV-001');
    expect(doc.status, 'draft');
  });

  test('getInvoices returns only non-deleted invoices', () async {
    await dao.createDocument(
      businessId: businessId, clientId: clientId,
      documentType: 'invoice', documentNumber: 'INV-001',
      status: 'draft', issueDate: '2026-03-22',
    );
    final est = await dao.createDocument(
      businessId: businessId, clientId: clientId,
      documentType: 'estimate', documentNumber: 'EST-001',
      status: 'draft', issueDate: '2026-03-22',
    );

    final invoices = await dao.getDocumentsByType(businessId, 'invoice');
    expect(invoices.length, 1);
    expect(invoices.first.documentNumber, 'INV-001');
  });

  test('updateStatus changes document status', () async {
    final doc = await dao.createDocument(
      businessId: businessId, clientId: clientId,
      documentType: 'invoice', documentNumber: 'INV-001',
      status: 'draft', issueDate: '2026-03-22',
    );
    await dao.updateStatus(doc.id, 'sent');
    final updated = await dao.getDocument(doc.id);
    expect(updated!.status, 'sent');
  });

  test('getOverdueInvoices returns invoices past due date', () async {
    await dao.createDocument(
      businessId: businessId, clientId: clientId,
      documentType: 'invoice', documentNumber: 'INV-001',
      status: 'sent', issueDate: '2026-01-01',
      dueDate: '2026-01-15',
      total: 1000, amountDue: 1000,
    );
    await dao.createDocument(
      businessId: businessId, clientId: clientId,
      documentType: 'invoice', documentNumber: 'INV-002',
      status: 'sent', issueDate: '2026-03-22',
      dueDate: '2099-12-31',
      total: 500, amountDue: 500,
    );

    final overdue = await dao.getOverdueInvoices(businessId);
    expect(overdue.length, 1);
    expect(overdue.first.documentNumber, 'INV-001');
  });

  test('getMonthlyInvoiceCount counts current month invoices', () async {
    final now = DateTime.now();
    await dao.createDocument(
      businessId: businessId, clientId: clientId,
      documentType: 'invoice', documentNumber: 'INV-001',
      status: 'draft', issueDate: now.toIso8601String(),
    );
    expect(await dao.getMonthlyInvoiceCount(businessId), 1);
  });
}
  • Step 6: Run tests to verify they fail
flutter test test/core/database/daos/document_dao_test.dart

Expected: FAIL — DocumentDao not found.

  • Step 7: Implement DocumentDao
// lib/core/database/daos/document_dao.dart
import 'package:drift/drift.dart';
import '../database.dart';
import '../../utils/uuid_generator.dart';

class DocumentDao {
  final AppDatabase _db;

  DocumentDao(this._db);

  Future<Document> createDocument({
    required String businessId,
    required String clientId,
    required String documentType,
    required String documentNumber,
    required String status,
    required String issueDate,
    String? dueDate,
    String currencyCode = 'USD',
    int subtotal = 0,
    int taxRate = 0,
    int taxAmount = 0,
    String? discountType,
    int discountValue = 0,
    int discountAmount = 0,
    int total = 0,
    int amountPaid = 0,
    int amountDue = 0,
    String? notes,
    String? convertedFromId,
  }) async {
    final now = DateTime.now().toIso8601String();
    final id = generateUuid();

    await _db.into(_db.documents).insert(DocumentsCompanion.insert(
          id: id,
          businessId: businessId,
          clientId: clientId,
          documentType: documentType,
          documentNumber: documentNumber,
          status: status,
          issueDate: issueDate,
          dueDate: Value(dueDate),
          currencyCode: Value(currencyCode),
          subtotal: Value(subtotal),
          taxRate: Value(taxRate),
          taxAmount: Value(taxAmount),
          discountType: Value(discountType),
          discountValue: Value(discountValue),
          discountAmount: Value(discountAmount),
          total: Value(total),
          amountPaid: Value(amountPaid),
          amountDue: Value(amountDue),
          docNotes: Value(notes),
          convertedFromId: Value(convertedFromId),
          createdAt: now,
          updatedAt: now,
        ));

    return (_db.select(_db.documents)..where((t) => t.id.equals(id))).getSingle();
  }

  Future<Document?> getDocument(String id) async {
    final results = await (_db.select(_db.documents)
          ..where((t) => t.id.equals(id)))
        .get();
    return results.isEmpty ? null : results.first;
  }

  Future<List<Document>> getDocumentsByType(String businessId, String type) async {
    return (_db.select(_db.documents)
          ..where((t) =>
              t.businessId.equals(businessId) &
              t.documentType.equals(type) &
              t.isDeleted.equals(0))
          ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
        .get();
  }

  Future<List<Document>> getDocumentsByStatus(
      String businessId, String type, String status) async {
    return (_db.select(_db.documents)
          ..where((t) =>
              t.businessId.equals(businessId) &
              t.documentType.equals(type) &
              t.status.equals(status) &
              t.isDeleted.equals(0))
          ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
        .get();
  }

  Future<List<Document>> getDocumentsForClient(String clientId) async {
    return (_db.select(_db.documents)
          ..where((t) => t.clientId.equals(clientId) & t.isDeleted.equals(0))
          ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
        .get();
  }

  Future<void> updateStatus(String id, String status) async {
    await (_db.update(_db.documents)..where((t) => t.id.equals(id))).write(
      DocumentsCompanion(
        status: Value(status),
        updatedAt: Value(DateTime.now().toIso8601String()),
      ),
    );
  }

  Future<void> updateTotals(String id, {
    required int subtotal,
    required int taxAmount,
    required int discountAmount,
    required int total,
    required int amountDue,
  }) async {
    await (_db.update(_db.documents)..where((t) => t.id.equals(id))).write(
      DocumentsCompanion(
        subtotal: Value(subtotal),
        taxAmount: Value(taxAmount),
        discountAmount: Value(discountAmount),
        total: Value(total),
        amountDue: Value(amountDue),
        updatedAt: Value(DateTime.now().toIso8601String()),
      ),
    );
  }

  Future<void> updatePaymentAmounts(String id, int amountPaid) async {
    final doc = await getDocument(id);
    if (doc == null) return;
    final amountDue = doc.total - amountPaid;
    String status;
    if (amountDue <= 0) {
      status = 'paid';
    } else if (amountPaid > 0) {
      status = 'partial';
    } else {
      status = doc.status;
    }
    await (_db.update(_db.documents)..where((t) => t.id.equals(id))).write(
      DocumentsCompanion(
        amountPaid: Value(amountPaid),
        amountDue: Value(amountDue),
        status: Value(status),
        updatedAt: Value(DateTime.now().toIso8601String()),
      ),
    );
  }

  Future<List<Document>> getOverdueInvoices(String businessId) async {
    final now = DateTime.now().toIso8601String().substring(0, 10);
    return (_db.select(_db.documents)
          ..where((t) =>
              t.businessId.equals(businessId) &
              t.documentType.equals('invoice') &
              t.isDeleted.equals(0) &
              t.dueDate.isSmallerThanValue(now) &
              t.status.isIn(['sent', 'partial']) &
              t.amountDue.isBiggerThanValue(0)))
        .get();
  }

  Future<void> markOverdueInvoices(String businessId) async {
    final now = DateTime.now().toIso8601String().substring(0, 10);
    await (_db.update(_db.documents)
          ..where((t) =>
              t.businessId.equals(businessId) &
              t.documentType.equals('invoice') &
              t.dueDate.isSmallerThanValue(now) &
              t.status.isIn(['sent', 'partial']) &
              t.amountDue.isBiggerThanValue(0)))
        .write(DocumentsCompanion(
      status: const Value('overdue'),
      updatedAt: Value(DateTime.now().toIso8601String()),
    ));
  }

  Future<int> getMonthlyInvoiceCount(String businessId) async {
    final now = DateTime.now();
    final monthStart = DateTime(now.year, now.month, 1).toIso8601String();
    final monthEnd = DateTime(now.year, now.month + 1, 1).toIso8601String();

    final count = _db.documents.id.count();
    final query = _db.selectOnly(_db.documents)
      ..addColumns([count])
      ..where(
        _db.documents.businessId.equals(businessId) &
        _db.documents.documentType.equals('invoice') &
        _db.documents.isDeleted.equals(0) &
        _db.documents.createdAt.isBiggerOrEqualValue(monthStart) &
        _db.documents.createdAt.isSmallerThanValue(monthEnd),
      );
    final result = await query.getSingle();
    return result.read(count)!;
  }

  Future<void> softDeleteDocument(String id) async {
    await (_db.update(_db.documents)..where((t) => t.id.equals(id))).write(
      DocumentsCompanion(
        isDeleted: const Value(1),
        updatedAt: Value(DateTime.now().toIso8601String()),
      ),
    );
  }

  /// Dashboard summary stats
  Future<Map<String, int>> getDashboardStats(String businessId) async {
    final docs = await getDocumentsByType(businessId, 'invoice');
    int outstanding = 0, overdue = 0, paidThisMonth = 0, draftCount = 0;
    final now = DateTime.now();
    final monthStart = DateTime(now.year, now.month, 1);

    for (final doc in docs) {
      if (doc.status == 'draft') draftCount++;
      if (['sent', 'partial', 'overdue'].contains(doc.status)) {
        outstanding += doc.amountDue;
      }
      if (doc.status == 'overdue') overdue += doc.amountDue;
      if (doc.status == 'paid') {
        final updated = DateTime.parse(doc.updatedAt);
        if (updated.isAfter(monthStart)) paidThisMonth += doc.total;
      }
    }
    return {
      'outstanding': outstanding,
      'overdue': overdue,
      'paidThisMonth': paidThisMonth,
      'draftCount': draftCount,
    };
  }
}
  • Step 8: Run document tests
flutter test test/core/database/daos/document_dao_test.dart -v

Expected: All 5 tests PASS.

  • Step 9: Write failing tests for LineItemDao, implement, and verify
// test/core/database/daos/line_item_dao_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/document_dao.dart';
import 'package:swift_invoice/core/database/daos/line_item_dao.dart';

void main() {
  late AppDatabase db;
  late LineItemDao dao;
  late String documentId;

  setUp(() async {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    dao = LineItemDao(db);
    final biz = await BusinessDao(db).createBusiness(name: 'Co');
    final client = await ClientDao(db).createClient(
        businessId: biz.id, name: 'C');
    final doc = await DocumentDao(db).createDocument(
      businessId: biz.id, clientId: client.id,
      documentType: 'invoice', documentNumber: 'INV-001',
      status: 'draft', issueDate: '2026-03-22',
    );
    documentId = doc.id;
  });

  tearDown(() => db.close());

  test('addLineItem creates and retrieves line item', () async {
    final item = await dao.addLineItem(
      documentId: documentId,
      description: 'Web Design',
      quantity: 2.0,
      unitPrice: 5000,
      amount: 10000,
      sortOrder: 0,
    );
    expect(item.description, 'Web Design');
    expect(item.amount, 10000);
  });

  test('getLineItems returns items in sort order', () async {
    await dao.addLineItem(
      documentId: documentId, description: 'B',
      quantity: 1, unitPrice: 100, amount: 100, sortOrder: 1,
    );
    await dao.addLineItem(
      documentId: documentId, description: 'A',
      quantity: 1, unitPrice: 200, amount: 200, sortOrder: 0,
    );

    final items = await dao.getLineItems(documentId);
    expect(items.length, 2);
    expect(items[0].description, 'A');
    expect(items[1].description, 'B');
  });

  test('deleteLineItem removes item', () async {
    final item = await dao.addLineItem(
      documentId: documentId, description: 'X',
      quantity: 1, unitPrice: 100, amount: 100, sortOrder: 0,
    );
    await dao.deleteLineItem(item.id);
    final items = await dao.getLineItems(documentId);
    expect(items, isEmpty);
  });

  test('deleteAllForDocument clears all line items', () async {
    await dao.addLineItem(
      documentId: documentId, description: 'A',
      quantity: 1, unitPrice: 100, amount: 100, sortOrder: 0,
    );
    await dao.addLineItem(
      documentId: documentId, description: 'B',
      quantity: 1, unitPrice: 200, amount: 200, sortOrder: 1,
    );
    await dao.deleteAllForDocument(documentId);
    expect(await dao.getLineItems(documentId), isEmpty);
  });
}
  • Step 10: Implement LineItemDao
// lib/core/database/daos/line_item_dao.dart
import 'package:drift/drift.dart';
import '../database.dart';
import '../../utils/uuid_generator.dart';

class LineItemDao {
  final AppDatabase _db;

  LineItemDao(this._db);

  Future<LineItem> addLineItem({
    required String documentId,
    required String description,
    required double quantity,
    required int unitPrice,
    required int amount,
    required int sortOrder,
  }) async {
    final now = DateTime.now().toIso8601String();
    final id = generateUuid();

    await _db.into(_db.lineItems).insert(LineItemsCompanion.insert(
          id: id,
          documentId: documentId,
          description: description,
          quantity: Value(quantity),
          unitPrice: unitPrice,
          amount: amount,
          sortOrder: Value(sortOrder),
          createdAt: now,
          updatedAt: now,
        ));

    return (_db.select(_db.lineItems)..where((t) => t.id.equals(id)))
        .getSingle();
  }

  Future<List<LineItem>> getLineItems(String documentId) async {
    return (_db.select(_db.lineItems)
          ..where((t) => t.documentId.equals(documentId))
          ..orderBy([(t) => OrderingTerm.asc(t.sortOrder)]))
        .get();
  }

  Future<void> updateLineItem(String id, {
    String? description,
    double? quantity,
    int? unitPrice,
    int? amount,
    int? sortOrder,
  }) async {
    await (_db.update(_db.lineItems)..where((t) => t.id.equals(id))).write(
      LineItemsCompanion(
        description: description != null ? Value(description) : const Value.absent(),
        quantity: quantity != null ? Value(quantity) : const Value.absent(),
        unitPrice: unitPrice != null ? Value(unitPrice) : const Value.absent(),
        amount: amount != null ? Value(amount) : const Value.absent(),
        sortOrder: sortOrder != null ? Value(sortOrder) : const Value.absent(),
        updatedAt: Value(DateTime.now().toIso8601String()),
      ),
    );
  }

  Future<void> deleteLineItem(String id) async {
    await (_db.delete(_db.lineItems)..where((t) => t.id.equals(id))).go();
  }

  Future<void> deleteAllForDocument(String documentId) async {
    await (_db.delete(_db.lineItems)
          ..where((t) => t.documentId.equals(documentId)))
        .go();
  }
}
  • Step 11: Run all DAO tests
flutter test test/core/database/daos/ -v

Expected: All tests PASS.

  • Step 12: Commit
git add lib/core/database/daos/ test/core/database/daos/
git commit -m "feat: implement Client, Document, and LineItem DAOs with tests"

Task 6: Payment DAO & Document Calculator

Files:

  • Create: lib/core/database/daos/payment_dao.dart

  • Create: lib/core/services/document_calculator.dart

  • Create: test/core/database/daos/payment_dao_test.dart

  • Create: test/core/services/document_calculator_test.dart

  • Step 1: Write failing tests for PaymentDao

// test/core/database/daos/payment_dao_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/document_dao.dart';
import 'package:swift_invoice/core/database/daos/payment_dao.dart';

void main() {
  late AppDatabase db;
  late PaymentDao dao;
  late String documentId;

  setUp(() async {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    dao = PaymentDao(db);
    final biz = await BusinessDao(db).createBusiness(name: 'Co');
    final client = await ClientDao(db).createClient(
        businessId: biz.id, name: 'C');
    final doc = await DocumentDao(db).createDocument(
      businessId: biz.id, clientId: client.id,
      documentType: 'invoice', documentNumber: 'INV-001',
      status: 'sent', issueDate: '2026-03-22',
      total: 10000, amountDue: 10000,
    );
    documentId = doc.id;
  });

  tearDown(() => db.close());

  test('recordPayment inserts and returns payment', () async {
    final payment = await dao.recordPayment(
      documentId: documentId,
      amount: 5000,
      method: 'cash',
      paidAt: '2026-03-22T10:00:00',
    );
    expect(payment.amount, 5000);
    expect(payment.method, 'cash');
  });

  test('getPaymentsForDocument returns all payments', () async {
    await dao.recordPayment(
      documentId: documentId, amount: 3000,
      method: 'cash', paidAt: '2026-03-22T10:00:00',
    );
    await dao.recordPayment(
      documentId: documentId, amount: 2000,
      method: 'card', paidAt: '2026-03-22T11:00:00',
    );
    final payments = await dao.getPaymentsForDocument(documentId);
    expect(payments.length, 2);
  });

  test('getTotalPaidForDocument sums payments', () async {
    await dao.recordPayment(
      documentId: documentId, amount: 3000,
      method: 'cash', paidAt: '2026-03-22T10:00:00',
    );
    await dao.recordPayment(
      documentId: documentId, amount: 2000,
      method: 'card', paidAt: '2026-03-22T11:00:00',
    );
    expect(await dao.getTotalPaidForDocument(documentId), 5000);
  });

  test('deletePayment removes payment', () async {
    final payment = await dao.recordPayment(
      documentId: documentId, amount: 5000,
      method: 'cash', paidAt: '2026-03-22T10:00:00',
    );
    await dao.deletePayment(payment.id);
    expect(await dao.getPaymentsForDocument(documentId), isEmpty);
  });
}
  • Step 2: Run tests to verify they fail
flutter test test/core/database/daos/payment_dao_test.dart

Expected: FAIL.

  • Step 3: Implement PaymentDao
// lib/core/database/daos/payment_dao.dart
import 'package:drift/drift.dart';
import '../database.dart';
import '../../utils/uuid_generator.dart';

class PaymentDao {
  final AppDatabase _db;

  PaymentDao(this._db);

  Future<Payment> recordPayment({
    required String documentId,
    required int amount,
    required String method,
    required String paidAt,
    String? notes,
  }) async {
    final now = DateTime.now().toIso8601String();
    final id = generateUuid();

    await _db.into(_db.payments).insert(PaymentsCompanion.insert(
          id: id,
          documentId: documentId,
          amount: amount,
          method: method,
          paidAt: paidAt,
          notes: Value(notes),
          createdAt: now,
          updatedAt: now,
        ));

    return (_db.select(_db.payments)..where((t) => t.id.equals(id)))
        .getSingle();
  }

  Future<List<Payment>> getPaymentsForDocument(String documentId) async {
    return (_db.select(_db.payments)
          ..where((t) => t.documentId.equals(documentId))
          ..orderBy([(t) => OrderingTerm.desc(t.paidAt)]))
        .get();
  }

  Future<int> getTotalPaidForDocument(String documentId) async {
    final sum = _db.payments.amount.sum();
    final query = _db.selectOnly(_db.payments)
      ..addColumns([sum])
      ..where(_db.payments.documentId.equals(documentId));
    final result = await query.getSingle();
    return result.read(sum) ?? 0;
  }

  Future<void> deletePayment(String id) async {
    await (_db.delete(_db.payments)..where((t) => t.id.equals(id))).go();
  }
}
  • Step 4: Run payment tests
flutter test test/core/database/daos/payment_dao_test.dart -v

Expected: All 4 tests PASS.

  • Step 5: Write failing tests for DocumentCalculator
// test/core/services/document_calculator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/services/document_calculator.dart';

void main() {
  test('calculates subtotal from line items', () {
    final items = [
      LineItemInput(quantity: 2.0, unitPriceCents: 5000),
      LineItemInput(quantity: 1.0, unitPriceCents: 3000),
    ];
    final result = DocumentCalculator.calculate(
      items: items,
      taxRateBasisPoints: 0,
    );
    expect(result.subtotal, 13000); // (2*5000) + (1*3000)
  });

  test('calculates tax correctly from basis points', () {
    final items = [
      LineItemInput(quantity: 1.0, unitPriceCents: 10000),
    ];
    final result = DocumentCalculator.calculate(
      items: items,
      taxRateBasisPoints: 825, // 8.25%
    );
    expect(result.subtotal, 10000);
    expect(result.taxAmount, 825); // 10000 * 825 / 10000
    expect(result.total, 10825);
  });

  test('calculates percentage discount', () {
    final items = [
      LineItemInput(quantity: 1.0, unitPriceCents: 10000),
    ];
    final result = DocumentCalculator.calculate(
      items: items,
      taxRateBasisPoints: 0,
      discountType: 'percentage',
      discountValueBasisPoints: 1000, // 10%
    );
    expect(result.discountAmount, 1000);
    expect(result.total, 9000);
  });

  test('calculates fixed discount', () {
    final items = [
      LineItemInput(quantity: 1.0, unitPriceCents: 10000),
    ];
    final result = DocumentCalculator.calculate(
      items: items,
      taxRateBasisPoints: 0,
      discountType: 'fixed',
      discountValueBasisPoints: 2500, // $25.00
    );
    expect(result.discountAmount, 2500);
    expect(result.total, 7500);
  });

  test('tax applied after discount', () {
    final items = [
      LineItemInput(quantity: 1.0, unitPriceCents: 10000),
    ];
    final result = DocumentCalculator.calculate(
      items: items,
      taxRateBasisPoints: 1000, // 10%
      discountType: 'fixed',
      discountValueBasisPoints: 2000,
    );
    // subtotal=10000, discount=2000, after_discount=8000, tax=800
    expect(result.subtotal, 10000);
    expect(result.discountAmount, 2000);
    expect(result.taxAmount, 800);
    expect(result.total, 8800);
  });

  test('handles empty line items', () {
    final result = DocumentCalculator.calculate(
      items: [],
      taxRateBasisPoints: 1000,
    );
    expect(result.subtotal, 0);
    expect(result.total, 0);
  });

  test('calculates fractional quantities correctly', () {
    final items = [
      LineItemInput(quantity: 2.5, unitPriceCents: 4000),
    ];
    final result = DocumentCalculator.calculate(
      items: items,
      taxRateBasisPoints: 0,
    );
    expect(result.subtotal, 10000); // 2.5 * 4000
  });
}
  • Step 6: Run tests to verify they fail
flutter test test/core/services/document_calculator_test.dart

Expected: FAIL.

  • Step 7: Implement DocumentCalculator
// lib/core/services/document_calculator.dart

class LineItemInput {
  final double quantity;
  final int unitPriceCents;

  const LineItemInput({required this.quantity, required this.unitPriceCents});

  int get amount => (quantity * unitPriceCents).round();
}

class CalculationResult {
  final int subtotal;
  final int taxAmount;
  final int discountAmount;
  final int total;

  const CalculationResult({
    required this.subtotal,
    required this.taxAmount,
    required this.discountAmount,
    required this.total,
  });
}

class DocumentCalculator {
  /// Calculate document totals.
  ///
  /// [taxRateBasisPoints]: e.g. 825 = 8.25%
  /// [discountType]: 'percentage' or 'fixed'
  /// [discountValueBasisPoints]: for percentage, basis points (1000 = 10%);
  ///   for fixed, amount in cents
  static CalculationResult calculate({
    required List<LineItemInput> items,
    required int taxRateBasisPoints,
    String? discountType,
    int discountValueBasisPoints = 0,
  }) {
    final subtotal = items.fold<int>(0, (sum, item) => sum + item.amount);

    int discountAmount = 0;
    if (discountType == 'percentage') {
      discountAmount = (subtotal * discountValueBasisPoints) ~/ 10000;
    } else if (discountType == 'fixed') {
      discountAmount = discountValueBasisPoints;
    }

    final afterDiscount = subtotal - discountAmount;
    final taxAmount = (afterDiscount * taxRateBasisPoints) ~/ 10000;
    final total = afterDiscount + taxAmount;

    return CalculationResult(
      subtotal: subtotal,
      taxAmount: taxAmount,
      discountAmount: discountAmount,
      total: total < 0 ? 0 : total,
    );
  }
}
  • Step 8: Run all tests
flutter test test/core/ -v

Expected: All tests PASS.

  • Step 9: Commit
git add lib/core/database/daos/payment_dao.dart lib/core/services/document_calculator.dart \
  test/core/database/daos/payment_dao_test.dart test/core/services/document_calculator_test.dart
git commit -m "feat: implement PaymentDao and DocumentCalculator with full test coverage"

Task 7: Navigation Shell & Onboarding Screen

Files:

  • Create: lib/core/utils/currency_formatter.dart

  • Create: lib/core/utils/date_formatter.dart

  • Create: lib/shared/widgets/app_scaffold.dart

  • Create: lib/features/onboarding/onboarding_screen.dart

  • Create: lib/features/onboarding/onboarding_notifier.dart

  • Create: lib/core/providers/business_provider.dart

  • Create: lib/core/providers/settings_provider.dart

  • Modify: lib/app.dart — add GoRouter with onboarding guard

  • Create: test/core/utils/currency_formatter_test.dart

  • Create: test/features/onboarding/onboarding_notifier_test.dart

  • Step 1: Write failing tests for currency formatter

// test/core/utils/currency_formatter_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/utils/currency_formatter.dart';

void main() {
  test('formats cents to dollar string', () {
    expect(CurrencyFormatter.format(10050), '\$100.50');
    expect(CurrencyFormatter.format(0), '\$0.00');
    expect(CurrencyFormatter.format(99), '\$0.99');
    expect(CurrencyFormatter.format(100000), '\$1,000.00');
  });

  test('formats basis points to percentage', () {
    expect(CurrencyFormatter.formatTaxRate(825), '8.25%');
    expect(CurrencyFormatter.formatTaxRate(1000), '10.00%');
    expect(CurrencyFormatter.formatTaxRate(0), '0.00%');
  });

  test('parseCentsToDollars converts string to cents', () {
    expect(CurrencyFormatter.parseToCents('100.50'), 10050);
    expect(CurrencyFormatter.parseToCents('0.99'), 99);
    expect(CurrencyFormatter.parseToCents('1000'), 100000);
  });

  test('parseTaxRate converts string to basis points', () {
    expect(CurrencyFormatter.parseTaxRate('8.25'), 825);
    expect(CurrencyFormatter.parseTaxRate('10'), 1000);
  });
}
  • Step 2: Run tests to verify they fail
flutter test test/core/utils/currency_formatter_test.dart

Expected: FAIL.

  • Step 3: Implement CurrencyFormatter
// lib/core/utils/currency_formatter.dart
import 'package:intl/intl.dart';

class CurrencyFormatter {
  static final _currencyFormat = NumberFormat.currency(symbol: '\$');

  static String format(int cents, {String currencyCode = 'USD'}) {
    return _currencyFormat.format(cents / 100);
  }

  static String formatTaxRate(int basisPoints) {
    return '${(basisPoints / 100).toStringAsFixed(2)}%';
  }

  static int parseToCents(String dollars) {
    final value = double.parse(dollars.replaceAll(',', ''));
    return (value * 100).round();
  }

  static int parseTaxRate(String percentage) {
    final value = double.parse(percentage);
    return (value * 100).round();
  }
}
  • Step 4: Create DateFormatter utility
// lib/core/utils/date_formatter.dart
import 'package:intl/intl.dart';

class DateFormatter {
  static final _displayFormat = DateFormat('MMM d, yyyy');
  static final _isoFormat = DateFormat('yyyy-MM-dd');

  static String toDisplay(String isoDate) {
    return _displayFormat.format(DateTime.parse(isoDate));
  }

  static String toIso(DateTime date) {
    return _isoFormat.format(date);
  }

  static DateTime addDays(DateTime date, int days) {
    return date.add(Duration(days: days));
  }
}
  • Step 5: Run currency formatter tests
flutter test test/core/utils/currency_formatter_test.dart -v

Expected: All 4 tests PASS.

  • Step 6: Create Riverpod providers for business and settings
// lib/core/providers/settings_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/daos/app_settings_dao.dart';
import 'database_provider.dart';

final appSettingsDaoProvider = Provider<AppSettingsDao>((ref) {
  return AppSettingsDao(ref.read(databaseProvider));
});

final isOnboardingCompleteProvider = FutureProvider<bool>((ref) async {
  return ref.read(appSettingsDaoProvider).isOnboardingComplete();
});

final subscriptionTierProvider = FutureProvider<String>((ref) async {
  return ref.read(appSettingsDaoProvider).getSubscriptionTier();
});
// lib/core/providers/business_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/database.dart';
import '../database/daos/business_dao.dart';
import 'database_provider.dart';

final businessDaoProvider = Provider<BusinessDao>((ref) {
  return BusinessDao(ref.read(databaseProvider));
});

final businessProvider = FutureProvider<Business?>((ref) async {
  return ref.read(businessDaoProvider).getBusiness();
});
  • Step 7: Create the navigation shell
// lib/shared/widgets/app_scaffold.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class AppScaffold extends StatelessWidget {
  final Widget child;

  const AppScaffold({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.receipt_long), label: 'Invoices'),
          NavigationDestination(icon: Icon(Icons.description), label: 'Estimates'),
          NavigationDestination(icon: Icon(Icons.people), label: 'Clients'),
          NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
        ],
      ),
    );
  }

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

  void _onItemTapped(int index, BuildContext context) {
    switch (index) {
      case 0: context.go('/invoices');
      case 1: context.go('/estimates');
      case 2: context.go('/clients');
      case 3: context.go('/settings');
    }
  }
}
  • Step 8: Create onboarding notifier and screen
// lib/features/onboarding/onboarding_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/database/daos/business_dao.dart';
import '../../core/database/daos/app_settings_dao.dart';
import '../../core/providers/database_provider.dart';

class OnboardingState {
  final String name;
  final String email;
  final String phone;
  final String defaultCurrency;
  final String taxRateText;
  final String? logoPath;
  final bool isSaving;
  final String? error;

  const OnboardingState({
    this.name = '',
    this.email = '',
    this.phone = '',
    this.defaultCurrency = 'USD',
    this.taxRateText = '',
    this.logoPath,
    this.isSaving = false,
    this.error,
  });

  OnboardingState copyWith({
    String? name,
    String? email,
    String? phone,
    String? defaultCurrency,
    String? taxRateText,
    String? logoPath,
    bool? isSaving,
    String? error,
  }) {
    return OnboardingState(
      name: name ?? this.name,
      email: email ?? this.email,
      phone: phone ?? this.phone,
      defaultCurrency: defaultCurrency ?? this.defaultCurrency,
      taxRateText: taxRateText ?? this.taxRateText,
      logoPath: logoPath ?? this.logoPath,
      isSaving: isSaving ?? this.isSaving,
      error: error,
    );
  }
}

class OnboardingNotifier extends StateNotifier<OnboardingState> {
  final BusinessDao _businessDao;
  final AppSettingsDao _settingsDao;

  OnboardingNotifier(this._businessDao, this._settingsDao)
      : super(const OnboardingState());

  void setName(String v) => state = state.copyWith(name: v);
  void setEmail(String v) => state = state.copyWith(email: v);
  void setPhone(String v) => state = state.copyWith(phone: v);
  void setCurrency(String v) => state = state.copyWith(defaultCurrency: v);
  void setTaxRate(String v) => state = state.copyWith(taxRateText: v);
  void setLogoPath(String? v) => state = state.copyWith(logoPath: v);

  Future<bool> save() async {
    if (state.name.trim().isEmpty) {
      state = state.copyWith(error: 'Business name is required');
      return false;
    }

    state = state.copyWith(isSaving: true, error: null);
    try {
      int taxRate = 0;
      if (state.taxRateText.isNotEmpty) {
        taxRate = (double.parse(state.taxRateText) * 100).round();
      }

      await _businessDao.createBusiness(
        name: state.name.trim(),
        email: state.email.trim().isEmpty ? null : state.email.trim(),
        phone: state.phone.trim().isEmpty ? null : state.phone.trim(),
        defaultCurrency: state.defaultCurrency,
        defaultTaxRate: taxRate,
        logoPath: state.logoPath,
      );

      await _settingsDao.markOnboardingComplete();
      state = state.copyWith(isSaving: false);
      return true;
    } catch (e) {
      state = state.copyWith(isSaving: false, error: e.toString());
      return false;
    }
  }
}

final onboardingNotifierProvider =
    StateNotifierProvider<OnboardingNotifier, OnboardingState>((ref) {
  final db = ref.read(databaseProvider);
  return OnboardingNotifier(BusinessDao(db), AppSettingsDao(db));
});
// lib/features/onboarding/onboarding_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'onboarding_notifier.dart';

class OnboardingScreen extends ConsumerWidget {
  const OnboardingScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(onboardingNotifierProvider);
    final notifier = ref.read(onboardingNotifierProvider.notifier);

    return Scaffold(
      appBar: AppBar(title: const Text('Set Up Your Business')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            TextField(
              decoration: const InputDecoration(
                labelText: 'Business Name *',
                hintText: 'e.g., Smith Plumbing LLC',
              ),
              onChanged: notifier.setName,
            ),
            const SizedBox(height: 16),
            TextField(
              decoration: const InputDecoration(
                labelText: 'Email',
                hintText: 'contact@yourbusiness.com',
              ),
              keyboardType: TextInputType.emailAddress,
              onChanged: notifier.setEmail,
            ),
            const SizedBox(height: 16),
            TextField(
              decoration: const InputDecoration(
                labelText: 'Phone',
              ),
              keyboardType: TextInputType.phone,
              onChanged: notifier.setPhone,
            ),
            const SizedBox(height: 16),
            TextField(
              decoration: const InputDecoration(
                labelText: 'Default Tax Rate (%)',
                hintText: '8.25',
              ),
              keyboardType: const TextInputType.numberWithOptions(decimal: true),
              onChanged: notifier.setTaxRate,
            ),
            const SizedBox(height: 24),
            if (state.error != null)
              Padding(
                padding: const EdgeInsets.only(bottom: 16),
                child: Text(
                  state.error!,
                  style: TextStyle(color: Theme.of(context).colorScheme.error),
                ),
              ),
            FilledButton(
              onPressed: state.isSaving
                  ? null
                  : () async {
                      final success = await notifier.save();
                      if (success && context.mounted) {
                        context.go('/invoices');
                      }
                    },
              child: state.isSaving
                  ? const CircularProgressIndicator()
                  : const Text('Save & Start'),
            ),
          ],
        ),
      ),
    );
  }
}
  • Step 9: Wire up GoRouter in app.dart with onboarding guard
// lib/app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'core/theme/app_theme.dart';
import 'core/providers/settings_provider.dart';
import 'features/onboarding/onboarding_screen.dart';
import 'shared/widgets/app_scaffold.dart';

final routerProvider = Provider<GoRouter>((ref) {
  return GoRouter(
    initialLocation: '/invoices',
    redirect: (context, state) async {
      final isComplete = await ref.read(isOnboardingCompleteProvider.future);
      final isOnboarding = state.uri.toString() == '/onboarding';
      if (!isComplete && !isOnboarding) return '/onboarding';
      if (isComplete && isOnboarding) return '/invoices';
      return null;
    },
    routes: [
      GoRoute(
        path: '/onboarding',
        builder: (context, state) => const OnboardingScreen(),
      ),
      ShellRoute(
        builder: (context, state, child) => AppScaffold(child: child),
        routes: [
          GoRoute(
            path: '/invoices',
            builder: (context, state) =>
                const Center(child: Text('Invoice Dashboard — Task 11')),
          ),
          GoRoute(
            path: '/estimates',
            builder: (context, state) =>
                const Center(child: Text('Estimates — Task 17')),
          ),
          GoRoute(
            path: '/clients',
            builder: (context, state) =>
                const Center(child: Text('Clients — Task 8')),
          ),
          GoRoute(
            path: '/settings',
            builder: (context, state) =>
                const Center(child: Text('Settings — Task 26')),
          ),
        ],
      ),
    ],
  );
});

class SwiftInvoiceApp extends ConsumerWidget {
  const SwiftInvoiceApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final router = ref.watch(routerProvider);
    return MaterialApp.router(
      title: 'SwiftInvoice',
      theme: AppTheme.light(),
      routerConfig: router,
    );
  }
}
  • Step 10: Write tests for OnboardingNotifier
// test/features/onboarding/onboarding_notifier_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';
import 'package:swift_invoice/features/onboarding/onboarding_notifier.dart';

void main() {
  late AppDatabase db;
  late OnboardingNotifier notifier;

  setUp(() {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    notifier = OnboardingNotifier(BusinessDao(db), AppSettingsDao(db));
  });

  tearDown(() => db.close());

  test('save fails with empty name', () async {
    final result = await notifier.save();
    expect(result, isFalse);
    expect(notifier.state.error, 'Business name is required');
  });

  test('save succeeds with valid name', () async {
    notifier.setName('Test Business');
    final result = await notifier.save();
    expect(result, isTrue);
    expect(notifier.state.error, isNull);

    // Verify business was created
    final biz = await BusinessDao(db).getBusiness();
    expect(biz, isNotNull);
    expect(biz!.name, 'Test Business');

    // Verify onboarding marked complete
    expect(await AppSettingsDao(db).isOnboardingComplete(), isTrue);
  });

  test('save parses tax rate correctly', () async {
    notifier.setName('Co');
    notifier.setTaxRate('8.25');
    await notifier.save();

    final biz = await BusinessDao(db).getBusiness();
    expect(biz!.defaultTaxRate, 825);
  });

  test('save trims whitespace from name', () async {
    notifier.setName('  My Company  ');
    await notifier.save();

    final biz = await BusinessDao(db).getBusiness();
    expect(biz!.name, 'My Company');
  });
}
  • Step 11: Run all tests
flutter test -v

Expected: All tests PASS.

  • Step 12: Run the app and verify onboarding flow
flutter run -d chrome

Expected: App shows onboarding screen. After entering a name and tapping "Save & Start", navigates to invoice dashboard placeholder with bottom nav.

  • Step 13: Commit
git add lib/ test/
git commit -m "feat: add navigation shell, onboarding screen, and Riverpod providers"

Phase 2: Core Invoicing (Weeks 35)

Task 8: Client List Screen

Files:

  • Create: lib/core/providers/client_provider.dart

  • Create: lib/features/clients/client_notifier.dart

  • Create: lib/features/clients/client_list_screen.dart

  • Create: test/features/clients/client_notifier_test.dart

  • Step 1: Write failing tests for ClientNotifier

// test/features/clients/client_notifier_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';
import 'package:swift_invoice/features/clients/client_notifier.dart';

void main() {
  late AppDatabase db;
  late ClientNotifier notifier;
  late String businessId;

  setUp(() async {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    final biz = await BusinessDao(db).createBusiness(name: 'Co');
    businessId = biz.id;
    notifier = ClientNotifier(ClientDao(db), AppSettingsDao(db), businessId);
  });

  tearDown(() => db.close());

  test('loadClients returns empty list initially', () async {
    await notifier.loadClients();
    expect(notifier.state.clients, isEmpty);
  });

  test('addClient adds to list', () async {
    await notifier.addClient(name: 'Jane', email: 'j@x.com');
    expect(notifier.state.clients.length, 1);
    expect(notifier.state.clients.first.name, 'Jane');
  });

  test('addClient enforces free tier limit of 2', () async {
    await notifier.addClient(name: 'A');
    await notifier.addClient(name: 'B');
    final result = await notifier.addClient(name: 'C');
    expect(result, isFalse);
    expect(notifier.state.showPaywall, isTrue);
  });

  test('deleteClient soft-deletes and removes from list', () async {
    await notifier.addClient(name: 'Jane');
    final clientId = notifier.state.clients.first.id;
    await notifier.deleteClient(clientId);
    expect(notifier.state.clients, isEmpty);
  });

  test('searchClients filters by name', () async {
    await notifier.addClient(name: 'Jane Doe');
    await notifier.addClient(name: 'John Smith');
    await notifier.searchClients('jan');
    expect(notifier.state.clients.length, 1);
  });
}
  • Step 2: Run tests to verify they fail
flutter test test/features/clients/client_notifier_test.dart

Expected: FAIL.

  • Step 3: Implement ClientNotifier
// lib/features/clients/client_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/database/database.dart';
import '../../core/database/daos/client_dao.dart';
import '../../core/database/daos/app_settings_dao.dart';

class ClientListState {
  final List<Client> clients;
  final bool isLoading;
  final bool showPaywall;
  final String? error;

  const ClientListState({
    this.clients = const [],
    this.isLoading = false,
    this.showPaywall = false,
    this.error,
  });

  ClientListState copyWith({
    List<Client>? clients,
    bool? isLoading,
    bool? showPaywall,
    String? error,
  }) {
    return ClientListState(
      clients: clients ?? this.clients,
      isLoading: isLoading ?? this.isLoading,
      showPaywall: showPaywall ?? this.showPaywall,
      error: error,
    );
  }
}

class ClientNotifier extends StateNotifier<ClientListState> {
  final ClientDao _clientDao;
  final AppSettingsDao _settingsDao;
  final String _businessId;

  static const _freeClientLimit = 2;

  ClientNotifier(this._clientDao, this._settingsDao, this._businessId)
      : super(const ClientListState());

  Future<void> loadClients() async {
    state = state.copyWith(isLoading: true);
    final clients = await _clientDao.getActiveClients(_businessId);
    state = state.copyWith(clients: clients, isLoading: false);
  }

  Future<bool> addClient({
    required String name,
    String? email,
    String? phone,
    String? addressLine1,
    String? addressLine2,
    String? city,
    String? clientState,
    String? postalCode,
    String? notes,
  }) async {
    // Check free tier limit
    final tier = await _settingsDao.getSubscriptionTier();
    if (tier == 'free') {
      final count = await _clientDao.getActiveClientCount(_businessId);
      if (count >= _freeClientLimit) {
        state = state.copyWith(showPaywall: true);
        return false;
      }
    }

    await _clientDao.createClient(
      businessId: _businessId,
      name: name,
      email: email,
      phone: phone,
      addressLine1: addressLine1,
      addressLine2: addressLine2,
      city: city,
      state: clientState,
      postalCode: postalCode,
      notes: notes,
    );
    await loadClients();
    return true;
  }

  Future<void> deleteClient(String clientId) async {
    await _clientDao.softDeleteClient(clientId);
    await loadClients();
  }

  Future<void> searchClients(String query) async {
    if (query.isEmpty) {
      await loadClients();
      return;
    }
    final results = await _clientDao.searchClients(_businessId, query);
    state = state.copyWith(clients: results);
  }

  void dismissPaywall() {
    state = state.copyWith(showPaywall: false);
  }
}
  • Step 4: Run tests
flutter test test/features/clients/client_notifier_test.dart -v

Expected: All 5 tests PASS.

  • Step 5: Create client list screen
// lib/features/clients/client_list_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/providers/business_provider.dart';
import '../../core/providers/client_provider.dart';
import 'client_notifier.dart';

final clientNotifierProvider =
    StateNotifierProvider<ClientNotifier, ClientListState>((ref) {
  final db = ref.read(databaseProvider);
  final business = ref.read(businessProvider).valueOrNull;
  return ClientNotifier(
    ClientDao(db),
    AppSettingsDao(db),
    business?.id ?? '',
  );
});

class ClientListScreen extends ConsumerStatefulWidget {
  const ClientListScreen({super.key});

  @override
  ConsumerState<ClientListScreen> createState() => _ClientListScreenState();
}

class _ClientListScreenState extends ConsumerState<ClientListScreen> {
  final _searchController = TextEditingController();

  @override
  void initState() {
    super.initState();
    Future.microtask(() =>
        ref.read(clientNotifierProvider.notifier).loadClients());
  }

  @override
  Widget build(BuildContext context) {
    final state = ref.watch(clientNotifierProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Clients'),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(56),
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            child: TextField(
              controller: _searchController,
              decoration: const InputDecoration(
                hintText: 'Search clients...',
                prefixIcon: Icon(Icons.search),
                isDense: true,
              ),
              onChanged: (query) {
                ref.read(clientNotifierProvider.notifier).searchClients(query);
              },
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.push('/clients/new'),
        child: const Icon(Icons.add),
      ),
      body: state.isLoading
          ? const Center(child: CircularProgressIndicator())
          : state.clients.isEmpty
              ? const Center(child: Text('No clients yet'))
              : ListView.builder(
                  itemCount: state.clients.length,
                  itemBuilder: (context, index) {
                    final client = state.clients[index];
                    return ListTile(
                      title: Text(client.name),
                      subtitle: Text(client.email ?? ''),
                      trailing: Text(
                        CurrencyFormatter.format(client.outstandingBalance),
                      ),
                      onTap: () => context.push('/clients/${client.id}'),
                    );
                  },
                ),
    );
  }

  @override
  void dispose() {
    _searchController.dispose();
    super.dispose();
  }
}
  • Step 6: Create Riverpod client provider
// lib/core/providers/client_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/daos/client_dao.dart';
import '../database/daos/app_settings_dao.dart';
import 'database_provider.dart';

final clientDaoProvider = Provider<ClientDao>((ref) {
  return ClientDao(ref.read(databaseProvider));
});
  • Step 7: Commit
git add lib/features/clients/ lib/core/providers/client_provider.dart \
  test/features/clients/
git commit -m "feat: implement client list with free-tier limit enforcement"

Task 9: Client Form & Detail Screens

Files:

  • Create: lib/features/clients/client_form_screen.dart

  • Create: lib/features/clients/client_detail_screen.dart

  • Create: lib/core/utils/validators.dart

  • Create: test/core/utils/validators_test.dart

  • Step 1: Write failing tests for validators

// test/core/utils/validators_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/utils/validators.dart';

void main() {
  test('validateRequired rejects empty strings', () {
    expect(Validators.required(''), isNotNull);
    expect(Validators.required('  '), isNotNull);
    expect(Validators.required('abc'), isNull);
  });

  test('validateEmail accepts valid emails', () {
    expect(Validators.email('a@b.com'), isNull);
    expect(Validators.email('not-an-email'), isNotNull);
    expect(Validators.email(''), isNull); // optional field
  });

  test('validatePositiveNumber rejects negatives', () {
    expect(Validators.positiveNumber('10'), isNull);
    expect(Validators.positiveNumber('0'), isNull);
    expect(Validators.positiveNumber('-1'), isNotNull);
    expect(Validators.positiveNumber('abc'), isNotNull);
  });
}
  • Step 2: Run tests to verify they fail
flutter test test/core/utils/validators_test.dart

Expected: FAIL.

  • Step 3: Implement Validators
// lib/core/utils/validators.dart

class Validators {
  static String? required(String? value) {
    if (value == null || value.trim().isEmpty) {
      return 'This field is required';
    }
    return null;
  }

  static String? email(String? value) {
    if (value == null || value.isEmpty) return null; // optional
    final regex = RegExp(r'^[\w\-.]+@([\w\-]+\.)+[\w\-]{2,}$');
    if (!regex.hasMatch(value)) return 'Invalid email address';
    return null;
  }

  static String? positiveNumber(String? value) {
    if (value == null || value.isEmpty) return 'Enter a number';
    final num = double.tryParse(value);
    if (num == null) return 'Invalid number';
    if (num < 0) return 'Must be positive';
    return null;
  }
}
  • Step 4: Run validators tests
flutter test test/core/utils/validators_test.dart -v

Expected: All 3 tests PASS.

  • Step 5: Create client form screen
// lib/features/clients/client_form_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/validators.dart';
import '../../core/database/daos/client_dao.dart';
import '../../core/providers/database_provider.dart';
import 'client_list_screen.dart';

class ClientFormScreen extends ConsumerStatefulWidget {
  final String? clientId; // null = create, non-null = edit

  const ClientFormScreen({super.key, this.clientId});

  @override
  ConsumerState<ClientFormScreen> createState() => _ClientFormScreenState();
}

class _ClientFormScreenState extends ConsumerState<ClientFormScreen> {
  final _formKey = GlobalKey<FormState>();
  final _nameController = TextEditingController();
  final _emailController = TextEditingController();
  final _phoneController = TextEditingController();
  final _addressLine1Controller = TextEditingController();
  final _cityController = TextEditingController();
  final _stateController = TextEditingController();
  final _postalCodeController = TextEditingController();
  final _notesController = TextEditingController();

  bool get isEditing => widget.clientId != null;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(isEditing ? 'Edit Client' : 'New Client'),
      ),
      body: Form(
        key: _formKey,
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(16),
          child: Column(
            children: [
              TextFormField(
                controller: _nameController,
                decoration: const InputDecoration(labelText: 'Name *'),
                validator: Validators.required,
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _emailController,
                decoration: const InputDecoration(labelText: 'Email'),
                keyboardType: TextInputType.emailAddress,
                validator: Validators.email,
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _phoneController,
                decoration: const InputDecoration(labelText: 'Phone'),
                keyboardType: TextInputType.phone,
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _addressLine1Controller,
                decoration: const InputDecoration(labelText: 'Address'),
              ),
              const SizedBox(height: 16),
              Row(
                children: [
                  Expanded(
                    child: TextFormField(
                      controller: _cityController,
                      decoration: const InputDecoration(labelText: 'City'),
                    ),
                  ),
                  const SizedBox(width: 16),
                  Expanded(
                    child: TextFormField(
                      controller: _stateController,
                      decoration: const InputDecoration(labelText: 'State'),
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _postalCodeController,
                decoration: const InputDecoration(labelText: 'Zip Code'),
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _notesController,
                decoration: const InputDecoration(labelText: 'Notes'),
                maxLines: 3,
              ),
              const SizedBox(height: 24),
              SizedBox(
                width: double.infinity,
                child: FilledButton(
                  onPressed: _save,
                  child: Text(isEditing ? 'Update' : 'Save Client'),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Future<void> _save() async {
    if (_formKey.currentState?.validate() ?? false) {
      final notifier = ref.read(clientNotifierProvider.notifier);
      if (isEditing) {
        await ClientDao(ref.read(databaseProvider)).updateClient(
          widget.clientId!,
          name: _nameController.text.trim(),
          email: _emailController.text.trim(),
          phone: _phoneController.text.trim(),
          addressLine1: _addressLine1Controller.text.trim(),
          city: _cityController.text.trim(),
          state: _stateController.text.trim(),
          postalCode: _postalCodeController.text.trim(),
          notes: _notesController.text.trim(),
        );
      } else {
        final success = await notifier.addClient(
          name: _nameController.text.trim(),
          email: _emailController.text.trim(),
          phone: _phoneController.text.trim(),
          addressLine1: _addressLine1Controller.text.trim(),
          city: _cityController.text.trim(),
          clientState: _stateController.text.trim(),
          postalCode: _postalCodeController.text.trim(),
          notes: _notesController.text.trim(),
        );
        if (!success && context.mounted) {
          context.push('/paywall');
          return;
        }
      }
      if (context.mounted) context.pop();
    }
  }

  @override
  void dispose() {
    _nameController.dispose();
    _emailController.dispose();
    _phoneController.dispose();
    _addressLine1Controller.dispose();
    _cityController.dispose();
    _stateController.dispose();
    _postalCodeController.dispose();
    _notesController.dispose();
    super.dispose();
  }
}
  • Step 6: Create client detail screen
// lib/features/clients/client_detail_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/utils/date_formatter.dart';
import '../../core/database/database.dart';
import '../../core/database/daos/client_dao.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/providers/database_provider.dart';
import '../invoices/widgets/status_pill.dart';
import 'client_list_screen.dart';

final clientDetailProvider =
    FutureProvider.family<Client?, String>((ref, clientId) {
  return ClientDao(ref.read(databaseProvider)).getClient(clientId);
});

final clientDocumentsProvider =
    FutureProvider.family<List<Document>, String>((ref, clientId) {
  return DocumentDao(ref.read(databaseProvider)).getDocumentsForClient(clientId);
});

class ClientDetailScreen extends ConsumerWidget {
  final String clientId;

  const ClientDetailScreen({super.key, required this.clientId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final clientAsync = ref.watch(clientDetailProvider(clientId));
    final docsAsync = ref.watch(clientDocumentsProvider(clientId));

    return Scaffold(
      appBar: AppBar(
        title: const Text('Client Details'),
        actions: [
          IconButton(
            icon: const Icon(Icons.edit),
            onPressed: () => context.push('/clients/$clientId/edit'),
          ),
          IconButton(
            icon: const Icon(Icons.delete),
            onPressed: () async {
              final confirm = await showDialog<bool>(
                context: context,
                builder: (ctx) => AlertDialog(
                  title: const Text('Delete Client?'),
                  content: const Text('This client will be archived. Their invoices will remain.'),
                  actions: [
                    TextButton(onPressed: () => ctx.pop(false), child: const Text('Cancel')),
                    TextButton(onPressed: () => ctx.pop(true), child: const Text('Delete')),
                  ],
                ),
              );
              if (confirm == true && context.mounted) {
                await ref.read(clientNotifierProvider.notifier).deleteClient(clientId);
                if (context.mounted) context.pop();
              }
            },
          ),
        ],
      ),
      body: clientAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => Center(child: Text('Error: $e')),
        data: (client) {
          if (client == null) return const Center(child: Text('Client not found'));
          return ListView(
            padding: const EdgeInsets.all(16),
            children: [
              Text(client.name, style: Theme.of(context).textTheme.headlineSmall),
              if (client.email != null) ...[
                const SizedBox(height: 4),
                Text(client.email!, style: Theme.of(context).textTheme.bodyMedium),
              ],
              if (client.phone != null) ...[
                const SizedBox(height: 4),
                Text(client.phone!),
              ],
              const SizedBox(height: 16),
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceAround,
                    children: [
                      Column(children: [
                        const Text('Outstanding'),
                        Text(CurrencyFormatter.format(client.outstandingBalance),
                            style: const TextStyle(fontWeight: FontWeight.bold)),
                      ]),
                    ],
                  ),
                ),
              ),
              const SizedBox(height: 16),
              Text('Documents', style: Theme.of(context).textTheme.titleMedium),
              const SizedBox(height: 8),
              docsAsync.when(
                loading: () => const CircularProgressIndicator(),
                error: (e, _) => Text('Error: $e'),
                data: (docs) => docs.isEmpty
                    ? const Text('No documents yet')
                    : Column(
                        children: docs.map((doc) => ListTile(
                          title: Text(doc.documentNumber),
                          subtitle: Text(DateFormatter.toDisplay(doc.issueDate)),
                          trailing: Row(
                            mainAxisSize: MainAxisSize.min,
                            children: [
                              StatusPill(status: doc.status),
                              const SizedBox(width: 8),
                              Text(CurrencyFormatter.format(doc.total)),
                            ],
                          ),
                          onTap: () => context.push('/invoices/${doc.id}'),
                        )).toList(),
                      ),
              ),
            ],
          );
        },
      ),
    );
  }
}
  • Step 7: Commit
git add lib/features/clients/ lib/core/utils/validators.dart test/core/utils/
git commit -m "feat: add client form, detail screen, and input validators"

Task 10: Invoice Creator — Notifier & Calculator Integration

Files:

  • Create: lib/features/invoices/invoice_creator_notifier.dart

  • Create: test/features/invoices/invoice_creator_notifier_test.dart

  • Step 1: Write failing tests for InvoiceCreatorNotifier

// test/features/invoices/invoice_creator_notifier_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/document_dao.dart';
import 'package:swift_invoice/core/database/daos/line_item_dao.dart';
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';
import 'package:swift_invoice/features/invoices/invoice_creator_notifier.dart';

void main() {
  late AppDatabase db;
  late InvoiceCreatorNotifier notifier;
  late String businessId;
  late String clientId;

  setUp(() async {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    final biz = await BusinessDao(db).createBusiness(name: 'Co');
    businessId = biz.id;
    final client = await ClientDao(db).createClient(
        businessId: businessId, name: 'Jane');
    clientId = client.id;
    notifier = InvoiceCreatorNotifier(
      businessDao: BusinessDao(db),
      documentDao: DocumentDao(db),
      lineItemDao: LineItemDao(db),
      settingsDao: AppSettingsDao(db),
      businessId: businessId,
    );
    await notifier.initialize();
  });

  tearDown(() => db.close());

  test('initialize sets auto-generated invoice number', () {
    expect(notifier.state.documentNumber, 'INV-1');
  });

  test('addLineItem recalculates totals', () {
    notifier.setClientId(clientId);
    notifier.addLineItem(description: 'Work', quantity: 2.0, unitPrice: 5000);
    expect(notifier.state.subtotal, 10000);
    expect(notifier.state.total, 10000);
    expect(notifier.state.lineItems.length, 1);
  });

  test('tax rate affects total', () {
    notifier.setClientId(clientId);
    notifier.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 10000);
    notifier.setTaxRate(1000); // 10%
    expect(notifier.state.taxAmount, 1000);
    expect(notifier.state.total, 11000);
  });

  test('save fails without client', () async {
    notifier.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 5000);
    final result = await notifier.save();
    expect(result, isNull);
    expect(notifier.state.error, contains('client'));
  });

  test('save fails without line items', () async {
    notifier.setClientId(clientId);
    final result = await notifier.save();
    expect(result, isNull);
    expect(notifier.state.error, contains('line item'));
  });

  test('save creates document and line items', () async {
    notifier.setClientId(clientId);
    notifier.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 5000);
    final docId = await notifier.save();
    expect(docId, isNotNull);

    final doc = await DocumentDao(db).getDocument(docId!);
    expect(doc!.documentNumber, 'INV-1');
    expect(doc.total, 5000);

    final items = await LineItemDao(db).getLineItems(docId);
    expect(items.length, 1);
  });

  test('free tier blocks 4th invoice in same month', () async {
    // Create 3 invoices
    for (var i = 0; i < 3; i++) {
      final n = InvoiceCreatorNotifier(
        businessDao: BusinessDao(db),
        documentDao: DocumentDao(db),
        lineItemDao: LineItemDao(db),
        settingsDao: AppSettingsDao(db),
        businessId: businessId,
      );
      await n.initialize();
      n.setClientId(clientId);
      n.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 1000);
      await n.save();
    }

    // 4th should be blocked
    final n4 = InvoiceCreatorNotifier(
      businessDao: BusinessDao(db),
      documentDao: DocumentDao(db),
      lineItemDao: LineItemDao(db),
      settingsDao: AppSettingsDao(db),
      businessId: businessId,
    );
    await n4.initialize();
    expect(n4.state.showPaywall, isTrue);
  });
}
  • Step 2: Run tests to verify they fail
flutter test test/features/invoices/invoice_creator_notifier_test.dart

Expected: FAIL.

  • Step 3: Implement InvoiceCreatorNotifier
// lib/features/invoices/invoice_creator_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/database/daos/business_dao.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/database/daos/line_item_dao.dart';
import '../../core/database/daos/app_settings_dao.dart';
import '../../core/services/document_calculator.dart';
import '../../core/utils/date_formatter.dart';

class LineItemFormData {
  final String description;
  final double quantity;
  final int unitPriceCents;
  int get amount => (quantity * unitPriceCents).round();

  const LineItemFormData({
    required this.description,
    required this.quantity,
    required this.unitPriceCents,
  });
}

class InvoiceCreatorState {
  final String? clientId;
  final String documentNumber;
  final String issueDate;
  final String dueDate;
  final List<LineItemFormData> lineItems;
  final int taxRateBasisPoints;
  final String? discountType;
  final int discountValue;
  final String? notes;
  final int subtotal;
  final int taxAmount;
  final int discountAmount;
  final int total;
  final bool isSaving;
  final bool showPaywall;
  final String? error;

  const InvoiceCreatorState({
    this.clientId,
    this.documentNumber = '',
    this.issueDate = '',
    this.dueDate = '',
    this.lineItems = const [],
    this.taxRateBasisPoints = 0,
    this.discountType,
    this.discountValue = 0,
    this.notes,
    this.subtotal = 0,
    this.taxAmount = 0,
    this.discountAmount = 0,
    this.total = 0,
    this.isSaving = false,
    this.showPaywall = false,
    this.error,
  });

  InvoiceCreatorState copyWith({
    String? clientId,
    String? documentNumber,
    String? issueDate,
    String? dueDate,
    List<LineItemFormData>? lineItems,
    int? taxRateBasisPoints,
    String? discountType,
    int? discountValue,
    String? notes,
    int? subtotal,
    int? taxAmount,
    int? discountAmount,
    int? total,
    bool? isSaving,
    bool? showPaywall,
    String? error,
  }) {
    return InvoiceCreatorState(
      clientId: clientId ?? this.clientId,
      documentNumber: documentNumber ?? this.documentNumber,
      issueDate: issueDate ?? this.issueDate,
      dueDate: dueDate ?? this.dueDate,
      lineItems: lineItems ?? this.lineItems,
      taxRateBasisPoints: taxRateBasisPoints ?? this.taxRateBasisPoints,
      discountType: discountType ?? this.discountType,
      discountValue: discountValue ?? this.discountValue,
      notes: notes ?? this.notes,
      subtotal: subtotal ?? this.subtotal,
      taxAmount: taxAmount ?? this.taxAmount,
      discountAmount: discountAmount ?? this.discountAmount,
      total: total ?? this.total,
      isSaving: isSaving ?? this.isSaving,
      showPaywall: showPaywall ?? this.showPaywall,
      error: error,
    );
  }
}

class InvoiceCreatorNotifier extends StateNotifier<InvoiceCreatorState> {
  final BusinessDao _businessDao;
  final DocumentDao _documentDao;
  final LineItemDao _lineItemDao;
  final AppSettingsDao _settingsDao;
  final String _businessId;

  static const _freeInvoiceLimit = 3;

  InvoiceCreatorNotifier({
    required BusinessDao businessDao,
    required DocumentDao documentDao,
    required LineItemDao lineItemDao,
    required AppSettingsDao settingsDao,
    required String businessId,
  })  : _businessDao = businessDao,
        _documentDao = documentDao,
        _lineItemDao = lineItemDao,
        _settingsDao = settingsDao,
        _businessId = businessId,
        super(const InvoiceCreatorState());

  Future<void> initialize() async {
    final biz = await _businessDao.getBusiness();
    if (biz == null) return;

    final now = DateTime.now();
    final dueDate = DateFormatter.addDays(now, biz.defaultPaymentTermsDays);
    final docNum = '${biz.invoicePrefix}-${biz.nextInvoiceNumber}';

    // Check free tier limit
    final tier = await _settingsDao.getSubscriptionTier();
    bool showPaywall = false;
    if (tier == 'free') {
      final count = await _documentDao.getMonthlyInvoiceCount(_businessId);
      if (count >= _freeInvoiceLimit) {
        showPaywall = true;
      }
    }

    state = state.copyWith(
      documentNumber: docNum,
      issueDate: DateFormatter.toIso(now),
      dueDate: DateFormatter.toIso(dueDate),
      taxRateBasisPoints: biz.defaultTaxRate,
      showPaywall: showPaywall,
    );
  }

  void setClientId(String id) => state = state.copyWith(clientId: id);
  void setDocumentNumber(String v) => state = state.copyWith(documentNumber: v);
  void setIssueDate(String v) => state = state.copyWith(issueDate: v);
  void setDueDate(String v) => state = state.copyWith(dueDate: v);
  void setNotes(String v) => state = state.copyWith(notes: v);

  void setTaxRate(int basisPoints) {
    state = state.copyWith(taxRateBasisPoints: basisPoints);
    _recalculate();
  }

  void setDiscount({String? type, int value = 0}) {
    state = state.copyWith(discountType: type, discountValue: value);
    _recalculate();
  }

  void addLineItem({
    required String description,
    required double quantity,
    required int unitPrice,
  }) {
    final items = [
      ...state.lineItems,
      LineItemFormData(
        description: description,
        quantity: quantity,
        unitPriceCents: unitPrice,
      ),
    ];
    state = state.copyWith(lineItems: items);
    _recalculate();
  }

  void removeLineItem(int index) {
    final items = [...state.lineItems]..removeAt(index);
    state = state.copyWith(lineItems: items);
    _recalculate();
  }

  void updateLineItem(int index, {
    String? description,
    double? quantity,
    int? unitPrice,
  }) {
    final items = [...state.lineItems];
    final old = items[index];
    items[index] = LineItemFormData(
      description: description ?? old.description,
      quantity: quantity ?? old.quantity,
      unitPriceCents: unitPrice ?? old.unitPriceCents,
    );
    state = state.copyWith(lineItems: items);
    _recalculate();
  }

  void _recalculate() {
    final inputs = state.lineItems
        .map((li) => LineItemInput(
              quantity: li.quantity,
              unitPriceCents: li.unitPriceCents,
            ))
        .toList();

    final result = DocumentCalculator.calculate(
      items: inputs,
      taxRateBasisPoints: state.taxRateBasisPoints,
      discountType: state.discountType,
      discountValueBasisPoints: state.discountValue,
    );

    state = state.copyWith(
      subtotal: result.subtotal,
      taxAmount: result.taxAmount,
      discountAmount: result.discountAmount,
      total: result.total,
    );
  }

  /// Returns the document ID on success, null on failure.
  Future<String?> save({bool markAsSent = false}) async {
    if (state.clientId == null) {
      state = state.copyWith(error: 'Please select a client');
      return null;
    }
    if (state.lineItems.isEmpty) {
      state = state.copyWith(error: 'Add at least one line item');
      return null;
    }

    state = state.copyWith(isSaving: true, error: null);
    try {
      final status = markAsSent ? 'sent' : 'draft';

      final doc = await _documentDao.createDocument(
        businessId: _businessId,
        clientId: state.clientId!,
        documentType: 'invoice',
        documentNumber: state.documentNumber,
        status: status,
        issueDate: state.issueDate,
        dueDate: state.dueDate,
        subtotal: state.subtotal,
        taxRate: state.taxRateBasisPoints,
        taxAmount: state.taxAmount,
        discountType: state.discountType,
        discountValue: state.discountValue,
        discountAmount: state.discountAmount,
        total: state.total,
        amountDue: state.total,
        notes: state.notes,
      );

      for (var i = 0; i < state.lineItems.length; i++) {
        final li = state.lineItems[i];
        await _lineItemDao.addLineItem(
          documentId: doc.id,
          description: li.description,
          quantity: li.quantity,
          unitPrice: li.unitPriceCents,
          amount: li.amount,
          sortOrder: i,
        );
      }

      // Increment the invoice number
      await _businessDao.getAndIncrementInvoiceNumber(_businessId);

      state = state.copyWith(isSaving: false);
      return doc.id;
    } catch (e) {
      state = state.copyWith(isSaving: false, error: e.toString());
      return null;
    }
  }
}
  • Step 4: Run tests
flutter test test/features/invoices/invoice_creator_notifier_test.dart -v

Expected: All 7 tests PASS.

  • Step 5: Commit
git add lib/features/invoices/invoice_creator_notifier.dart \
  test/features/invoices/invoice_creator_notifier_test.dart
git commit -m "feat: implement InvoiceCreatorNotifier with calculator integration and tier gating"

Task 11: Invoice Creator Screen (UI)

Files:

  • Create: lib/features/invoices/invoice_creator_screen.dart

  • Create: lib/features/invoices/widgets/line_item_row.dart

  • Create: lib/shared/widgets/search_dropdown.dart

  • Step 1: Create line item row widget

// lib/features/invoices/widgets/line_item_row.dart
import 'package:flutter/material.dart';
import '../../../core/utils/currency_formatter.dart';

class LineItemRow extends StatelessWidget {
  final TextEditingController descriptionController;
  final TextEditingController quantityController;
  final TextEditingController rateController;
  final int lineTotal;
  final VoidCallback onDelete;
  final VoidCallback onChanged;

  const LineItemRow({
    super.key,
    required this.descriptionController,
    required this.quantityController,
    required this.rateController,
    required this.lineTotal,
    required this.onDelete,
    required this.onChanged,
  });

  @override
  Widget build(BuildContext context) {
    return Dismissible(
      key: UniqueKey(),
      direction: DismissDirection.endToStart,
      onDismissed: (_) => onDelete(),
      background: Container(
        color: Theme.of(context).colorScheme.error,
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 16),
        child: const Icon(Icons.delete, color: Colors.white),
      ),
      child: Card(
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Column(
            children: [
              TextField(
                controller: descriptionController,
                decoration: const InputDecoration(
                  labelText: 'Description',
                  isDense: true,
                ),
                onChanged: (_) => onChanged(),
              ),
              const SizedBox(height: 8),
              Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: quantityController,
                      decoration: const InputDecoration(
                        labelText: 'Qty',
                        isDense: true,
                      ),
                      keyboardType:
                          const TextInputType.numberWithOptions(decimal: true),
                      onChanged: (_) => onChanged(),
                    ),
                  ),
                  const SizedBox(width: 8),
                  Expanded(
                    child: TextField(
                      controller: rateController,
                      decoration: const InputDecoration(
                        labelText: 'Rate',
                        isDense: true,
                      ),
                      keyboardType:
                          const TextInputType.numberWithOptions(decimal: true),
                      onChanged: (_) => onChanged(),
                    ),
                  ),
                  const SizedBox(width: 8),
                  SizedBox(
                    width: 80,
                    child: Text(
                      CurrencyFormatter.format(lineTotal),
                      textAlign: TextAlign.right,
                      style: const TextStyle(fontWeight: FontWeight.bold),
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}
  • Step 2: Create invoice creator screen
// lib/features/invoices/invoice_creator_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/database/daos/business_dao.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/database/daos/line_item_dao.dart';
import '../../core/database/daos/app_settings_dao.dart';
import '../../core/database/daos/client_dao.dart';
import '../../core/database/database.dart';
import '../../core/providers/database_provider.dart';
import '../../core/providers/business_provider.dart';
import 'invoice_creator_notifier.dart';
import 'widgets/line_item_row.dart';

final invoiceCreatorProvider =
    StateNotifierProvider.autoDispose<InvoiceCreatorNotifier, InvoiceCreatorState>(
        (ref) {
  final db = ref.read(databaseProvider);
  final business = ref.read(businessProvider).valueOrNull;
  return InvoiceCreatorNotifier(
    businessDao: BusinessDao(db),
    documentDao: DocumentDao(db),
    lineItemDao: LineItemDao(db),
    settingsDao: AppSettingsDao(db),
    businessId: business?.id ?? '',
  );
});

final activeClientsProvider = FutureProvider<List<Client>>((ref) {
  final db = ref.read(databaseProvider);
  final business = ref.read(businessProvider).valueOrNull;
  if (business == null) return [];
  return ClientDao(db).getActiveClients(business.id);
});

class InvoiceCreatorScreen extends ConsumerStatefulWidget {
  const InvoiceCreatorScreen({super.key});

  @override
  ConsumerState<InvoiceCreatorScreen> createState() =>
      _InvoiceCreatorScreenState();
}

class _InvoiceCreatorScreenState extends ConsumerState<InvoiceCreatorScreen> {
  final _descController = TextEditingController();
  final _qtyController = TextEditingController(text: '1');
  final _rateController = TextEditingController();
  final _notesController = TextEditingController();

  @override
  void initState() {
    super.initState();
    Future.microtask(() =>
        ref.read(invoiceCreatorProvider.notifier).initialize());
  }

  @override
  Widget build(BuildContext context) {
    final state = ref.watch(invoiceCreatorProvider);
    final notifier = ref.read(invoiceCreatorProvider.notifier);
    final clientsAsync = ref.watch(activeClientsProvider);

    if (state.showPaywall) {
      Future.microtask(() => context.push('/paywall'));
    }

    return Scaffold(
      appBar: AppBar(title: const Text('New Invoice')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // Client selector
            clientsAsync.when(
              loading: () => const CircularProgressIndicator(),
              error: (e, _) => Text('Error: $e'),
              data: (clients) => DropdownButtonFormField<String>(
                decoration: const InputDecoration(labelText: 'Client *'),
                value: state.clientId,
                items: clients
                    .map((c) => DropdownMenuItem(value: c.id, child: Text(c.name)))
                    .toList(),
                onChanged: (id) {
                  if (id != null) notifier.setClientId(id);
                },
              ),
            ),
            const SizedBox(height: 16),

            // Invoice number
            Text('Invoice #: ${state.documentNumber}',
                style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 16),

            // Line items section
            const Text('Line Items', style: TextStyle(fontWeight: FontWeight.bold)),
            const SizedBox(height: 8),
            ...state.lineItems.asMap().entries.map((entry) {
              final i = entry.key;
              final li = entry.value;
              return Card(
                child: ListTile(
                  title: Text(li.description),
                  subtitle: Text('${li.quantity} x ${CurrencyFormatter.format(li.unitPriceCents)}'),
                  trailing: Row(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      Text(CurrencyFormatter.format(li.amount)),
                      IconButton(
                        icon: const Icon(Icons.delete, size: 20),
                        onPressed: () => notifier.removeLineItem(i),
                      ),
                    ],
                  ),
                ),
              );
            }),

            // Add line item inline form
            Card(
              child: Padding(
                padding: const EdgeInsets.all(12),
                child: Column(
                  children: [
                    TextField(
                      controller: _descController,
                      decoration: const InputDecoration(labelText: 'Description', isDense: true),
                    ),
                    const SizedBox(height: 8),
                    Row(
                      children: [
                        Expanded(
                          child: TextField(
                            controller: _qtyController,
                            decoration: const InputDecoration(labelText: 'Qty', isDense: true),
                            keyboardType: const TextInputType.numberWithOptions(decimal: true),
                          ),
                        ),
                        const SizedBox(width: 8),
                        Expanded(
                          child: TextField(
                            controller: _rateController,
                            decoration: const InputDecoration(labelText: 'Rate (\$)', isDense: true),
                            keyboardType: const TextInputType.numberWithOptions(decimal: true),
                          ),
                        ),
                      ],
                    ),
                    const SizedBox(height: 8),
                    TextButton.icon(
                      onPressed: () {
                        final desc = _descController.text.trim();
                        final qty = double.tryParse(_qtyController.text) ?? 1.0;
                        final rate = CurrencyFormatter.parseToCents(_rateController.text);
                        if (desc.isNotEmpty && rate > 0) {
                          notifier.addLineItem(description: desc, quantity: qty, unitPrice: rate);
                          _descController.clear();
                          _qtyController.text = '1';
                          _rateController.clear();
                        }
                      },
                      icon: const Icon(Icons.add),
                      label: const Text('Add Item'),
                    ),
                  ],
                ),
              ),
            ),

            const Divider(),

            // Totals section
            TotalRow(label: 'Subtotal', value: state.subtotal),
            TotalRow(label: 'Tax', value: state.taxAmount),
            if (state.discountAmount > 0)
              TotalRow(label: 'Discount', value: -state.discountAmount),
            const Divider(),
            TotalRow(label: 'Total', value: state.total, bold: true),

            const SizedBox(height: 16),
            TextField(
              controller: _notesController,
              decoration: const InputDecoration(
                labelText: 'Notes / Terms',
                hintText: 'Payment terms, thank you message...',
              ),
              maxLines: 3,
              onChanged: notifier.setNotes,
            ),

            if (state.error != null) ...[
              const SizedBox(height: 8),
              Text(state.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
            ],

            const SizedBox(height: 24),
            Row(
              children: [
                Expanded(
                  child: OutlinedButton(
                    onPressed: state.isSaving
                        ? null
                        : () async {
                            final docId = await notifier.save();
                            if (docId != null && context.mounted) context.pop();
                          },
                    child: const Text('Save as Draft'),
                  ),
                ),
                const SizedBox(width: 16),
                Expanded(
                  child: FilledButton(
                    onPressed: state.isSaving
                        ? null
                        : () async {
                            final docId = await notifier.save(markAsSent: true);
                            if (docId != null && context.mounted) {
                              context.push('/invoices/$docId');
                            }
                          },
                    child: const Text('Save & Share'),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _descController.dispose();
    _qtyController.dispose();
    _rateController.dispose();
    _notesController.dispose();
    super.dispose();
  }
}

class TotalRow extends StatelessWidget {
  final String label;
  final int value;
  final bool bold;

  const TotalRow({required this.label, required this.value, this.bold = false});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(label, style: bold ? const TextStyle(fontWeight: FontWeight.bold) : null),
          Text(CurrencyFormatter.format(value),
              style: bold ? const TextStyle(fontWeight: FontWeight.bold) : null),
        ],
      ),
    );
  }
}
  • Step 3: Add route for invoice creator in app.dart

Add inside the ShellRoute's routes list:

GoRoute(
  path: '/invoices/new',
  builder: (context, state) => const InvoiceCreatorScreen(),
),
  • Step 4: Verify the screen renders
flutter run -d chrome

Navigate to /invoices/new — screen should render without errors.

  • Step 5: Commit
git add lib/features/invoices/ lib/shared/widgets/search_dropdown.dart
git commit -m "feat: add invoice creator screen with line item UI components"

Task 12: Status Pill & Summary Card Widgets

Files:

  • Create: lib/features/invoices/widgets/status_pill.dart

  • Create: lib/features/invoices/widgets/summary_card.dart

  • Step 1: Create StatusPill widget

// lib/features/invoices/widgets/status_pill.dart
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';

class StatusPill extends StatelessWidget {
  final String status;

  const StatusPill({super.key, required this.status});

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
      decoration: BoxDecoration(
        color: _color.withOpacity(0.15),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Text(
        _label,
        style: TextStyle(
          color: _color,
          fontSize: 12,
          fontWeight: FontWeight.w600,
        ),
      ),
    );
  }

  String get _label => status[0].toUpperCase() + status.substring(1);

  Color get _color => switch (status) {
        'draft' => AppColors.statusDraft,
        'sent' => AppColors.statusSent,
        'paid' => AppColors.statusPaid,
        'overdue' => AppColors.statusOverdue,
        'partial' => AppColors.statusPartial,
        'void' => AppColors.statusVoid,
        'accepted' => AppColors.statusPaid,
        'declined' => AppColors.statusOverdue,
        'expired' => AppColors.statusDraft,
        'converted' => AppColors.statusSent,
        _ => AppColors.statusDraft,
      };
}
  • Step 2: Create SummaryCard widget
// lib/features/invoices/widgets/summary_card.dart
import 'package:flutter/material.dart';

class SummaryCard extends StatelessWidget {
  final String label;
  final String value;
  final Color? valueColor;
  final IconData? icon;

  const SummaryCard({
    super.key,
    required this.label,
    required this.value,
    this.valueColor,
    this.icon,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                if (icon != null) ...[
                  Icon(icon, size: 16, color: Theme.of(context).colorScheme.outline),
                  const SizedBox(width: 4),
                ],
                Text(
                  label,
                  style: Theme.of(context).textTheme.labelSmall?.copyWith(
                        color: Theme.of(context).colorScheme.outline,
                      ),
                ),
              ],
            ),
            const SizedBox(height: 4),
            Text(
              value,
              style: Theme.of(context).textTheme.titleMedium?.copyWith(
                    fontWeight: FontWeight.bold,
                    color: valueColor,
                  ),
            ),
          ],
        ),
      ),
    );
  }
}
  • Step 3: Write widget tests for StatusPill and SummaryCard
// test/features/invoices/widgets/status_pill_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/features/invoices/widgets/status_pill.dart';

void main() {
  testWidgets('StatusPill displays capitalized label', (tester) async {
    await tester.pumpWidget(
      const MaterialApp(home: Scaffold(body: StatusPill(status: 'draft'))),
    );
    expect(find.text('Draft'), findsOneWidget);
  });

  testWidgets('StatusPill displays Paid label', (tester) async {
    await tester.pumpWidget(
      const MaterialApp(home: Scaffold(body: StatusPill(status: 'paid'))),
    );
    expect(find.text('Paid'), findsOneWidget);
  });

  testWidgets('StatusPill displays Overdue label', (tester) async {
    await tester.pumpWidget(
      const MaterialApp(home: Scaffold(body: StatusPill(status: 'overdue'))),
    );
    expect(find.text('Overdue'), findsOneWidget);
  });
}
// test/features/invoices/widgets/summary_card_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/features/invoices/widgets/summary_card.dart';

void main() {
  testWidgets('SummaryCard displays label and value', (tester) async {
    await tester.pumpWidget(
      const MaterialApp(
        home: Scaffold(
          body: SummaryCard(label: 'Outstanding', value: '\$500.00'),
        ),
      ),
    );
    expect(find.text('Outstanding'), findsOneWidget);
    expect(find.text('\$500.00'), findsOneWidget);
  });
}
  • Step 4: Run widget tests
flutter test test/features/invoices/widgets/ -v

Expected: All 4 tests PASS.

  • Step 5: Commit
git add lib/features/invoices/widgets/ test/features/invoices/widgets/
git commit -m "feat: add StatusPill and SummaryCard reusable widgets with tests"

Task 13: Invoice Dashboard Screen

Files:

  • Create: lib/features/invoices/invoice_dashboard_notifier.dart

  • Create: lib/features/invoices/invoice_dashboard_screen.dart

  • Create: test/features/invoices/invoice_dashboard_notifier_test.dart

  • Step 1: Write failing tests for InvoiceDashboardNotifier

// test/features/invoices/invoice_dashboard_notifier_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/document_dao.dart';
import 'package:swift_invoice/features/invoices/invoice_dashboard_notifier.dart';

void main() {
  late AppDatabase db;
  late InvoiceDashboardNotifier notifier;
  late String businessId;
  late String clientId;

  setUp(() async {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    final biz = await BusinessDao(db).createBusiness(name: 'Co');
    businessId = biz.id;
    final client = await ClientDao(db).createClient(
        businessId: businessId, name: 'C');
    clientId = client.id;
    notifier = InvoiceDashboardNotifier(DocumentDao(db), businessId);
  });

  tearDown(() => db.close());

  test('loadDashboard populates invoices', () async {
    await DocumentDao(db).createDocument(
      businessId: businessId, clientId: clientId,
      documentType: 'invoice', documentNumber: 'INV-001',
      status: 'draft', issueDate: '2026-03-22',
      total: 5000, amountDue: 5000,
    );
    await notifier.loadDashboard();
    expect(notifier.state.invoices.length, 1);
  });

  test('loadDashboard calculates stats', () async {
    await DocumentDao(db).createDocument(
      businessId: businessId, clientId: clientId,
      documentType: 'invoice', documentNumber: 'INV-001',
      status: 'sent', issueDate: '2026-03-22',
      total: 10000, amountDue: 10000,
    );
    await DocumentDao(db).createDocument(
      businessId: businessId, clientId: clientId,
      documentType: 'invoice', documentNumber: 'INV-002',
      status: 'draft', issueDate: '2026-03-22',
      total: 5000, amountDue: 5000,
    );
    await notifier.loadDashboard();
    expect(notifier.state.stats['outstanding'], 10000);
    expect(notifier.state.stats['draftCount'], 1);
  });

  test('filterByStatus filters invoices', () async {
    await DocumentDao(db).createDocument(
      businessId: businessId, clientId: clientId,
      documentType: 'invoice', documentNumber: 'INV-001',
      status: 'draft', issueDate: '2026-03-22',
    );
    await DocumentDao(db).createDocument(
      businessId: businessId, clientId: clientId,
      documentType: 'invoice', documentNumber: 'INV-002',
      status: 'sent', issueDate: '2026-03-22',
    );
    await notifier.loadDashboard();
    notifier.filterByStatus('draft');
    expect(notifier.state.filteredInvoices.length, 1);
    expect(notifier.state.filteredInvoices.first.status, 'draft');
  });
}
  • Step 2: Run tests to verify they fail
flutter test test/features/invoices/invoice_dashboard_notifier_test.dart

Expected: FAIL.

  • Step 3: Implement InvoiceDashboardNotifier
// lib/features/invoices/invoice_dashboard_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/database/database.dart';
import '../../core/database/daos/document_dao.dart';

class DashboardState {
  final List<Document> invoices;
  final List<Document> filteredInvoices;
  final Map<String, int> stats;
  final String? activeFilter;
  final bool isLoading;

  const DashboardState({
    this.invoices = const [],
    this.filteredInvoices = const [],
    this.stats = const {},
    this.activeFilter,
    this.isLoading = false,
  });

  DashboardState copyWith({
    List<Document>? invoices,
    List<Document>? filteredInvoices,
    Map<String, int>? stats,
    String? activeFilter,
    bool? isLoading,
  }) {
    return DashboardState(
      invoices: invoices ?? this.invoices,
      filteredInvoices: filteredInvoices ?? this.filteredInvoices,
      stats: stats ?? this.stats,
      activeFilter: activeFilter,
      isLoading: isLoading ?? this.isLoading,
    );
  }
}

class InvoiceDashboardNotifier extends StateNotifier<DashboardState> {
  final DocumentDao _documentDao;
  final String _businessId;

  InvoiceDashboardNotifier(this._documentDao, this._businessId)
      : super(const DashboardState());

  Future<void> loadDashboard() async {
    state = state.copyWith(isLoading: true);

    // Mark overdue invoices
    await _documentDao.markOverdueInvoices(_businessId);

    final invoices = await _documentDao.getDocumentsByType(_businessId, 'invoice');
    final stats = await _documentDao.getDashboardStats(_businessId);

    state = state.copyWith(
      invoices: invoices,
      filteredInvoices: invoices,
      stats: stats,
      isLoading: false,
    );
  }

  void filterByStatus(String? status) {
    if (status == null) {
      state = state.copyWith(
        filteredInvoices: state.invoices,
        activeFilter: null,
      );
    } else {
      state = state.copyWith(
        filteredInvoices:
            state.invoices.where((d) => d.status == status).toList(),
        activeFilter: status,
      );
    }
  }
}
  • Step 4: Run tests
flutter test test/features/invoices/invoice_dashboard_notifier_test.dart -v

Expected: All 3 tests PASS.

  • Step 5: Create Invoice Dashboard Screen
// lib/features/invoices/invoice_dashboard_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/utils/date_formatter.dart';
import '../../core/theme/app_colors.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/providers/database_provider.dart';
import '../../core/providers/business_provider.dart';
import 'invoice_dashboard_notifier.dart';
import 'widgets/status_pill.dart';
import 'widgets/summary_card.dart';

final dashboardNotifierProvider =
    StateNotifierProvider<InvoiceDashboardNotifier, DashboardState>((ref) {
  final db = ref.read(databaseProvider);
  final business = ref.read(businessProvider).valueOrNull;
  return InvoiceDashboardNotifier(DocumentDao(db), business?.id ?? '');
});

class InvoiceDashboardScreen extends ConsumerStatefulWidget {
  const InvoiceDashboardScreen({super.key});

  @override
  ConsumerState<InvoiceDashboardScreen> createState() =>
      _InvoiceDashboardScreenState();
}

class _InvoiceDashboardScreenState
    extends ConsumerState<InvoiceDashboardScreen> {
  @override
  void initState() {
    super.initState();
    Future.microtask(() =>
        ref.read(dashboardNotifierProvider.notifier).loadDashboard());
  }

  @override
  Widget build(BuildContext context) {
    final state = ref.watch(dashboardNotifierProvider);
    final notifier = ref.read(dashboardNotifierProvider.notifier);
    final stats = state.stats;

    return Scaffold(
      appBar: AppBar(title: const Text('Invoices')),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: () => context.push('/invoices/new'),
        icon: const Icon(Icons.add),
        label: const Text('New Invoice'),
      ),
      body: state.isLoading
          ? const Center(child: CircularProgressIndicator())
          : CustomScrollView(
              slivers: [
                // Summary stats bar
                SliverToBoxAdapter(
                  child: Padding(
                    padding: const EdgeInsets.all(8),
                    child: Row(
                      children: [
                        Expanded(
                          child: SummaryCard(
                            label: 'Outstanding',
                            value: CurrencyFormatter.format(stats['outstanding'] ?? 0),
                            icon: Icons.account_balance_wallet,
                          ),
                        ),
                        Expanded(
                          child: SummaryCard(
                            label: 'Overdue',
                            value: CurrencyFormatter.format(stats['overdue'] ?? 0),
                            valueColor: AppColors.error,
                            icon: Icons.warning,
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
                SliverToBoxAdapter(
                  child: Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 8),
                    child: Row(
                      children: [
                        Expanded(
                          child: SummaryCard(
                            label: 'Paid (Month)',
                            value: CurrencyFormatter.format(stats['paidThisMonth'] ?? 0),
                            valueColor: AppColors.success,
                            icon: Icons.check_circle,
                          ),
                        ),
                        Expanded(
                          child: SummaryCard(
                            label: 'Drafts',
                            value: '${stats['draftCount'] ?? 0}',
                            icon: Icons.edit_note,
                          ),
                        ),
                      ],
                    ),
                  ),
                ),

                // Filter chips
                SliverToBoxAdapter(
                  child: Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                    child: Wrap(
                      spacing: 8,
                      children: [
                        FilterChip(
                          label: const Text('All'),
                          selected: state.activeFilter == null,
                          onSelected: (_) => notifier.filterByStatus(null),
                        ),
                        for (final status in ['draft', 'sent', 'overdue', 'paid'])
                          FilterChip(
                            label: Text(status[0].toUpperCase() + status.substring(1)),
                            selected: state.activeFilter == status,
                            onSelected: (_) => notifier.filterByStatus(status),
                          ),
                      ],
                    ),
                  ),
                ),

                // Invoice list
                if (state.filteredInvoices.isEmpty)
                  const SliverFillRemaining(
                    child: Center(child: Text('No invoices yet')),
                  )
                else
                  SliverList(
                    delegate: SliverChildBuilderDelegate(
                      (context, index) {
                        final invoice = state.filteredInvoices[index];
                        return ListTile(
                          title: Text(invoice.documentNumber),
                          subtitle: Text(invoice.dueDate != null
                              ? 'Due: ${DateFormatter.toDisplay(invoice.dueDate!)}'
                              : ''),
                          trailing: Row(
                            mainAxisSize: MainAxisSize.min,
                            children: [
                              StatusPill(status: invoice.status),
                              const SizedBox(width: 8),
                              Text(CurrencyFormatter.format(invoice.total)),
                            ],
                          ),
                          onTap: () => context.push('/invoices/${invoice.id}'),
                        );
                      },
                      childCount: state.filteredInvoices.length,
                    ),
                  ),
              ],
            ),
    );
  }
}
  • Step 6: Update router to use InvoiceDashboardScreen

Replace the /invoices placeholder route in app.dart:

GoRoute(
  path: '/invoices',
  builder: (context, state) => const InvoiceDashboardScreen(),
),
  • Step 7: Commit
git add lib/features/invoices/ test/features/invoices/
git commit -m "feat: implement invoice dashboard with summary stats and filter chips"

Task 14: Invoice Detail Screen & Route Wiring

Files:

  • Create: lib/features/invoices/invoice_detail_screen.dart

  • Modify: lib/app.dart — add all remaining routes

  • Create: lib/core/providers/document_provider.dart

  • Step 1: Create invoice detail screen

// lib/features/invoices/invoice_detail_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/utils/date_formatter.dart';
import '../../core/database/database.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/database/daos/line_item_dao.dart';
import '../../core/database/daos/client_dao.dart';
import '../../core/database/daos/business_dao.dart';
import '../../core/database/daos/app_settings_dao.dart';
import '../../core/providers/database_provider.dart';
import '../../core/services/pdf_service.dart';
import '../../features/pdf/pdf_preview_screen.dart';
import '../../features/payments/payment_bottom_sheet.dart';
import 'widgets/status_pill.dart';

final invoiceDetailProvider =
    FutureProvider.family<Document?, String>((ref, docId) {
  return DocumentDao(ref.read(databaseProvider)).getDocument(docId);
});

final invoiceLineItemsProvider =
    FutureProvider.family<List<LineItem>, String>((ref, docId) {
  return LineItemDao(ref.read(databaseProvider)).getLineItems(docId);
});

class InvoiceDetailScreen extends ConsumerWidget {
  final String documentId;

  const InvoiceDetailScreen({super.key, required this.documentId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final docAsync = ref.watch(invoiceDetailProvider(documentId));
    final itemsAsync = ref.watch(invoiceLineItemsProvider(documentId));

    return docAsync.when(
      loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
      error: (e, _) => Scaffold(body: Center(child: Text('Error: $e'))),
      data: (doc) {
        if (doc == null) {
          return const Scaffold(body: Center(child: Text('Invoice not found')));
        }
        return Scaffold(
          appBar: AppBar(
            title: Text(doc.documentNumber),
            actions: [
              IconButton(
                icon: const Icon(Icons.share),
                onPressed: () => _sharePdf(context, ref, doc),
              ),
              PopupMenuButton<String>(
                onSelected: (value) => _handleAction(context, ref, doc, value),
                itemBuilder: (context) => [
                  const PopupMenuItem(value: 'payment', child: Text('Record Payment')),
                  const PopupMenuItem(value: 'void', child: Text('Void Invoice')),
                  const PopupMenuItem(value: 'delete', child: Text('Delete')),
                ],
              ),
            ],
          ),
          body: ListView(
            padding: const EdgeInsets.all(16),
            children: [
              // Status and dates
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  StatusPill(status: doc.status),
                  Text(DateFormatter.toDisplay(doc.issueDate)),
                ],
              ),
              if (doc.dueDate != null) ...[
                const SizedBox(height: 4),
                Text('Due: ${DateFormatter.toDisplay(doc.dueDate!)}',
                    style: Theme.of(context).textTheme.bodySmall),
              ],
              const SizedBox(height: 16),

              // Line items
              itemsAsync.when(
                loading: () => const CircularProgressIndicator(),
                error: (e, _) => Text('Error: $e'),
                data: (items) => Column(
                  children: items.map((item) => ListTile(
                    dense: true,
                    title: Text(item.description),
                    subtitle: Text('${item.quantity} x ${CurrencyFormatter.format(item.unitPrice)}'),
                    trailing: Text(CurrencyFormatter.format(item.amount)),
                  )).toList(),
                ),
              ),

              const Divider(),

              // Totals
              _DetailRow('Subtotal', CurrencyFormatter.format(doc.subtotal)),
              if (doc.taxRate > 0)
                _DetailRow('Tax (${CurrencyFormatter.formatTaxRate(doc.taxRate)})',
                    CurrencyFormatter.format(doc.taxAmount)),
              if (doc.discountAmount > 0)
                _DetailRow('Discount', '-${CurrencyFormatter.format(doc.discountAmount)}'),
              const Divider(),
              _DetailRow('Total', CurrencyFormatter.format(doc.total), bold: true),
              _DetailRow('Paid', CurrencyFormatter.format(doc.amountPaid)),
              _DetailRow('Balance Due', CurrencyFormatter.format(doc.amountDue), bold: true),

              if (doc.docNotes != null && doc.docNotes!.isNotEmpty) ...[
                const SizedBox(height: 16),
                Text('Notes', style: Theme.of(context).textTheme.titleSmall),
                Text(doc.docNotes!),
              ],
            ],
          ),
        );
      },
    );
  }

  Future<void> _sharePdf(BuildContext context, WidgetRef ref, Document doc) async {
    final db = ref.read(databaseProvider);
    final business = await BusinessDao(db).getBusiness();
    final client = await ClientDao(db).getClient(doc.clientId);
    final items = await LineItemDao(db).getLineItems(doc.id);
    final tier = await AppSettingsDao(db).getSubscriptionTier();

    final pdfBytes = await PdfService.generateDocument(
      businessName: business?.name ?? '',
      businessEmail: business?.email,
      businessPhone: business?.phone,
      clientName: client?.name ?? '',
      documentTitle: doc.documentType == 'estimate' ? 'ESTIMATE' : 'INVOICE',
      documentNumber: doc.documentNumber,
      issueDate: doc.issueDate,
      dueDate: doc.dueDate,
      lineItems: items.map((li) => PdfLineItem(
        description: li.description,
        quantity: li.quantity,
        rate: li.unitPrice,
        amount: li.amount,
      )).toList(),
      subtotal: doc.subtotal,
      taxRate: doc.taxRate,
      taxAmount: doc.taxAmount,
      discountAmount: doc.discountAmount,
      total: doc.total,
      amountDue: doc.amountDue,
      notes: doc.docNotes,
      showWatermark: tier == 'free',
    );

    // Update status to sent if currently draft
    if (doc.status == 'draft') {
      await DocumentDao(db).updateStatus(doc.id, 'sent');
    }

    if (context.mounted) {
      Navigator.push(context, MaterialPageRoute(
        builder: (_) => PdfPreviewScreen(
          pdfBytes: pdfBytes,
          fileName: '${doc.documentNumber}.pdf',
        ),
      ));
    }
  }

  void _handleAction(BuildContext context, WidgetRef ref, Document doc, String action) {
    switch (action) {
      case 'payment':
        showModalBottomSheet(
          context: context,
          isScrollControlled: true,
          builder: (_) => PaymentBottomSheet(
            documentId: doc.id,
            clientId: doc.clientId,
            amountDue: doc.amountDue,
          ),
        );
      case 'void':
        DocumentDao(ref.read(databaseProvider)).updateStatus(doc.id, 'void');
        ref.invalidate(invoiceDetailProvider(documentId));
      case 'delete':
        DocumentDao(ref.read(databaseProvider)).softDeleteDocument(doc.id);
        context.pop();
    }
  }
}

class _DetailRow extends StatelessWidget {
  final String label;
  final String value;
  final bool bold;

  const _DetailRow(this.label, this.value, {this.bold = false});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 2),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(label, style: bold ? const TextStyle(fontWeight: FontWeight.bold) : null),
          Text(value, style: bold ? const TextStyle(fontWeight: FontWeight.bold) : null),
        ],
      ),
    );
  }
}
  • Step 2: Create document provider
// lib/core/providers/document_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/daos/document_dao.dart';
import '../database/daos/line_item_dao.dart';
import 'database_provider.dart';

final documentDaoProvider = Provider<DocumentDao>((ref) {
  return DocumentDao(ref.read(databaseProvider));
});

final lineItemDaoProvider = Provider<LineItemDao>((ref) {
  return LineItemDao(ref.read(databaseProvider));
});
  • Step 3: Add all remaining routes to app.dart

Add inside the ShellRoute:

GoRoute(
  path: '/invoices/:id',
  builder: (context, state) => InvoiceDetailScreen(
    documentId: state.pathParameters['id']!,
  ),
),
GoRoute(
  path: '/clients/new',
  builder: (context, state) => const ClientFormScreen(),
),
GoRoute(
  path: '/clients/:id',
  builder: (context, state) => ClientDetailScreen(
    clientId: state.pathParameters['id']!,
  ),
),
GoRoute(
  path: '/clients/:id/edit',
  builder: (context, state) => ClientFormScreen(
    clientId: state.pathParameters['id'],
  ),
),
  • Step 4: Verify full navigation flow
flutter run -d chrome

Expected: Can navigate between all 4 tabs. FAB on invoices goes to creator. All routes load without errors.

  • Step 5: Commit
git add lib/features/invoices/invoice_detail_screen.dart \
  lib/core/providers/document_provider.dart lib/app.dart
git commit -m "feat: add invoice detail screen and wire up all routes"

Phase 3: PDF & Pro Features (Weeks 67)

Task 15: PDF Generation Service

Files:

  • Create: lib/core/services/pdf_service.dart

  • Create: lib/features/pdf/pdf_template.dart

  • Create: lib/features/pdf/pdf_preview_screen.dart

  • Create: test/core/services/pdf_service_test.dart

  • Step 1: Write failing tests for PdfService

// test/core/services/pdf_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/services/pdf_service.dart';

void main() {
  test('generateInvoicePdf returns non-empty bytes', () async {
    final bytes = await PdfService.generateDocument(
      businessName: 'Test Co',
      businessEmail: 'test@co.com',
      clientName: 'Jane Doe',
      documentTitle: 'INVOICE',
      documentNumber: 'INV-001',
      issueDate: '2026-03-22',
      dueDate: '2026-04-21',
      lineItems: [
        PdfLineItem(description: 'Web Design', quantity: 2.0, rate: 5000, amount: 10000),
      ],
      subtotal: 10000,
      taxRate: 825,
      taxAmount: 825,
      discountAmount: 0,
      total: 10825,
      amountDue: 10825,
      notes: 'Payment due in 30 days',
      showWatermark: true,
    );
    expect(bytes, isNotEmpty);
    // PDF magic bytes
    expect(bytes[0], 0x25); // %
    expect(bytes[1], 0x50); // P
    expect(bytes[2], 0x44); // D
    expect(bytes[3], 0x46); // F
  });

  test('generateDocument without watermark for pro users', () async {
    final bytes = await PdfService.generateDocument(
      businessName: 'Pro Co',
      clientName: 'Client',
      documentTitle: 'INVOICE',
      documentNumber: 'INV-002',
      issueDate: '2026-03-22',
      lineItems: [],
      subtotal: 0,
      taxRate: 0,
      taxAmount: 0,
      discountAmount: 0,
      total: 0,
      amountDue: 0,
      showWatermark: false,
    );
    expect(bytes, isNotEmpty);
  });
}
  • Step 2: Run tests to verify they fail
flutter test test/core/services/pdf_service_test.dart

Expected: FAIL.

  • Step 3: Implement PdfService
// lib/core/services/pdf_service.dart
import 'dart:typed_data';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;

class PdfLineItem {
  final String description;
  final double quantity;
  final int rate;
  final int amount;

  const PdfLineItem({
    required this.description,
    required this.quantity,
    required this.rate,
    required this.amount,
  });
}

class PdfService {
  static Future<Uint8List> generateDocument({
    required String businessName,
    String? businessEmail,
    String? businessPhone,
    String? businessAddress,
    String? logoPath,
    required String clientName,
    String? clientAddress,
    required String documentTitle,
    required String documentNumber,
    required String issueDate,
    String? dueDate,
    required List<PdfLineItem> lineItems,
    required int subtotal,
    required int taxRate,
    required int taxAmount,
    required int discountAmount,
    required int total,
    required int amountDue,
    String? notes,
    required bool showWatermark,
  }) async {
    final pdf = pw.Document();

    pdf.addPage(
      pw.Page(
        pageFormat: PdfPageFormat.letter,
        margin: const pw.EdgeInsets.all(40),
        build: (pw.Context context) {
          return pw.Column(
            crossAxisAlignment: pw.CrossAxisAlignment.start,
            children: [
              // Header
              pw.Row(
                mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
                children: [
                  pw.Column(
                    crossAxisAlignment: pw.CrossAxisAlignment.start,
                    children: [
                      pw.Text(businessName,
                          style: pw.TextStyle(
                              fontSize: 20, fontWeight: pw.FontWeight.bold)),
                      if (businessEmail != null)
                        pw.Text(businessEmail, style: const pw.TextStyle(fontSize: 10)),
                      if (businessPhone != null)
                        pw.Text(businessPhone, style: const pw.TextStyle(fontSize: 10)),
                      if (businessAddress != null)
                        pw.Text(businessAddress, style: const pw.TextStyle(fontSize: 10)),
                    ],
                  ),
                  pw.Column(
                    crossAxisAlignment: pw.CrossAxisAlignment.end,
                    children: [
                      pw.Text(documentTitle,
                          style: pw.TextStyle(
                              fontSize: 24, fontWeight: pw.FontWeight.bold,
                              color: PdfColors.blue800)),
                      pw.Text(documentNumber, style: const pw.TextStyle(fontSize: 12)),
                    ],
                  ),
                ],
              ),

              pw.SizedBox(height: 30),

              // Bill To + Dates
              pw.Row(
                mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
                children: [
                  pw.Column(
                    crossAxisAlignment: pw.CrossAxisAlignment.start,
                    children: [
                      pw.Text('Bill To', style: pw.TextStyle(
                          fontWeight: pw.FontWeight.bold, color: PdfColors.grey600)),
                      pw.Text(clientName, style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
                      if (clientAddress != null) pw.Text(clientAddress),
                    ],
                  ),
                  pw.Column(
                    crossAxisAlignment: pw.CrossAxisAlignment.end,
                    children: [
                      pw.Text('Issue Date: $issueDate'),
                      if (dueDate != null) pw.Text('Due Date: $dueDate'),
                    ],
                  ),
                ],
              ),

              pw.SizedBox(height: 20),

              // Line items table
              pw.TableHelper.fromTextArray(
                headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
                headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200),
                cellPadding: const pw.EdgeInsets.all(6),
                headers: ['Description', 'Qty', 'Rate', 'Amount'],
                data: lineItems.map((li) => [
                  li.description,
                  li.quantity.toString(),
                  _formatCents(li.rate),
                  _formatCents(li.amount),
                ]).toList(),
                columnWidths: {
                  0: const pw.FlexColumnWidth(4),
                  1: const pw.FlexColumnWidth(1),
                  2: const pw.FlexColumnWidth(1.5),
                  3: const pw.FlexColumnWidth(1.5),
                },
              ),

              pw.SizedBox(height: 10),

              // Totals
              pw.Align(
                alignment: pw.Alignment.centerRight,
                child: pw.SizedBox(
                  width: 200,
                  child: pw.Column(
                    children: [
                      _totalRow('Subtotal', subtotal),
                      if (taxRate > 0)
                        _totalRow('Tax (${(taxRate / 100).toStringAsFixed(2)}%)', taxAmount),
                      if (discountAmount > 0)
                        _totalRow('Discount', -discountAmount),
                      pw.Divider(),
                      _totalRow('Total Due', amountDue, bold: true),
                    ],
                  ),
                ),
              ),

              pw.SizedBox(height: 20),

              // Notes
              if (notes != null && notes.isNotEmpty) ...[
                pw.Text('Notes', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
                pw.Text(notes),
              ],

              pw.Spacer(),

              // Watermark
              if (showWatermark)
                pw.Center(
                  child: pw.Text(
                    'Created with SwiftInvoice',
                    style: pw.TextStyle(fontSize: 8, color: PdfColors.grey400),
                  ),
                ),
            ],
          );
        },
      ),
    );

    return pdf.save();
  }

  static pw.Widget _totalRow(String label, int cents, {bool bold = false}) {
    return pw.Padding(
      padding: const pw.EdgeInsets.symmetric(vertical: 2),
      child: pw.Row(
        mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
        children: [
          pw.Text(label, style: pw.TextStyle(
              fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal)),
          pw.Text(_formatCents(cents), style: pw.TextStyle(
              fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal)),
        ],
      ),
    );
  }

  static String _formatCents(int cents) {
    final negative = cents < 0;
    final abs = cents.abs();
    final dollars = abs ~/ 100;
    final remainder = abs % 100;
    final formatted = '\$${dollars.toString().replaceAllMapped(
        RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (m) => '${m[1]},'
    )}.${remainder.toString().padLeft(2, '0')}';
    return negative ? '-$formatted' : formatted;
  }
}
  • Step 4: Run tests
flutter test test/core/services/pdf_service_test.dart -v

Expected: All 2 tests PASS.

  • Step 5: Create PDF preview screen
// lib/features/pdf/pdf_preview_screen.dart
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:printing/printing.dart';
import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import 'package:path/path.dart' as p;

class PdfPreviewScreen extends StatelessWidget {
  final Uint8List pdfBytes;
  final String fileName;

  const PdfPreviewScreen({
    super.key,
    required this.pdfBytes,
    required this.fileName,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Preview'),
        actions: [
          IconButton(
            icon: const Icon(Icons.share),
            onPressed: () => _share(context),
          ),
        ],
      ),
      body: PdfPreview(
        build: (_) => pdfBytes,
        canChangeOrientation: false,
        canChangePageFormat: false,
        canDebug: false,
      ),
    );
  }

  Future<void> _share(BuildContext context) async {
    final dir = await getTemporaryDirectory();
    final file = File(p.join(dir.path, fileName));
    await file.writeAsBytes(pdfBytes);
    await Share.shareXFiles([XFile(file.path)], text: 'Invoice from SwiftInvoice');
  }
}
  • Step 6: Commit
git add lib/core/services/pdf_service.dart lib/features/pdf/ \
  test/core/services/pdf_service_test.dart
git commit -m "feat: implement PDF generation with branded template and share flow"

Task 16: PDF Integration — Verify Share Flow

Note: The _sharePdf() method was already implemented in Task 14's InvoiceDetailScreen. This task verifies the integration end-to-end.

Files:

  • No new files — verify existing wiring from Task 14

  • Step 1: Run the app and test PDF flow

flutter run -d chrome
  1. Create a client and an invoice with 2+ line items
  2. Open the invoice detail screen
  3. Tap the share icon
  4. Verify PDF preview renders with: business name, client name, line items table, totals, and watermark (free tier)
  5. Tap share — verify system share sheet opens
  • Step 2: Verify status update on share

After sharing a draft invoice, navigate back to dashboard and verify the invoice's status pill changed from "Draft" (gray) to "Sent" (blue).

  • Step 3: Commit (if any fixes were needed)
git add lib/features/invoices/invoice_detail_screen.dart
git commit -m "fix: polish PDF share integration"

Task 17: Estimate Creator with Conversion

Files:

  • Create: lib/features/estimates/estimate_creator_notifier.dart

  • Create: lib/features/estimates/estimate_creator_screen.dart

  • Create: lib/features/estimates/estimate_list_screen.dart

  • Create: test/features/estimates/estimate_creator_notifier_test.dart

  • Step 1: Write failing tests for estimate conversion

// test/features/estimates/estimate_creator_notifier_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/document_dao.dart';
import 'package:swift_invoice/core/database/daos/line_item_dao.dart';
import 'package:swift_invoice/features/estimates/estimate_creator_notifier.dart';

void main() {
  late AppDatabase db;
  late EstimateCreatorNotifier notifier;
  late String businessId;
  late String clientId;

  setUp(() async {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    final biz = await BusinessDao(db).createBusiness(name: 'Co');
    businessId = biz.id;
    final client = await ClientDao(db).createClient(
        businessId: businessId, name: 'Jane');
    clientId = client.id;
    notifier = EstimateCreatorNotifier(
      businessDao: BusinessDao(db),
      documentDao: DocumentDao(db),
      lineItemDao: LineItemDao(db),
      businessId: businessId,
    );
    await notifier.initialize();
  });

  tearDown(() => db.close());

  test('initialize sets estimate number with EST prefix', () {
    expect(notifier.state.documentNumber, startsWith('EST'));
  });

  test('convertToInvoice creates invoice from estimate', () async {
    // Create and save an estimate
    notifier.setClientId(clientId);
    notifier.addLineItem(description: 'Design', quantity: 3.0, unitPrice: 10000);
    final estimateId = await notifier.save();
    expect(estimateId, isNotNull);

    // Convert it
    final invoiceId = await notifier.convertToInvoice(estimateId!);
    expect(invoiceId, isNotNull);

    // Verify the invoice
    final invoice = await DocumentDao(db).getDocument(invoiceId!);
    expect(invoice!.documentType, 'invoice');
    expect(invoice.total, 30000);
    expect(invoice.convertedFromId, estimateId);

    // Verify estimate status changed
    final estimate = await DocumentDao(db).getDocument(estimateId);
    expect(estimate!.status, 'converted');

    // Verify line items copied
    final items = await LineItemDao(db).getLineItems(invoiceId);
    expect(items.length, 1);
    expect(items.first.description, 'Design');
  });
}
  • Step 2: Run tests to verify they fail
flutter test test/features/estimates/estimate_creator_notifier_test.dart

Expected: FAIL.

  • Step 3: Implement EstimateCreatorNotifier

This reuses much of InvoiceCreatorNotifier's logic. Key differences:

  • Uses estimate_prefix and next_estimate_number
  • documentType = 'estimate'
  • Adds convertToInvoice() method implementing the 5-step conversion logic from the spec
// lib/features/estimates/estimate_creator_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/database/daos/business_dao.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/database/daos/line_item_dao.dart';
import '../../core/services/document_calculator.dart';
import '../../core/utils/date_formatter.dart';
import '../invoices/invoice_creator_notifier.dart';

class EstimateCreatorNotifier extends StateNotifier<InvoiceCreatorState> {
  final BusinessDao _businessDao;
  final DocumentDao _documentDao;
  final LineItemDao _lineItemDao;
  final String _businessId;

  EstimateCreatorNotifier({
    required BusinessDao businessDao,
    required DocumentDao documentDao,
    required LineItemDao lineItemDao,
    required String businessId,
  })  : _businessDao = businessDao,
        _documentDao = documentDao,
        _lineItemDao = lineItemDao,
        _businessId = businessId,
        super(const InvoiceCreatorState());

  Future<void> initialize() async {
    final biz = await _businessDao.getBusiness();
    if (biz == null) return;

    final now = DateTime.now();
    final expiryDate = DateFormatter.addDays(now, 30);
    final docNum = '${biz.estimatePrefix}-${biz.nextEstimateNumber}';

    state = state.copyWith(
      documentNumber: docNum,
      issueDate: DateFormatter.toIso(now),
      dueDate: DateFormatter.toIso(expiryDate),
      taxRateBasisPoints: biz.defaultTaxRate,
    );
  }

  void setClientId(String id) => state = state.copyWith(clientId: id);

  void addLineItem({
    required String description,
    required double quantity,
    required int unitPrice,
  }) {
    final items = [
      ...state.lineItems,
      LineItemFormData(
        description: description,
        quantity: quantity,
        unitPriceCents: unitPrice,
      ),
    ];
    state = state.copyWith(lineItems: items);
    _recalculate();
  }

  void setTaxRate(int basisPoints) {
    state = state.copyWith(taxRateBasisPoints: basisPoints);
    _recalculate();
  }

  void _recalculate() {
    final inputs = state.lineItems
        .map((li) => LineItemInput(
              quantity: li.quantity,
              unitPriceCents: li.unitPriceCents,
            ))
        .toList();
    final result = DocumentCalculator.calculate(
      items: inputs,
      taxRateBasisPoints: state.taxRateBasisPoints,
      discountType: state.discountType,
      discountValueBasisPoints: state.discountValue,
    );
    state = state.copyWith(
      subtotal: result.subtotal,
      taxAmount: result.taxAmount,
      discountAmount: result.discountAmount,
      total: result.total,
    );
  }

  Future<String?> save() async {
    if (state.clientId == null || state.lineItems.isEmpty) {
      state = state.copyWith(error: 'Client and line items required');
      return null;
    }

    final doc = await _documentDao.createDocument(
      businessId: _businessId,
      clientId: state.clientId!,
      documentType: 'estimate',
      documentNumber: state.documentNumber,
      status: 'draft',
      issueDate: state.issueDate,
      dueDate: state.dueDate,
      subtotal: state.subtotal,
      taxRate: state.taxRateBasisPoints,
      taxAmount: state.taxAmount,
      total: state.total,
      amountDue: state.total,
    );

    for (var i = 0; i < state.lineItems.length; i++) {
      final li = state.lineItems[i];
      await _lineItemDao.addLineItem(
        documentId: doc.id,
        description: li.description,
        quantity: li.quantity,
        unitPrice: li.unitPriceCents,
        amount: li.amount,
        sortOrder: i,
      );
    }

    await _businessDao.getAndIncrementEstimateNumber(_businessId);
    return doc.id;
  }

  /// One-tap estimate → invoice conversion (spec section 3.6.2)
  Future<String?> convertToInvoice(String estimateId) async {
    final estimate = await _documentDao.getDocument(estimateId);
    if (estimate == null) return null;

    final biz = await _businessDao.getBusiness();
    if (biz == null) return null;

    // 1. Create new invoice copying estimate fields
    final invoiceNum = '${biz.invoicePrefix}-${biz.nextInvoiceNumber}';
    final now = DateTime.now();

    final invoice = await _documentDao.createDocument(
      businessId: estimate.businessId,
      clientId: estimate.clientId,
      documentType: 'invoice',
      documentNumber: invoiceNum,
      status: 'draft',
      issueDate: DateFormatter.toIso(now),
      dueDate: DateFormatter.toIso(
          DateFormatter.addDays(now, biz.defaultPaymentTermsDays)),
      currencyCode: estimate.currencyCode,
      subtotal: estimate.subtotal,
      taxRate: estimate.taxRate,
      taxAmount: estimate.taxAmount,
      discountType: estimate.discountType,
      discountValue: estimate.discountValue,
      discountAmount: estimate.discountAmount,
      total: estimate.total,
      amountDue: estimate.total,
      notes: estimate.docNotes,
      convertedFromId: estimateId,
    );

    // 2. Copy all line items
    final items = await _lineItemDao.getLineItems(estimateId);
    for (final item in items) {
      await _lineItemDao.addLineItem(
        documentId: invoice.id,
        description: item.description,
        quantity: item.quantity,
        unitPrice: item.unitPrice,
        amount: item.amount,
        sortOrder: item.sortOrder,
      );
    }

    // 3. Mark estimate as converted
    await _documentDao.updateStatus(estimateId, 'converted');

    // 4. Increment invoice number
    await _businessDao.getAndIncrementInvoiceNumber(_businessId);

    return invoice.id;
  }
}
  • Step 4: Run tests
flutter test test/features/estimates/estimate_creator_notifier_test.dart -v

Expected: All 3 tests PASS.

  • Step 5: Create estimate list screen
// lib/features/estimates/estimate_list_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/utils/date_formatter.dart';
import '../../core/database/database.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/providers/database_provider.dart';
import '../../core/providers/business_provider.dart';
import '../invoices/widgets/status_pill.dart';

final estimatesProvider = FutureProvider<List<Document>>((ref) {
  final db = ref.read(databaseProvider);
  final business = ref.read(businessProvider).valueOrNull;
  if (business == null) return [];
  return DocumentDao(db).getDocumentsByType(business.id, 'estimate');
});

class EstimateListScreen extends ConsumerWidget {
  const EstimateListScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final estimatesAsync = ref.watch(estimatesProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Estimates')),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: () => context.push('/estimates/new'),
        icon: const Icon(Icons.add),
        label: const Text('New Estimate'),
      ),
      body: estimatesAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => Center(child: Text('Error: $e')),
        data: (estimates) => estimates.isEmpty
            ? const Center(child: Text('No estimates yet'))
            : ListView.builder(
                itemCount: estimates.length,
                itemBuilder: (context, index) {
                  final est = estimates[index];
                  return ListTile(
                    title: Text(est.documentNumber),
                    subtitle: Text(est.dueDate != null
                        ? 'Valid until: ${DateFormatter.toDisplay(est.dueDate!)}'
                        : ''),
                    trailing: Row(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        StatusPill(status: est.status),
                        const SizedBox(width: 8),
                        Text(CurrencyFormatter.format(est.total)),
                      ],
                    ),
                  );
                },
              ),
      ),
    );
  }
}
  • Step 6: Create EstimateCreatorScreen
// lib/features/estimates/estimate_creator_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/database/daos/business_dao.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/database/daos/line_item_dao.dart';
import '../../core/database/daos/client_dao.dart';
import '../../core/database/database.dart';
import '../../core/providers/database_provider.dart';
import '../../core/providers/business_provider.dart';
import '../invoices/invoice_creator_screen.dart';
import 'estimate_creator_notifier.dart';

final estimateCreatorProvider =
    StateNotifierProvider.autoDispose<EstimateCreatorNotifier, InvoiceCreatorState>(
        (ref) {
  final db = ref.read(databaseProvider);
  final business = ref.read(businessProvider).valueOrNull;
  return EstimateCreatorNotifier(
    businessDao: BusinessDao(db),
    documentDao: DocumentDao(db),
    lineItemDao: LineItemDao(db),
    businessId: business?.id ?? '',
  );
});

class EstimateCreatorScreen extends ConsumerStatefulWidget {
  const EstimateCreatorScreen({super.key});

  @override
  ConsumerState<EstimateCreatorScreen> createState() =>
      _EstimateCreatorScreenState();
}

class _EstimateCreatorScreenState extends ConsumerState<EstimateCreatorScreen> {
  final _descController = TextEditingController();
  final _qtyController = TextEditingController(text: '1');
  final _rateController = TextEditingController();

  @override
  void initState() {
    super.initState();
    Future.microtask(() =>
        ref.read(estimateCreatorProvider.notifier).initialize());
  }

  @override
  Widget build(BuildContext context) {
    final state = ref.watch(estimateCreatorProvider);
    final notifier = ref.read(estimateCreatorProvider.notifier);
    final clientsAsync = ref.watch(activeClientsProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('New Estimate')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            clientsAsync.when(
              loading: () => const CircularProgressIndicator(),
              error: (e, _) => Text('Error: $e'),
              data: (clients) => DropdownButtonFormField<String>(
                decoration: const InputDecoration(labelText: 'Client *'),
                value: state.clientId,
                items: clients
                    .map((c) => DropdownMenuItem(value: c.id, child: Text(c.name)))
                    .toList(),
                onChanged: (id) {
                  if (id != null) notifier.setClientId(id);
                },
              ),
            ),
            const SizedBox(height: 16),
            Text('Estimate #: ${state.documentNumber}',
                style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 8),
            Text('Valid Until: ${state.dueDate}',
                style: Theme.of(context).textTheme.bodySmall),
            const SizedBox(height: 16),
            const Text('Line Items', style: TextStyle(fontWeight: FontWeight.bold)),
            ...state.lineItems.asMap().entries.map((entry) {
              final li = entry.value;
              return Card(
                child: ListTile(
                  title: Text(li.description),
                  subtitle: Text('${li.quantity} x ${CurrencyFormatter.format(li.unitPriceCents)}'),
                  trailing: Text(CurrencyFormatter.format(li.amount)),
                ),
              );
            }),
            Card(
              child: Padding(
                padding: const EdgeInsets.all(12),
                child: Column(
                  children: [
                    TextField(
                      controller: _descController,
                      decoration: const InputDecoration(labelText: 'Description', isDense: true),
                    ),
                    const SizedBox(height: 8),
                    Row(
                      children: [
                        Expanded(child: TextField(
                          controller: _qtyController,
                          decoration: const InputDecoration(labelText: 'Qty', isDense: true),
                          keyboardType: const TextInputType.numberWithOptions(decimal: true),
                        )),
                        const SizedBox(width: 8),
                        Expanded(child: TextField(
                          controller: _rateController,
                          decoration: const InputDecoration(labelText: 'Rate (\$)', isDense: true),
                          keyboardType: const TextInputType.numberWithOptions(decimal: true),
                        )),
                      ],
                    ),
                    TextButton.icon(
                      onPressed: () {
                        final desc = _descController.text.trim();
                        final qty = double.tryParse(_qtyController.text) ?? 1.0;
                        final rate = CurrencyFormatter.parseToCents(_rateController.text);
                        if (desc.isNotEmpty && rate > 0) {
                          notifier.addLineItem(description: desc, quantity: qty, unitPrice: rate);
                          _descController.clear();
                          _qtyController.text = '1';
                          _rateController.clear();
                        }
                      },
                      icon: const Icon(Icons.add),
                      label: const Text('Add Item'),
                    ),
                  ],
                ),
              ),
            ),
            const Divider(),
            TotalRow(label: 'Total', value: state.total, bold: true),
            const SizedBox(height: 24),
            FilledButton(
              onPressed: state.isSaving
                  ? null
                  : () async {
                      final id = await notifier.save();
                      if (id != null && context.mounted) context.pop();
                    },
              child: const Text('Save Estimate'),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _descController.dispose();
    _qtyController.dispose();
    _rateController.dispose();
    super.dispose();
  }
}
  • Step 7: Add estimate routes and update router
// Add to ShellRoute routes in app.dart:
GoRoute(
  path: '/estimates/new',
  builder: (context, state) => const EstimateCreatorScreen(),
),
  • Step 8: Commit
git add lib/features/estimates/ test/features/estimates/
git commit -m "feat: implement estimate creator with one-tap invoice conversion"

Task 18: Payment Tracker

Files:

  • Create: lib/features/payments/payment_notifier.dart

  • Create: lib/features/payments/payment_bottom_sheet.dart

  • Create: test/features/payments/payment_notifier_test.dart

  • Step 1: Write failing tests for PaymentNotifier

// test/features/payments/payment_notifier_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/document_dao.dart';
import 'package:swift_invoice/core/database/daos/payment_dao.dart';
import 'package:swift_invoice/features/payments/payment_notifier.dart';

void main() {
  late AppDatabase db;
  late PaymentNotifier notifier;
  late String documentId;
  late String clientId;

  setUp(() async {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    final biz = await BusinessDao(db).createBusiness(name: 'Co');
    final client = await ClientDao(db).createClient(
        businessId: biz.id, name: 'C');
    clientId = client.id;
    final doc = await DocumentDao(db).createDocument(
      businessId: biz.id, clientId: clientId,
      documentType: 'invoice', documentNumber: 'INV-001',
      status: 'sent', issueDate: '2026-03-22',
      total: 10000, amountDue: 10000,
    );
    documentId = doc.id;
    notifier = PaymentNotifier(
      paymentDao: PaymentDao(db),
      documentDao: DocumentDao(db),
      clientDao: ClientDao(db),
    );
  });

  tearDown(() => db.close());

  test('recordPayment updates document amounts', () async {
    await notifier.recordPayment(
      documentId: documentId,
      clientId: clientId,
      amount: 5000,
      method: 'cash',
    );
    final doc = await DocumentDao(db).getDocument(documentId);
    expect(doc!.amountPaid, 5000);
    expect(doc.amountDue, 5000);
    expect(doc.status, 'partial');
  });

  test('full payment marks invoice as paid', () async {
    await notifier.recordPayment(
      documentId: documentId,
      clientId: clientId,
      amount: 10000,
      method: 'card',
    );
    final doc = await DocumentDao(db).getDocument(documentId);
    expect(doc!.status, 'paid');
    expect(doc.amountDue, 0);
  });

  test('rejects payment exceeding amount due', () async {
    final result = await notifier.recordPayment(
      documentId: documentId,
      clientId: clientId,
      amount: 15000,
      method: 'cash',
    );
    expect(result, isFalse);
    expect(notifier.state.error, contains('exceed'));
  });

  test('deletePayment reverses amounts', () async {
    await notifier.recordPayment(
      documentId: documentId,
      clientId: clientId,
      amount: 5000,
      method: 'cash',
    );
    final payments = await PaymentDao(db).getPaymentsForDocument(documentId);
    await notifier.deletePayment(
      paymentId: payments.first.id,
      documentId: documentId,
      clientId: clientId,
    );
    final doc = await DocumentDao(db).getDocument(documentId);
    expect(doc!.amountPaid, 0);
    expect(doc.amountDue, 10000);
  });

  test('cumulative payments cannot exceed total', () async {
    // First payment of 7000 succeeds (10000 total)
    final result1 = await notifier.recordPayment(
      documentId: documentId,
      clientId: clientId,
      amount: 7000,
      method: 'cash',
    );
    expect(result1, isTrue);

    // Second payment of 5000 would exceed remaining 3000
    final result2 = await notifier.recordPayment(
      documentId: documentId,
      clientId: clientId,
      amount: 5000,
      method: 'card',
    );
    expect(result2, isFalse);
    expect(notifier.state.error, contains('exceed'));

    // Payment of exactly 3000 should succeed
    final result3 = await notifier.recordPayment(
      documentId: documentId,
      clientId: clientId,
      amount: 3000,
      method: 'card',
    );
    expect(result3, isTrue);

    final doc = await DocumentDao(db).getDocument(documentId);
    expect(doc!.status, 'paid');
    expect(doc.amountDue, 0);
  });
}
  • Step 2: Run tests to verify they fail
flutter test test/features/payments/payment_notifier_test.dart

Expected: FAIL.

  • Step 3: Implement PaymentNotifier
// lib/features/payments/payment_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/database/database.dart';
import '../../core/database/daos/payment_dao.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/database/daos/client_dao.dart';

class PaymentState {
  final List<Payment> payments;
  final bool isLoading;
  final String? error;

  const PaymentState({
    this.payments = const [],
    this.isLoading = false,
    this.error,
  });

  PaymentState copyWith({
    List<Payment>? payments,
    bool? isLoading,
    String? error,
  }) {
    return PaymentState(
      payments: payments ?? this.payments,
      isLoading: isLoading ?? this.isLoading,
      error: error,
    );
  }
}

class PaymentNotifier extends StateNotifier<PaymentState> {
  final PaymentDao _paymentDao;
  final DocumentDao _documentDao;
  final ClientDao _clientDao;

  PaymentNotifier({
    required PaymentDao paymentDao,
    required DocumentDao documentDao,
    required ClientDao clientDao,
  })  : _paymentDao = paymentDao,
        _documentDao = documentDao,
        _clientDao = clientDao,
        super(const PaymentState());

  Future<void> loadPayments(String documentId) async {
    state = state.copyWith(isLoading: true);
    final payments = await _paymentDao.getPaymentsForDocument(documentId);
    state = state.copyWith(payments: payments, isLoading: false);
  }

  Future<bool> recordPayment({
    required String documentId,
    required String clientId,
    required int amount,
    required String method,
    String? notes,
  }) async {
    // Validate amount doesn't exceed amount_due
    final doc = await _documentDao.getDocument(documentId);
    if (doc == null) return false;
    if (amount > doc.amountDue) {
      state = state.copyWith(error: 'Payment cannot exceed amount due');
      return false;
    }

    await _paymentDao.recordPayment(
      documentId: documentId,
      amount: amount,
      method: method,
      paidAt: DateTime.now().toIso8601String(),
      notes: notes,
    );

    // Update document amounts
    final totalPaid = await _paymentDao.getTotalPaidForDocument(documentId);
    await _documentDao.updatePaymentAmounts(documentId, totalPaid);

    // Update client outstanding balance
    await _recalculateClientBalance(clientId);

    await loadPayments(documentId);
    return true;
  }

  Future<void> deletePayment({
    required String paymentId,
    required String documentId,
    required String clientId,
  }) async {
    await _paymentDao.deletePayment(paymentId);

    final totalPaid = await _paymentDao.getTotalPaidForDocument(documentId);
    await _documentDao.updatePaymentAmounts(documentId, totalPaid);
    await _recalculateClientBalance(clientId);

    await loadPayments(documentId);
  }

  Future<void> _recalculateClientBalance(String clientId) async {
    // Sum all unpaid invoice amounts for this client
    final docs = await _documentDao.getDocumentsForClient(clientId);
    int balance = 0;
    for (final doc in docs) {
      if (doc.documentType == 'invoice' &&
          !['void', 'paid'].contains(doc.status)) {
        balance += doc.amountDue;
      }
    }
    await _clientDao.updateOutstandingBalance(clientId, balance);
  }
}
  • Step 4: Run tests
flutter test test/features/payments/payment_notifier_test.dart -v

Expected: All 5 tests PASS.

  • Step 5: Create payment bottom sheet UI
// lib/features/payments/payment_bottom_sheet.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/database/daos/payment_dao.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/database/daos/client_dao.dart';
import '../../core/providers/database_provider.dart';
import 'payment_notifier.dart';

final paymentNotifierProvider =
    StateNotifierProvider<PaymentNotifier, PaymentState>((ref) {
  final db = ref.read(databaseProvider);
  return PaymentNotifier(
    paymentDao: PaymentDao(db),
    documentDao: DocumentDao(db),
    clientDao: ClientDao(db),
  );
});

class PaymentBottomSheet extends ConsumerStatefulWidget {
  final String documentId;
  final String clientId;
  final int amountDue;

  const PaymentBottomSheet({
    super.key,
    required this.documentId,
    required this.clientId,
    required this.amountDue,
  });

  @override
  ConsumerState<PaymentBottomSheet> createState() => _PaymentBottomSheetState();
}

class _PaymentBottomSheetState extends ConsumerState<PaymentBottomSheet> {
  late TextEditingController _amountController;
  String _method = 'cash';
  final _notesController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _amountController = TextEditingController(
      text: (widget.amountDue / 100).toStringAsFixed(2),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.only(
        left: 16,
        right: 16,
        top: 16,
        bottom: MediaQuery.of(context).viewInsets.bottom + 16,
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text('Record Payment',
              style: Theme.of(context).textTheme.titleLarge),
          const SizedBox(height: 16),
          TextField(
            controller: _amountController,
            decoration: InputDecoration(
              labelText: 'Amount',
              prefixText: '\$',
              helperText: 'Due: ${CurrencyFormatter.format(widget.amountDue)}',
            ),
            keyboardType: const TextInputType.numberWithOptions(decimal: true),
          ),
          const SizedBox(height: 16),
          DropdownButtonFormField<String>(
            value: _method,
            decoration: const InputDecoration(labelText: 'Payment Method'),
            items: const [
              DropdownMenuItem(value: 'cash', child: Text('Cash')),
              DropdownMenuItem(value: 'card', child: Text('Card')),
              DropdownMenuItem(value: 'bank_transfer', child: Text('Bank Transfer')),
              DropdownMenuItem(value: 'check', child: Text('Check')),
              DropdownMenuItem(value: 'other', child: Text('Other')),
            ],
            onChanged: (v) => setState(() => _method = v!),
          ),
          const SizedBox(height: 16),
          TextField(
            controller: _notesController,
            decoration: const InputDecoration(labelText: 'Notes (optional)'),
          ),
          const SizedBox(height: 24),
          Row(
            children: [
              Expanded(
                child: OutlinedButton(
                  onPressed: () => Navigator.pop(context),
                  child: const Text('Cancel'),
                ),
              ),
              const SizedBox(width: 16),
              Expanded(
                child: FilledButton(
                  onPressed: () async {
                    final cents = CurrencyFormatter.parseToCents(
                        _amountController.text);
                    final notifier = ref.read(paymentNotifierProvider.notifier);
                    final success = await notifier.recordPayment(
                      documentId: widget.documentId,
                      clientId: widget.clientId,
                      amount: cents,
                      method: _method,
                      notes: _notesController.text.isEmpty
                          ? null
                          : _notesController.text,
                    );
                    if (context.mounted) {
                      if (success) {
                        Navigator.pop(context);
                      } else {
                        ScaffoldMessenger.of(context).showSnackBar(
                          SnackBar(content: Text(
                              notifier.state.error ?? 'Payment failed')),
                        );
                      }
                    }
                  },
                  child: const Text('Save Payment'),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _amountController.dispose();
    _notesController.dispose();
    super.dispose();
  }
}
  • Step 6: Commit
git add lib/features/payments/ test/features/payments/
git commit -m "feat: implement payment tracker with partial payment support"

Task 19: Overdue Reminders (Local Notifications)

Files:

  • Create: lib/core/services/notification_service.dart

  • Create: test/core/services/notification_service_test.dart

  • Step 1: Write failing test for NotificationService

// test/core/services/notification_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/services/notification_service.dart';

void main() {
  test('generateNotificationId is deterministic for same documentId', () {
    final id1 = NotificationService.generateNotificationId('doc-123');
    final id2 = NotificationService.generateNotificationId('doc-123');
    expect(id1, id2);
  });

  test('generateNotificationId differs for different documentIds', () {
    final id1 = NotificationService.generateNotificationId('doc-123');
    final id2 = NotificationService.generateNotificationId('doc-456');
    expect(id1, isNot(id2));
  });

  test('buildNotificationBody formats correctly', () {
    final body = NotificationService.buildNotificationBody(
      invoiceNumber: 'INV-001',
      clientName: 'Jane Doe',
    );
    expect(body, contains('INV-001'));
    expect(body, contains('Jane Doe'));
    expect(body, contains('overdue'));
  });
}
  • Step 2: Run tests to verify they fail
flutter test test/core/services/notification_service_test.dart

Expected: FAIL.

  • Step 3: Implement NotificationService
// lib/core/services/notification_service.dart
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;

class NotificationService {
  static final _plugin = FlutterLocalNotificationsPlugin();

  static Future<void> initialize() async {
    const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
    const iosSettings = DarwinInitializationSettings(
      requestAlertPermission: true,
      requestBadgePermission: true,
      requestSoundPermission: true,
    );
    const settings = InitializationSettings(
      android: androidSettings,
      iOS: iosSettings,
    );
    await _plugin.initialize(settings);
  }

  static int generateNotificationId(String documentId) {
    return documentId.hashCode.abs() % 100000;
  }

  static String buildNotificationBody({
    required String invoiceNumber,
    required String clientName,
  }) {
    return 'Invoice $invoiceNumber for $clientName is overdue. Tap to view.';
  }

  static Future<void> scheduleOverdueReminder({
    required String documentId,
    required String invoiceNumber,
    required String clientName,
    required DateTime dueDate,
  }) async {
    final notificationId = generateNotificationId(documentId);

    // Schedule for 9:00 AM the day after due date
    final scheduledDate = DateTime(
      dueDate.year,
      dueDate.month,
      dueDate.day + 1,
      9,
      0,
    );

    // Don't schedule if the date is in the past
    if (scheduledDate.isBefore(DateTime.now())) return;

    const androidDetails = AndroidNotificationDetails(
      'overdue_invoices',
      'Overdue Invoices',
      channelDescription: 'Notifications for overdue invoices',
      importance: Importance.high,
      priority: Priority.high,
    );
    const iosDetails = DarwinNotificationDetails();
    const details = NotificationDetails(
      android: androidDetails,
      iOS: iosDetails,
    );

    await _plugin.zonedSchedule(
      notificationId,
      'Invoice Overdue',
      buildNotificationBody(
        invoiceNumber: invoiceNumber,
        clientName: clientName,
      ),
      tz.TZDateTime.from(scheduledDate, tz.local),
      details,
      androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
      payload: documentId,
    );
  }

  static Future<void> cancelReminder(String documentId) async {
    final notificationId = generateNotificationId(documentId);
    await _plugin.cancel(notificationId);
  }
}
  • Step 4: Run tests
flutter test test/core/services/notification_service_test.dart -v

Expected: All 3 tests PASS.

  • Step 5: Commit
git add lib/core/services/notification_service.dart \
  test/core/services/notification_service_test.dart
git commit -m "feat: implement local notification scheduling for overdue invoice reminders"

Task 20: Wire Notifications into Invoice Lifecycle

Files:

  • Modify: lib/features/invoices/invoice_creator_notifier.dart — schedule notification on save

  • Modify: lib/features/payments/payment_notifier.dart — cancel notification on full payment

  • Step 1: Schedule notification when invoice is created with due date

In InvoiceCreatorNotifier.save(), after creating the document, add:

if (doc.dueDate != null) {
  await NotificationService.scheduleOverdueReminder(
    documentId: doc.id,
    invoiceNumber: doc.documentNumber,
    clientName: clientName, // fetch from client
    dueDate: DateTime.parse(doc.dueDate!),
  );
}
  • Step 2: Cancel notification when invoice is fully paid

In PaymentNotifier.recordPayment(), after updating document amounts, check:

final updatedDoc = await _documentDao.getDocument(documentId);
if (updatedDoc?.status == 'paid' || updatedDoc?.status == 'void') {
  await NotificationService.cancelReminder(documentId);
}
  • Step 3: Write tests for notification wiring

The notification service uses platform plugins that can't run in unit tests. Instead, extract the notification calls behind an interface so they can be verified via mock.

Add to test/features/invoices/invoice_creator_notifier_test.dart:

test('save with due date schedules notification (verify via DAO side effects)', () async {
  notifier.setClientId(clientId);
  notifier.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 5000);
  final docId = await notifier.save();
  expect(docId, isNotNull);

  // Verify the document has a due date set (notification scheduling depends on this)
  final doc = await DocumentDao(db).getDocument(docId!);
  expect(doc!.dueDate, isNotNull);
  expect(doc.dueDate, isNotEmpty);
});

Add to test/features/payments/payment_notifier_test.dart:

test('full payment results in paid status (notification cancel trigger)', () async {
  await notifier.recordPayment(
    documentId: documentId,
    clientId: clientId,
    amount: 10000,
    method: 'card',
  );
  final doc = await DocumentDao(db).getDocument(documentId);
  // Paid status is the trigger for NotificationService.cancelReminder
  expect(doc!.status, 'paid');
});
  • Step 4: Run tests
flutter test test/features/invoices/invoice_creator_notifier_test.dart \
  test/features/payments/payment_notifier_test.dart -v

Expected: All tests PASS.

  • Step 5: Commit
git add lib/features/invoices/invoice_creator_notifier.dart \
  lib/features/payments/payment_notifier.dart \
  test/features/invoices/invoice_creator_notifier_test.dart \
  test/features/payments/payment_notifier_test.dart
git commit -m "feat: wire notification scheduling into invoice create/payment lifecycle"

Task 21: Settings Screen

Files:

  • Create: lib/features/settings/settings_screen.dart

  • Step 1: Create settings screen

// lib/features/settings/settings_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/providers/business_provider.dart';

class SettingsScreen extends ConsumerWidget {
  const SettingsScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final businessAsync = ref.watch(businessProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Settings')),
      body: ListView(
        children: [
          // Business profile section
          businessAsync.when(
            data: (biz) => ListTile(
              leading: const Icon(Icons.business),
              title: Text(biz?.name ?? 'Set up business'),
              subtitle: const Text('Edit business profile'),
              trailing: const Icon(Icons.chevron_right),
              onTap: () => context.push('/onboarding'),
            ),
            loading: () => const ListTile(
              leading: CircularProgressIndicator(),
              title: Text('Loading...'),
            ),
            error: (e, _) => ListTile(title: Text('Error: $e')),
          ),

          const Divider(),

          // Subscription
          ListTile(
            leading: const Icon(Icons.star),
            title: const Text('Subscription'),
            subtitle: const Text('Free Plan'),
            trailing: const Icon(Icons.chevron_right),
            onTap: () {
              context.push('/paywall');
            },
          ),

          const Divider(),

          // About
          const ListTile(
            leading: Icon(Icons.info),
            title: Text('About SwiftInvoice'),
            subtitle: Text('Version 1.0.0'),
          ),
        ],
      ),
    );
  }
}
  • Step 2: Update router to use SettingsScreen

Replace the /settings placeholder in app.dart.

  • Step 3: Commit
git add lib/features/settings/settings_screen.dart lib/app.dart
git commit -m "feat: add settings screen with business profile editing"

Phase 4: Monetization & Launch (Weeks 89)

Task 22: RevenueCat Integration

Files:

  • Create: lib/core/services/subscription_service.dart

  • Create: lib/core/providers/subscription_provider.dart

  • Create: test/core/services/subscription_service_test.dart

  • Step 1: Write failing tests for SubscriptionService

// test/core/services/subscription_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/services/subscription_service.dart';

void main() {
  test('tierFromEntitlement maps correctly', () {
    expect(SubscriptionService.tierFromEntitlement(null), 'free');
    expect(SubscriptionService.tierFromEntitlement('pro'), 'pro');
    expect(SubscriptionService.tierFromEntitlement('lifetime'), 'lifetime');
  });

  test('isProOrAbove checks correctly', () {
    expect(SubscriptionService.isProOrAbove('free'), isFalse);
    expect(SubscriptionService.isProOrAbove('pro'), isTrue);
    expect(SubscriptionService.isProOrAbove('lifetime'), isTrue);
  });
}
  • Step 2: Run tests to verify they fail
flutter test test/core/services/subscription_service_test.dart

Expected: FAIL.

  • Step 3: Implement SubscriptionService
// lib/core/services/subscription_service.dart
import 'package:purchases_flutter/purchases_flutter.dart';
import '../database/daos/app_settings_dao.dart';

class SubscriptionService {
  final AppSettingsDao _settingsDao;

  SubscriptionService(this._settingsDao);

  static const _revenueCatApiKey = String.fromEnvironment(
    'REVENUECAT_API_KEY',
    defaultValue: '', // Set via --dart-define at build time
  );

  Future<void> initialize() async {
    if (_revenueCatApiKey.isEmpty) return; // Skip in dev/test

    final config = PurchasesConfiguration(_revenueCatApiKey);
    await Purchases.configure(config);
    await syncSubscriptionStatus();
  }

  Future<void> syncSubscriptionStatus() async {
    try {
      final customerInfo = await Purchases.getCustomerInfo();
      final entitlement = customerInfo.entitlements.active['pro'];
      final tier = tierFromEntitlement(entitlement?.identifier);
      await _settingsDao.setSubscriptionTier(tier);
    } catch (_) {
      // Offline or error — keep cached tier
    }
  }

  static String tierFromEntitlement(String? entitlementId) {
    if (entitlementId == null) return 'free';
    if (entitlementId == 'lifetime') return 'lifetime';
    return 'pro';
  }

  static bool isProOrAbove(String tier) {
    return tier == 'pro' || tier == 'lifetime';
  }

  Future<bool> purchase(String packageId) async {
    try {
      final offerings = await Purchases.getOfferings();
      final package = offerings.current?.availablePackages
          .firstWhere((p) => p.identifier == packageId);
      if (package == null) return false;

      await Purchases.purchasePackage(package);
      await syncSubscriptionStatus();
      return true;
    } catch (_) {
      return false;
    }
  }

  Future<bool> restorePurchases() async {
    try {
      await Purchases.restorePurchases();
      await syncSubscriptionStatus();
      final tier = await _settingsDao.getSubscriptionTier();
      return tier != 'free';
    } catch (_) {
      return false;
    }
  }
}
  • Step 4: Run tests
flutter test test/core/services/subscription_service_test.dart -v

Expected: All 2 tests PASS.

  • Step 5: Commit
git add lib/core/services/subscription_service.dart \
  test/core/services/subscription_service_test.dart
git commit -m "feat: implement RevenueCat subscription service with offline-safe tier caching"

Task 23: Paywall Screen

Files:

  • Create: lib/features/paywall/paywall_screen.dart

  • Create: lib/features/paywall/paywall_notifier.dart

  • Create: test/features/paywall/paywall_notifier_test.dart

  • Step 1: Write failing tests for PaywallNotifier

// test/features/paywall/paywall_notifier_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';
import 'package:swift_invoice/features/paywall/paywall_notifier.dart';

void main() {
  late AppDatabase db;
  late PaywallNotifier notifier;

  setUp(() {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    notifier = PaywallNotifier(AppSettingsDao(db));
  });

  tearDown(() => db.close());

  test('initial state is free tier', () async {
    await notifier.loadTier();
    expect(notifier.state.currentTier, 'free');
    expect(notifier.state.isPro, isFalse);
  });

  test('after upgrade, isPro is true', () async {
    await AppSettingsDao(db).setSubscriptionTier('pro');
    await notifier.loadTier();
    expect(notifier.state.isPro, isTrue);
  });
}
  • Step 2: Run tests to verify they fail
flutter test test/features/paywall/paywall_notifier_test.dart

Expected: FAIL.

  • Step 3: Implement PaywallNotifier
// lib/features/paywall/paywall_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/database/daos/app_settings_dao.dart';
import '../../core/services/subscription_service.dart';

class PaywallState {
  final String currentTier;
  final bool isPurchasing;
  final String? error;

  bool get isPro => SubscriptionService.isProOrAbove(currentTier);

  const PaywallState({
    this.currentTier = 'free',
    this.isPurchasing = false,
    this.error,
  });

  PaywallState copyWith({
    String? currentTier,
    bool? isPurchasing,
    String? error,
  }) {
    return PaywallState(
      currentTier: currentTier ?? this.currentTier,
      isPurchasing: isPurchasing ?? this.isPurchasing,
      error: error,
    );
  }
}

class PaywallNotifier extends StateNotifier<PaywallState> {
  final AppSettingsDao _settingsDao;

  PaywallNotifier(this._settingsDao) : super(const PaywallState());

  Future<void> loadTier() async {
    final tier = await _settingsDao.getSubscriptionTier();
    state = state.copyWith(currentTier: tier);
  }
}
  • Step 4: Run tests
flutter test test/features/paywall/paywall_notifier_test.dart -v

Expected: All 2 tests PASS.

  • Step 5: Create paywall screen UI
// lib/features/paywall/paywall_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/services/subscription_service.dart';
import '../../core/database/daos/app_settings_dao.dart';
import '../../core/providers/database_provider.dart';

final subscriptionServiceProvider = Provider<SubscriptionService>((ref) {
  return SubscriptionService(AppSettingsDao(ref.read(databaseProvider)));
});

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final subService = ref.read(subscriptionServiceProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Upgrade to Pro'),
        leading: IconButton(
          icon: const Icon(Icons.close),
          onPressed: () => Navigator.pop(context),
        ),
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            const Text(
              'Unlock All Features',
              style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 24),

            // Plan cards
            _PlanCard(
              title: 'Monthly',
              price: '\$3.99/mo',
              badge: null,
              onTap: () async {
                final success = await subService.purchase('monthly');
                if (success && context.mounted) Navigator.pop(context);
              },
            ),
            const SizedBox(height: 12),
            _PlanCard(
              title: 'Yearly',
              price: '\$29.99/yr',
              badge: 'Save 37%',
              onTap: () async {
                final success = await subService.purchase('yearly');
                if (success && context.mounted) Navigator.pop(context);
              },
            ),
            const SizedBox(height: 12),
            _PlanCard(
              title: 'Lifetime',
              price: '\$49.99',
              badge: 'Best Value',
              highlighted: true,
              onTap: () async {
                final success = await subService.purchase('lifetime');
                if (success && context.mounted) Navigator.pop(context);
              },
            ),

            const SizedBox(height: 24),

            // Feature list
            const _FeatureRow(text: 'Unlimited invoices', free: false, pro: true),
            const _FeatureRow(text: 'Unlimited clients', free: false, pro: true),
            const _FeatureRow(text: 'Estimates & conversion', free: false, pro: true),
            const _FeatureRow(text: 'Payment tracking', free: false, pro: true),
            const _FeatureRow(text: 'Overdue reminders', free: false, pro: true),
            const _FeatureRow(text: 'No watermark on PDFs', free: false, pro: true),
            const _FeatureRow(text: 'PDF invoicing', free: true, pro: true),
            const _FeatureRow(text: '3 invoices/month', free: true, pro: true),
            const _FeatureRow(text: '2 clients', free: true, pro: true),

            const SizedBox(height: 16),

            TextButton(
              onPressed: () async {
                final restored = await subService.restorePurchases();
                if (context.mounted) {
                  if (restored) {
                    Navigator.pop(context);
                  } else {
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(content: Text('No purchases found to restore')),
                    );
                  }
                }
              },
              child: const Text('Restore Purchases'),
            ),
          ],
        ),
      ),
    );
  }
}

class _PlanCard extends StatelessWidget {
  final String title;
  final String price;
  final String? badge;
  final bool highlighted;
  final VoidCallback onTap;

  const _PlanCard({
    required this.title,
    required this.price,
    this.badge,
    this.highlighted = false,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: highlighted ? 4 : 1,
      shape: highlighted
          ? RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(12),
              side: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2),
            )
          : null,
      child: InkWell(
        onTap: onTap,
        borderRadius: BorderRadius.circular(12),
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            children: [
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(title, style: const TextStyle(
                        fontSize: 18, fontWeight: FontWeight.bold)),
                    Text(price, style: TextStyle(
                        color: Theme.of(context).colorScheme.primary)),
                  ],
                ),
              ),
              if (badge != null)
                Container(
                  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                  decoration: BoxDecoration(
                    color: Theme.of(context).colorScheme.primaryContainer,
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: Text(badge!,
                      style: TextStyle(
                        fontSize: 12,
                        fontWeight: FontWeight.bold,
                        color: Theme.of(context).colorScheme.onPrimaryContainer,
                      )),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

class _FeatureRow extends StatelessWidget {
  final String text;
  final bool free;
  final bool pro;

  const _FeatureRow({required this.text, required this.free, required this.pro});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Row(
        children: [
          Expanded(flex: 3, child: Text(text)),
          Expanded(
            child: Icon(
              free ? Icons.check_circle : Icons.cancel,
              color: free ? Colors.green : Colors.grey,
              size: 20,
            ),
          ),
          Expanded(
            child: Icon(
              pro ? Icons.check_circle : Icons.cancel,
              color: pro ? Colors.green : Colors.grey,
              size: 20,
            ),
          ),
        ],
      ),
    );
  }
}
  • Step 6: Commit
git add lib/features/paywall/ test/features/paywall/
git commit -m "feat: implement paywall screen with 3-tier plan selection"

Task 24: Free Tier Enforcement Integration

Files:

  • Modify: lib/features/invoices/invoice_creator_notifier.dart — show paywall route instead of just flag

  • Modify: lib/features/clients/client_notifier.dart — show paywall route

  • Modify: lib/app.dart — add paywall route

  • Step 1: Add paywall route

// In app.dart, add as a top-level route (not inside ShellRoute):
GoRoute(
  path: '/paywall',
  builder: (context, state) => const PaywallScreen(),
),
  • Step 2: Verify tier gating works end-to-end

Manual test:

  1. Create 2 clients — 3rd should show paywall
  2. Create 3 invoices — 4th should show paywall
  3. Dismiss paywall — back to previous screen
  • Step 3: Commit
git add lib/app.dart lib/features/invoices/invoice_creator_notifier.dart \
  lib/features/clients/client_notifier.dart
git commit -m "feat: integrate paywall into invoice/client creation flows"

Task 25: Firebase Analytics Setup

Files:

  • Modify: lib/main.dart — initialize Firebase

  • Create: platform config files (google-services.json, GoogleService-Info.plist)

  • Step 1: Initialize Firebase in main.dart

// Update lib/main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:swift_invoice/core/services/notification_service.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  await NotificationService.initialize();
  runApp(const ProviderScope(child: SwiftInvoiceApp()));
}
  • Step 2: Generate Firebase config files using FlutterFire CLI
# Install FlutterFire CLI
dart pub global activate flutterfire_cli

# Run the configurator (interactive — select your Firebase project + platforms)
flutterfire configure --project=your-firebase-project-id

This generates:

  • lib/firebase_options.dart — platform-specific config (commit this)
  • android/app/google-services.json — Android config
  • ios/Runner/GoogleService-Info.plist — iOS config

Update main.dart to use the generated options:

import 'firebase_options.dart';

await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

Add to .gitignore:

android/app/google-services.json
ios/Runner/GoogleService-Info.plist
  • Step 3: Commit
git add lib/main.dart
git commit -m "feat: initialize Firebase Analytics and local notifications on app start"

Task 26: Polish, Testing & Store Submission

  • Step 1: Run full test suite
flutter test --coverage

Expected: All tests pass. Coverage report generated.

  • Step 2: Fix any remaining lint warnings
flutter analyze

Expected: No issues found.

  • Step 3: Test offline workflow

Enable airplane mode. Verify:

  • Create client

  • Create invoice

  • Generate PDF

  • Share via email draft All work without errors.

  • Step 4: Performance check

With 100 invoices in DB, dashboard should load in <200ms. Profile with:

flutter run --profile
  • Step 5: App icons and splash screen

Generate icons using flutter_launcher_icons package. Add to pubspec.yaml:

dev_dependencies:
  flutter_launcher_icons: ^0.14.0

flutter_launcher_icons:
  android: true
  ios: true
  image_path: "assets/icon/app_icon.png"
dart run flutter_launcher_icons
  • Step 6: Build release APK and IPA
flutter build apk --release
flutter build ipa --release  # requires Xcode on macOS
  • Step 7: Submit to stores

  • Android: Upload APK to Google Play Console

  • iOS: Upload IPA via Xcode or Codemagic CI

  • Step 8: Final commit

git add .
git commit -m "chore: polish, app icons, and store submission prep"

Summary

Phase Tasks Key Deliverable
1: Foundation 17 App boots, DB works, onboarding complete, navigation wired
2: Core Invoicing 814 Clients CRUD, invoice creation with live totals, dashboard with stats/filters
3: PDF & Pro 1521 PDF generation, estimates with conversion, payments, notifications, settings
4: Monetization 2226 RevenueCat IAP, paywall, Firebase analytics, store submission

Total tasks: 26 Total test files: 15 Estimated commits: ~26+