# 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+