From 9ad1d1b6f9891b7b732df83e207b5a2f8bc42d92 Mon Sep 17 00:00:00 2001 From: jeet Date: Mon, 23 Mar 2026 00:10:14 +0000 Subject: [PATCH] Upload files to "docs/superpowers/plans" --- .../plans/2026-03-22-swiftinvoice-mvp.md | 7393 +++++++++++++++++ 1 file changed, 7393 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-22-swiftinvoice-mvp.md diff --git a/docs/superpowers/plans/2026-03-22-swiftinvoice-mvp.md b/docs/superpowers/plans/2026-03-22-swiftinvoice-mvp.md new file mode 100644 index 0000000..c50df23 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-swiftinvoice-mvp.md @@ -0,0 +1,7393 @@ +# 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 1–2) | Tasks 1–7 | Scaffold, DB, navigation, onboarding — app boots and persists business profile | +| **Phase 2: Core Invoicing** (Weeks 3–5) | Tasks 8–14 | Clients CRUD, invoice creator, dashboard — user can create clients + invoices | +| **Phase 3: PDF & Pro Features** (Weeks 6–7) | Tasks 15–21 | PDF export, estimates, payments, notifications — full feature set | +| **Phase 4: Monetization & Launch** (Weeks 8–9) | Tasks 22–26 | 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 1–2) + +### 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** + +```bash +flutter create swift_invoice --org com.swiftinvoice --platforms ios,android +cd swift_invoice +``` + +- [ ] **Step 2: Add dependencies to pubspec.yaml** + +```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 +``` + +```bash +flutter pub get +``` + +- [ ] **Step 3: Create app theme** + +```dart +// 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 +} +``` + +```dart +// 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** + +```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())); +} +``` + +```dart +// 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** + +```dart +// 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** + +```bash +flutter test test/app_test.dart -v +``` +Expected: PASS. + +- [ ] **Step 7: Verify app builds and runs** + +```bash +flutter run -d chrome # or connected device +``` +Expected: App launches showing "SwiftInvoice" centered text. + +- [ ] **Step 8: Commit** + +```bash +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** + +```dart +// 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** + +```dart +// 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 get primaryKey => {id}; +} +``` + +```dart +// 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 get primaryKey => {id}; +} +``` + +```dart +// 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 get primaryKey => {id}; +} +``` + +```dart +// 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 get primaryKey => {id}; +} +``` + +```dart +// 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 get primaryKey => {id}; +} +``` + +```dart +// 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 get primaryKey => {key}; +} +``` + +- [ ] **Step 3: Commit** + +```bash +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** + +```dart +// 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** + +```dart +// lib/core/utils/uuid_generator.dart +import 'package:uuid/uuid.dart'; + +const _uuid = Uuid(); + +String generateUuid() => _uuid.v4(); +``` + +- [ ] **Step 3: Create database provider** + +```dart +// lib/core/providers/database_provider.dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../database/database.dart'; + +final databaseProvider = Provider((ref) { + final db = AppDatabase(); + ref.onDispose(() => db.close()); + return db; +}); +``` + +- [ ] **Step 4: Run code generation** + +```bash +dart run build_runner build --delete-conflicting-outputs +``` +Expected: `database.g.dart` generated without errors. + +- [ ] **Step 5: Write schema validation test** + +```dart +// 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** + +```bash +flutter test test/core/database/database_test.dart -v +``` +Expected: All 2 tests PASS. + +- [ ] **Step 7: Verify app still compiles** + +```bash +flutter build apk --debug 2>&1 | tail -5 +``` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 8: Commit** + +```bash +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** + +```dart +// 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** + +```bash +flutter test test/core/database/daos/business_dao_test.dart +``` +Expected: FAIL — `BusinessDao` not found. + +- [ ] **Step 3: Implement BusinessDao** + +```dart +// 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 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 getBusiness() async { + final results = await _db.select(_db.businesses).get(); + return results.isEmpty ? null : results.first; + } + + Future 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 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 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** + +```bash +flutter test test/core/database/daos/business_dao_test.dart -v +``` +Expected: All 6 tests PASS. + +- [ ] **Step 5: Write failing tests for AppSettingsDao** + +```dart +// 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** + +```bash +flutter test test/core/database/daos/app_settings_dao_test.dart +``` +Expected: FAIL — `AppSettingsDao` not found. + +- [ ] **Step 7: Implement AppSettingsDao** + +```dart +// 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 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 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 isOnboardingComplete() async { + final val = await getSetting('onboarding_complete'); + return val == 'true'; + } + + Future markOnboardingComplete() async { + await setSetting('onboarding_complete', 'true'); + } + + Future getSubscriptionTier() async { + return await getSetting('subscription_tier') ?? 'free'; + } + + Future setSubscriptionTier(String tier) async { + await setSetting('subscription_tier', tier); + } +} +``` + +- [ ] **Step 8: Run all DAO tests** + +```bash +flutter test test/core/database/daos/ -v +``` +Expected: All 11 tests PASS. + +- [ ] **Step 9: Commit** + +```bash +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** + +```dart +// 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** + +```bash +flutter test test/core/database/daos/client_dao_test.dart +``` +Expected: FAIL — `ClientDao` not found. + +- [ ] **Step 3: Implement ClientDao** + +```dart +// 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 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 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> 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> 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 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 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 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 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** + +```bash +flutter test test/core/database/daos/client_dao_test.dart -v +``` +Expected: All 5 tests PASS. + +- [ ] **Step 5: Write failing tests for DocumentDao** + +```dart +// 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** + +```bash +flutter test test/core/database/daos/document_dao_test.dart +``` +Expected: FAIL — `DocumentDao` not found. + +- [ ] **Step 7: Implement DocumentDao** + +```dart +// 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 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 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> 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> 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> 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 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 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 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> 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 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 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 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> 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** + +```bash +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** + +```dart +// 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** + +```dart +// 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 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> getLineItems(String documentId) async { + return (_db.select(_db.lineItems) + ..where((t) => t.documentId.equals(documentId)) + ..orderBy([(t) => OrderingTerm.asc(t.sortOrder)])) + .get(); + } + + Future 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 deleteLineItem(String id) async { + await (_db.delete(_db.lineItems)..where((t) => t.id.equals(id))).go(); + } + + Future deleteAllForDocument(String documentId) async { + await (_db.delete(_db.lineItems) + ..where((t) => t.documentId.equals(documentId))) + .go(); + } +} +``` + +- [ ] **Step 11: Run all DAO tests** + +```bash +flutter test test/core/database/daos/ -v +``` +Expected: All tests PASS. + +- [ ] **Step 12: Commit** + +```bash +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** + +```dart +// 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** + +```bash +flutter test test/core/database/daos/payment_dao_test.dart +``` +Expected: FAIL. + +- [ ] **Step 3: Implement PaymentDao** + +```dart +// 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 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> getPaymentsForDocument(String documentId) async { + return (_db.select(_db.payments) + ..where((t) => t.documentId.equals(documentId)) + ..orderBy([(t) => OrderingTerm.desc(t.paidAt)])) + .get(); + } + + Future 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 deletePayment(String id) async { + await (_db.delete(_db.payments)..where((t) => t.id.equals(id))).go(); + } +} +``` + +- [ ] **Step 4: Run payment tests** + +```bash +flutter test test/core/database/daos/payment_dao_test.dart -v +``` +Expected: All 4 tests PASS. + +- [ ] **Step 5: Write failing tests for DocumentCalculator** + +```dart +// 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** + +```bash +flutter test test/core/services/document_calculator_test.dart +``` +Expected: FAIL. + +- [ ] **Step 7: Implement DocumentCalculator** + +```dart +// 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 items, + required int taxRateBasisPoints, + String? discountType, + int discountValueBasisPoints = 0, + }) { + final subtotal = items.fold(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** + +```bash +flutter test test/core/ -v +``` +Expected: All tests PASS. + +- [ ] **Step 9: Commit** + +```bash +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** + +```dart +// 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** + +```bash +flutter test test/core/utils/currency_formatter_test.dart +``` +Expected: FAIL. + +- [ ] **Step 3: Implement CurrencyFormatter** + +```dart +// 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** + +```dart +// 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** + +```bash +flutter test test/core/utils/currency_formatter_test.dart -v +``` +Expected: All 4 tests PASS. + +- [ ] **Step 6: Create Riverpod providers for business and settings** + +```dart +// 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((ref) { + return AppSettingsDao(ref.read(databaseProvider)); +}); + +final isOnboardingCompleteProvider = FutureProvider((ref) async { + return ref.read(appSettingsDaoProvider).isOnboardingComplete(); +}); + +final subscriptionTierProvider = FutureProvider((ref) async { + return ref.read(appSettingsDaoProvider).getSubscriptionTier(); +}); +``` + +```dart +// 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((ref) { + return BusinessDao(ref.read(databaseProvider)); +}); + +final businessProvider = FutureProvider((ref) async { + return ref.read(businessDaoProvider).getBusiness(); +}); +``` + +- [ ] **Step 7: Create the navigation shell** + +```dart +// 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** + +```dart +// 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 { + 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 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((ref) { + final db = ref.read(databaseProvider); + return OnboardingNotifier(BusinessDao(db), AppSettingsDao(db)); +}); +``` + +```dart +// 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** + +```dart +// 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((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** + +```dart +// 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** + +```bash +flutter test -v +``` +Expected: All tests PASS. + +- [ ] **Step 12: Run the app and verify onboarding flow** + +```bash +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** + +```bash +git add lib/ test/ +git commit -m "feat: add navigation shell, onboarding screen, and Riverpod providers" +``` + +--- + +## Phase 2: Core Invoicing (Weeks 3–5) + +### 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** + +```dart +// 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** + +```bash +flutter test test/features/clients/client_notifier_test.dart +``` +Expected: FAIL. + +- [ ] **Step 3: Implement ClientNotifier** + +```dart +// 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 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? 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 { + final ClientDao _clientDao; + final AppSettingsDao _settingsDao; + final String _businessId; + + static const _freeClientLimit = 2; + + ClientNotifier(this._clientDao, this._settingsDao, this._businessId) + : super(const ClientListState()); + + Future loadClients() async { + state = state.copyWith(isLoading: true); + final clients = await _clientDao.getActiveClients(_businessId); + state = state.copyWith(clients: clients, isLoading: false); + } + + Future 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 deleteClient(String clientId) async { + await _clientDao.softDeleteClient(clientId); + await loadClients(); + } + + Future 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** + +```bash +flutter test test/features/clients/client_notifier_test.dart -v +``` +Expected: All 5 tests PASS. + +- [ ] **Step 5: Create client list screen** + +```dart +// 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((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 createState() => _ClientListScreenState(); +} + +class _ClientListScreenState extends ConsumerState { + 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** + +```dart +// 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((ref) { + return ClientDao(ref.read(databaseProvider)); +}); +``` + +- [ ] **Step 7: Commit** + +```bash +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** + +```dart +// 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** + +```bash +flutter test test/core/utils/validators_test.dart +``` +Expected: FAIL. + +- [ ] **Step 3: Implement Validators** + +```dart +// 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** + +```bash +flutter test test/core/utils/validators_test.dart -v +``` +Expected: All 3 tests PASS. + +- [ ] **Step 5: Create client form screen** + +```dart +// 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 createState() => _ClientFormScreenState(); +} + +class _ClientFormScreenState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _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** + +```dart +// 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((ref, clientId) { + return ClientDao(ref.read(databaseProvider)).getClient(clientId); +}); + +final clientDocumentsProvider = + FutureProvider.family, 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( + 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** + +```bash +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** + +```dart +// 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** + +```bash +flutter test test/features/invoices/invoice_creator_notifier_test.dart +``` +Expected: FAIL. + +- [ ] **Step 3: Implement InvoiceCreatorNotifier** + +```dart +// 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 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? 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 { + 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 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 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** + +```bash +flutter test test/features/invoices/invoice_creator_notifier_test.dart -v +``` +Expected: All 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +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** + +```dart +// 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** + +```dart +// 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( + (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>((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 createState() => + _InvoiceCreatorScreenState(); +} + +class _InvoiceCreatorScreenState extends ConsumerState { + 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( + 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: +```dart +GoRoute( + path: '/invoices/new', + builder: (context, state) => const InvoiceCreatorScreen(), +), +``` + +- [ ] **Step 4: Verify the screen renders** + +```bash +flutter run -d chrome +``` +Navigate to `/invoices/new` — screen should render without errors. + +- [ ] **Step 5: Commit** + +```bash +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** + +```dart +// 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** + +```dart +// 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** + +```dart +// 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); + }); +} +``` + +```dart +// 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** + +```bash +flutter test test/features/invoices/widgets/ -v +``` +Expected: All 4 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +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** + +```dart +// 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** + +```bash +flutter test test/features/invoices/invoice_dashboard_notifier_test.dart +``` +Expected: FAIL. + +- [ ] **Step 3: Implement InvoiceDashboardNotifier** + +```dart +// 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 invoices; + final List filteredInvoices; + final Map 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? invoices, + List? filteredInvoices, + Map? 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 { + final DocumentDao _documentDao; + final String _businessId; + + InvoiceDashboardNotifier(this._documentDao, this._businessId) + : super(const DashboardState()); + + Future 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** + +```bash +flutter test test/features/invoices/invoice_dashboard_notifier_test.dart -v +``` +Expected: All 3 tests PASS. + +- [ ] **Step 5: Create Invoice Dashboard Screen** + +```dart +// 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((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 createState() => + _InvoiceDashboardScreenState(); +} + +class _InvoiceDashboardScreenState + extends ConsumerState { + @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`: +```dart +GoRoute( + path: '/invoices', + builder: (context, state) => const InvoiceDashboardScreen(), +), +``` + +- [ ] **Step 7: Commit** + +```bash +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** + +```dart +// 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((ref, docId) { + return DocumentDao(ref.read(databaseProvider)).getDocument(docId); +}); + +final invoiceLineItemsProvider = + FutureProvider.family, 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( + 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 _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** + +```dart +// 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((ref) { + return DocumentDao(ref.read(databaseProvider)); +}); + +final lineItemDaoProvider = Provider((ref) { + return LineItemDao(ref.read(databaseProvider)); +}); +``` + +- [ ] **Step 3: Add all remaining routes to app.dart** + +Add inside the ShellRoute: +```dart +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** + +```bash +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** + +```bash +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 6–7) + +### 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** + +```dart +// 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** + +```bash +flutter test test/core/services/pdf_service_test.dart +``` +Expected: FAIL. + +- [ ] **Step 3: Implement PdfService** + +```dart +// 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 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 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** + +```bash +flutter test test/core/services/pdf_service_test.dart -v +``` +Expected: All 2 tests PASS. + +- [ ] **Step 5: Create PDF preview screen** + +```dart +// 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 _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** + +```bash +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** + +```bash +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)** + +```bash +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** + +```dart +// 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** + +```bash +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 + +```dart +// 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 { + 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 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 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 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** + +```bash +flutter test test/features/estimates/estimate_creator_notifier_test.dart -v +``` +Expected: All 3 tests PASS. + +- [ ] **Step 5: Create estimate list screen** + +```dart +// 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>((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** + +```dart +// 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( + (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 createState() => + _EstimateCreatorScreenState(); +} + +class _EstimateCreatorScreenState extends ConsumerState { + 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( + 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** + +```dart +// Add to ShellRoute routes in app.dart: +GoRoute( + path: '/estimates/new', + builder: (context, state) => const EstimateCreatorScreen(), +), +``` + +- [ ] **Step 8: Commit** + +```bash +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** + +```dart +// 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** + +```bash +flutter test test/features/payments/payment_notifier_test.dart +``` +Expected: FAIL. + +- [ ] **Step 3: Implement PaymentNotifier** + +```dart +// 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 payments; + final bool isLoading; + final String? error; + + const PaymentState({ + this.payments = const [], + this.isLoading = false, + this.error, + }); + + PaymentState copyWith({ + List? payments, + bool? isLoading, + String? error, + }) { + return PaymentState( + payments: payments ?? this.payments, + isLoading: isLoading ?? this.isLoading, + error: error, + ); + } +} + +class PaymentNotifier extends StateNotifier { + 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 loadPayments(String documentId) async { + state = state.copyWith(isLoading: true); + final payments = await _paymentDao.getPaymentsForDocument(documentId); + state = state.copyWith(payments: payments, isLoading: false); + } + + Future 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 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 _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** + +```bash +flutter test test/features/payments/payment_notifier_test.dart -v +``` +Expected: All 5 tests PASS. + +- [ ] **Step 5: Create payment bottom sheet UI** + +```dart +// 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((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 createState() => _PaymentBottomSheetState(); +} + +class _PaymentBottomSheetState extends ConsumerState { + 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( + 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** + +```bash +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** + +```dart +// 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** + +```bash +flutter test test/core/services/notification_service_test.dart +``` +Expected: FAIL. + +- [ ] **Step 3: Implement NotificationService** + +```dart +// 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 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 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 cancelReminder(String documentId) async { + final notificationId = generateNotificationId(documentId); + await _plugin.cancel(notificationId); + } +} +``` + +- [ ] **Step 4: Run tests** + +```bash +flutter test test/core/services/notification_service_test.dart -v +``` +Expected: All 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +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: +```dart +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: +```dart +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`: +```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`: +```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** + +```bash +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** + +```bash +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** + +```dart +// 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** + +```bash +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 8–9) + +### 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** + +```dart +// 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** + +```bash +flutter test test/core/services/subscription_service_test.dart +``` +Expected: FAIL. + +- [ ] **Step 3: Implement SubscriptionService** + +```dart +// 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 initialize() async { + if (_revenueCatApiKey.isEmpty) return; // Skip in dev/test + + final config = PurchasesConfiguration(_revenueCatApiKey); + await Purchases.configure(config); + await syncSubscriptionStatus(); + } + + Future 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 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 restorePurchases() async { + try { + await Purchases.restorePurchases(); + await syncSubscriptionStatus(); + final tier = await _settingsDao.getSubscriptionTier(); + return tier != 'free'; + } catch (_) { + return false; + } + } +} +``` + +- [ ] **Step 4: Run tests** + +```bash +flutter test test/core/services/subscription_service_test.dart -v +``` +Expected: All 2 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +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** + +```dart +// 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** + +```bash +flutter test test/features/paywall/paywall_notifier_test.dart +``` +Expected: FAIL. + +- [ ] **Step 3: Implement PaywallNotifier** + +```dart +// 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 { + final AppSettingsDao _settingsDao; + + PaywallNotifier(this._settingsDao) : super(const PaywallState()); + + Future loadTier() async { + final tier = await _settingsDao.getSubscriptionTier(); + state = state.copyWith(currentTier: tier); + } +} +``` + +- [ ] **Step 4: Run tests** + +```bash +flutter test test/features/paywall/paywall_notifier_test.dart -v +``` +Expected: All 2 tests PASS. + +- [ ] **Step 5: Create paywall screen UI** + +```dart +// 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((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** + +```bash +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** + +```dart +// 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** + +```bash +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** + +```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** + +```bash +# 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: +```dart +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** + +```bash +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** + +```bash +flutter test --coverage +``` +Expected: All tests pass. Coverage report generated. + +- [ ] **Step 2: Fix any remaining lint warnings** + +```bash +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: +```bash +flutter run --profile +``` + +- [ ] **Step 5: App icons and splash screen** + +Generate icons using `flutter_launcher_icons` package. Add to `pubspec.yaml`: +```yaml +dev_dependencies: + flutter_launcher_icons: ^0.14.0 + +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/icon/app_icon.png" +``` + +```bash +dart run flutter_launcher_icons +``` + +- [ ] **Step 6: Build release APK and IPA** + +```bash +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** + +```bash +git add . +git commit -m "chore: polish, app icons, and store submission prep" +``` + +--- + +## Summary + +| Phase | Tasks | Key Deliverable | +|-------|-------|-----------------| +| 1: Foundation | 1–7 | App boots, DB works, onboarding complete, navigation wired | +| 2: Core Invoicing | 8–14 | Clients CRUD, invoice creation with live totals, dashboard with stats/filters | +| 3: PDF & Pro | 15–21 | PDF generation, estimates with conversion, payments, notifications, settings | +| 4: Monetization | 22–26 | RevenueCat IAP, paywall, Firebase analytics, store submission | + +**Total tasks:** 26 +**Total test files:** 15 +**Estimated commits:** ~26+