226 KiB
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
flutter create swift_invoice --org com.swiftinvoice --platforms ios,android
cd swift_invoice
- Step 2: Add dependencies to pubspec.yaml
dependencies:
flutter:
sdk: flutter
drift: ^2.22.0
sqlite3_flutter_libs: ^0.5.0
path_provider: ^2.1.0
path: ^1.9.0
flutter_riverpod: ^2.6.0
riverpod_annotation: ^2.6.0
uuid: ^4.5.0
pdf: ^3.11.0
printing: ^5.13.0
share_plus: ^10.1.0
flutter_local_notifications: ^18.0.0
purchases_flutter: ^8.6.0
firebase_core: ^3.8.0
firebase_analytics: ^11.4.0
go_router: ^14.6.0
intl: ^0.19.0
image_picker: ^1.1.0
dev_dependencies:
flutter_test:
sdk: flutter
drift_dev: ^2.22.0
build_runner: ^2.4.0
riverpod_generator: ^2.6.0
mocktail: ^1.0.0
flutter_lints: ^5.0.0
flutter pub get
- Step 3: Create app theme
// lib/core/theme/app_colors.dart
import 'package:flutter/material.dart';
class AppColors {
static const Color primary = Color(0xFF2563EB); // Blue 600
static const Color success = Color(0xFF16A34A); // Green 600
static const Color warning = Color(0xFFF59E0B); // Amber 500
static const Color error = Color(0xFFDC2626); // Red 600
static const Color neutral = Color(0xFF6B7280); // Gray 500
// Status pill colors
static const Color statusDraft = Color(0xFF9CA3AF); // Gray 400
static const Color statusSent = Color(0xFF3B82F6); // Blue 500
static const Color statusPaid = Color(0xFF22C55E); // Green 500
static const Color statusOverdue = Color(0xFFEF4444); // Red 500
static const Color statusPartial = Color(0xFFF97316); // Orange 500
static const Color statusVoid = Color(0xFF6B7280); // Gray 500
}
// lib/core/theme/app_theme.dart
import 'package:flutter/material.dart';
import 'app_colors.dart';
class AppTheme {
static ThemeData light() {
return ThemeData(
useMaterial3: true,
colorSchemeSeed: AppColors.primary,
brightness: Brightness.light,
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
cardTheme: const CardTheme(
elevation: 1,
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
),
);
}
}
- Step 4: Create main.dart and app.dart
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const ProviderScope(child: SwiftInvoiceApp()));
}
// lib/app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/theme/app_theme.dart';
class SwiftInvoiceApp extends ConsumerWidget {
const SwiftInvoiceApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp(
title: 'SwiftInvoice',
theme: AppTheme.light(),
home: const Scaffold(
body: Center(child: Text('SwiftInvoice')),
),
);
}
}
- Step 5: Write smoke widget test
// test/app_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:swift_invoice/app.dart';
void main() {
testWidgets('app renders without crashing', (tester) async {
await tester.pumpWidget(const ProviderScope(child: SwiftInvoiceApp()));
expect(find.text('SwiftInvoice'), findsOneWidget);
});
}
- Step 6: Run smoke test
flutter test test/app_test.dart -v
Expected: PASS.
- Step 7: Verify app builds and runs
flutter run -d chrome # or connected device
Expected: App launches showing "SwiftInvoice" centered text.
- Step 8: Commit
git init
git add .
git commit -m "feat: scaffold Flutter project with dependencies and Material 3 theme"
Task 2: Drift Database — Table Definitions
Files:
-
Create:
lib/core/models/enums.dart -
Create:
lib/core/database/tables/businesses.dart -
Create:
lib/core/database/tables/clients.dart -
Create:
lib/core/database/tables/documents.dart -
Create:
lib/core/database/tables/line_items.dart -
Create:
lib/core/database/tables/payments.dart -
Create:
lib/core/database/tables/app_settings.dart -
Step 1: Define enums
// lib/core/models/enums.dart
enum DocumentType {
invoice,
estimate;
String get value => name;
static DocumentType fromString(String s) =>
DocumentType.values.firstWhere((e) => e.name == s);
}
enum DocumentStatus {
// Invoice statuses
draft,
sent,
partial,
paid,
overdue,
void_,
// Estimate statuses
accepted,
declined,
expired,
converted;
String get value => this == void_ ? 'void' : name;
String get displayName => switch (this) {
void_ => 'Void',
_ => name[0].toUpperCase() + name.substring(1),
};
static DocumentStatus fromString(String s) {
if (s == 'void') return DocumentStatus.void_;
return DocumentStatus.values.firstWhere((e) => e.name == s);
}
}
enum PaymentMethod {
cash,
card,
bankTransfer,
check,
other;
String get value => switch (this) {
bankTransfer => 'bank_transfer',
_ => name,
};
static PaymentMethod fromString(String s) => switch (s) {
'bank_transfer' => PaymentMethod.bankTransfer,
_ => PaymentMethod.values.firstWhere((e) => e.name == s),
};
}
enum DiscountType {
percentage,
fixed;
String get value => name;
static DiscountType fromString(String s) =>
DiscountType.values.firstWhere((e) => e.name == s);
}
- Step 2: Define all Drift table classes
// lib/core/database/tables/businesses.dart
import 'package:drift/drift.dart';
class Businesses extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
TextColumn get email => text().nullable()();
TextColumn get phone => text().nullable()();
TextColumn get addressLine1 => text().nullable()();
TextColumn get addressLine2 => text().nullable()();
TextColumn get city => text().nullable()();
TextColumn get state => text().nullable()();
TextColumn get postalCode => text().nullable()();
TextColumn get countryCode => text().withDefault(const Constant('US'))();
TextColumn get taxNumber => text().nullable()();
TextColumn get logoPath => text().nullable()();
TextColumn get defaultCurrency => text().withDefault(const Constant('USD'))();
IntColumn get defaultTaxRate => integer().withDefault(const Constant(0))();
IntColumn get defaultPaymentTermsDays => integer().withDefault(const Constant(30))();
TextColumn get invoicePrefix => text().withDefault(const Constant('INV'))();
TextColumn get estimatePrefix => text().withDefault(const Constant('EST'))();
IntColumn get nextInvoiceNumber => integer().withDefault(const Constant(1))();
IntColumn get nextEstimateNumber => integer().withDefault(const Constant(1))();
TextColumn get createdAt => text()();
TextColumn get updatedAt => text()();
@override
Set<Column> get primaryKey => {id};
}
// lib/core/database/tables/clients.dart
import 'package:drift/drift.dart';
class Clients extends Table {
TextColumn get id => text()();
TextColumn get businessId => text().references(Businesses, #id)();
TextColumn get name => text()();
TextColumn get email => text().nullable()();
TextColumn get phone => text().nullable()();
TextColumn get addressLine1 => text().nullable()();
TextColumn get addressLine2 => text().nullable()();
TextColumn get city => text().nullable()();
TextColumn get state => text().nullable()();
TextColumn get postalCode => text().nullable()();
TextColumn get countryCode => text().nullable()();
TextColumn get notes => text().nullable()();
IntColumn get outstandingBalance => integer().withDefault(const Constant(0))();
IntColumn get isDeleted => integer().withDefault(const Constant(0))();
TextColumn get createdAt => text()();
TextColumn get updatedAt => text()();
@override
Set<Column> get primaryKey => {id};
}
// lib/core/database/tables/documents.dart
import 'package:drift/drift.dart';
class Documents extends Table {
TextColumn get id => text()();
TextColumn get businessId => text()();
TextColumn get clientId => text()();
TextColumn get documentType => text()();
TextColumn get documentNumber => text().unique()();
TextColumn get status => text()();
TextColumn get issueDate => text()();
TextColumn get dueDate => text().nullable()();
TextColumn get currencyCode => text().withDefault(const Constant('USD'))();
IntColumn get subtotal => integer().withDefault(const Constant(0))();
IntColumn get taxRate => integer().withDefault(const Constant(0))();
IntColumn get taxAmount => integer().withDefault(const Constant(0))();
TextColumn get discountType => text().nullable()();
IntColumn get discountValue => integer().withDefault(const Constant(0))();
IntColumn get discountAmount => integer().withDefault(const Constant(0))();
IntColumn get total => integer().withDefault(const Constant(0))();
IntColumn get amountPaid => integer().withDefault(const Constant(0))();
IntColumn get amountDue => integer().withDefault(const Constant(0))();
TextColumn get docNotes => text().nullable()();
TextColumn get convertedFromId => text().nullable()();
IntColumn get isDeleted => integer().withDefault(const Constant(0))();
TextColumn get createdAt => text()();
TextColumn get updatedAt => text()();
@override
Set<Column> get primaryKey => {id};
}
// lib/core/database/tables/line_items.dart
import 'package:drift/drift.dart';
class LineItems extends Table {
TextColumn get id => text()();
TextColumn get documentId => text()();
TextColumn get description => text()();
RealColumn get quantity => real().withDefault(const Constant(1.0))();
IntColumn get unitPrice => integer()();
IntColumn get amount => integer()();
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
TextColumn get createdAt => text()();
TextColumn get updatedAt => text()();
@override
Set<Column> get primaryKey => {id};
}
// lib/core/database/tables/payments.dart
import 'package:drift/drift.dart';
class Payments extends Table {
TextColumn get id => text()();
TextColumn get documentId => text()();
IntColumn get amount => integer()();
TextColumn get method => text()();
TextColumn get paidAt => text()();
TextColumn get notes => text().nullable()();
TextColumn get createdAt => text()();
TextColumn get updatedAt => text()();
@override
Set<Column> get primaryKey => {id};
}
// lib/core/database/tables/app_settings.dart
import 'package:drift/drift.dart';
class AppSettings extends Table {
TextColumn get key => text()();
TextColumn get value => text()();
TextColumn get updatedAt => text()();
@override
Set<Column> get primaryKey => {key};
}
- Step 3: Commit
git add lib/core/models/enums.dart lib/core/database/tables/
git commit -m "feat: define Drift table classes and domain enums for all 6 MVP tables"
Task 3: Drift Database — AppDatabase & Code Generation
Files:
-
Create:
lib/core/database/database.dart -
Create:
lib/core/providers/database_provider.dart -
Create:
lib/core/utils/uuid_generator.dart -
Step 1: Create AppDatabase class
// lib/core/database/database.dart
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'tables/businesses.dart';
import 'tables/clients.dart';
import 'tables/documents.dart';
import 'tables/line_items.dart';
import 'tables/payments.dart';
import 'tables/app_settings.dart';
part 'database.g.dart';
@DriftDatabase(tables: [
Businesses,
Clients,
Documents,
LineItems,
Payments,
AppSettings,
])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
AppDatabase.forTesting(super.e);
@override
int get schemaVersion => 1;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
// Create indexes
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_clients_business ON clients(business_id, is_deleted)');
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_documents_client ON documents(client_id, document_type, status)');
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status, due_date)');
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(document_type, is_deleted)');
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_line_items_doc ON line_items(document_id, sort_order)');
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_payments_doc ON payments(document_id)');
},
);
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'swift_invoice.sqlite'));
return NativeDatabase.createInBackground(file);
});
}
- Step 2: Create UUID utility
// lib/core/utils/uuid_generator.dart
import 'package:uuid/uuid.dart';
const _uuid = Uuid();
String generateUuid() => _uuid.v4();
- Step 3: Create database provider
// lib/core/providers/database_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/database.dart';
final databaseProvider = Provider<AppDatabase>((ref) {
final db = AppDatabase();
ref.onDispose(() => db.close());
return db;
});
- Step 4: Run code generation
dart run build_runner build --delete-conflicting-outputs
Expected: database.g.dart generated without errors.
- Step 5: Write schema validation test
// test/core/database/database_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
void main() {
late AppDatabase db;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
});
tearDown(() => db.close());
test('database creates all 6 tables without error', () async {
// Verify each table is accessible by selecting from it
expect(await db.select(db.businesses).get(), isEmpty);
expect(await db.select(db.clients).get(), isEmpty);
expect(await db.select(db.documents).get(), isEmpty);
expect(await db.select(db.lineItems).get(), isEmpty);
expect(await db.select(db.payments).get(), isEmpty);
expect(await db.select(db.appSettings).get(), isEmpty);
});
test('schema version is 1', () {
expect(db.schemaVersion, 1);
});
}
- Step 6: Run schema test
flutter test test/core/database/database_test.dart -v
Expected: All 2 tests PASS.
- Step 7: Verify app still compiles
flutter build apk --debug 2>&1 | tail -5
Expected: BUILD SUCCESSFUL
- Step 8: Commit
git add lib/core/database/database.dart lib/core/database/database.g.dart \
lib/core/utils/uuid_generator.dart lib/core/providers/database_provider.dart \
test/core/database/database_test.dart
git commit -m "feat: wire up AppDatabase with Drift code generation, indexes, and schema tests"
Task 4: DAOs — Business & AppSettings
Files:
-
Create:
lib/core/database/daos/business_dao.dart -
Create:
lib/core/database/daos/app_settings_dao.dart -
Create:
test/core/database/daos/business_dao_test.dart -
Create:
test/core/database/daos/app_settings_dao_test.dart -
Step 1: Write failing tests for BusinessDao
// test/core/database/daos/business_dao_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
void main() {
late AppDatabase db;
late BusinessDao dao;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
dao = BusinessDao(db);
});
tearDown(() => db.close());
test('createBusiness inserts and returns a business', () async {
final biz = await dao.createBusiness(name: 'Test Co');
expect(biz.name, 'Test Co');
expect(biz.id, isNotEmpty);
expect(biz.defaultCurrency, 'USD');
expect(biz.defaultPaymentTermsDays, 30);
});
test('getBusiness returns the single business row', () async {
await dao.createBusiness(name: 'Test Co');
final biz = await dao.getBusiness();
expect(biz, isNotNull);
expect(biz!.name, 'Test Co');
});
test('getBusiness returns null when no business exists', () async {
final biz = await dao.getBusiness();
expect(biz, isNull);
});
test('updateBusiness modifies fields', () async {
final biz = await dao.createBusiness(name: 'Old Name');
await dao.updateBusiness(biz.id, name: 'New Name', email: 'a@b.com');
final updated = await dao.getBusiness();
expect(updated!.name, 'New Name');
expect(updated.email, 'a@b.com');
});
test('incrementInvoiceNumber returns current and increments', () async {
final biz = await dao.createBusiness(name: 'Co');
final num1 = await dao.getAndIncrementInvoiceNumber(biz.id);
expect(num1, 1);
final num2 = await dao.getAndIncrementInvoiceNumber(biz.id);
expect(num2, 2);
});
test('incrementEstimateNumber returns current and increments', () async {
final biz = await dao.createBusiness(name: 'Co');
final num1 = await dao.getAndIncrementEstimateNumber(biz.id);
expect(num1, 1);
final num2 = await dao.getAndIncrementEstimateNumber(biz.id);
expect(num2, 2);
});
}
- Step 2: Run test to verify it fails
flutter test test/core/database/daos/business_dao_test.dart
Expected: FAIL — BusinessDao not found.
- Step 3: Implement BusinessDao
// lib/core/database/daos/business_dao.dart
import 'package:drift/drift.dart';
import '../database.dart';
import '../../utils/uuid_generator.dart';
class BusinessDao {
final AppDatabase _db;
BusinessDao(this._db);
Future<Business> createBusiness({
required String name,
String? email,
String? phone,
String? logoPath,
String defaultCurrency = 'USD',
int defaultTaxRate = 0,
int defaultPaymentTermsDays = 30,
}) async {
final now = DateTime.now().toIso8601String();
final id = generateUuid();
await _db.into(_db.businesses).insert(BusinessesCompanion.insert(
id: id,
name: name,
email: Value(email),
phone: Value(phone),
logoPath: Value(logoPath),
defaultCurrency: Value(defaultCurrency),
defaultTaxRate: Value(defaultTaxRate),
defaultPaymentTermsDays: Value(defaultPaymentTermsDays),
createdAt: now,
updatedAt: now,
));
return (await (_db.select(_db.businesses)
..where((t) => t.id.equals(id)))
.getSingle());
}
Future<Business?> getBusiness() async {
final results = await _db.select(_db.businesses).get();
return results.isEmpty ? null : results.first;
}
Future<void> updateBusiness(
String id, {
String? name,
String? email,
String? phone,
String? addressLine1,
String? addressLine2,
String? city,
String? state,
String? postalCode,
String? countryCode,
String? taxNumber,
String? logoPath,
String? defaultCurrency,
int? defaultTaxRate,
int? defaultPaymentTermsDays,
String? invoicePrefix,
String? estimatePrefix,
}) async {
final now = DateTime.now().toIso8601String();
await (_db.update(_db.businesses)..where((t) => t.id.equals(id))).write(
BusinessesCompanion(
name: name != null ? Value(name) : const Value.absent(),
email: email != null ? Value(email) : const Value.absent(),
phone: phone != null ? Value(phone) : const Value.absent(),
addressLine1:
addressLine1 != null ? Value(addressLine1) : const Value.absent(),
addressLine2:
addressLine2 != null ? Value(addressLine2) : const Value.absent(),
city: city != null ? Value(city) : const Value.absent(),
state: state != null ? Value(state) : const Value.absent(),
postalCode:
postalCode != null ? Value(postalCode) : const Value.absent(),
countryCode:
countryCode != null ? Value(countryCode) : const Value.absent(),
taxNumber: taxNumber != null ? Value(taxNumber) : const Value.absent(),
logoPath: logoPath != null ? Value(logoPath) : const Value.absent(),
defaultCurrency: defaultCurrency != null
? Value(defaultCurrency)
: const Value.absent(),
defaultTaxRate:
defaultTaxRate != null ? Value(defaultTaxRate) : const Value.absent(),
defaultPaymentTermsDays: defaultPaymentTermsDays != null
? Value(defaultPaymentTermsDays)
: const Value.absent(),
invoicePrefix:
invoicePrefix != null ? Value(invoicePrefix) : const Value.absent(),
estimatePrefix: estimatePrefix != null
? Value(estimatePrefix)
: const Value.absent(),
updatedAt: Value(now),
),
);
}
Future<int> getAndIncrementInvoiceNumber(String businessId) async {
final biz = await (_db.select(_db.businesses)
..where((t) => t.id.equals(businessId)))
.getSingle();
final current = biz.nextInvoiceNumber;
await (_db.update(_db.businesses)
..where((t) => t.id.equals(businessId)))
.write(BusinessesCompanion(
nextInvoiceNumber: Value(current + 1),
updatedAt: Value(DateTime.now().toIso8601String()),
));
return current;
}
Future<int> getAndIncrementEstimateNumber(String businessId) async {
final biz = await (_db.select(_db.businesses)
..where((t) => t.id.equals(businessId)))
.getSingle();
final current = biz.nextEstimateNumber;
await (_db.update(_db.businesses)
..where((t) => t.id.equals(businessId)))
.write(BusinessesCompanion(
nextEstimateNumber: Value(current + 1),
updatedAt: Value(DateTime.now().toIso8601String()),
));
return current;
}
}
- Step 4: Run tests to verify they pass
flutter test test/core/database/daos/business_dao_test.dart -v
Expected: All 6 tests PASS.
- Step 5: Write failing tests for AppSettingsDao
// test/core/database/daos/app_settings_dao_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';
void main() {
late AppDatabase db;
late AppSettingsDao dao;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
dao = AppSettingsDao(db);
});
tearDown(() => db.close());
test('getSetting returns null for non-existent key', () async {
expect(await dao.getSetting('foo'), isNull);
});
test('setSetting creates and retrieves a setting', () async {
await dao.setSetting('theme', 'dark');
expect(await dao.getSetting('theme'), 'dark');
});
test('setSetting overwrites existing value', () async {
await dao.setSetting('theme', 'dark');
await dao.setSetting('theme', 'light');
expect(await dao.getSetting('theme'), 'light');
});
test('isOnboardingComplete returns false by default', () async {
expect(await dao.isOnboardingComplete(), isFalse);
});
test('markOnboardingComplete sets flag', () async {
await dao.markOnboardingComplete();
expect(await dao.isOnboardingComplete(), isTrue);
});
}
- Step 6: Run tests to verify they fail
flutter test test/core/database/daos/app_settings_dao_test.dart
Expected: FAIL — AppSettingsDao not found.
- Step 7: Implement AppSettingsDao
// lib/core/database/daos/app_settings_dao.dart
import 'package:drift/drift.dart';
import '../database.dart';
class AppSettingsDao {
final AppDatabase _db;
AppSettingsDao(this._db);
Future<String?> getSetting(String key) async {
final results = await (_db.select(_db.appSettings)
..where((t) => t.key.equals(key)))
.get();
return results.isEmpty ? null : results.first.value;
}
Future<void> setSetting(String key, String value) async {
final now = DateTime.now().toIso8601String();
await _db.into(_db.appSettings).insertOnConflictUpdate(
AppSettingsCompanion.insert(
key: key,
value: value,
updatedAt: now,
),
);
}
Future<bool> isOnboardingComplete() async {
final val = await getSetting('onboarding_complete');
return val == 'true';
}
Future<void> markOnboardingComplete() async {
await setSetting('onboarding_complete', 'true');
}
Future<String> getSubscriptionTier() async {
return await getSetting('subscription_tier') ?? 'free';
}
Future<void> setSubscriptionTier(String tier) async {
await setSetting('subscription_tier', tier);
}
}
- Step 8: Run all DAO tests
flutter test test/core/database/daos/ -v
Expected: All 11 tests PASS.
- Step 9: Commit
git add lib/core/database/daos/business_dao.dart lib/core/database/daos/app_settings_dao.dart \
test/core/database/daos/
git commit -m "feat: implement BusinessDao and AppSettingsDao with full test coverage"
Task 5: DAOs — Client & Document
Files:
-
Create:
lib/core/database/daos/client_dao.dart -
Create:
lib/core/database/daos/document_dao.dart -
Create:
lib/core/database/daos/line_item_dao.dart -
Create:
test/core/database/daos/client_dao_test.dart -
Create:
test/core/database/daos/document_dao_test.dart -
Create:
test/core/database/daos/line_item_dao_test.dart -
Step 1: Write failing tests for ClientDao
// test/core/database/daos/client_dao_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
void main() {
late AppDatabase db;
late ClientDao dao;
late String businessId;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
dao = ClientDao(db);
final biz = await BusinessDao(db).createBusiness(name: 'Test Co');
businessId = biz.id;
});
tearDown(() => db.close());
test('createClient inserts and returns client', () async {
final client = await dao.createClient(
businessId: businessId,
name: 'Jane Doe',
email: 'jane@example.com',
);
expect(client.name, 'Jane Doe');
expect(client.email, 'jane@example.com');
expect(client.outstandingBalance, 0);
});
test('getActiveClients returns only non-deleted clients', () async {
await dao.createClient(businessId: businessId, name: 'Active');
final deleted = await dao.createClient(businessId: businessId, name: 'Deleted');
await dao.softDeleteClient(deleted.id);
final clients = await dao.getActiveClients(businessId);
expect(clients.length, 1);
expect(clients.first.name, 'Active');
});
test('searchClients matches partial name', () async {
await dao.createClient(businessId: businessId, name: 'Jane Doe');
await dao.createClient(businessId: businessId, name: 'John Smith');
final results = await dao.searchClients(businessId, 'jan');
expect(results.length, 1);
expect(results.first.name, 'Jane Doe');
});
test('getActiveClientCount counts non-deleted clients', () async {
await dao.createClient(businessId: businessId, name: 'A');
await dao.createClient(businessId: businessId, name: 'B');
final c = await dao.createClient(businessId: businessId, name: 'C');
await dao.softDeleteClient(c.id);
expect(await dao.getActiveClientCount(businessId), 2);
});
test('updateOutstandingBalance sets correct value', () async {
final client = await dao.createClient(businessId: businessId, name: 'A');
await dao.updateOutstandingBalance(client.id, 5000);
final updated = await dao.getClient(client.id);
expect(updated!.outstandingBalance, 5000);
});
}
- Step 2: Run tests to verify they fail
flutter test test/core/database/daos/client_dao_test.dart
Expected: FAIL — ClientDao not found.
- Step 3: Implement ClientDao
// lib/core/database/daos/client_dao.dart
import 'package:drift/drift.dart';
import '../database.dart';
import '../../utils/uuid_generator.dart';
class ClientDao {
final AppDatabase _db;
ClientDao(this._db);
Future<Client> createClient({
required String businessId,
required String name,
String? email,
String? phone,
String? addressLine1,
String? addressLine2,
String? city,
String? state,
String? postalCode,
String? countryCode,
String? notes,
}) async {
final now = DateTime.now().toIso8601String();
final id = generateUuid();
await _db.into(_db.clients).insert(ClientsCompanion.insert(
id: id,
businessId: businessId,
name: name,
email: Value(email),
phone: Value(phone),
addressLine1: Value(addressLine1),
addressLine2: Value(addressLine2),
city: Value(city),
state: Value(state),
postalCode: Value(postalCode),
countryCode: Value(countryCode),
notes: Value(notes),
createdAt: now,
updatedAt: now,
));
return (_db.select(_db.clients)..where((t) => t.id.equals(id))).getSingle();
}
Future<Client?> getClient(String id) async {
final results = await (_db.select(_db.clients)
..where((t) => t.id.equals(id)))
.get();
return results.isEmpty ? null : results.first;
}
Future<List<Client>> getActiveClients(String businessId) async {
return (_db.select(_db.clients)
..where((t) =>
t.businessId.equals(businessId) & t.isDeleted.equals(0))
..orderBy([(t) => OrderingTerm.asc(t.name)]))
.get();
}
Future<List<Client>> searchClients(String businessId, String query) async {
return (_db.select(_db.clients)
..where((t) =>
t.businessId.equals(businessId) &
t.isDeleted.equals(0) &
t.name.lower().like('%${query.toLowerCase()}%'))
..orderBy([(t) => OrderingTerm.asc(t.name)]))
.get();
}
Future<int> getActiveClientCount(String businessId) async {
final count = _db.clients.id.count();
final query = _db.selectOnly(_db.clients)
..addColumns([count])
..where(
_db.clients.businessId.equals(businessId) & _db.clients.isDeleted.equals(0));
final result = await query.getSingle();
return result.read(count)!;
}
Future<void> updateClient(String id, {
String? name,
String? email,
String? phone,
String? addressLine1,
String? addressLine2,
String? city,
String? state,
String? postalCode,
String? countryCode,
String? notes,
}) async {
await (_db.update(_db.clients)..where((t) => t.id.equals(id))).write(
ClientsCompanion(
name: name != null ? Value(name) : const Value.absent(),
email: email != null ? Value(email) : const Value.absent(),
phone: phone != null ? Value(phone) : const Value.absent(),
addressLine1: addressLine1 != null ? Value(addressLine1) : const Value.absent(),
addressLine2: addressLine2 != null ? Value(addressLine2) : const Value.absent(),
city: city != null ? Value(city) : const Value.absent(),
state: state != null ? Value(state) : const Value.absent(),
postalCode: postalCode != null ? Value(postalCode) : const Value.absent(),
countryCode: countryCode != null ? Value(countryCode) : const Value.absent(),
notes: notes != null ? Value(notes) : const Value.absent(),
updatedAt: Value(DateTime.now().toIso8601String()),
),
);
}
Future<void> softDeleteClient(String id) async {
await (_db.update(_db.clients)..where((t) => t.id.equals(id))).write(
ClientsCompanion(
isDeleted: const Value(1),
updatedAt: Value(DateTime.now().toIso8601String()),
),
);
}
Future<void> updateOutstandingBalance(String clientId, int balanceCents) async {
await (_db.update(_db.clients)..where((t) => t.id.equals(clientId))).write(
ClientsCompanion(
outstandingBalance: Value(balanceCents),
updatedAt: Value(DateTime.now().toIso8601String()),
),
);
}
}
- Step 4: Run client tests
flutter test test/core/database/daos/client_dao_test.dart -v
Expected: All 5 tests PASS.
- Step 5: Write failing tests for DocumentDao
// test/core/database/daos/document_dao_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/document_dao.dart';
void main() {
late AppDatabase db;
late DocumentDao dao;
late String businessId;
late String clientId;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
dao = DocumentDao(db);
final biz = await BusinessDao(db).createBusiness(name: 'Co');
businessId = biz.id;
final client = await ClientDao(db).createClient(
businessId: businessId,
name: 'Client',
);
clientId = client.id;
});
tearDown(() => db.close());
test('createDocument inserts an invoice', () async {
final doc = await dao.createDocument(
businessId: businessId,
clientId: clientId,
documentType: 'invoice',
documentNumber: 'INV-001',
status: 'draft',
issueDate: '2026-03-22',
);
expect(doc.documentType, 'invoice');
expect(doc.documentNumber, 'INV-001');
expect(doc.status, 'draft');
});
test('getInvoices returns only non-deleted invoices', () async {
await dao.createDocument(
businessId: businessId, clientId: clientId,
documentType: 'invoice', documentNumber: 'INV-001',
status: 'draft', issueDate: '2026-03-22',
);
final est = await dao.createDocument(
businessId: businessId, clientId: clientId,
documentType: 'estimate', documentNumber: 'EST-001',
status: 'draft', issueDate: '2026-03-22',
);
final invoices = await dao.getDocumentsByType(businessId, 'invoice');
expect(invoices.length, 1);
expect(invoices.first.documentNumber, 'INV-001');
});
test('updateStatus changes document status', () async {
final doc = await dao.createDocument(
businessId: businessId, clientId: clientId,
documentType: 'invoice', documentNumber: 'INV-001',
status: 'draft', issueDate: '2026-03-22',
);
await dao.updateStatus(doc.id, 'sent');
final updated = await dao.getDocument(doc.id);
expect(updated!.status, 'sent');
});
test('getOverdueInvoices returns invoices past due date', () async {
await dao.createDocument(
businessId: businessId, clientId: clientId,
documentType: 'invoice', documentNumber: 'INV-001',
status: 'sent', issueDate: '2026-01-01',
dueDate: '2026-01-15',
total: 1000, amountDue: 1000,
);
await dao.createDocument(
businessId: businessId, clientId: clientId,
documentType: 'invoice', documentNumber: 'INV-002',
status: 'sent', issueDate: '2026-03-22',
dueDate: '2099-12-31',
total: 500, amountDue: 500,
);
final overdue = await dao.getOverdueInvoices(businessId);
expect(overdue.length, 1);
expect(overdue.first.documentNumber, 'INV-001');
});
test('getMonthlyInvoiceCount counts current month invoices', () async {
final now = DateTime.now();
await dao.createDocument(
businessId: businessId, clientId: clientId,
documentType: 'invoice', documentNumber: 'INV-001',
status: 'draft', issueDate: now.toIso8601String(),
);
expect(await dao.getMonthlyInvoiceCount(businessId), 1);
});
}
- Step 6: Run tests to verify they fail
flutter test test/core/database/daos/document_dao_test.dart
Expected: FAIL — DocumentDao not found.
- Step 7: Implement DocumentDao
// lib/core/database/daos/document_dao.dart
import 'package:drift/drift.dart';
import '../database.dart';
import '../../utils/uuid_generator.dart';
class DocumentDao {
final AppDatabase _db;
DocumentDao(this._db);
Future<Document> createDocument({
required String businessId,
required String clientId,
required String documentType,
required String documentNumber,
required String status,
required String issueDate,
String? dueDate,
String currencyCode = 'USD',
int subtotal = 0,
int taxRate = 0,
int taxAmount = 0,
String? discountType,
int discountValue = 0,
int discountAmount = 0,
int total = 0,
int amountPaid = 0,
int amountDue = 0,
String? notes,
String? convertedFromId,
}) async {
final now = DateTime.now().toIso8601String();
final id = generateUuid();
await _db.into(_db.documents).insert(DocumentsCompanion.insert(
id: id,
businessId: businessId,
clientId: clientId,
documentType: documentType,
documentNumber: documentNumber,
status: status,
issueDate: issueDate,
dueDate: Value(dueDate),
currencyCode: Value(currencyCode),
subtotal: Value(subtotal),
taxRate: Value(taxRate),
taxAmount: Value(taxAmount),
discountType: Value(discountType),
discountValue: Value(discountValue),
discountAmount: Value(discountAmount),
total: Value(total),
amountPaid: Value(amountPaid),
amountDue: Value(amountDue),
docNotes: Value(notes),
convertedFromId: Value(convertedFromId),
createdAt: now,
updatedAt: now,
));
return (_db.select(_db.documents)..where((t) => t.id.equals(id))).getSingle();
}
Future<Document?> getDocument(String id) async {
final results = await (_db.select(_db.documents)
..where((t) => t.id.equals(id)))
.get();
return results.isEmpty ? null : results.first;
}
Future<List<Document>> getDocumentsByType(String businessId, String type) async {
return (_db.select(_db.documents)
..where((t) =>
t.businessId.equals(businessId) &
t.documentType.equals(type) &
t.isDeleted.equals(0))
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
.get();
}
Future<List<Document>> getDocumentsByStatus(
String businessId, String type, String status) async {
return (_db.select(_db.documents)
..where((t) =>
t.businessId.equals(businessId) &
t.documentType.equals(type) &
t.status.equals(status) &
t.isDeleted.equals(0))
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
.get();
}
Future<List<Document>> getDocumentsForClient(String clientId) async {
return (_db.select(_db.documents)
..where((t) => t.clientId.equals(clientId) & t.isDeleted.equals(0))
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
.get();
}
Future<void> updateStatus(String id, String status) async {
await (_db.update(_db.documents)..where((t) => t.id.equals(id))).write(
DocumentsCompanion(
status: Value(status),
updatedAt: Value(DateTime.now().toIso8601String()),
),
);
}
Future<void> updateTotals(String id, {
required int subtotal,
required int taxAmount,
required int discountAmount,
required int total,
required int amountDue,
}) async {
await (_db.update(_db.documents)..where((t) => t.id.equals(id))).write(
DocumentsCompanion(
subtotal: Value(subtotal),
taxAmount: Value(taxAmount),
discountAmount: Value(discountAmount),
total: Value(total),
amountDue: Value(amountDue),
updatedAt: Value(DateTime.now().toIso8601String()),
),
);
}
Future<void> updatePaymentAmounts(String id, int amountPaid) async {
final doc = await getDocument(id);
if (doc == null) return;
final amountDue = doc.total - amountPaid;
String status;
if (amountDue <= 0) {
status = 'paid';
} else if (amountPaid > 0) {
status = 'partial';
} else {
status = doc.status;
}
await (_db.update(_db.documents)..where((t) => t.id.equals(id))).write(
DocumentsCompanion(
amountPaid: Value(amountPaid),
amountDue: Value(amountDue),
status: Value(status),
updatedAt: Value(DateTime.now().toIso8601String()),
),
);
}
Future<List<Document>> getOverdueInvoices(String businessId) async {
final now = DateTime.now().toIso8601String().substring(0, 10);
return (_db.select(_db.documents)
..where((t) =>
t.businessId.equals(businessId) &
t.documentType.equals('invoice') &
t.isDeleted.equals(0) &
t.dueDate.isSmallerThanValue(now) &
t.status.isIn(['sent', 'partial']) &
t.amountDue.isBiggerThanValue(0)))
.get();
}
Future<void> markOverdueInvoices(String businessId) async {
final now = DateTime.now().toIso8601String().substring(0, 10);
await (_db.update(_db.documents)
..where((t) =>
t.businessId.equals(businessId) &
t.documentType.equals('invoice') &
t.dueDate.isSmallerThanValue(now) &
t.status.isIn(['sent', 'partial']) &
t.amountDue.isBiggerThanValue(0)))
.write(DocumentsCompanion(
status: const Value('overdue'),
updatedAt: Value(DateTime.now().toIso8601String()),
));
}
Future<int> getMonthlyInvoiceCount(String businessId) async {
final now = DateTime.now();
final monthStart = DateTime(now.year, now.month, 1).toIso8601String();
final monthEnd = DateTime(now.year, now.month + 1, 1).toIso8601String();
final count = _db.documents.id.count();
final query = _db.selectOnly(_db.documents)
..addColumns([count])
..where(
_db.documents.businessId.equals(businessId) &
_db.documents.documentType.equals('invoice') &
_db.documents.isDeleted.equals(0) &
_db.documents.createdAt.isBiggerOrEqualValue(monthStart) &
_db.documents.createdAt.isSmallerThanValue(monthEnd),
);
final result = await query.getSingle();
return result.read(count)!;
}
Future<void> softDeleteDocument(String id) async {
await (_db.update(_db.documents)..where((t) => t.id.equals(id))).write(
DocumentsCompanion(
isDeleted: const Value(1),
updatedAt: Value(DateTime.now().toIso8601String()),
),
);
}
/// Dashboard summary stats
Future<Map<String, int>> getDashboardStats(String businessId) async {
final docs = await getDocumentsByType(businessId, 'invoice');
int outstanding = 0, overdue = 0, paidThisMonth = 0, draftCount = 0;
final now = DateTime.now();
final monthStart = DateTime(now.year, now.month, 1);
for (final doc in docs) {
if (doc.status == 'draft') draftCount++;
if (['sent', 'partial', 'overdue'].contains(doc.status)) {
outstanding += doc.amountDue;
}
if (doc.status == 'overdue') overdue += doc.amountDue;
if (doc.status == 'paid') {
final updated = DateTime.parse(doc.updatedAt);
if (updated.isAfter(monthStart)) paidThisMonth += doc.total;
}
}
return {
'outstanding': outstanding,
'overdue': overdue,
'paidThisMonth': paidThisMonth,
'draftCount': draftCount,
};
}
}
- Step 8: Run document tests
flutter test test/core/database/daos/document_dao_test.dart -v
Expected: All 5 tests PASS.
- Step 9: Write failing tests for LineItemDao, implement, and verify
// test/core/database/daos/line_item_dao_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/document_dao.dart';
import 'package:swift_invoice/core/database/daos/line_item_dao.dart';
void main() {
late AppDatabase db;
late LineItemDao dao;
late String documentId;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
dao = LineItemDao(db);
final biz = await BusinessDao(db).createBusiness(name: 'Co');
final client = await ClientDao(db).createClient(
businessId: biz.id, name: 'C');
final doc = await DocumentDao(db).createDocument(
businessId: biz.id, clientId: client.id,
documentType: 'invoice', documentNumber: 'INV-001',
status: 'draft', issueDate: '2026-03-22',
);
documentId = doc.id;
});
tearDown(() => db.close());
test('addLineItem creates and retrieves line item', () async {
final item = await dao.addLineItem(
documentId: documentId,
description: 'Web Design',
quantity: 2.0,
unitPrice: 5000,
amount: 10000,
sortOrder: 0,
);
expect(item.description, 'Web Design');
expect(item.amount, 10000);
});
test('getLineItems returns items in sort order', () async {
await dao.addLineItem(
documentId: documentId, description: 'B',
quantity: 1, unitPrice: 100, amount: 100, sortOrder: 1,
);
await dao.addLineItem(
documentId: documentId, description: 'A',
quantity: 1, unitPrice: 200, amount: 200, sortOrder: 0,
);
final items = await dao.getLineItems(documentId);
expect(items.length, 2);
expect(items[0].description, 'A');
expect(items[1].description, 'B');
});
test('deleteLineItem removes item', () async {
final item = await dao.addLineItem(
documentId: documentId, description: 'X',
quantity: 1, unitPrice: 100, amount: 100, sortOrder: 0,
);
await dao.deleteLineItem(item.id);
final items = await dao.getLineItems(documentId);
expect(items, isEmpty);
});
test('deleteAllForDocument clears all line items', () async {
await dao.addLineItem(
documentId: documentId, description: 'A',
quantity: 1, unitPrice: 100, amount: 100, sortOrder: 0,
);
await dao.addLineItem(
documentId: documentId, description: 'B',
quantity: 1, unitPrice: 200, amount: 200, sortOrder: 1,
);
await dao.deleteAllForDocument(documentId);
expect(await dao.getLineItems(documentId), isEmpty);
});
}
- Step 10: Implement LineItemDao
// lib/core/database/daos/line_item_dao.dart
import 'package:drift/drift.dart';
import '../database.dart';
import '../../utils/uuid_generator.dart';
class LineItemDao {
final AppDatabase _db;
LineItemDao(this._db);
Future<LineItem> addLineItem({
required String documentId,
required String description,
required double quantity,
required int unitPrice,
required int amount,
required int sortOrder,
}) async {
final now = DateTime.now().toIso8601String();
final id = generateUuid();
await _db.into(_db.lineItems).insert(LineItemsCompanion.insert(
id: id,
documentId: documentId,
description: description,
quantity: Value(quantity),
unitPrice: unitPrice,
amount: amount,
sortOrder: Value(sortOrder),
createdAt: now,
updatedAt: now,
));
return (_db.select(_db.lineItems)..where((t) => t.id.equals(id)))
.getSingle();
}
Future<List<LineItem>> getLineItems(String documentId) async {
return (_db.select(_db.lineItems)
..where((t) => t.documentId.equals(documentId))
..orderBy([(t) => OrderingTerm.asc(t.sortOrder)]))
.get();
}
Future<void> updateLineItem(String id, {
String? description,
double? quantity,
int? unitPrice,
int? amount,
int? sortOrder,
}) async {
await (_db.update(_db.lineItems)..where((t) => t.id.equals(id))).write(
LineItemsCompanion(
description: description != null ? Value(description) : const Value.absent(),
quantity: quantity != null ? Value(quantity) : const Value.absent(),
unitPrice: unitPrice != null ? Value(unitPrice) : const Value.absent(),
amount: amount != null ? Value(amount) : const Value.absent(),
sortOrder: sortOrder != null ? Value(sortOrder) : const Value.absent(),
updatedAt: Value(DateTime.now().toIso8601String()),
),
);
}
Future<void> deleteLineItem(String id) async {
await (_db.delete(_db.lineItems)..where((t) => t.id.equals(id))).go();
}
Future<void> deleteAllForDocument(String documentId) async {
await (_db.delete(_db.lineItems)
..where((t) => t.documentId.equals(documentId)))
.go();
}
}
- Step 11: Run all DAO tests
flutter test test/core/database/daos/ -v
Expected: All tests PASS.
- Step 12: Commit
git add lib/core/database/daos/ test/core/database/daos/
git commit -m "feat: implement Client, Document, and LineItem DAOs with tests"
Task 6: Payment DAO & Document Calculator
Files:
-
Create:
lib/core/database/daos/payment_dao.dart -
Create:
lib/core/services/document_calculator.dart -
Create:
test/core/database/daos/payment_dao_test.dart -
Create:
test/core/services/document_calculator_test.dart -
Step 1: Write failing tests for PaymentDao
// test/core/database/daos/payment_dao_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/document_dao.dart';
import 'package:swift_invoice/core/database/daos/payment_dao.dart';
void main() {
late AppDatabase db;
late PaymentDao dao;
late String documentId;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
dao = PaymentDao(db);
final biz = await BusinessDao(db).createBusiness(name: 'Co');
final client = await ClientDao(db).createClient(
businessId: biz.id, name: 'C');
final doc = await DocumentDao(db).createDocument(
businessId: biz.id, clientId: client.id,
documentType: 'invoice', documentNumber: 'INV-001',
status: 'sent', issueDate: '2026-03-22',
total: 10000, amountDue: 10000,
);
documentId = doc.id;
});
tearDown(() => db.close());
test('recordPayment inserts and returns payment', () async {
final payment = await dao.recordPayment(
documentId: documentId,
amount: 5000,
method: 'cash',
paidAt: '2026-03-22T10:00:00',
);
expect(payment.amount, 5000);
expect(payment.method, 'cash');
});
test('getPaymentsForDocument returns all payments', () async {
await dao.recordPayment(
documentId: documentId, amount: 3000,
method: 'cash', paidAt: '2026-03-22T10:00:00',
);
await dao.recordPayment(
documentId: documentId, amount: 2000,
method: 'card', paidAt: '2026-03-22T11:00:00',
);
final payments = await dao.getPaymentsForDocument(documentId);
expect(payments.length, 2);
});
test('getTotalPaidForDocument sums payments', () async {
await dao.recordPayment(
documentId: documentId, amount: 3000,
method: 'cash', paidAt: '2026-03-22T10:00:00',
);
await dao.recordPayment(
documentId: documentId, amount: 2000,
method: 'card', paidAt: '2026-03-22T11:00:00',
);
expect(await dao.getTotalPaidForDocument(documentId), 5000);
});
test('deletePayment removes payment', () async {
final payment = await dao.recordPayment(
documentId: documentId, amount: 5000,
method: 'cash', paidAt: '2026-03-22T10:00:00',
);
await dao.deletePayment(payment.id);
expect(await dao.getPaymentsForDocument(documentId), isEmpty);
});
}
- Step 2: Run tests to verify they fail
flutter test test/core/database/daos/payment_dao_test.dart
Expected: FAIL.
- Step 3: Implement PaymentDao
// lib/core/database/daos/payment_dao.dart
import 'package:drift/drift.dart';
import '../database.dart';
import '../../utils/uuid_generator.dart';
class PaymentDao {
final AppDatabase _db;
PaymentDao(this._db);
Future<Payment> recordPayment({
required String documentId,
required int amount,
required String method,
required String paidAt,
String? notes,
}) async {
final now = DateTime.now().toIso8601String();
final id = generateUuid();
await _db.into(_db.payments).insert(PaymentsCompanion.insert(
id: id,
documentId: documentId,
amount: amount,
method: method,
paidAt: paidAt,
notes: Value(notes),
createdAt: now,
updatedAt: now,
));
return (_db.select(_db.payments)..where((t) => t.id.equals(id)))
.getSingle();
}
Future<List<Payment>> getPaymentsForDocument(String documentId) async {
return (_db.select(_db.payments)
..where((t) => t.documentId.equals(documentId))
..orderBy([(t) => OrderingTerm.desc(t.paidAt)]))
.get();
}
Future<int> getTotalPaidForDocument(String documentId) async {
final sum = _db.payments.amount.sum();
final query = _db.selectOnly(_db.payments)
..addColumns([sum])
..where(_db.payments.documentId.equals(documentId));
final result = await query.getSingle();
return result.read(sum) ?? 0;
}
Future<void> deletePayment(String id) async {
await (_db.delete(_db.payments)..where((t) => t.id.equals(id))).go();
}
}
- Step 4: Run payment tests
flutter test test/core/database/daos/payment_dao_test.dart -v
Expected: All 4 tests PASS.
- Step 5: Write failing tests for DocumentCalculator
// test/core/services/document_calculator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/services/document_calculator.dart';
void main() {
test('calculates subtotal from line items', () {
final items = [
LineItemInput(quantity: 2.0, unitPriceCents: 5000),
LineItemInput(quantity: 1.0, unitPriceCents: 3000),
];
final result = DocumentCalculator.calculate(
items: items,
taxRateBasisPoints: 0,
);
expect(result.subtotal, 13000); // (2*5000) + (1*3000)
});
test('calculates tax correctly from basis points', () {
final items = [
LineItemInput(quantity: 1.0, unitPriceCents: 10000),
];
final result = DocumentCalculator.calculate(
items: items,
taxRateBasisPoints: 825, // 8.25%
);
expect(result.subtotal, 10000);
expect(result.taxAmount, 825); // 10000 * 825 / 10000
expect(result.total, 10825);
});
test('calculates percentage discount', () {
final items = [
LineItemInput(quantity: 1.0, unitPriceCents: 10000),
];
final result = DocumentCalculator.calculate(
items: items,
taxRateBasisPoints: 0,
discountType: 'percentage',
discountValueBasisPoints: 1000, // 10%
);
expect(result.discountAmount, 1000);
expect(result.total, 9000);
});
test('calculates fixed discount', () {
final items = [
LineItemInput(quantity: 1.0, unitPriceCents: 10000),
];
final result = DocumentCalculator.calculate(
items: items,
taxRateBasisPoints: 0,
discountType: 'fixed',
discountValueBasisPoints: 2500, // $25.00
);
expect(result.discountAmount, 2500);
expect(result.total, 7500);
});
test('tax applied after discount', () {
final items = [
LineItemInput(quantity: 1.0, unitPriceCents: 10000),
];
final result = DocumentCalculator.calculate(
items: items,
taxRateBasisPoints: 1000, // 10%
discountType: 'fixed',
discountValueBasisPoints: 2000,
);
// subtotal=10000, discount=2000, after_discount=8000, tax=800
expect(result.subtotal, 10000);
expect(result.discountAmount, 2000);
expect(result.taxAmount, 800);
expect(result.total, 8800);
});
test('handles empty line items', () {
final result = DocumentCalculator.calculate(
items: [],
taxRateBasisPoints: 1000,
);
expect(result.subtotal, 0);
expect(result.total, 0);
});
test('calculates fractional quantities correctly', () {
final items = [
LineItemInput(quantity: 2.5, unitPriceCents: 4000),
];
final result = DocumentCalculator.calculate(
items: items,
taxRateBasisPoints: 0,
);
expect(result.subtotal, 10000); // 2.5 * 4000
});
}
- Step 6: Run tests to verify they fail
flutter test test/core/services/document_calculator_test.dart
Expected: FAIL.
- Step 7: Implement DocumentCalculator
// lib/core/services/document_calculator.dart
class LineItemInput {
final double quantity;
final int unitPriceCents;
const LineItemInput({required this.quantity, required this.unitPriceCents});
int get amount => (quantity * unitPriceCents).round();
}
class CalculationResult {
final int subtotal;
final int taxAmount;
final int discountAmount;
final int total;
const CalculationResult({
required this.subtotal,
required this.taxAmount,
required this.discountAmount,
required this.total,
});
}
class DocumentCalculator {
/// Calculate document totals.
///
/// [taxRateBasisPoints]: e.g. 825 = 8.25%
/// [discountType]: 'percentage' or 'fixed'
/// [discountValueBasisPoints]: for percentage, basis points (1000 = 10%);
/// for fixed, amount in cents
static CalculationResult calculate({
required List<LineItemInput> items,
required int taxRateBasisPoints,
String? discountType,
int discountValueBasisPoints = 0,
}) {
final subtotal = items.fold<int>(0, (sum, item) => sum + item.amount);
int discountAmount = 0;
if (discountType == 'percentage') {
discountAmount = (subtotal * discountValueBasisPoints) ~/ 10000;
} else if (discountType == 'fixed') {
discountAmount = discountValueBasisPoints;
}
final afterDiscount = subtotal - discountAmount;
final taxAmount = (afterDiscount * taxRateBasisPoints) ~/ 10000;
final total = afterDiscount + taxAmount;
return CalculationResult(
subtotal: subtotal,
taxAmount: taxAmount,
discountAmount: discountAmount,
total: total < 0 ? 0 : total,
);
}
}
- Step 8: Run all tests
flutter test test/core/ -v
Expected: All tests PASS.
- Step 9: Commit
git add lib/core/database/daos/payment_dao.dart lib/core/services/document_calculator.dart \
test/core/database/daos/payment_dao_test.dart test/core/services/document_calculator_test.dart
git commit -m "feat: implement PaymentDao and DocumentCalculator with full test coverage"
Task 7: Navigation Shell & Onboarding Screen
Files:
-
Create:
lib/core/utils/currency_formatter.dart -
Create:
lib/core/utils/date_formatter.dart -
Create:
lib/shared/widgets/app_scaffold.dart -
Create:
lib/features/onboarding/onboarding_screen.dart -
Create:
lib/features/onboarding/onboarding_notifier.dart -
Create:
lib/core/providers/business_provider.dart -
Create:
lib/core/providers/settings_provider.dart -
Modify:
lib/app.dart— add GoRouter with onboarding guard -
Create:
test/core/utils/currency_formatter_test.dart -
Create:
test/features/onboarding/onboarding_notifier_test.dart -
Step 1: Write failing tests for currency formatter
// test/core/utils/currency_formatter_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/utils/currency_formatter.dart';
void main() {
test('formats cents to dollar string', () {
expect(CurrencyFormatter.format(10050), '\$100.50');
expect(CurrencyFormatter.format(0), '\$0.00');
expect(CurrencyFormatter.format(99), '\$0.99');
expect(CurrencyFormatter.format(100000), '\$1,000.00');
});
test('formats basis points to percentage', () {
expect(CurrencyFormatter.formatTaxRate(825), '8.25%');
expect(CurrencyFormatter.formatTaxRate(1000), '10.00%');
expect(CurrencyFormatter.formatTaxRate(0), '0.00%');
});
test('parseCentsToDollars converts string to cents', () {
expect(CurrencyFormatter.parseToCents('100.50'), 10050);
expect(CurrencyFormatter.parseToCents('0.99'), 99);
expect(CurrencyFormatter.parseToCents('1000'), 100000);
});
test('parseTaxRate converts string to basis points', () {
expect(CurrencyFormatter.parseTaxRate('8.25'), 825);
expect(CurrencyFormatter.parseTaxRate('10'), 1000);
});
}
- Step 2: Run tests to verify they fail
flutter test test/core/utils/currency_formatter_test.dart
Expected: FAIL.
- Step 3: Implement CurrencyFormatter
// lib/core/utils/currency_formatter.dart
import 'package:intl/intl.dart';
class CurrencyFormatter {
static final _currencyFormat = NumberFormat.currency(symbol: '\$');
static String format(int cents, {String currencyCode = 'USD'}) {
return _currencyFormat.format(cents / 100);
}
static String formatTaxRate(int basisPoints) {
return '${(basisPoints / 100).toStringAsFixed(2)}%';
}
static int parseToCents(String dollars) {
final value = double.parse(dollars.replaceAll(',', ''));
return (value * 100).round();
}
static int parseTaxRate(String percentage) {
final value = double.parse(percentage);
return (value * 100).round();
}
}
- Step 4: Create DateFormatter utility
// lib/core/utils/date_formatter.dart
import 'package:intl/intl.dart';
class DateFormatter {
static final _displayFormat = DateFormat('MMM d, yyyy');
static final _isoFormat = DateFormat('yyyy-MM-dd');
static String toDisplay(String isoDate) {
return _displayFormat.format(DateTime.parse(isoDate));
}
static String toIso(DateTime date) {
return _isoFormat.format(date);
}
static DateTime addDays(DateTime date, int days) {
return date.add(Duration(days: days));
}
}
- Step 5: Run currency formatter tests
flutter test test/core/utils/currency_formatter_test.dart -v
Expected: All 4 tests PASS.
- Step 6: Create Riverpod providers for business and settings
// lib/core/providers/settings_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/daos/app_settings_dao.dart';
import 'database_provider.dart';
final appSettingsDaoProvider = Provider<AppSettingsDao>((ref) {
return AppSettingsDao(ref.read(databaseProvider));
});
final isOnboardingCompleteProvider = FutureProvider<bool>((ref) async {
return ref.read(appSettingsDaoProvider).isOnboardingComplete();
});
final subscriptionTierProvider = FutureProvider<String>((ref) async {
return ref.read(appSettingsDaoProvider).getSubscriptionTier();
});
// lib/core/providers/business_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/database.dart';
import '../database/daos/business_dao.dart';
import 'database_provider.dart';
final businessDaoProvider = Provider<BusinessDao>((ref) {
return BusinessDao(ref.read(databaseProvider));
});
final businessProvider = FutureProvider<Business?>((ref) async {
return ref.read(businessDaoProvider).getBusiness();
});
- Step 7: Create the navigation shell
// lib/shared/widgets/app_scaffold.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class AppScaffold extends StatelessWidget {
final Widget child;
const AppScaffold({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: _calculateSelectedIndex(context),
onDestinationSelected: (index) => _onItemTapped(index, context),
destinations: const [
NavigationDestination(icon: Icon(Icons.receipt_long), label: 'Invoices'),
NavigationDestination(icon: Icon(Icons.description), label: 'Estimates'),
NavigationDestination(icon: Icon(Icons.people), label: 'Clients'),
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
],
),
);
}
int _calculateSelectedIndex(BuildContext context) {
final location = GoRouterState.of(context).uri.toString();
if (location.startsWith('/estimates')) return 1;
if (location.startsWith('/clients')) return 2;
if (location.startsWith('/settings')) return 3;
return 0;
}
void _onItemTapped(int index, BuildContext context) {
switch (index) {
case 0: context.go('/invoices');
case 1: context.go('/estimates');
case 2: context.go('/clients');
case 3: context.go('/settings');
}
}
}
- Step 8: Create onboarding notifier and screen
// lib/features/onboarding/onboarding_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/database/daos/business_dao.dart';
import '../../core/database/daos/app_settings_dao.dart';
import '../../core/providers/database_provider.dart';
class OnboardingState {
final String name;
final String email;
final String phone;
final String defaultCurrency;
final String taxRateText;
final String? logoPath;
final bool isSaving;
final String? error;
const OnboardingState({
this.name = '',
this.email = '',
this.phone = '',
this.defaultCurrency = 'USD',
this.taxRateText = '',
this.logoPath,
this.isSaving = false,
this.error,
});
OnboardingState copyWith({
String? name,
String? email,
String? phone,
String? defaultCurrency,
String? taxRateText,
String? logoPath,
bool? isSaving,
String? error,
}) {
return OnboardingState(
name: name ?? this.name,
email: email ?? this.email,
phone: phone ?? this.phone,
defaultCurrency: defaultCurrency ?? this.defaultCurrency,
taxRateText: taxRateText ?? this.taxRateText,
logoPath: logoPath ?? this.logoPath,
isSaving: isSaving ?? this.isSaving,
error: error,
);
}
}
class OnboardingNotifier extends StateNotifier<OnboardingState> {
final BusinessDao _businessDao;
final AppSettingsDao _settingsDao;
OnboardingNotifier(this._businessDao, this._settingsDao)
: super(const OnboardingState());
void setName(String v) => state = state.copyWith(name: v);
void setEmail(String v) => state = state.copyWith(email: v);
void setPhone(String v) => state = state.copyWith(phone: v);
void setCurrency(String v) => state = state.copyWith(defaultCurrency: v);
void setTaxRate(String v) => state = state.copyWith(taxRateText: v);
void setLogoPath(String? v) => state = state.copyWith(logoPath: v);
Future<bool> save() async {
if (state.name.trim().isEmpty) {
state = state.copyWith(error: 'Business name is required');
return false;
}
state = state.copyWith(isSaving: true, error: null);
try {
int taxRate = 0;
if (state.taxRateText.isNotEmpty) {
taxRate = (double.parse(state.taxRateText) * 100).round();
}
await _businessDao.createBusiness(
name: state.name.trim(),
email: state.email.trim().isEmpty ? null : state.email.trim(),
phone: state.phone.trim().isEmpty ? null : state.phone.trim(),
defaultCurrency: state.defaultCurrency,
defaultTaxRate: taxRate,
logoPath: state.logoPath,
);
await _settingsDao.markOnboardingComplete();
state = state.copyWith(isSaving: false);
return true;
} catch (e) {
state = state.copyWith(isSaving: false, error: e.toString());
return false;
}
}
}
final onboardingNotifierProvider =
StateNotifierProvider<OnboardingNotifier, OnboardingState>((ref) {
final db = ref.read(databaseProvider);
return OnboardingNotifier(BusinessDao(db), AppSettingsDao(db));
});
// lib/features/onboarding/onboarding_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'onboarding_notifier.dart';
class OnboardingScreen extends ConsumerWidget {
const OnboardingScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(onboardingNotifierProvider);
final notifier = ref.read(onboardingNotifierProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('Set Up Your Business')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
decoration: const InputDecoration(
labelText: 'Business Name *',
hintText: 'e.g., Smith Plumbing LLC',
),
onChanged: notifier.setName,
),
const SizedBox(height: 16),
TextField(
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'contact@yourbusiness.com',
),
keyboardType: TextInputType.emailAddress,
onChanged: notifier.setEmail,
),
const SizedBox(height: 16),
TextField(
decoration: const InputDecoration(
labelText: 'Phone',
),
keyboardType: TextInputType.phone,
onChanged: notifier.setPhone,
),
const SizedBox(height: 16),
TextField(
decoration: const InputDecoration(
labelText: 'Default Tax Rate (%)',
hintText: '8.25',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: notifier.setTaxRate,
),
const SizedBox(height: 24),
if (state.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
state.error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
FilledButton(
onPressed: state.isSaving
? null
: () async {
final success = await notifier.save();
if (success && context.mounted) {
context.go('/invoices');
}
},
child: state.isSaving
? const CircularProgressIndicator()
: const Text('Save & Start'),
),
],
),
),
);
}
}
- Step 9: Wire up GoRouter in app.dart with onboarding guard
// lib/app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'core/theme/app_theme.dart';
import 'core/providers/settings_provider.dart';
import 'features/onboarding/onboarding_screen.dart';
import 'shared/widgets/app_scaffold.dart';
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: '/invoices',
redirect: (context, state) async {
final isComplete = await ref.read(isOnboardingCompleteProvider.future);
final isOnboarding = state.uri.toString() == '/onboarding';
if (!isComplete && !isOnboarding) return '/onboarding';
if (isComplete && isOnboarding) return '/invoices';
return null;
},
routes: [
GoRoute(
path: '/onboarding',
builder: (context, state) => const OnboardingScreen(),
),
ShellRoute(
builder: (context, state, child) => AppScaffold(child: child),
routes: [
GoRoute(
path: '/invoices',
builder: (context, state) =>
const Center(child: Text('Invoice Dashboard — Task 11')),
),
GoRoute(
path: '/estimates',
builder: (context, state) =>
const Center(child: Text('Estimates — Task 17')),
),
GoRoute(
path: '/clients',
builder: (context, state) =>
const Center(child: Text('Clients — Task 8')),
),
GoRoute(
path: '/settings',
builder: (context, state) =>
const Center(child: Text('Settings — Task 26')),
),
],
),
],
);
});
class SwiftInvoiceApp extends ConsumerWidget {
const SwiftInvoiceApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
return MaterialApp.router(
title: 'SwiftInvoice',
theme: AppTheme.light(),
routerConfig: router,
);
}
}
- Step 10: Write tests for OnboardingNotifier
// test/features/onboarding/onboarding_notifier_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';
import 'package:swift_invoice/features/onboarding/onboarding_notifier.dart';
void main() {
late AppDatabase db;
late OnboardingNotifier notifier;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
notifier = OnboardingNotifier(BusinessDao(db), AppSettingsDao(db));
});
tearDown(() => db.close());
test('save fails with empty name', () async {
final result = await notifier.save();
expect(result, isFalse);
expect(notifier.state.error, 'Business name is required');
});
test('save succeeds with valid name', () async {
notifier.setName('Test Business');
final result = await notifier.save();
expect(result, isTrue);
expect(notifier.state.error, isNull);
// Verify business was created
final biz = await BusinessDao(db).getBusiness();
expect(biz, isNotNull);
expect(biz!.name, 'Test Business');
// Verify onboarding marked complete
expect(await AppSettingsDao(db).isOnboardingComplete(), isTrue);
});
test('save parses tax rate correctly', () async {
notifier.setName('Co');
notifier.setTaxRate('8.25');
await notifier.save();
final biz = await BusinessDao(db).getBusiness();
expect(biz!.defaultTaxRate, 825);
});
test('save trims whitespace from name', () async {
notifier.setName(' My Company ');
await notifier.save();
final biz = await BusinessDao(db).getBusiness();
expect(biz!.name, 'My Company');
});
}
- Step 11: Run all tests
flutter test -v
Expected: All tests PASS.
- Step 12: Run the app and verify onboarding flow
flutter run -d chrome
Expected: App shows onboarding screen. After entering a name and tapping "Save & Start", navigates to invoice dashboard placeholder with bottom nav.
- Step 13: Commit
git add lib/ test/
git commit -m "feat: add navigation shell, onboarding screen, and Riverpod providers"
Phase 2: Core Invoicing (Weeks 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
// test/features/clients/client_notifier_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';
import 'package:swift_invoice/features/clients/client_notifier.dart';
void main() {
late AppDatabase db;
late ClientNotifier notifier;
late String businessId;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
final biz = await BusinessDao(db).createBusiness(name: 'Co');
businessId = biz.id;
notifier = ClientNotifier(ClientDao(db), AppSettingsDao(db), businessId);
});
tearDown(() => db.close());
test('loadClients returns empty list initially', () async {
await notifier.loadClients();
expect(notifier.state.clients, isEmpty);
});
test('addClient adds to list', () async {
await notifier.addClient(name: 'Jane', email: 'j@x.com');
expect(notifier.state.clients.length, 1);
expect(notifier.state.clients.first.name, 'Jane');
});
test('addClient enforces free tier limit of 2', () async {
await notifier.addClient(name: 'A');
await notifier.addClient(name: 'B');
final result = await notifier.addClient(name: 'C');
expect(result, isFalse);
expect(notifier.state.showPaywall, isTrue);
});
test('deleteClient soft-deletes and removes from list', () async {
await notifier.addClient(name: 'Jane');
final clientId = notifier.state.clients.first.id;
await notifier.deleteClient(clientId);
expect(notifier.state.clients, isEmpty);
});
test('searchClients filters by name', () async {
await notifier.addClient(name: 'Jane Doe');
await notifier.addClient(name: 'John Smith');
await notifier.searchClients('jan');
expect(notifier.state.clients.length, 1);
});
}
- Step 2: Run tests to verify they fail
flutter test test/features/clients/client_notifier_test.dart
Expected: FAIL.
- Step 3: Implement ClientNotifier
// lib/features/clients/client_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/database/database.dart';
import '../../core/database/daos/client_dao.dart';
import '../../core/database/daos/app_settings_dao.dart';
class ClientListState {
final List<Client> clients;
final bool isLoading;
final bool showPaywall;
final String? error;
const ClientListState({
this.clients = const [],
this.isLoading = false,
this.showPaywall = false,
this.error,
});
ClientListState copyWith({
List<Client>? clients,
bool? isLoading,
bool? showPaywall,
String? error,
}) {
return ClientListState(
clients: clients ?? this.clients,
isLoading: isLoading ?? this.isLoading,
showPaywall: showPaywall ?? this.showPaywall,
error: error,
);
}
}
class ClientNotifier extends StateNotifier<ClientListState> {
final ClientDao _clientDao;
final AppSettingsDao _settingsDao;
final String _businessId;
static const _freeClientLimit = 2;
ClientNotifier(this._clientDao, this._settingsDao, this._businessId)
: super(const ClientListState());
Future<void> loadClients() async {
state = state.copyWith(isLoading: true);
final clients = await _clientDao.getActiveClients(_businessId);
state = state.copyWith(clients: clients, isLoading: false);
}
Future<bool> addClient({
required String name,
String? email,
String? phone,
String? addressLine1,
String? addressLine2,
String? city,
String? clientState,
String? postalCode,
String? notes,
}) async {
// Check free tier limit
final tier = await _settingsDao.getSubscriptionTier();
if (tier == 'free') {
final count = await _clientDao.getActiveClientCount(_businessId);
if (count >= _freeClientLimit) {
state = state.copyWith(showPaywall: true);
return false;
}
}
await _clientDao.createClient(
businessId: _businessId,
name: name,
email: email,
phone: phone,
addressLine1: addressLine1,
addressLine2: addressLine2,
city: city,
state: clientState,
postalCode: postalCode,
notes: notes,
);
await loadClients();
return true;
}
Future<void> deleteClient(String clientId) async {
await _clientDao.softDeleteClient(clientId);
await loadClients();
}
Future<void> searchClients(String query) async {
if (query.isEmpty) {
await loadClients();
return;
}
final results = await _clientDao.searchClients(_businessId, query);
state = state.copyWith(clients: results);
}
void dismissPaywall() {
state = state.copyWith(showPaywall: false);
}
}
- Step 4: Run tests
flutter test test/features/clients/client_notifier_test.dart -v
Expected: All 5 tests PASS.
- Step 5: Create client list screen
// lib/features/clients/client_list_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/providers/business_provider.dart';
import '../../core/providers/client_provider.dart';
import 'client_notifier.dart';
final clientNotifierProvider =
StateNotifierProvider<ClientNotifier, ClientListState>((ref) {
final db = ref.read(databaseProvider);
final business = ref.read(businessProvider).valueOrNull;
return ClientNotifier(
ClientDao(db),
AppSettingsDao(db),
business?.id ?? '',
);
});
class ClientListScreen extends ConsumerStatefulWidget {
const ClientListScreen({super.key});
@override
ConsumerState<ClientListScreen> createState() => _ClientListScreenState();
}
class _ClientListScreenState extends ConsumerState<ClientListScreen> {
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
Future.microtask(() =>
ref.read(clientNotifierProvider.notifier).loadClients());
}
@override
Widget build(BuildContext context) {
final state = ref.watch(clientNotifierProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Clients'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
hintText: 'Search clients...',
prefixIcon: Icon(Icons.search),
isDense: true,
),
onChanged: (query) {
ref.read(clientNotifierProvider.notifier).searchClients(query);
},
),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/clients/new'),
child: const Icon(Icons.add),
),
body: state.isLoading
? const Center(child: CircularProgressIndicator())
: state.clients.isEmpty
? const Center(child: Text('No clients yet'))
: ListView.builder(
itemCount: state.clients.length,
itemBuilder: (context, index) {
final client = state.clients[index];
return ListTile(
title: Text(client.name),
subtitle: Text(client.email ?? ''),
trailing: Text(
CurrencyFormatter.format(client.outstandingBalance),
),
onTap: () => context.push('/clients/${client.id}'),
);
},
),
);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
}
- Step 6: Create Riverpod client provider
// lib/core/providers/client_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/daos/client_dao.dart';
import '../database/daos/app_settings_dao.dart';
import 'database_provider.dart';
final clientDaoProvider = Provider<ClientDao>((ref) {
return ClientDao(ref.read(databaseProvider));
});
- Step 7: Commit
git add lib/features/clients/ lib/core/providers/client_provider.dart \
test/features/clients/
git commit -m "feat: implement client list with free-tier limit enforcement"
Task 9: Client Form & Detail Screens
Files:
-
Create:
lib/features/clients/client_form_screen.dart -
Create:
lib/features/clients/client_detail_screen.dart -
Create:
lib/core/utils/validators.dart -
Create:
test/core/utils/validators_test.dart -
Step 1: Write failing tests for validators
// test/core/utils/validators_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/utils/validators.dart';
void main() {
test('validateRequired rejects empty strings', () {
expect(Validators.required(''), isNotNull);
expect(Validators.required(' '), isNotNull);
expect(Validators.required('abc'), isNull);
});
test('validateEmail accepts valid emails', () {
expect(Validators.email('a@b.com'), isNull);
expect(Validators.email('not-an-email'), isNotNull);
expect(Validators.email(''), isNull); // optional field
});
test('validatePositiveNumber rejects negatives', () {
expect(Validators.positiveNumber('10'), isNull);
expect(Validators.positiveNumber('0'), isNull);
expect(Validators.positiveNumber('-1'), isNotNull);
expect(Validators.positiveNumber('abc'), isNotNull);
});
}
- Step 2: Run tests to verify they fail
flutter test test/core/utils/validators_test.dart
Expected: FAIL.
- Step 3: Implement Validators
// lib/core/utils/validators.dart
class Validators {
static String? required(String? value) {
if (value == null || value.trim().isEmpty) {
return 'This field is required';
}
return null;
}
static String? email(String? value) {
if (value == null || value.isEmpty) return null; // optional
final regex = RegExp(r'^[\w\-.]+@([\w\-]+\.)+[\w\-]{2,}$');
if (!regex.hasMatch(value)) return 'Invalid email address';
return null;
}
static String? positiveNumber(String? value) {
if (value == null || value.isEmpty) return 'Enter a number';
final num = double.tryParse(value);
if (num == null) return 'Invalid number';
if (num < 0) return 'Must be positive';
return null;
}
}
- Step 4: Run validators tests
flutter test test/core/utils/validators_test.dart -v
Expected: All 3 tests PASS.
- Step 5: Create client form screen
// lib/features/clients/client_form_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/validators.dart';
import '../../core/database/daos/client_dao.dart';
import '../../core/providers/database_provider.dart';
import 'client_list_screen.dart';
class ClientFormScreen extends ConsumerStatefulWidget {
final String? clientId; // null = create, non-null = edit
const ClientFormScreen({super.key, this.clientId});
@override
ConsumerState<ClientFormScreen> createState() => _ClientFormScreenState();
}
class _ClientFormScreenState extends ConsumerState<ClientFormScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _phoneController = TextEditingController();
final _addressLine1Controller = TextEditingController();
final _cityController = TextEditingController();
final _stateController = TextEditingController();
final _postalCodeController = TextEditingController();
final _notesController = TextEditingController();
bool get isEditing => widget.clientId != null;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(isEditing ? 'Edit Client' : 'New Client'),
),
body: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name *'),
validator: Validators.required,
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
validator: Validators.email,
),
const SizedBox(height: 16),
TextFormField(
controller: _phoneController,
decoration: const InputDecoration(labelText: 'Phone'),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 16),
TextFormField(
controller: _addressLine1Controller,
decoration: const InputDecoration(labelText: 'Address'),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _cityController,
decoration: const InputDecoration(labelText: 'City'),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _stateController,
decoration: const InputDecoration(labelText: 'State'),
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _postalCodeController,
decoration: const InputDecoration(labelText: 'Zip Code'),
),
const SizedBox(height: 16),
TextFormField(
controller: _notesController,
decoration: const InputDecoration(labelText: 'Notes'),
maxLines: 3,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _save,
child: Text(isEditing ? 'Update' : 'Save Client'),
),
),
],
),
),
),
);
}
Future<void> _save() async {
if (_formKey.currentState?.validate() ?? false) {
final notifier = ref.read(clientNotifierProvider.notifier);
if (isEditing) {
await ClientDao(ref.read(databaseProvider)).updateClient(
widget.clientId!,
name: _nameController.text.trim(),
email: _emailController.text.trim(),
phone: _phoneController.text.trim(),
addressLine1: _addressLine1Controller.text.trim(),
city: _cityController.text.trim(),
state: _stateController.text.trim(),
postalCode: _postalCodeController.text.trim(),
notes: _notesController.text.trim(),
);
} else {
final success = await notifier.addClient(
name: _nameController.text.trim(),
email: _emailController.text.trim(),
phone: _phoneController.text.trim(),
addressLine1: _addressLine1Controller.text.trim(),
city: _cityController.text.trim(),
clientState: _stateController.text.trim(),
postalCode: _postalCodeController.text.trim(),
notes: _notesController.text.trim(),
);
if (!success && context.mounted) {
context.push('/paywall');
return;
}
}
if (context.mounted) context.pop();
}
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_phoneController.dispose();
_addressLine1Controller.dispose();
_cityController.dispose();
_stateController.dispose();
_postalCodeController.dispose();
_notesController.dispose();
super.dispose();
}
}
- Step 6: Create client detail screen
// lib/features/clients/client_detail_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/utils/date_formatter.dart';
import '../../core/database/database.dart';
import '../../core/database/daos/client_dao.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/providers/database_provider.dart';
import '../invoices/widgets/status_pill.dart';
import 'client_list_screen.dart';
final clientDetailProvider =
FutureProvider.family<Client?, String>((ref, clientId) {
return ClientDao(ref.read(databaseProvider)).getClient(clientId);
});
final clientDocumentsProvider =
FutureProvider.family<List<Document>, String>((ref, clientId) {
return DocumentDao(ref.read(databaseProvider)).getDocumentsForClient(clientId);
});
class ClientDetailScreen extends ConsumerWidget {
final String clientId;
const ClientDetailScreen({super.key, required this.clientId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final clientAsync = ref.watch(clientDetailProvider(clientId));
final docsAsync = ref.watch(clientDocumentsProvider(clientId));
return Scaffold(
appBar: AppBar(
title: const Text('Client Details'),
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => context.push('/clients/$clientId/edit'),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Client?'),
content: const Text('This client will be archived. Their invoices will remain.'),
actions: [
TextButton(onPressed: () => ctx.pop(false), child: const Text('Cancel')),
TextButton(onPressed: () => ctx.pop(true), child: const Text('Delete')),
],
),
);
if (confirm == true && context.mounted) {
await ref.read(clientNotifierProvider.notifier).deleteClient(clientId);
if (context.mounted) context.pop();
}
},
),
],
),
body: clientAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (client) {
if (client == null) return const Center(child: Text('Client not found'));
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(client.name, style: Theme.of(context).textTheme.headlineSmall),
if (client.email != null) ...[
const SizedBox(height: 4),
Text(client.email!, style: Theme.of(context).textTheme.bodyMedium),
],
if (client.phone != null) ...[
const SizedBox(height: 4),
Text(client.phone!),
],
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Column(children: [
const Text('Outstanding'),
Text(CurrencyFormatter.format(client.outstandingBalance),
style: const TextStyle(fontWeight: FontWeight.bold)),
]),
],
),
),
),
const SizedBox(height: 16),
Text('Documents', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
docsAsync.when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
data: (docs) => docs.isEmpty
? const Text('No documents yet')
: Column(
children: docs.map((doc) => ListTile(
title: Text(doc.documentNumber),
subtitle: Text(DateFormatter.toDisplay(doc.issueDate)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
StatusPill(status: doc.status),
const SizedBox(width: 8),
Text(CurrencyFormatter.format(doc.total)),
],
),
onTap: () => context.push('/invoices/${doc.id}'),
)).toList(),
),
),
],
);
},
),
);
}
}
- Step 7: Commit
git add lib/features/clients/ lib/core/utils/validators.dart test/core/utils/
git commit -m "feat: add client form, detail screen, and input validators"
Task 10: Invoice Creator — Notifier & Calculator Integration
Files:
-
Create:
lib/features/invoices/invoice_creator_notifier.dart -
Create:
test/features/invoices/invoice_creator_notifier_test.dart -
Step 1: Write failing tests for InvoiceCreatorNotifier
// test/features/invoices/invoice_creator_notifier_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/document_dao.dart';
import 'package:swift_invoice/core/database/daos/line_item_dao.dart';
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';
import 'package:swift_invoice/features/invoices/invoice_creator_notifier.dart';
void main() {
late AppDatabase db;
late InvoiceCreatorNotifier notifier;
late String businessId;
late String clientId;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
final biz = await BusinessDao(db).createBusiness(name: 'Co');
businessId = biz.id;
final client = await ClientDao(db).createClient(
businessId: businessId, name: 'Jane');
clientId = client.id;
notifier = InvoiceCreatorNotifier(
businessDao: BusinessDao(db),
documentDao: DocumentDao(db),
lineItemDao: LineItemDao(db),
settingsDao: AppSettingsDao(db),
businessId: businessId,
);
await notifier.initialize();
});
tearDown(() => db.close());
test('initialize sets auto-generated invoice number', () {
expect(notifier.state.documentNumber, 'INV-1');
});
test('addLineItem recalculates totals', () {
notifier.setClientId(clientId);
notifier.addLineItem(description: 'Work', quantity: 2.0, unitPrice: 5000);
expect(notifier.state.subtotal, 10000);
expect(notifier.state.total, 10000);
expect(notifier.state.lineItems.length, 1);
});
test('tax rate affects total', () {
notifier.setClientId(clientId);
notifier.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 10000);
notifier.setTaxRate(1000); // 10%
expect(notifier.state.taxAmount, 1000);
expect(notifier.state.total, 11000);
});
test('save fails without client', () async {
notifier.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 5000);
final result = await notifier.save();
expect(result, isNull);
expect(notifier.state.error, contains('client'));
});
test('save fails without line items', () async {
notifier.setClientId(clientId);
final result = await notifier.save();
expect(result, isNull);
expect(notifier.state.error, contains('line item'));
});
test('save creates document and line items', () async {
notifier.setClientId(clientId);
notifier.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 5000);
final docId = await notifier.save();
expect(docId, isNotNull);
final doc = await DocumentDao(db).getDocument(docId!);
expect(doc!.documentNumber, 'INV-1');
expect(doc.total, 5000);
final items = await LineItemDao(db).getLineItems(docId);
expect(items.length, 1);
});
test('free tier blocks 4th invoice in same month', () async {
// Create 3 invoices
for (var i = 0; i < 3; i++) {
final n = InvoiceCreatorNotifier(
businessDao: BusinessDao(db),
documentDao: DocumentDao(db),
lineItemDao: LineItemDao(db),
settingsDao: AppSettingsDao(db),
businessId: businessId,
);
await n.initialize();
n.setClientId(clientId);
n.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 1000);
await n.save();
}
// 4th should be blocked
final n4 = InvoiceCreatorNotifier(
businessDao: BusinessDao(db),
documentDao: DocumentDao(db),
lineItemDao: LineItemDao(db),
settingsDao: AppSettingsDao(db),
businessId: businessId,
);
await n4.initialize();
expect(n4.state.showPaywall, isTrue);
});
}
- Step 2: Run tests to verify they fail
flutter test test/features/invoices/invoice_creator_notifier_test.dart
Expected: FAIL.
- Step 3: Implement InvoiceCreatorNotifier
// lib/features/invoices/invoice_creator_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/database/daos/business_dao.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/database/daos/line_item_dao.dart';
import '../../core/database/daos/app_settings_dao.dart';
import '../../core/services/document_calculator.dart';
import '../../core/utils/date_formatter.dart';
class LineItemFormData {
final String description;
final double quantity;
final int unitPriceCents;
int get amount => (quantity * unitPriceCents).round();
const LineItemFormData({
required this.description,
required this.quantity,
required this.unitPriceCents,
});
}
class InvoiceCreatorState {
final String? clientId;
final String documentNumber;
final String issueDate;
final String dueDate;
final List<LineItemFormData> lineItems;
final int taxRateBasisPoints;
final String? discountType;
final int discountValue;
final String? notes;
final int subtotal;
final int taxAmount;
final int discountAmount;
final int total;
final bool isSaving;
final bool showPaywall;
final String? error;
const InvoiceCreatorState({
this.clientId,
this.documentNumber = '',
this.issueDate = '',
this.dueDate = '',
this.lineItems = const [],
this.taxRateBasisPoints = 0,
this.discountType,
this.discountValue = 0,
this.notes,
this.subtotal = 0,
this.taxAmount = 0,
this.discountAmount = 0,
this.total = 0,
this.isSaving = false,
this.showPaywall = false,
this.error,
});
InvoiceCreatorState copyWith({
String? clientId,
String? documentNumber,
String? issueDate,
String? dueDate,
List<LineItemFormData>? lineItems,
int? taxRateBasisPoints,
String? discountType,
int? discountValue,
String? notes,
int? subtotal,
int? taxAmount,
int? discountAmount,
int? total,
bool? isSaving,
bool? showPaywall,
String? error,
}) {
return InvoiceCreatorState(
clientId: clientId ?? this.clientId,
documentNumber: documentNumber ?? this.documentNumber,
issueDate: issueDate ?? this.issueDate,
dueDate: dueDate ?? this.dueDate,
lineItems: lineItems ?? this.lineItems,
taxRateBasisPoints: taxRateBasisPoints ?? this.taxRateBasisPoints,
discountType: discountType ?? this.discountType,
discountValue: discountValue ?? this.discountValue,
notes: notes ?? this.notes,
subtotal: subtotal ?? this.subtotal,
taxAmount: taxAmount ?? this.taxAmount,
discountAmount: discountAmount ?? this.discountAmount,
total: total ?? this.total,
isSaving: isSaving ?? this.isSaving,
showPaywall: showPaywall ?? this.showPaywall,
error: error,
);
}
}
class InvoiceCreatorNotifier extends StateNotifier<InvoiceCreatorState> {
final BusinessDao _businessDao;
final DocumentDao _documentDao;
final LineItemDao _lineItemDao;
final AppSettingsDao _settingsDao;
final String _businessId;
static const _freeInvoiceLimit = 3;
InvoiceCreatorNotifier({
required BusinessDao businessDao,
required DocumentDao documentDao,
required LineItemDao lineItemDao,
required AppSettingsDao settingsDao,
required String businessId,
}) : _businessDao = businessDao,
_documentDao = documentDao,
_lineItemDao = lineItemDao,
_settingsDao = settingsDao,
_businessId = businessId,
super(const InvoiceCreatorState());
Future<void> initialize() async {
final biz = await _businessDao.getBusiness();
if (biz == null) return;
final now = DateTime.now();
final dueDate = DateFormatter.addDays(now, biz.defaultPaymentTermsDays);
final docNum = '${biz.invoicePrefix}-${biz.nextInvoiceNumber}';
// Check free tier limit
final tier = await _settingsDao.getSubscriptionTier();
bool showPaywall = false;
if (tier == 'free') {
final count = await _documentDao.getMonthlyInvoiceCount(_businessId);
if (count >= _freeInvoiceLimit) {
showPaywall = true;
}
}
state = state.copyWith(
documentNumber: docNum,
issueDate: DateFormatter.toIso(now),
dueDate: DateFormatter.toIso(dueDate),
taxRateBasisPoints: biz.defaultTaxRate,
showPaywall: showPaywall,
);
}
void setClientId(String id) => state = state.copyWith(clientId: id);
void setDocumentNumber(String v) => state = state.copyWith(documentNumber: v);
void setIssueDate(String v) => state = state.copyWith(issueDate: v);
void setDueDate(String v) => state = state.copyWith(dueDate: v);
void setNotes(String v) => state = state.copyWith(notes: v);
void setTaxRate(int basisPoints) {
state = state.copyWith(taxRateBasisPoints: basisPoints);
_recalculate();
}
void setDiscount({String? type, int value = 0}) {
state = state.copyWith(discountType: type, discountValue: value);
_recalculate();
}
void addLineItem({
required String description,
required double quantity,
required int unitPrice,
}) {
final items = [
...state.lineItems,
LineItemFormData(
description: description,
quantity: quantity,
unitPriceCents: unitPrice,
),
];
state = state.copyWith(lineItems: items);
_recalculate();
}
void removeLineItem(int index) {
final items = [...state.lineItems]..removeAt(index);
state = state.copyWith(lineItems: items);
_recalculate();
}
void updateLineItem(int index, {
String? description,
double? quantity,
int? unitPrice,
}) {
final items = [...state.lineItems];
final old = items[index];
items[index] = LineItemFormData(
description: description ?? old.description,
quantity: quantity ?? old.quantity,
unitPriceCents: unitPrice ?? old.unitPriceCents,
);
state = state.copyWith(lineItems: items);
_recalculate();
}
void _recalculate() {
final inputs = state.lineItems
.map((li) => LineItemInput(
quantity: li.quantity,
unitPriceCents: li.unitPriceCents,
))
.toList();
final result = DocumentCalculator.calculate(
items: inputs,
taxRateBasisPoints: state.taxRateBasisPoints,
discountType: state.discountType,
discountValueBasisPoints: state.discountValue,
);
state = state.copyWith(
subtotal: result.subtotal,
taxAmount: result.taxAmount,
discountAmount: result.discountAmount,
total: result.total,
);
}
/// Returns the document ID on success, null on failure.
Future<String?> save({bool markAsSent = false}) async {
if (state.clientId == null) {
state = state.copyWith(error: 'Please select a client');
return null;
}
if (state.lineItems.isEmpty) {
state = state.copyWith(error: 'Add at least one line item');
return null;
}
state = state.copyWith(isSaving: true, error: null);
try {
final status = markAsSent ? 'sent' : 'draft';
final doc = await _documentDao.createDocument(
businessId: _businessId,
clientId: state.clientId!,
documentType: 'invoice',
documentNumber: state.documentNumber,
status: status,
issueDate: state.issueDate,
dueDate: state.dueDate,
subtotal: state.subtotal,
taxRate: state.taxRateBasisPoints,
taxAmount: state.taxAmount,
discountType: state.discountType,
discountValue: state.discountValue,
discountAmount: state.discountAmount,
total: state.total,
amountDue: state.total,
notes: state.notes,
);
for (var i = 0; i < state.lineItems.length; i++) {
final li = state.lineItems[i];
await _lineItemDao.addLineItem(
documentId: doc.id,
description: li.description,
quantity: li.quantity,
unitPrice: li.unitPriceCents,
amount: li.amount,
sortOrder: i,
);
}
// Increment the invoice number
await _businessDao.getAndIncrementInvoiceNumber(_businessId);
state = state.copyWith(isSaving: false);
return doc.id;
} catch (e) {
state = state.copyWith(isSaving: false, error: e.toString());
return null;
}
}
}
- Step 4: Run tests
flutter test test/features/invoices/invoice_creator_notifier_test.dart -v
Expected: All 7 tests PASS.
- Step 5: Commit
git add lib/features/invoices/invoice_creator_notifier.dart \
test/features/invoices/invoice_creator_notifier_test.dart
git commit -m "feat: implement InvoiceCreatorNotifier with calculator integration and tier gating"
Task 11: Invoice Creator Screen (UI)
Files:
-
Create:
lib/features/invoices/invoice_creator_screen.dart -
Create:
lib/features/invoices/widgets/line_item_row.dart -
Create:
lib/shared/widgets/search_dropdown.dart -
Step 1: Create line item row widget
// lib/features/invoices/widgets/line_item_row.dart
import 'package:flutter/material.dart';
import '../../../core/utils/currency_formatter.dart';
class LineItemRow extends StatelessWidget {
final TextEditingController descriptionController;
final TextEditingController quantityController;
final TextEditingController rateController;
final int lineTotal;
final VoidCallback onDelete;
final VoidCallback onChanged;
const LineItemRow({
super.key,
required this.descriptionController,
required this.quantityController,
required this.rateController,
required this.lineTotal,
required this.onDelete,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Dismissible(
key: UniqueKey(),
direction: DismissDirection.endToStart,
onDismissed: (_) => onDelete(),
background: Container(
color: Theme.of(context).colorScheme.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.delete, color: Colors.white),
),
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
TextField(
controller: descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
isDense: true,
),
onChanged: (_) => onChanged(),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: quantityController,
decoration: const InputDecoration(
labelText: 'Qty',
isDense: true,
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: rateController,
decoration: const InputDecoration(
labelText: 'Rate',
isDense: true,
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 8),
SizedBox(
width: 80,
child: Text(
CurrencyFormatter.format(lineTotal),
textAlign: TextAlign.right,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
],
),
),
),
);
}
}
- Step 2: Create invoice creator screen
// lib/features/invoices/invoice_creator_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/database/daos/business_dao.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/database/daos/line_item_dao.dart';
import '../../core/database/daos/app_settings_dao.dart';
import '../../core/database/daos/client_dao.dart';
import '../../core/database/database.dart';
import '../../core/providers/database_provider.dart';
import '../../core/providers/business_provider.dart';
import 'invoice_creator_notifier.dart';
import 'widgets/line_item_row.dart';
final invoiceCreatorProvider =
StateNotifierProvider.autoDispose<InvoiceCreatorNotifier, InvoiceCreatorState>(
(ref) {
final db = ref.read(databaseProvider);
final business = ref.read(businessProvider).valueOrNull;
return InvoiceCreatorNotifier(
businessDao: BusinessDao(db),
documentDao: DocumentDao(db),
lineItemDao: LineItemDao(db),
settingsDao: AppSettingsDao(db),
businessId: business?.id ?? '',
);
});
final activeClientsProvider = FutureProvider<List<Client>>((ref) {
final db = ref.read(databaseProvider);
final business = ref.read(businessProvider).valueOrNull;
if (business == null) return [];
return ClientDao(db).getActiveClients(business.id);
});
class InvoiceCreatorScreen extends ConsumerStatefulWidget {
const InvoiceCreatorScreen({super.key});
@override
ConsumerState<InvoiceCreatorScreen> createState() =>
_InvoiceCreatorScreenState();
}
class _InvoiceCreatorScreenState extends ConsumerState<InvoiceCreatorScreen> {
final _descController = TextEditingController();
final _qtyController = TextEditingController(text: '1');
final _rateController = TextEditingController();
final _notesController = TextEditingController();
@override
void initState() {
super.initState();
Future.microtask(() =>
ref.read(invoiceCreatorProvider.notifier).initialize());
}
@override
Widget build(BuildContext context) {
final state = ref.watch(invoiceCreatorProvider);
final notifier = ref.read(invoiceCreatorProvider.notifier);
final clientsAsync = ref.watch(activeClientsProvider);
if (state.showPaywall) {
Future.microtask(() => context.push('/paywall'));
}
return Scaffold(
appBar: AppBar(title: const Text('New Invoice')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Client selector
clientsAsync.when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
data: (clients) => DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: 'Client *'),
value: state.clientId,
items: clients
.map((c) => DropdownMenuItem(value: c.id, child: Text(c.name)))
.toList(),
onChanged: (id) {
if (id != null) notifier.setClientId(id);
},
),
),
const SizedBox(height: 16),
// Invoice number
Text('Invoice #: ${state.documentNumber}',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
// Line items section
const Text('Line Items', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
...state.lineItems.asMap().entries.map((entry) {
final i = entry.key;
final li = entry.value;
return Card(
child: ListTile(
title: Text(li.description),
subtitle: Text('${li.quantity} x ${CurrencyFormatter.format(li.unitPriceCents)}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(CurrencyFormatter.format(li.amount)),
IconButton(
icon: const Icon(Icons.delete, size: 20),
onPressed: () => notifier.removeLineItem(i),
),
],
),
),
);
}),
// Add line item inline form
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
TextField(
controller: _descController,
decoration: const InputDecoration(labelText: 'Description', isDense: true),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _qtyController,
decoration: const InputDecoration(labelText: 'Qty', isDense: true),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _rateController,
decoration: const InputDecoration(labelText: 'Rate (\$)', isDense: true),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
],
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () {
final desc = _descController.text.trim();
final qty = double.tryParse(_qtyController.text) ?? 1.0;
final rate = CurrencyFormatter.parseToCents(_rateController.text);
if (desc.isNotEmpty && rate > 0) {
notifier.addLineItem(description: desc, quantity: qty, unitPrice: rate);
_descController.clear();
_qtyController.text = '1';
_rateController.clear();
}
},
icon: const Icon(Icons.add),
label: const Text('Add Item'),
),
],
),
),
),
const Divider(),
// Totals section
TotalRow(label: 'Subtotal', value: state.subtotal),
TotalRow(label: 'Tax', value: state.taxAmount),
if (state.discountAmount > 0)
TotalRow(label: 'Discount', value: -state.discountAmount),
const Divider(),
TotalRow(label: 'Total', value: state.total, bold: true),
const SizedBox(height: 16),
TextField(
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Notes / Terms',
hintText: 'Payment terms, thank you message...',
),
maxLines: 3,
onChanged: notifier.setNotes,
),
if (state.error != null) ...[
const SizedBox(height: 8),
Text(state.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: state.isSaving
? null
: () async {
final docId = await notifier.save();
if (docId != null && context.mounted) context.pop();
},
child: const Text('Save as Draft'),
),
),
const SizedBox(width: 16),
Expanded(
child: FilledButton(
onPressed: state.isSaving
? null
: () async {
final docId = await notifier.save(markAsSent: true);
if (docId != null && context.mounted) {
context.push('/invoices/$docId');
}
},
child: const Text('Save & Share'),
),
),
],
),
],
),
),
);
}
@override
void dispose() {
_descController.dispose();
_qtyController.dispose();
_rateController.dispose();
_notesController.dispose();
super.dispose();
}
}
class TotalRow extends StatelessWidget {
final String label;
final int value;
final bool bold;
const TotalRow({required this.label, required this.value, this.bold = false});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: bold ? const TextStyle(fontWeight: FontWeight.bold) : null),
Text(CurrencyFormatter.format(value),
style: bold ? const TextStyle(fontWeight: FontWeight.bold) : null),
],
),
);
}
}
- Step 3: Add route for invoice creator in app.dart
Add inside the ShellRoute's routes list:
GoRoute(
path: '/invoices/new',
builder: (context, state) => const InvoiceCreatorScreen(),
),
- Step 4: Verify the screen renders
flutter run -d chrome
Navigate to /invoices/new — screen should render without errors.
- Step 5: Commit
git add lib/features/invoices/ lib/shared/widgets/search_dropdown.dart
git commit -m "feat: add invoice creator screen with line item UI components"
Task 12: Status Pill & Summary Card Widgets
Files:
-
Create:
lib/features/invoices/widgets/status_pill.dart -
Create:
lib/features/invoices/widgets/summary_card.dart -
Step 1: Create StatusPill widget
// lib/features/invoices/widgets/status_pill.dart
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
class StatusPill extends StatelessWidget {
final String status;
const StatusPill({super.key, required this.status});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _color.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_label,
style: TextStyle(
color: _color,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
String get _label => status[0].toUpperCase() + status.substring(1);
Color get _color => switch (status) {
'draft' => AppColors.statusDraft,
'sent' => AppColors.statusSent,
'paid' => AppColors.statusPaid,
'overdue' => AppColors.statusOverdue,
'partial' => AppColors.statusPartial,
'void' => AppColors.statusVoid,
'accepted' => AppColors.statusPaid,
'declined' => AppColors.statusOverdue,
'expired' => AppColors.statusDraft,
'converted' => AppColors.statusSent,
_ => AppColors.statusDraft,
};
}
- Step 2: Create SummaryCard widget
// lib/features/invoices/widgets/summary_card.dart
import 'package:flutter/material.dart';
class SummaryCard extends StatelessWidget {
final String label;
final String value;
final Color? valueColor;
final IconData? icon;
const SummaryCard({
super.key,
required this.label,
required this.value,
this.valueColor,
this.icon,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (icon != null) ...[
Icon(icon, size: 16, color: Theme.of(context).colorScheme.outline),
const SizedBox(width: 4),
],
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: valueColor,
),
),
],
),
),
);
}
}
- Step 3: Write widget tests for StatusPill and SummaryCard
// test/features/invoices/widgets/status_pill_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/features/invoices/widgets/status_pill.dart';
void main() {
testWidgets('StatusPill displays capitalized label', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: StatusPill(status: 'draft'))),
);
expect(find.text('Draft'), findsOneWidget);
});
testWidgets('StatusPill displays Paid label', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: StatusPill(status: 'paid'))),
);
expect(find.text('Paid'), findsOneWidget);
});
testWidgets('StatusPill displays Overdue label', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: StatusPill(status: 'overdue'))),
);
expect(find.text('Overdue'), findsOneWidget);
});
}
// test/features/invoices/widgets/summary_card_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/features/invoices/widgets/summary_card.dart';
void main() {
testWidgets('SummaryCard displays label and value', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: SummaryCard(label: 'Outstanding', value: '\$500.00'),
),
),
);
expect(find.text('Outstanding'), findsOneWidget);
expect(find.text('\$500.00'), findsOneWidget);
});
}
- Step 4: Run widget tests
flutter test test/features/invoices/widgets/ -v
Expected: All 4 tests PASS.
- Step 5: Commit
git add lib/features/invoices/widgets/ test/features/invoices/widgets/
git commit -m "feat: add StatusPill and SummaryCard reusable widgets with tests"
Task 13: Invoice Dashboard Screen
Files:
-
Create:
lib/features/invoices/invoice_dashboard_notifier.dart -
Create:
lib/features/invoices/invoice_dashboard_screen.dart -
Create:
test/features/invoices/invoice_dashboard_notifier_test.dart -
Step 1: Write failing tests for InvoiceDashboardNotifier
// test/features/invoices/invoice_dashboard_notifier_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/document_dao.dart';
import 'package:swift_invoice/features/invoices/invoice_dashboard_notifier.dart';
void main() {
late AppDatabase db;
late InvoiceDashboardNotifier notifier;
late String businessId;
late String clientId;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
final biz = await BusinessDao(db).createBusiness(name: 'Co');
businessId = biz.id;
final client = await ClientDao(db).createClient(
businessId: businessId, name: 'C');
clientId = client.id;
notifier = InvoiceDashboardNotifier(DocumentDao(db), businessId);
});
tearDown(() => db.close());
test('loadDashboard populates invoices', () async {
await DocumentDao(db).createDocument(
businessId: businessId, clientId: clientId,
documentType: 'invoice', documentNumber: 'INV-001',
status: 'draft', issueDate: '2026-03-22',
total: 5000, amountDue: 5000,
);
await notifier.loadDashboard();
expect(notifier.state.invoices.length, 1);
});
test('loadDashboard calculates stats', () async {
await DocumentDao(db).createDocument(
businessId: businessId, clientId: clientId,
documentType: 'invoice', documentNumber: 'INV-001',
status: 'sent', issueDate: '2026-03-22',
total: 10000, amountDue: 10000,
);
await DocumentDao(db).createDocument(
businessId: businessId, clientId: clientId,
documentType: 'invoice', documentNumber: 'INV-002',
status: 'draft', issueDate: '2026-03-22',
total: 5000, amountDue: 5000,
);
await notifier.loadDashboard();
expect(notifier.state.stats['outstanding'], 10000);
expect(notifier.state.stats['draftCount'], 1);
});
test('filterByStatus filters invoices', () async {
await DocumentDao(db).createDocument(
businessId: businessId, clientId: clientId,
documentType: 'invoice', documentNumber: 'INV-001',
status: 'draft', issueDate: '2026-03-22',
);
await DocumentDao(db).createDocument(
businessId: businessId, clientId: clientId,
documentType: 'invoice', documentNumber: 'INV-002',
status: 'sent', issueDate: '2026-03-22',
);
await notifier.loadDashboard();
notifier.filterByStatus('draft');
expect(notifier.state.filteredInvoices.length, 1);
expect(notifier.state.filteredInvoices.first.status, 'draft');
});
}
- Step 2: Run tests to verify they fail
flutter test test/features/invoices/invoice_dashboard_notifier_test.dart
Expected: FAIL.
- Step 3: Implement InvoiceDashboardNotifier
// lib/features/invoices/invoice_dashboard_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/database/database.dart';
import '../../core/database/daos/document_dao.dart';
class DashboardState {
final List<Document> invoices;
final List<Document> filteredInvoices;
final Map<String, int> stats;
final String? activeFilter;
final bool isLoading;
const DashboardState({
this.invoices = const [],
this.filteredInvoices = const [],
this.stats = const {},
this.activeFilter,
this.isLoading = false,
});
DashboardState copyWith({
List<Document>? invoices,
List<Document>? filteredInvoices,
Map<String, int>? stats,
String? activeFilter,
bool? isLoading,
}) {
return DashboardState(
invoices: invoices ?? this.invoices,
filteredInvoices: filteredInvoices ?? this.filteredInvoices,
stats: stats ?? this.stats,
activeFilter: activeFilter,
isLoading: isLoading ?? this.isLoading,
);
}
}
class InvoiceDashboardNotifier extends StateNotifier<DashboardState> {
final DocumentDao _documentDao;
final String _businessId;
InvoiceDashboardNotifier(this._documentDao, this._businessId)
: super(const DashboardState());
Future<void> loadDashboard() async {
state = state.copyWith(isLoading: true);
// Mark overdue invoices
await _documentDao.markOverdueInvoices(_businessId);
final invoices = await _documentDao.getDocumentsByType(_businessId, 'invoice');
final stats = await _documentDao.getDashboardStats(_businessId);
state = state.copyWith(
invoices: invoices,
filteredInvoices: invoices,
stats: stats,
isLoading: false,
);
}
void filterByStatus(String? status) {
if (status == null) {
state = state.copyWith(
filteredInvoices: state.invoices,
activeFilter: null,
);
} else {
state = state.copyWith(
filteredInvoices:
state.invoices.where((d) => d.status == status).toList(),
activeFilter: status,
);
}
}
}
- Step 4: Run tests
flutter test test/features/invoices/invoice_dashboard_notifier_test.dart -v
Expected: All 3 tests PASS.
- Step 5: Create Invoice Dashboard Screen
// lib/features/invoices/invoice_dashboard_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/utils/date_formatter.dart';
import '../../core/theme/app_colors.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/providers/database_provider.dart';
import '../../core/providers/business_provider.dart';
import 'invoice_dashboard_notifier.dart';
import 'widgets/status_pill.dart';
import 'widgets/summary_card.dart';
final dashboardNotifierProvider =
StateNotifierProvider<InvoiceDashboardNotifier, DashboardState>((ref) {
final db = ref.read(databaseProvider);
final business = ref.read(businessProvider).valueOrNull;
return InvoiceDashboardNotifier(DocumentDao(db), business?.id ?? '');
});
class InvoiceDashboardScreen extends ConsumerStatefulWidget {
const InvoiceDashboardScreen({super.key});
@override
ConsumerState<InvoiceDashboardScreen> createState() =>
_InvoiceDashboardScreenState();
}
class _InvoiceDashboardScreenState
extends ConsumerState<InvoiceDashboardScreen> {
@override
void initState() {
super.initState();
Future.microtask(() =>
ref.read(dashboardNotifierProvider.notifier).loadDashboard());
}
@override
Widget build(BuildContext context) {
final state = ref.watch(dashboardNotifierProvider);
final notifier = ref.read(dashboardNotifierProvider.notifier);
final stats = state.stats;
return Scaffold(
appBar: AppBar(title: const Text('Invoices')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.push('/invoices/new'),
icon: const Icon(Icons.add),
label: const Text('New Invoice'),
),
body: state.isLoading
? const Center(child: CircularProgressIndicator())
: CustomScrollView(
slivers: [
// Summary stats bar
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: SummaryCard(
label: 'Outstanding',
value: CurrencyFormatter.format(stats['outstanding'] ?? 0),
icon: Icons.account_balance_wallet,
),
),
Expanded(
child: SummaryCard(
label: 'Overdue',
value: CurrencyFormatter.format(stats['overdue'] ?? 0),
valueColor: AppColors.error,
icon: Icons.warning,
),
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Expanded(
child: SummaryCard(
label: 'Paid (Month)',
value: CurrencyFormatter.format(stats['paidThisMonth'] ?? 0),
valueColor: AppColors.success,
icon: Icons.check_circle,
),
),
Expanded(
child: SummaryCard(
label: 'Drafts',
value: '${stats['draftCount'] ?? 0}',
icon: Icons.edit_note,
),
),
],
),
),
),
// Filter chips
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Wrap(
spacing: 8,
children: [
FilterChip(
label: const Text('All'),
selected: state.activeFilter == null,
onSelected: (_) => notifier.filterByStatus(null),
),
for (final status in ['draft', 'sent', 'overdue', 'paid'])
FilterChip(
label: Text(status[0].toUpperCase() + status.substring(1)),
selected: state.activeFilter == status,
onSelected: (_) => notifier.filterByStatus(status),
),
],
),
),
),
// Invoice list
if (state.filteredInvoices.isEmpty)
const SliverFillRemaining(
child: Center(child: Text('No invoices yet')),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final invoice = state.filteredInvoices[index];
return ListTile(
title: Text(invoice.documentNumber),
subtitle: Text(invoice.dueDate != null
? 'Due: ${DateFormatter.toDisplay(invoice.dueDate!)}'
: ''),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
StatusPill(status: invoice.status),
const SizedBox(width: 8),
Text(CurrencyFormatter.format(invoice.total)),
],
),
onTap: () => context.push('/invoices/${invoice.id}'),
);
},
childCount: state.filteredInvoices.length,
),
),
],
),
);
}
}
- Step 6: Update router to use InvoiceDashboardScreen
Replace the /invoices placeholder route in app.dart:
GoRoute(
path: '/invoices',
builder: (context, state) => const InvoiceDashboardScreen(),
),
- Step 7: Commit
git add lib/features/invoices/ test/features/invoices/
git commit -m "feat: implement invoice dashboard with summary stats and filter chips"
Task 14: Invoice Detail Screen & Route Wiring
Files:
-
Create:
lib/features/invoices/invoice_detail_screen.dart -
Modify:
lib/app.dart— add all remaining routes -
Create:
lib/core/providers/document_provider.dart -
Step 1: Create invoice detail screen
// lib/features/invoices/invoice_detail_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/utils/date_formatter.dart';
import '../../core/database/database.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/database/daos/line_item_dao.dart';
import '../../core/database/daos/client_dao.dart';
import '../../core/database/daos/business_dao.dart';
import '../../core/database/daos/app_settings_dao.dart';
import '../../core/providers/database_provider.dart';
import '../../core/services/pdf_service.dart';
import '../../features/pdf/pdf_preview_screen.dart';
import '../../features/payments/payment_bottom_sheet.dart';
import 'widgets/status_pill.dart';
final invoiceDetailProvider =
FutureProvider.family<Document?, String>((ref, docId) {
return DocumentDao(ref.read(databaseProvider)).getDocument(docId);
});
final invoiceLineItemsProvider =
FutureProvider.family<List<LineItem>, String>((ref, docId) {
return LineItemDao(ref.read(databaseProvider)).getLineItems(docId);
});
class InvoiceDetailScreen extends ConsumerWidget {
final String documentId;
const InvoiceDetailScreen({super.key, required this.documentId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final docAsync = ref.watch(invoiceDetailProvider(documentId));
final itemsAsync = ref.watch(invoiceLineItemsProvider(documentId));
return docAsync.when(
loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
error: (e, _) => Scaffold(body: Center(child: Text('Error: $e'))),
data: (doc) {
if (doc == null) {
return const Scaffold(body: Center(child: Text('Invoice not found')));
}
return Scaffold(
appBar: AppBar(
title: Text(doc.documentNumber),
actions: [
IconButton(
icon: const Icon(Icons.share),
onPressed: () => _sharePdf(context, ref, doc),
),
PopupMenuButton<String>(
onSelected: (value) => _handleAction(context, ref, doc, value),
itemBuilder: (context) => [
const PopupMenuItem(value: 'payment', child: Text('Record Payment')),
const PopupMenuItem(value: 'void', child: Text('Void Invoice')),
const PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Status and dates
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
StatusPill(status: doc.status),
Text(DateFormatter.toDisplay(doc.issueDate)),
],
),
if (doc.dueDate != null) ...[
const SizedBox(height: 4),
Text('Due: ${DateFormatter.toDisplay(doc.dueDate!)}',
style: Theme.of(context).textTheme.bodySmall),
],
const SizedBox(height: 16),
// Line items
itemsAsync.when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
data: (items) => Column(
children: items.map((item) => ListTile(
dense: true,
title: Text(item.description),
subtitle: Text('${item.quantity} x ${CurrencyFormatter.format(item.unitPrice)}'),
trailing: Text(CurrencyFormatter.format(item.amount)),
)).toList(),
),
),
const Divider(),
// Totals
_DetailRow('Subtotal', CurrencyFormatter.format(doc.subtotal)),
if (doc.taxRate > 0)
_DetailRow('Tax (${CurrencyFormatter.formatTaxRate(doc.taxRate)})',
CurrencyFormatter.format(doc.taxAmount)),
if (doc.discountAmount > 0)
_DetailRow('Discount', '-${CurrencyFormatter.format(doc.discountAmount)}'),
const Divider(),
_DetailRow('Total', CurrencyFormatter.format(doc.total), bold: true),
_DetailRow('Paid', CurrencyFormatter.format(doc.amountPaid)),
_DetailRow('Balance Due', CurrencyFormatter.format(doc.amountDue), bold: true),
if (doc.docNotes != null && doc.docNotes!.isNotEmpty) ...[
const SizedBox(height: 16),
Text('Notes', style: Theme.of(context).textTheme.titleSmall),
Text(doc.docNotes!),
],
],
),
);
},
);
}
Future<void> _sharePdf(BuildContext context, WidgetRef ref, Document doc) async {
final db = ref.read(databaseProvider);
final business = await BusinessDao(db).getBusiness();
final client = await ClientDao(db).getClient(doc.clientId);
final items = await LineItemDao(db).getLineItems(doc.id);
final tier = await AppSettingsDao(db).getSubscriptionTier();
final pdfBytes = await PdfService.generateDocument(
businessName: business?.name ?? '',
businessEmail: business?.email,
businessPhone: business?.phone,
clientName: client?.name ?? '',
documentTitle: doc.documentType == 'estimate' ? 'ESTIMATE' : 'INVOICE',
documentNumber: doc.documentNumber,
issueDate: doc.issueDate,
dueDate: doc.dueDate,
lineItems: items.map((li) => PdfLineItem(
description: li.description,
quantity: li.quantity,
rate: li.unitPrice,
amount: li.amount,
)).toList(),
subtotal: doc.subtotal,
taxRate: doc.taxRate,
taxAmount: doc.taxAmount,
discountAmount: doc.discountAmount,
total: doc.total,
amountDue: doc.amountDue,
notes: doc.docNotes,
showWatermark: tier == 'free',
);
// Update status to sent if currently draft
if (doc.status == 'draft') {
await DocumentDao(db).updateStatus(doc.id, 'sent');
}
if (context.mounted) {
Navigator.push(context, MaterialPageRoute(
builder: (_) => PdfPreviewScreen(
pdfBytes: pdfBytes,
fileName: '${doc.documentNumber}.pdf',
),
));
}
}
void _handleAction(BuildContext context, WidgetRef ref, Document doc, String action) {
switch (action) {
case 'payment':
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => PaymentBottomSheet(
documentId: doc.id,
clientId: doc.clientId,
amountDue: doc.amountDue,
),
);
case 'void':
DocumentDao(ref.read(databaseProvider)).updateStatus(doc.id, 'void');
ref.invalidate(invoiceDetailProvider(documentId));
case 'delete':
DocumentDao(ref.read(databaseProvider)).softDeleteDocument(doc.id);
context.pop();
}
}
}
class _DetailRow extends StatelessWidget {
final String label;
final String value;
final bool bold;
const _DetailRow(this.label, this.value, {this.bold = false});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: bold ? const TextStyle(fontWeight: FontWeight.bold) : null),
Text(value, style: bold ? const TextStyle(fontWeight: FontWeight.bold) : null),
],
),
);
}
}
- Step 2: Create document provider
// lib/core/providers/document_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/daos/document_dao.dart';
import '../database/daos/line_item_dao.dart';
import 'database_provider.dart';
final documentDaoProvider = Provider<DocumentDao>((ref) {
return DocumentDao(ref.read(databaseProvider));
});
final lineItemDaoProvider = Provider<LineItemDao>((ref) {
return LineItemDao(ref.read(databaseProvider));
});
- Step 3: Add all remaining routes to app.dart
Add inside the ShellRoute:
GoRoute(
path: '/invoices/:id',
builder: (context, state) => InvoiceDetailScreen(
documentId: state.pathParameters['id']!,
),
),
GoRoute(
path: '/clients/new',
builder: (context, state) => const ClientFormScreen(),
),
GoRoute(
path: '/clients/:id',
builder: (context, state) => ClientDetailScreen(
clientId: state.pathParameters['id']!,
),
),
GoRoute(
path: '/clients/:id/edit',
builder: (context, state) => ClientFormScreen(
clientId: state.pathParameters['id'],
),
),
- Step 4: Verify full navigation flow
flutter run -d chrome
Expected: Can navigate between all 4 tabs. FAB on invoices goes to creator. All routes load without errors.
- Step 5: Commit
git add lib/features/invoices/invoice_detail_screen.dart \
lib/core/providers/document_provider.dart lib/app.dart
git commit -m "feat: add invoice detail screen and wire up all routes"
Phase 3: PDF & Pro Features (Weeks 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
// test/core/services/pdf_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/services/pdf_service.dart';
void main() {
test('generateInvoicePdf returns non-empty bytes', () async {
final bytes = await PdfService.generateDocument(
businessName: 'Test Co',
businessEmail: 'test@co.com',
clientName: 'Jane Doe',
documentTitle: 'INVOICE',
documentNumber: 'INV-001',
issueDate: '2026-03-22',
dueDate: '2026-04-21',
lineItems: [
PdfLineItem(description: 'Web Design', quantity: 2.0, rate: 5000, amount: 10000),
],
subtotal: 10000,
taxRate: 825,
taxAmount: 825,
discountAmount: 0,
total: 10825,
amountDue: 10825,
notes: 'Payment due in 30 days',
showWatermark: true,
);
expect(bytes, isNotEmpty);
// PDF magic bytes
expect(bytes[0], 0x25); // %
expect(bytes[1], 0x50); // P
expect(bytes[2], 0x44); // D
expect(bytes[3], 0x46); // F
});
test('generateDocument without watermark for pro users', () async {
final bytes = await PdfService.generateDocument(
businessName: 'Pro Co',
clientName: 'Client',
documentTitle: 'INVOICE',
documentNumber: 'INV-002',
issueDate: '2026-03-22',
lineItems: [],
subtotal: 0,
taxRate: 0,
taxAmount: 0,
discountAmount: 0,
total: 0,
amountDue: 0,
showWatermark: false,
);
expect(bytes, isNotEmpty);
});
}
- Step 2: Run tests to verify they fail
flutter test test/core/services/pdf_service_test.dart
Expected: FAIL.
- Step 3: Implement PdfService
// lib/core/services/pdf_service.dart
import 'dart:typed_data';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
class PdfLineItem {
final String description;
final double quantity;
final int rate;
final int amount;
const PdfLineItem({
required this.description,
required this.quantity,
required this.rate,
required this.amount,
});
}
class PdfService {
static Future<Uint8List> generateDocument({
required String businessName,
String? businessEmail,
String? businessPhone,
String? businessAddress,
String? logoPath,
required String clientName,
String? clientAddress,
required String documentTitle,
required String documentNumber,
required String issueDate,
String? dueDate,
required List<PdfLineItem> lineItems,
required int subtotal,
required int taxRate,
required int taxAmount,
required int discountAmount,
required int total,
required int amountDue,
String? notes,
required bool showWatermark,
}) async {
final pdf = pw.Document();
pdf.addPage(
pw.Page(
pageFormat: PdfPageFormat.letter,
margin: const pw.EdgeInsets.all(40),
build: (pw.Context context) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
// Header
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(businessName,
style: pw.TextStyle(
fontSize: 20, fontWeight: pw.FontWeight.bold)),
if (businessEmail != null)
pw.Text(businessEmail, style: const pw.TextStyle(fontSize: 10)),
if (businessPhone != null)
pw.Text(businessPhone, style: const pw.TextStyle(fontSize: 10)),
if (businessAddress != null)
pw.Text(businessAddress, style: const pw.TextStyle(fontSize: 10)),
],
),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(documentTitle,
style: pw.TextStyle(
fontSize: 24, fontWeight: pw.FontWeight.bold,
color: PdfColors.blue800)),
pw.Text(documentNumber, style: const pw.TextStyle(fontSize: 12)),
],
),
],
),
pw.SizedBox(height: 30),
// Bill To + Dates
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Bill To', style: pw.TextStyle(
fontWeight: pw.FontWeight.bold, color: PdfColors.grey600)),
pw.Text(clientName, style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
if (clientAddress != null) pw.Text(clientAddress),
],
),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text('Issue Date: $issueDate'),
if (dueDate != null) pw.Text('Due Date: $dueDate'),
],
),
],
),
pw.SizedBox(height: 20),
// Line items table
pw.TableHelper.fromTextArray(
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200),
cellPadding: const pw.EdgeInsets.all(6),
headers: ['Description', 'Qty', 'Rate', 'Amount'],
data: lineItems.map((li) => [
li.description,
li.quantity.toString(),
_formatCents(li.rate),
_formatCents(li.amount),
]).toList(),
columnWidths: {
0: const pw.FlexColumnWidth(4),
1: const pw.FlexColumnWidth(1),
2: const pw.FlexColumnWidth(1.5),
3: const pw.FlexColumnWidth(1.5),
},
),
pw.SizedBox(height: 10),
// Totals
pw.Align(
alignment: pw.Alignment.centerRight,
child: pw.SizedBox(
width: 200,
child: pw.Column(
children: [
_totalRow('Subtotal', subtotal),
if (taxRate > 0)
_totalRow('Tax (${(taxRate / 100).toStringAsFixed(2)}%)', taxAmount),
if (discountAmount > 0)
_totalRow('Discount', -discountAmount),
pw.Divider(),
_totalRow('Total Due', amountDue, bold: true),
],
),
),
),
pw.SizedBox(height: 20),
// Notes
if (notes != null && notes.isNotEmpty) ...[
pw.Text('Notes', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
pw.Text(notes),
],
pw.Spacer(),
// Watermark
if (showWatermark)
pw.Center(
child: pw.Text(
'Created with SwiftInvoice',
style: pw.TextStyle(fontSize: 8, color: PdfColors.grey400),
),
),
],
);
},
),
);
return pdf.save();
}
static pw.Widget _totalRow(String label, int cents, {bool bold = false}) {
return pw.Padding(
padding: const pw.EdgeInsets.symmetric(vertical: 2),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(label, style: pw.TextStyle(
fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal)),
pw.Text(_formatCents(cents), style: pw.TextStyle(
fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal)),
],
),
);
}
static String _formatCents(int cents) {
final negative = cents < 0;
final abs = cents.abs();
final dollars = abs ~/ 100;
final remainder = abs % 100;
final formatted = '\$${dollars.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (m) => '${m[1]},'
)}.${remainder.toString().padLeft(2, '0')}';
return negative ? '-$formatted' : formatted;
}
}
- Step 4: Run tests
flutter test test/core/services/pdf_service_test.dart -v
Expected: All 2 tests PASS.
- Step 5: Create PDF preview screen
// lib/features/pdf/pdf_preview_screen.dart
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:printing/printing.dart';
import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import 'package:path/path.dart' as p;
class PdfPreviewScreen extends StatelessWidget {
final Uint8List pdfBytes;
final String fileName;
const PdfPreviewScreen({
super.key,
required this.pdfBytes,
required this.fileName,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Preview'),
actions: [
IconButton(
icon: const Icon(Icons.share),
onPressed: () => _share(context),
),
],
),
body: PdfPreview(
build: (_) => pdfBytes,
canChangeOrientation: false,
canChangePageFormat: false,
canDebug: false,
),
);
}
Future<void> _share(BuildContext context) async {
final dir = await getTemporaryDirectory();
final file = File(p.join(dir.path, fileName));
await file.writeAsBytes(pdfBytes);
await Share.shareXFiles([XFile(file.path)], text: 'Invoice from SwiftInvoice');
}
}
- Step 6: Commit
git add lib/core/services/pdf_service.dart lib/features/pdf/ \
test/core/services/pdf_service_test.dart
git commit -m "feat: implement PDF generation with branded template and share flow"
Task 16: PDF Integration — Verify Share Flow
Note: The
_sharePdf()method was already implemented in Task 14'sInvoiceDetailScreen. This task verifies the integration end-to-end.
Files:
-
No new files — verify existing wiring from Task 14
-
Step 1: Run the app and test PDF flow
flutter run -d chrome
- Create a client and an invoice with 2+ line items
- Open the invoice detail screen
- Tap the share icon
- Verify PDF preview renders with: business name, client name, line items table, totals, and watermark (free tier)
- Tap share — verify system share sheet opens
- Step 2: Verify status update on share
After sharing a draft invoice, navigate back to dashboard and verify the invoice's status pill changed from "Draft" (gray) to "Sent" (blue).
- Step 3: Commit (if any fixes were needed)
git add lib/features/invoices/invoice_detail_screen.dart
git commit -m "fix: polish PDF share integration"
Task 17: Estimate Creator with Conversion
Files:
-
Create:
lib/features/estimates/estimate_creator_notifier.dart -
Create:
lib/features/estimates/estimate_creator_screen.dart -
Create:
lib/features/estimates/estimate_list_screen.dart -
Create:
test/features/estimates/estimate_creator_notifier_test.dart -
Step 1: Write failing tests for estimate conversion
// test/features/estimates/estimate_creator_notifier_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/document_dao.dart';
import 'package:swift_invoice/core/database/daos/line_item_dao.dart';
import 'package:swift_invoice/features/estimates/estimate_creator_notifier.dart';
void main() {
late AppDatabase db;
late EstimateCreatorNotifier notifier;
late String businessId;
late String clientId;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
final biz = await BusinessDao(db).createBusiness(name: 'Co');
businessId = biz.id;
final client = await ClientDao(db).createClient(
businessId: businessId, name: 'Jane');
clientId = client.id;
notifier = EstimateCreatorNotifier(
businessDao: BusinessDao(db),
documentDao: DocumentDao(db),
lineItemDao: LineItemDao(db),
businessId: businessId,
);
await notifier.initialize();
});
tearDown(() => db.close());
test('initialize sets estimate number with EST prefix', () {
expect(notifier.state.documentNumber, startsWith('EST'));
});
test('convertToInvoice creates invoice from estimate', () async {
// Create and save an estimate
notifier.setClientId(clientId);
notifier.addLineItem(description: 'Design', quantity: 3.0, unitPrice: 10000);
final estimateId = await notifier.save();
expect(estimateId, isNotNull);
// Convert it
final invoiceId = await notifier.convertToInvoice(estimateId!);
expect(invoiceId, isNotNull);
// Verify the invoice
final invoice = await DocumentDao(db).getDocument(invoiceId!);
expect(invoice!.documentType, 'invoice');
expect(invoice.total, 30000);
expect(invoice.convertedFromId, estimateId);
// Verify estimate status changed
final estimate = await DocumentDao(db).getDocument(estimateId);
expect(estimate!.status, 'converted');
// Verify line items copied
final items = await LineItemDao(db).getLineItems(invoiceId);
expect(items.length, 1);
expect(items.first.description, 'Design');
});
}
- Step 2: Run tests to verify they fail
flutter test test/features/estimates/estimate_creator_notifier_test.dart
Expected: FAIL.
- Step 3: Implement EstimateCreatorNotifier
This reuses much of InvoiceCreatorNotifier's logic. Key differences:
- Uses
estimate_prefixandnext_estimate_number documentType = 'estimate'- Adds
convertToInvoice()method implementing the 5-step conversion logic from the spec
// lib/features/estimates/estimate_creator_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/database/daos/business_dao.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/database/daos/line_item_dao.dart';
import '../../core/services/document_calculator.dart';
import '../../core/utils/date_formatter.dart';
import '../invoices/invoice_creator_notifier.dart';
class EstimateCreatorNotifier extends StateNotifier<InvoiceCreatorState> {
final BusinessDao _businessDao;
final DocumentDao _documentDao;
final LineItemDao _lineItemDao;
final String _businessId;
EstimateCreatorNotifier({
required BusinessDao businessDao,
required DocumentDao documentDao,
required LineItemDao lineItemDao,
required String businessId,
}) : _businessDao = businessDao,
_documentDao = documentDao,
_lineItemDao = lineItemDao,
_businessId = businessId,
super(const InvoiceCreatorState());
Future<void> initialize() async {
final biz = await _businessDao.getBusiness();
if (biz == null) return;
final now = DateTime.now();
final expiryDate = DateFormatter.addDays(now, 30);
final docNum = '${biz.estimatePrefix}-${biz.nextEstimateNumber}';
state = state.copyWith(
documentNumber: docNum,
issueDate: DateFormatter.toIso(now),
dueDate: DateFormatter.toIso(expiryDate),
taxRateBasisPoints: biz.defaultTaxRate,
);
}
void setClientId(String id) => state = state.copyWith(clientId: id);
void addLineItem({
required String description,
required double quantity,
required int unitPrice,
}) {
final items = [
...state.lineItems,
LineItemFormData(
description: description,
quantity: quantity,
unitPriceCents: unitPrice,
),
];
state = state.copyWith(lineItems: items);
_recalculate();
}
void setTaxRate(int basisPoints) {
state = state.copyWith(taxRateBasisPoints: basisPoints);
_recalculate();
}
void _recalculate() {
final inputs = state.lineItems
.map((li) => LineItemInput(
quantity: li.quantity,
unitPriceCents: li.unitPriceCents,
))
.toList();
final result = DocumentCalculator.calculate(
items: inputs,
taxRateBasisPoints: state.taxRateBasisPoints,
discountType: state.discountType,
discountValueBasisPoints: state.discountValue,
);
state = state.copyWith(
subtotal: result.subtotal,
taxAmount: result.taxAmount,
discountAmount: result.discountAmount,
total: result.total,
);
}
Future<String?> save() async {
if (state.clientId == null || state.lineItems.isEmpty) {
state = state.copyWith(error: 'Client and line items required');
return null;
}
final doc = await _documentDao.createDocument(
businessId: _businessId,
clientId: state.clientId!,
documentType: 'estimate',
documentNumber: state.documentNumber,
status: 'draft',
issueDate: state.issueDate,
dueDate: state.dueDate,
subtotal: state.subtotal,
taxRate: state.taxRateBasisPoints,
taxAmount: state.taxAmount,
total: state.total,
amountDue: state.total,
);
for (var i = 0; i < state.lineItems.length; i++) {
final li = state.lineItems[i];
await _lineItemDao.addLineItem(
documentId: doc.id,
description: li.description,
quantity: li.quantity,
unitPrice: li.unitPriceCents,
amount: li.amount,
sortOrder: i,
);
}
await _businessDao.getAndIncrementEstimateNumber(_businessId);
return doc.id;
}
/// One-tap estimate → invoice conversion (spec section 3.6.2)
Future<String?> convertToInvoice(String estimateId) async {
final estimate = await _documentDao.getDocument(estimateId);
if (estimate == null) return null;
final biz = await _businessDao.getBusiness();
if (biz == null) return null;
// 1. Create new invoice copying estimate fields
final invoiceNum = '${biz.invoicePrefix}-${biz.nextInvoiceNumber}';
final now = DateTime.now();
final invoice = await _documentDao.createDocument(
businessId: estimate.businessId,
clientId: estimate.clientId,
documentType: 'invoice',
documentNumber: invoiceNum,
status: 'draft',
issueDate: DateFormatter.toIso(now),
dueDate: DateFormatter.toIso(
DateFormatter.addDays(now, biz.defaultPaymentTermsDays)),
currencyCode: estimate.currencyCode,
subtotal: estimate.subtotal,
taxRate: estimate.taxRate,
taxAmount: estimate.taxAmount,
discountType: estimate.discountType,
discountValue: estimate.discountValue,
discountAmount: estimate.discountAmount,
total: estimate.total,
amountDue: estimate.total,
notes: estimate.docNotes,
convertedFromId: estimateId,
);
// 2. Copy all line items
final items = await _lineItemDao.getLineItems(estimateId);
for (final item in items) {
await _lineItemDao.addLineItem(
documentId: invoice.id,
description: item.description,
quantity: item.quantity,
unitPrice: item.unitPrice,
amount: item.amount,
sortOrder: item.sortOrder,
);
}
// 3. Mark estimate as converted
await _documentDao.updateStatus(estimateId, 'converted');
// 4. Increment invoice number
await _businessDao.getAndIncrementInvoiceNumber(_businessId);
return invoice.id;
}
}
- Step 4: Run tests
flutter test test/features/estimates/estimate_creator_notifier_test.dart -v
Expected: All 3 tests PASS.
- Step 5: Create estimate list screen
// lib/features/estimates/estimate_list_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/utils/date_formatter.dart';
import '../../core/database/database.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/providers/database_provider.dart';
import '../../core/providers/business_provider.dart';
import '../invoices/widgets/status_pill.dart';
final estimatesProvider = FutureProvider<List<Document>>((ref) {
final db = ref.read(databaseProvider);
final business = ref.read(businessProvider).valueOrNull;
if (business == null) return [];
return DocumentDao(db).getDocumentsByType(business.id, 'estimate');
});
class EstimateListScreen extends ConsumerWidget {
const EstimateListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final estimatesAsync = ref.watch(estimatesProvider);
return Scaffold(
appBar: AppBar(title: const Text('Estimates')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.push('/estimates/new'),
icon: const Icon(Icons.add),
label: const Text('New Estimate'),
),
body: estimatesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (estimates) => estimates.isEmpty
? const Center(child: Text('No estimates yet'))
: ListView.builder(
itemCount: estimates.length,
itemBuilder: (context, index) {
final est = estimates[index];
return ListTile(
title: Text(est.documentNumber),
subtitle: Text(est.dueDate != null
? 'Valid until: ${DateFormatter.toDisplay(est.dueDate!)}'
: ''),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
StatusPill(status: est.status),
const SizedBox(width: 8),
Text(CurrencyFormatter.format(est.total)),
],
),
);
},
),
),
);
}
}
- Step 6: Create EstimateCreatorScreen
// lib/features/estimates/estimate_creator_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/database/daos/business_dao.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/database/daos/line_item_dao.dart';
import '../../core/database/daos/client_dao.dart';
import '../../core/database/database.dart';
import '../../core/providers/database_provider.dart';
import '../../core/providers/business_provider.dart';
import '../invoices/invoice_creator_screen.dart';
import 'estimate_creator_notifier.dart';
final estimateCreatorProvider =
StateNotifierProvider.autoDispose<EstimateCreatorNotifier, InvoiceCreatorState>(
(ref) {
final db = ref.read(databaseProvider);
final business = ref.read(businessProvider).valueOrNull;
return EstimateCreatorNotifier(
businessDao: BusinessDao(db),
documentDao: DocumentDao(db),
lineItemDao: LineItemDao(db),
businessId: business?.id ?? '',
);
});
class EstimateCreatorScreen extends ConsumerStatefulWidget {
const EstimateCreatorScreen({super.key});
@override
ConsumerState<EstimateCreatorScreen> createState() =>
_EstimateCreatorScreenState();
}
class _EstimateCreatorScreenState extends ConsumerState<EstimateCreatorScreen> {
final _descController = TextEditingController();
final _qtyController = TextEditingController(text: '1');
final _rateController = TextEditingController();
@override
void initState() {
super.initState();
Future.microtask(() =>
ref.read(estimateCreatorProvider.notifier).initialize());
}
@override
Widget build(BuildContext context) {
final state = ref.watch(estimateCreatorProvider);
final notifier = ref.read(estimateCreatorProvider.notifier);
final clientsAsync = ref.watch(activeClientsProvider);
return Scaffold(
appBar: AppBar(title: const Text('New Estimate')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
clientsAsync.when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
data: (clients) => DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: 'Client *'),
value: state.clientId,
items: clients
.map((c) => DropdownMenuItem(value: c.id, child: Text(c.name)))
.toList(),
onChanged: (id) {
if (id != null) notifier.setClientId(id);
},
),
),
const SizedBox(height: 16),
Text('Estimate #: ${state.documentNumber}',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text('Valid Until: ${state.dueDate}',
style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 16),
const Text('Line Items', style: TextStyle(fontWeight: FontWeight.bold)),
...state.lineItems.asMap().entries.map((entry) {
final li = entry.value;
return Card(
child: ListTile(
title: Text(li.description),
subtitle: Text('${li.quantity} x ${CurrencyFormatter.format(li.unitPriceCents)}'),
trailing: Text(CurrencyFormatter.format(li.amount)),
),
);
}),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
TextField(
controller: _descController,
decoration: const InputDecoration(labelText: 'Description', isDense: true),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: TextField(
controller: _qtyController,
decoration: const InputDecoration(labelText: 'Qty', isDense: true),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
)),
const SizedBox(width: 8),
Expanded(child: TextField(
controller: _rateController,
decoration: const InputDecoration(labelText: 'Rate (\$)', isDense: true),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
)),
],
),
TextButton.icon(
onPressed: () {
final desc = _descController.text.trim();
final qty = double.tryParse(_qtyController.text) ?? 1.0;
final rate = CurrencyFormatter.parseToCents(_rateController.text);
if (desc.isNotEmpty && rate > 0) {
notifier.addLineItem(description: desc, quantity: qty, unitPrice: rate);
_descController.clear();
_qtyController.text = '1';
_rateController.clear();
}
},
icon: const Icon(Icons.add),
label: const Text('Add Item'),
),
],
),
),
),
const Divider(),
TotalRow(label: 'Total', value: state.total, bold: true),
const SizedBox(height: 24),
FilledButton(
onPressed: state.isSaving
? null
: () async {
final id = await notifier.save();
if (id != null && context.mounted) context.pop();
},
child: const Text('Save Estimate'),
),
],
),
),
);
}
@override
void dispose() {
_descController.dispose();
_qtyController.dispose();
_rateController.dispose();
super.dispose();
}
}
- Step 7: Add estimate routes and update router
// Add to ShellRoute routes in app.dart:
GoRoute(
path: '/estimates/new',
builder: (context, state) => const EstimateCreatorScreen(),
),
- Step 8: Commit
git add lib/features/estimates/ test/features/estimates/
git commit -m "feat: implement estimate creator with one-tap invoice conversion"
Task 18: Payment Tracker
Files:
-
Create:
lib/features/payments/payment_notifier.dart -
Create:
lib/features/payments/payment_bottom_sheet.dart -
Create:
test/features/payments/payment_notifier_test.dart -
Step 1: Write failing tests for PaymentNotifier
// test/features/payments/payment_notifier_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/business_dao.dart';
import 'package:swift_invoice/core/database/daos/client_dao.dart';
import 'package:swift_invoice/core/database/daos/document_dao.dart';
import 'package:swift_invoice/core/database/daos/payment_dao.dart';
import 'package:swift_invoice/features/payments/payment_notifier.dart';
void main() {
late AppDatabase db;
late PaymentNotifier notifier;
late String documentId;
late String clientId;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
final biz = await BusinessDao(db).createBusiness(name: 'Co');
final client = await ClientDao(db).createClient(
businessId: biz.id, name: 'C');
clientId = client.id;
final doc = await DocumentDao(db).createDocument(
businessId: biz.id, clientId: clientId,
documentType: 'invoice', documentNumber: 'INV-001',
status: 'sent', issueDate: '2026-03-22',
total: 10000, amountDue: 10000,
);
documentId = doc.id;
notifier = PaymentNotifier(
paymentDao: PaymentDao(db),
documentDao: DocumentDao(db),
clientDao: ClientDao(db),
);
});
tearDown(() => db.close());
test('recordPayment updates document amounts', () async {
await notifier.recordPayment(
documentId: documentId,
clientId: clientId,
amount: 5000,
method: 'cash',
);
final doc = await DocumentDao(db).getDocument(documentId);
expect(doc!.amountPaid, 5000);
expect(doc.amountDue, 5000);
expect(doc.status, 'partial');
});
test('full payment marks invoice as paid', () async {
await notifier.recordPayment(
documentId: documentId,
clientId: clientId,
amount: 10000,
method: 'card',
);
final doc = await DocumentDao(db).getDocument(documentId);
expect(doc!.status, 'paid');
expect(doc.amountDue, 0);
});
test('rejects payment exceeding amount due', () async {
final result = await notifier.recordPayment(
documentId: documentId,
clientId: clientId,
amount: 15000,
method: 'cash',
);
expect(result, isFalse);
expect(notifier.state.error, contains('exceed'));
});
test('deletePayment reverses amounts', () async {
await notifier.recordPayment(
documentId: documentId,
clientId: clientId,
amount: 5000,
method: 'cash',
);
final payments = await PaymentDao(db).getPaymentsForDocument(documentId);
await notifier.deletePayment(
paymentId: payments.first.id,
documentId: documentId,
clientId: clientId,
);
final doc = await DocumentDao(db).getDocument(documentId);
expect(doc!.amountPaid, 0);
expect(doc.amountDue, 10000);
});
test('cumulative payments cannot exceed total', () async {
// First payment of 7000 succeeds (10000 total)
final result1 = await notifier.recordPayment(
documentId: documentId,
clientId: clientId,
amount: 7000,
method: 'cash',
);
expect(result1, isTrue);
// Second payment of 5000 would exceed remaining 3000
final result2 = await notifier.recordPayment(
documentId: documentId,
clientId: clientId,
amount: 5000,
method: 'card',
);
expect(result2, isFalse);
expect(notifier.state.error, contains('exceed'));
// Payment of exactly 3000 should succeed
final result3 = await notifier.recordPayment(
documentId: documentId,
clientId: clientId,
amount: 3000,
method: 'card',
);
expect(result3, isTrue);
final doc = await DocumentDao(db).getDocument(documentId);
expect(doc!.status, 'paid');
expect(doc.amountDue, 0);
});
}
- Step 2: Run tests to verify they fail
flutter test test/features/payments/payment_notifier_test.dart
Expected: FAIL.
- Step 3: Implement PaymentNotifier
// lib/features/payments/payment_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/database/database.dart';
import '../../core/database/daos/payment_dao.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/database/daos/client_dao.dart';
class PaymentState {
final List<Payment> payments;
final bool isLoading;
final String? error;
const PaymentState({
this.payments = const [],
this.isLoading = false,
this.error,
});
PaymentState copyWith({
List<Payment>? payments,
bool? isLoading,
String? error,
}) {
return PaymentState(
payments: payments ?? this.payments,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
class PaymentNotifier extends StateNotifier<PaymentState> {
final PaymentDao _paymentDao;
final DocumentDao _documentDao;
final ClientDao _clientDao;
PaymentNotifier({
required PaymentDao paymentDao,
required DocumentDao documentDao,
required ClientDao clientDao,
}) : _paymentDao = paymentDao,
_documentDao = documentDao,
_clientDao = clientDao,
super(const PaymentState());
Future<void> loadPayments(String documentId) async {
state = state.copyWith(isLoading: true);
final payments = await _paymentDao.getPaymentsForDocument(documentId);
state = state.copyWith(payments: payments, isLoading: false);
}
Future<bool> recordPayment({
required String documentId,
required String clientId,
required int amount,
required String method,
String? notes,
}) async {
// Validate amount doesn't exceed amount_due
final doc = await _documentDao.getDocument(documentId);
if (doc == null) return false;
if (amount > doc.amountDue) {
state = state.copyWith(error: 'Payment cannot exceed amount due');
return false;
}
await _paymentDao.recordPayment(
documentId: documentId,
amount: amount,
method: method,
paidAt: DateTime.now().toIso8601String(),
notes: notes,
);
// Update document amounts
final totalPaid = await _paymentDao.getTotalPaidForDocument(documentId);
await _documentDao.updatePaymentAmounts(documentId, totalPaid);
// Update client outstanding balance
await _recalculateClientBalance(clientId);
await loadPayments(documentId);
return true;
}
Future<void> deletePayment({
required String paymentId,
required String documentId,
required String clientId,
}) async {
await _paymentDao.deletePayment(paymentId);
final totalPaid = await _paymentDao.getTotalPaidForDocument(documentId);
await _documentDao.updatePaymentAmounts(documentId, totalPaid);
await _recalculateClientBalance(clientId);
await loadPayments(documentId);
}
Future<void> _recalculateClientBalance(String clientId) async {
// Sum all unpaid invoice amounts for this client
final docs = await _documentDao.getDocumentsForClient(clientId);
int balance = 0;
for (final doc in docs) {
if (doc.documentType == 'invoice' &&
!['void', 'paid'].contains(doc.status)) {
balance += doc.amountDue;
}
}
await _clientDao.updateOutstandingBalance(clientId, balance);
}
}
- Step 4: Run tests
flutter test test/features/payments/payment_notifier_test.dart -v
Expected: All 5 tests PASS.
- Step 5: Create payment bottom sheet UI
// lib/features/payments/payment_bottom_sheet.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/currency_formatter.dart';
import '../../core/database/daos/payment_dao.dart';
import '../../core/database/daos/document_dao.dart';
import '../../core/database/daos/client_dao.dart';
import '../../core/providers/database_provider.dart';
import 'payment_notifier.dart';
final paymentNotifierProvider =
StateNotifierProvider<PaymentNotifier, PaymentState>((ref) {
final db = ref.read(databaseProvider);
return PaymentNotifier(
paymentDao: PaymentDao(db),
documentDao: DocumentDao(db),
clientDao: ClientDao(db),
);
});
class PaymentBottomSheet extends ConsumerStatefulWidget {
final String documentId;
final String clientId;
final int amountDue;
const PaymentBottomSheet({
super.key,
required this.documentId,
required this.clientId,
required this.amountDue,
});
@override
ConsumerState<PaymentBottomSheet> createState() => _PaymentBottomSheetState();
}
class _PaymentBottomSheetState extends ConsumerState<PaymentBottomSheet> {
late TextEditingController _amountController;
String _method = 'cash';
final _notesController = TextEditingController();
@override
void initState() {
super.initState();
_amountController = TextEditingController(
text: (widget.amountDue / 100).toStringAsFixed(2),
);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Record Payment',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
TextField(
controller: _amountController,
decoration: InputDecoration(
labelText: 'Amount',
prefixText: '\$',
helperText: 'Due: ${CurrencyFormatter.format(widget.amountDue)}',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _method,
decoration: const InputDecoration(labelText: 'Payment Method'),
items: const [
DropdownMenuItem(value: 'cash', child: Text('Cash')),
DropdownMenuItem(value: 'card', child: Text('Card')),
DropdownMenuItem(value: 'bank_transfer', child: Text('Bank Transfer')),
DropdownMenuItem(value: 'check', child: Text('Check')),
DropdownMenuItem(value: 'other', child: Text('Other')),
],
onChanged: (v) => setState(() => _method = v!),
),
const SizedBox(height: 16),
TextField(
controller: _notesController,
decoration: const InputDecoration(labelText: 'Notes (optional)'),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
),
const SizedBox(width: 16),
Expanded(
child: FilledButton(
onPressed: () async {
final cents = CurrencyFormatter.parseToCents(
_amountController.text);
final notifier = ref.read(paymentNotifierProvider.notifier);
final success = await notifier.recordPayment(
documentId: widget.documentId,
clientId: widget.clientId,
amount: cents,
method: _method,
notes: _notesController.text.isEmpty
? null
: _notesController.text,
);
if (context.mounted) {
if (success) {
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(
notifier.state.error ?? 'Payment failed')),
);
}
}
},
child: const Text('Save Payment'),
),
),
],
),
],
),
);
}
@override
void dispose() {
_amountController.dispose();
_notesController.dispose();
super.dispose();
}
}
- Step 6: Commit
git add lib/features/payments/ test/features/payments/
git commit -m "feat: implement payment tracker with partial payment support"
Task 19: Overdue Reminders (Local Notifications)
Files:
-
Create:
lib/core/services/notification_service.dart -
Create:
test/core/services/notification_service_test.dart -
Step 1: Write failing test for NotificationService
// test/core/services/notification_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/services/notification_service.dart';
void main() {
test('generateNotificationId is deterministic for same documentId', () {
final id1 = NotificationService.generateNotificationId('doc-123');
final id2 = NotificationService.generateNotificationId('doc-123');
expect(id1, id2);
});
test('generateNotificationId differs for different documentIds', () {
final id1 = NotificationService.generateNotificationId('doc-123');
final id2 = NotificationService.generateNotificationId('doc-456');
expect(id1, isNot(id2));
});
test('buildNotificationBody formats correctly', () {
final body = NotificationService.buildNotificationBody(
invoiceNumber: 'INV-001',
clientName: 'Jane Doe',
);
expect(body, contains('INV-001'));
expect(body, contains('Jane Doe'));
expect(body, contains('overdue'));
});
}
- Step 2: Run tests to verify they fail
flutter test test/core/services/notification_service_test.dart
Expected: FAIL.
- Step 3: Implement NotificationService
// lib/core/services/notification_service.dart
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
class NotificationService {
static final _plugin = FlutterLocalNotificationsPlugin();
static Future<void> initialize() async {
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const settings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
await _plugin.initialize(settings);
}
static int generateNotificationId(String documentId) {
return documentId.hashCode.abs() % 100000;
}
static String buildNotificationBody({
required String invoiceNumber,
required String clientName,
}) {
return 'Invoice $invoiceNumber for $clientName is overdue. Tap to view.';
}
static Future<void> scheduleOverdueReminder({
required String documentId,
required String invoiceNumber,
required String clientName,
required DateTime dueDate,
}) async {
final notificationId = generateNotificationId(documentId);
// Schedule for 9:00 AM the day after due date
final scheduledDate = DateTime(
dueDate.year,
dueDate.month,
dueDate.day + 1,
9,
0,
);
// Don't schedule if the date is in the past
if (scheduledDate.isBefore(DateTime.now())) return;
const androidDetails = AndroidNotificationDetails(
'overdue_invoices',
'Overdue Invoices',
channelDescription: 'Notifications for overdue invoices',
importance: Importance.high,
priority: Priority.high,
);
const iosDetails = DarwinNotificationDetails();
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _plugin.zonedSchedule(
notificationId,
'Invoice Overdue',
buildNotificationBody(
invoiceNumber: invoiceNumber,
clientName: clientName,
),
tz.TZDateTime.from(scheduledDate, tz.local),
details,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
payload: documentId,
);
}
static Future<void> cancelReminder(String documentId) async {
final notificationId = generateNotificationId(documentId);
await _plugin.cancel(notificationId);
}
}
- Step 4: Run tests
flutter test test/core/services/notification_service_test.dart -v
Expected: All 3 tests PASS.
- Step 5: Commit
git add lib/core/services/notification_service.dart \
test/core/services/notification_service_test.dart
git commit -m "feat: implement local notification scheduling for overdue invoice reminders"
Task 20: Wire Notifications into Invoice Lifecycle
Files:
-
Modify:
lib/features/invoices/invoice_creator_notifier.dart— schedule notification on save -
Modify:
lib/features/payments/payment_notifier.dart— cancel notification on full payment -
Step 1: Schedule notification when invoice is created with due date
In InvoiceCreatorNotifier.save(), after creating the document, add:
if (doc.dueDate != null) {
await NotificationService.scheduleOverdueReminder(
documentId: doc.id,
invoiceNumber: doc.documentNumber,
clientName: clientName, // fetch from client
dueDate: DateTime.parse(doc.dueDate!),
);
}
- Step 2: Cancel notification when invoice is fully paid
In PaymentNotifier.recordPayment(), after updating document amounts, check:
final updatedDoc = await _documentDao.getDocument(documentId);
if (updatedDoc?.status == 'paid' || updatedDoc?.status == 'void') {
await NotificationService.cancelReminder(documentId);
}
- Step 3: Write tests for notification wiring
The notification service uses platform plugins that can't run in unit tests. Instead, extract the notification calls behind an interface so they can be verified via mock.
Add to test/features/invoices/invoice_creator_notifier_test.dart:
test('save with due date schedules notification (verify via DAO side effects)', () async {
notifier.setClientId(clientId);
notifier.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 5000);
final docId = await notifier.save();
expect(docId, isNotNull);
// Verify the document has a due date set (notification scheduling depends on this)
final doc = await DocumentDao(db).getDocument(docId!);
expect(doc!.dueDate, isNotNull);
expect(doc.dueDate, isNotEmpty);
});
Add to test/features/payments/payment_notifier_test.dart:
test('full payment results in paid status (notification cancel trigger)', () async {
await notifier.recordPayment(
documentId: documentId,
clientId: clientId,
amount: 10000,
method: 'card',
);
final doc = await DocumentDao(db).getDocument(documentId);
// Paid status is the trigger for NotificationService.cancelReminder
expect(doc!.status, 'paid');
});
- Step 4: Run tests
flutter test test/features/invoices/invoice_creator_notifier_test.dart \
test/features/payments/payment_notifier_test.dart -v
Expected: All tests PASS.
- Step 5: Commit
git add lib/features/invoices/invoice_creator_notifier.dart \
lib/features/payments/payment_notifier.dart \
test/features/invoices/invoice_creator_notifier_test.dart \
test/features/payments/payment_notifier_test.dart
git commit -m "feat: wire notification scheduling into invoice create/payment lifecycle"
Task 21: Settings Screen
Files:
-
Create:
lib/features/settings/settings_screen.dart -
Step 1: Create settings screen
// lib/features/settings/settings_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/providers/business_provider.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final businessAsync = ref.watch(businessProvider);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
// Business profile section
businessAsync.when(
data: (biz) => ListTile(
leading: const Icon(Icons.business),
title: Text(biz?.name ?? 'Set up business'),
subtitle: const Text('Edit business profile'),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/onboarding'),
),
loading: () => const ListTile(
leading: CircularProgressIndicator(),
title: Text('Loading...'),
),
error: (e, _) => ListTile(title: Text('Error: $e')),
),
const Divider(),
// Subscription
ListTile(
leading: const Icon(Icons.star),
title: const Text('Subscription'),
subtitle: const Text('Free Plan'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
context.push('/paywall');
},
),
const Divider(),
// About
const ListTile(
leading: Icon(Icons.info),
title: Text('About SwiftInvoice'),
subtitle: Text('Version 1.0.0'),
),
],
),
);
}
}
- Step 2: Update router to use SettingsScreen
Replace the /settings placeholder in app.dart.
- Step 3: Commit
git add lib/features/settings/settings_screen.dart lib/app.dart
git commit -m "feat: add settings screen with business profile editing"
Phase 4: Monetization & Launch (Weeks 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
// test/core/services/subscription_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/services/subscription_service.dart';
void main() {
test('tierFromEntitlement maps correctly', () {
expect(SubscriptionService.tierFromEntitlement(null), 'free');
expect(SubscriptionService.tierFromEntitlement('pro'), 'pro');
expect(SubscriptionService.tierFromEntitlement('lifetime'), 'lifetime');
});
test('isProOrAbove checks correctly', () {
expect(SubscriptionService.isProOrAbove('free'), isFalse);
expect(SubscriptionService.isProOrAbove('pro'), isTrue);
expect(SubscriptionService.isProOrAbove('lifetime'), isTrue);
});
}
- Step 2: Run tests to verify they fail
flutter test test/core/services/subscription_service_test.dart
Expected: FAIL.
- Step 3: Implement SubscriptionService
// lib/core/services/subscription_service.dart
import 'package:purchases_flutter/purchases_flutter.dart';
import '../database/daos/app_settings_dao.dart';
class SubscriptionService {
final AppSettingsDao _settingsDao;
SubscriptionService(this._settingsDao);
static const _revenueCatApiKey = String.fromEnvironment(
'REVENUECAT_API_KEY',
defaultValue: '', // Set via --dart-define at build time
);
Future<void> initialize() async {
if (_revenueCatApiKey.isEmpty) return; // Skip in dev/test
final config = PurchasesConfiguration(_revenueCatApiKey);
await Purchases.configure(config);
await syncSubscriptionStatus();
}
Future<void> syncSubscriptionStatus() async {
try {
final customerInfo = await Purchases.getCustomerInfo();
final entitlement = customerInfo.entitlements.active['pro'];
final tier = tierFromEntitlement(entitlement?.identifier);
await _settingsDao.setSubscriptionTier(tier);
} catch (_) {
// Offline or error — keep cached tier
}
}
static String tierFromEntitlement(String? entitlementId) {
if (entitlementId == null) return 'free';
if (entitlementId == 'lifetime') return 'lifetime';
return 'pro';
}
static bool isProOrAbove(String tier) {
return tier == 'pro' || tier == 'lifetime';
}
Future<bool> purchase(String packageId) async {
try {
final offerings = await Purchases.getOfferings();
final package = offerings.current?.availablePackages
.firstWhere((p) => p.identifier == packageId);
if (package == null) return false;
await Purchases.purchasePackage(package);
await syncSubscriptionStatus();
return true;
} catch (_) {
return false;
}
}
Future<bool> restorePurchases() async {
try {
await Purchases.restorePurchases();
await syncSubscriptionStatus();
final tier = await _settingsDao.getSubscriptionTier();
return tier != 'free';
} catch (_) {
return false;
}
}
}
- Step 4: Run tests
flutter test test/core/services/subscription_service_test.dart -v
Expected: All 2 tests PASS.
- Step 5: Commit
git add lib/core/services/subscription_service.dart \
test/core/services/subscription_service_test.dart
git commit -m "feat: implement RevenueCat subscription service with offline-safe tier caching"
Task 23: Paywall Screen
Files:
-
Create:
lib/features/paywall/paywall_screen.dart -
Create:
lib/features/paywall/paywall_notifier.dart -
Create:
test/features/paywall/paywall_notifier_test.dart -
Step 1: Write failing tests for PaywallNotifier
// test/features/paywall/paywall_notifier_test.dart
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_invoice/core/database/database.dart';
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';
import 'package:swift_invoice/features/paywall/paywall_notifier.dart';
void main() {
late AppDatabase db;
late PaywallNotifier notifier;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
notifier = PaywallNotifier(AppSettingsDao(db));
});
tearDown(() => db.close());
test('initial state is free tier', () async {
await notifier.loadTier();
expect(notifier.state.currentTier, 'free');
expect(notifier.state.isPro, isFalse);
});
test('after upgrade, isPro is true', () async {
await AppSettingsDao(db).setSubscriptionTier('pro');
await notifier.loadTier();
expect(notifier.state.isPro, isTrue);
});
}
- Step 2: Run tests to verify they fail
flutter test test/features/paywall/paywall_notifier_test.dart
Expected: FAIL.
- Step 3: Implement PaywallNotifier
// lib/features/paywall/paywall_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/database/daos/app_settings_dao.dart';
import '../../core/services/subscription_service.dart';
class PaywallState {
final String currentTier;
final bool isPurchasing;
final String? error;
bool get isPro => SubscriptionService.isProOrAbove(currentTier);
const PaywallState({
this.currentTier = 'free',
this.isPurchasing = false,
this.error,
});
PaywallState copyWith({
String? currentTier,
bool? isPurchasing,
String? error,
}) {
return PaywallState(
currentTier: currentTier ?? this.currentTier,
isPurchasing: isPurchasing ?? this.isPurchasing,
error: error,
);
}
}
class PaywallNotifier extends StateNotifier<PaywallState> {
final AppSettingsDao _settingsDao;
PaywallNotifier(this._settingsDao) : super(const PaywallState());
Future<void> loadTier() async {
final tier = await _settingsDao.getSubscriptionTier();
state = state.copyWith(currentTier: tier);
}
}
- Step 4: Run tests
flutter test test/features/paywall/paywall_notifier_test.dart -v
Expected: All 2 tests PASS.
- Step 5: Create paywall screen UI
// lib/features/paywall/paywall_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/services/subscription_service.dart';
import '../../core/database/daos/app_settings_dao.dart';
import '../../core/providers/database_provider.dart';
final subscriptionServiceProvider = Provider<SubscriptionService>((ref) {
return SubscriptionService(AppSettingsDao(ref.read(databaseProvider)));
});
class PaywallScreen extends ConsumerWidget {
const PaywallScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final subService = ref.read(subscriptionServiceProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Upgrade to Pro'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Text(
'Unlock All Features',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
// Plan cards
_PlanCard(
title: 'Monthly',
price: '\$3.99/mo',
badge: null,
onTap: () async {
final success = await subService.purchase('monthly');
if (success && context.mounted) Navigator.pop(context);
},
),
const SizedBox(height: 12),
_PlanCard(
title: 'Yearly',
price: '\$29.99/yr',
badge: 'Save 37%',
onTap: () async {
final success = await subService.purchase('yearly');
if (success && context.mounted) Navigator.pop(context);
},
),
const SizedBox(height: 12),
_PlanCard(
title: 'Lifetime',
price: '\$49.99',
badge: 'Best Value',
highlighted: true,
onTap: () async {
final success = await subService.purchase('lifetime');
if (success && context.mounted) Navigator.pop(context);
},
),
const SizedBox(height: 24),
// Feature list
const _FeatureRow(text: 'Unlimited invoices', free: false, pro: true),
const _FeatureRow(text: 'Unlimited clients', free: false, pro: true),
const _FeatureRow(text: 'Estimates & conversion', free: false, pro: true),
const _FeatureRow(text: 'Payment tracking', free: false, pro: true),
const _FeatureRow(text: 'Overdue reminders', free: false, pro: true),
const _FeatureRow(text: 'No watermark on PDFs', free: false, pro: true),
const _FeatureRow(text: 'PDF invoicing', free: true, pro: true),
const _FeatureRow(text: '3 invoices/month', free: true, pro: true),
const _FeatureRow(text: '2 clients', free: true, pro: true),
const SizedBox(height: 16),
TextButton(
onPressed: () async {
final restored = await subService.restorePurchases();
if (context.mounted) {
if (restored) {
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No purchases found to restore')),
);
}
}
},
child: const Text('Restore Purchases'),
),
],
),
),
);
}
}
class _PlanCard extends StatelessWidget {
final String title;
final String price;
final String? badge;
final bool highlighted;
final VoidCallback onTap;
const _PlanCard({
required this.title,
required this.price,
this.badge,
this.highlighted = false,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: highlighted ? 4 : 1,
shape: highlighted
? RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2),
)
: null,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold)),
Text(price, style: TextStyle(
color: Theme.of(context).colorScheme.primary)),
],
),
),
if (badge != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(badge!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimaryContainer,
)),
),
],
),
),
),
);
}
}
class _FeatureRow extends StatelessWidget {
final String text;
final bool free;
final bool pro;
const _FeatureRow({required this.text, required this.free, required this.pro});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(flex: 3, child: Text(text)),
Expanded(
child: Icon(
free ? Icons.check_circle : Icons.cancel,
color: free ? Colors.green : Colors.grey,
size: 20,
),
),
Expanded(
child: Icon(
pro ? Icons.check_circle : Icons.cancel,
color: pro ? Colors.green : Colors.grey,
size: 20,
),
),
],
),
);
}
}
- Step 6: Commit
git add lib/features/paywall/ test/features/paywall/
git commit -m "feat: implement paywall screen with 3-tier plan selection"
Task 24: Free Tier Enforcement Integration
Files:
-
Modify:
lib/features/invoices/invoice_creator_notifier.dart— show paywall route instead of just flag -
Modify:
lib/features/clients/client_notifier.dart— show paywall route -
Modify:
lib/app.dart— add paywall route -
Step 1: Add paywall route
// In app.dart, add as a top-level route (not inside ShellRoute):
GoRoute(
path: '/paywall',
builder: (context, state) => const PaywallScreen(),
),
- Step 2: Verify tier gating works end-to-end
Manual test:
- Create 2 clients — 3rd should show paywall
- Create 3 invoices — 4th should show paywall
- Dismiss paywall — back to previous screen
- Step 3: Commit
git add lib/app.dart lib/features/invoices/invoice_creator_notifier.dart \
lib/features/clients/client_notifier.dart
git commit -m "feat: integrate paywall into invoice/client creation flows"
Task 25: Firebase Analytics Setup
Files:
-
Modify:
lib/main.dart— initialize Firebase -
Create: platform config files (google-services.json, GoogleService-Info.plist)
-
Step 1: Initialize Firebase in main.dart
// Update lib/main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:swift_invoice/core/services/notification_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await NotificationService.initialize();
runApp(const ProviderScope(child: SwiftInvoiceApp()));
}
- Step 2: Generate Firebase config files using FlutterFire CLI
# Install FlutterFire CLI
dart pub global activate flutterfire_cli
# Run the configurator (interactive — select your Firebase project + platforms)
flutterfire configure --project=your-firebase-project-id
This generates:
lib/firebase_options.dart— platform-specific config (commit this)android/app/google-services.json— Android configios/Runner/GoogleService-Info.plist— iOS config
Update main.dart to use the generated options:
import 'firebase_options.dart';
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
Add to .gitignore:
android/app/google-services.json
ios/Runner/GoogleService-Info.plist
- Step 3: Commit
git add lib/main.dart
git commit -m "feat: initialize Firebase Analytics and local notifications on app start"
Task 26: Polish, Testing & Store Submission
- Step 1: Run full test suite
flutter test --coverage
Expected: All tests pass. Coverage report generated.
- Step 2: Fix any remaining lint warnings
flutter analyze
Expected: No issues found.
- Step 3: Test offline workflow
Enable airplane mode. Verify:
-
Create client
-
Create invoice
-
Generate PDF
-
Share via email draft All work without errors.
-
Step 4: Performance check
With 100 invoices in DB, dashboard should load in <200ms. Profile with:
flutter run --profile
- Step 5: App icons and splash screen
Generate icons using flutter_launcher_icons package. Add to pubspec.yaml:
dev_dependencies:
flutter_launcher_icons: ^0.14.0
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/icon/app_icon.png"
dart run flutter_launcher_icons
- Step 6: Build release APK and IPA
flutter build apk --release
flutter build ipa --release # requires Xcode on macOS
-
Step 7: Submit to stores
-
Android: Upload APK to Google Play Console
-
iOS: Upload IPA via Xcode or Codemagic CI
-
Step 8: Final commit
git add .
git commit -m "chore: polish, app icons, and store submission prep"
Summary
| Phase | Tasks | Key Deliverable |
|---|---|---|
| 1: Foundation | 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+