7394 lines
226 KiB
Markdown
7394 lines
226 KiB
Markdown
# SwiftInvoice MVP Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build a cross-platform offline-first invoicing app for freelancers using Flutter, targeting App Store submission in 9 weeks.
|
||
|
||
**Architecture:** Offline-first SQLite (Drift ORM) with UUID keys for future cloud sync. Unified `documents` table for invoices and estimates with discriminator column. Riverpod for reactive state. Feature-first folder structure. All monetary values stored as integer cents.
|
||
|
||
**Tech Stack:** Flutter 3.x, Dart 3.x, Drift 2.x (SQLite), Riverpod 2.x, `pdf` package, `flutter_local_notifications`, `purchases_flutter` (RevenueCat), Firebase Analytics
|
||
|
||
**Spec reference:** `docs/SwiftInvoice_Implementation_Plan.md`
|
||
|
||
---
|
||
|
||
## Scope Breakdown — 4 Phases
|
||
|
||
| Phase | Tasks | Produces |
|
||
|-------|-------|----------|
|
||
| **Phase 1: Foundation** (Weeks 1–2) | Tasks 1–7 | Scaffold, DB, navigation, onboarding — app boots and persists business profile |
|
||
| **Phase 2: Core Invoicing** (Weeks 3–5) | Tasks 8–14 | Clients CRUD, invoice creator, dashboard — user can create clients + invoices |
|
||
| **Phase 3: PDF & Pro Features** (Weeks 6–7) | Tasks 15–21 | PDF export, estimates, payments, notifications — full feature set |
|
||
| **Phase 4: Monetization & Launch** (Weeks 8–9) | Tasks 22–26 | RevenueCat IAP, paywall, polish, store submission |
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
swift_invoice/
|
||
lib/
|
||
main.dart # Entry point, ProviderScope
|
||
app.dart # MaterialApp.router, theme
|
||
core/
|
||
database/
|
||
database.dart # AppDatabase class (Drift)
|
||
database.g.dart # Generated (drift_dev)
|
||
tables/
|
||
businesses.dart # Businesses table definition
|
||
clients.dart # Clients table definition
|
||
documents.dart # Documents table definition
|
||
line_items.dart # LineItems table definition
|
||
payments.dart # Payments table definition
|
||
app_settings.dart # AppSettings table definition
|
||
daos/
|
||
business_dao.dart # Business CRUD
|
||
client_dao.dart # Client CRUD + search
|
||
document_dao.dart # Document CRUD + status queries
|
||
line_item_dao.dart # Line item CRUD
|
||
payment_dao.dart # Payment CRUD + balance updates
|
||
app_settings_dao.dart # Key-value settings
|
||
models/
|
||
enums.dart # DocumentType, DocumentStatus, PaymentMethod, DiscountType
|
||
providers/
|
||
database_provider.dart # Riverpod provider for AppDatabase
|
||
business_provider.dart # Business state
|
||
client_provider.dart # Client list/detail state
|
||
document_provider.dart # Invoice/estimate state
|
||
payment_provider.dart # Payment state
|
||
settings_provider.dart # App settings state
|
||
subscription_provider.dart # Tier gating logic
|
||
services/
|
||
pdf_service.dart # PDF generation
|
||
notification_service.dart # Local notification scheduling
|
||
subscription_service.dart # RevenueCat wrapper
|
||
document_calculator.dart # Subtotal/tax/discount/total logic
|
||
utils/
|
||
currency_formatter.dart # Cents → display string
|
||
date_formatter.dart # ISO 8601 helpers
|
||
uuid_generator.dart # UUID v4 wrapper
|
||
validators.dart # Form field validators
|
||
theme/
|
||
app_theme.dart # Material 3 theme data
|
||
app_colors.dart # Color constants
|
||
features/
|
||
onboarding/
|
||
onboarding_screen.dart # Business profile setup form
|
||
onboarding_notifier.dart # Form state + save logic
|
||
invoices/
|
||
invoice_dashboard_screen.dart # Home: stats + list
|
||
invoice_dashboard_notifier.dart # Dashboard state
|
||
invoice_creator_screen.dart # Single-screen invoice form
|
||
invoice_creator_notifier.dart # Creator form state
|
||
invoice_detail_screen.dart # Read-only view + actions
|
||
widgets/
|
||
status_pill.dart # Colored status badge
|
||
summary_card.dart # Stat card (Outstanding, Overdue, etc.)
|
||
line_item_row.dart # Editable line item widget
|
||
estimates/
|
||
estimate_list_screen.dart # Estimate tab
|
||
estimate_creator_screen.dart # Estimate form (reuses invoice form)
|
||
estimate_creator_notifier.dart # Estimate form state
|
||
clients/
|
||
client_list_screen.dart # Alphabetical client list
|
||
client_form_screen.dart # Create/edit client
|
||
client_detail_screen.dart # Client detail + doc history
|
||
client_notifier.dart # Client state
|
||
payments/
|
||
payment_bottom_sheet.dart # Record payment form
|
||
payment_notifier.dart # Payment state
|
||
pdf/
|
||
pdf_template.dart # PDF layout builder
|
||
pdf_preview_screen.dart # Preview + share
|
||
paywall/
|
||
paywall_screen.dart # 3-plan paywall
|
||
paywall_notifier.dart # Purchase flow state
|
||
settings/
|
||
settings_screen.dart # App settings
|
||
shared/
|
||
widgets/
|
||
app_scaffold.dart # Bottom nav shell
|
||
search_dropdown.dart # Searchable dropdown
|
||
confirm_dialog.dart # Confirmation dialog
|
||
test/
|
||
core/
|
||
database/
|
||
daos/
|
||
business_dao_test.dart
|
||
client_dao_test.dart
|
||
document_dao_test.dart
|
||
line_item_dao_test.dart
|
||
payment_dao_test.dart
|
||
app_settings_dao_test.dart
|
||
services/
|
||
document_calculator_test.dart
|
||
pdf_service_test.dart
|
||
notification_service_test.dart
|
||
subscription_service_test.dart
|
||
utils/
|
||
currency_formatter_test.dart
|
||
validators_test.dart
|
||
features/
|
||
onboarding/
|
||
onboarding_notifier_test.dart
|
||
invoices/
|
||
invoice_dashboard_notifier_test.dart
|
||
invoice_creator_notifier_test.dart
|
||
estimates/
|
||
estimate_creator_notifier_test.dart
|
||
clients/
|
||
client_notifier_test.dart
|
||
payments/
|
||
payment_notifier_test.dart
|
||
paywall/
|
||
paywall_notifier_test.dart
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 1: Foundation (Weeks 1–2)
|
||
|
||
### Task 1: Project Scaffold & Dependencies
|
||
|
||
**Files:**
|
||
- Create: `swift_invoice/` (Flutter project)
|
||
- Modify: `pubspec.yaml`
|
||
- Create: `lib/main.dart`
|
||
- Create: `lib/app.dart`
|
||
- Create: `lib/core/theme/app_theme.dart`
|
||
- Create: `lib/core/theme/app_colors.dart`
|
||
|
||
- [ ] **Step 1: Create Flutter project**
|
||
|
||
```bash
|
||
flutter create swift_invoice --org com.swiftinvoice --platforms ios,android
|
||
cd swift_invoice
|
||
```
|
||
|
||
- [ ] **Step 2: Add dependencies to pubspec.yaml**
|
||
|
||
```yaml
|
||
dependencies:
|
||
flutter:
|
||
sdk: flutter
|
||
drift: ^2.22.0
|
||
sqlite3_flutter_libs: ^0.5.0
|
||
path_provider: ^2.1.0
|
||
path: ^1.9.0
|
||
flutter_riverpod: ^2.6.0
|
||
riverpod_annotation: ^2.6.0
|
||
uuid: ^4.5.0
|
||
pdf: ^3.11.0
|
||
printing: ^5.13.0
|
||
share_plus: ^10.1.0
|
||
flutter_local_notifications: ^18.0.0
|
||
purchases_flutter: ^8.6.0
|
||
firebase_core: ^3.8.0
|
||
firebase_analytics: ^11.4.0
|
||
go_router: ^14.6.0
|
||
intl: ^0.19.0
|
||
image_picker: ^1.1.0
|
||
|
||
dev_dependencies:
|
||
flutter_test:
|
||
sdk: flutter
|
||
drift_dev: ^2.22.0
|
||
build_runner: ^2.4.0
|
||
riverpod_generator: ^2.6.0
|
||
mocktail: ^1.0.0
|
||
flutter_lints: ^5.0.0
|
||
```
|
||
|
||
```bash
|
||
flutter pub get
|
||
```
|
||
|
||
- [ ] **Step 3: Create app theme**
|
||
|
||
```dart
|
||
// lib/core/theme/app_colors.dart
|
||
import 'package:flutter/material.dart';
|
||
|
||
class AppColors {
|
||
static const Color primary = Color(0xFF2563EB); // Blue 600
|
||
static const Color success = Color(0xFF16A34A); // Green 600
|
||
static const Color warning = Color(0xFFF59E0B); // Amber 500
|
||
static const Color error = Color(0xFFDC2626); // Red 600
|
||
static const Color neutral = Color(0xFF6B7280); // Gray 500
|
||
|
||
// Status pill colors
|
||
static const Color statusDraft = Color(0xFF9CA3AF); // Gray 400
|
||
static const Color statusSent = Color(0xFF3B82F6); // Blue 500
|
||
static const Color statusPaid = Color(0xFF22C55E); // Green 500
|
||
static const Color statusOverdue = Color(0xFFEF4444); // Red 500
|
||
static const Color statusPartial = Color(0xFFF97316); // Orange 500
|
||
static const Color statusVoid = Color(0xFF6B7280); // Gray 500
|
||
}
|
||
```
|
||
|
||
```dart
|
||
// lib/core/theme/app_theme.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'app_colors.dart';
|
||
|
||
class AppTheme {
|
||
static ThemeData light() {
|
||
return ThemeData(
|
||
useMaterial3: true,
|
||
colorSchemeSeed: AppColors.primary,
|
||
brightness: Brightness.light,
|
||
inputDecorationTheme: const InputDecorationTheme(
|
||
border: OutlineInputBorder(),
|
||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
),
|
||
cardTheme: const CardTheme(
|
||
elevation: 1,
|
||
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create main.dart and app.dart**
|
||
|
||
```dart
|
||
// lib/main.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'app.dart';
|
||
|
||
void main() {
|
||
WidgetsFlutterBinding.ensureInitialized();
|
||
runApp(const ProviderScope(child: SwiftInvoiceApp()));
|
||
}
|
||
```
|
||
|
||
```dart
|
||
// lib/app.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'core/theme/app_theme.dart';
|
||
|
||
class SwiftInvoiceApp extends ConsumerWidget {
|
||
const SwiftInvoiceApp({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
return MaterialApp(
|
||
title: 'SwiftInvoice',
|
||
theme: AppTheme.light(),
|
||
home: const Scaffold(
|
||
body: Center(child: Text('SwiftInvoice')),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Write smoke widget test**
|
||
|
||
```dart
|
||
// test/app_test.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:swift_invoice/app.dart';
|
||
|
||
void main() {
|
||
testWidgets('app renders without crashing', (tester) async {
|
||
await tester.pumpWidget(const ProviderScope(child: SwiftInvoiceApp()));
|
||
expect(find.text('SwiftInvoice'), findsOneWidget);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run smoke test**
|
||
|
||
```bash
|
||
flutter test test/app_test.dart -v
|
||
```
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: Verify app builds and runs**
|
||
|
||
```bash
|
||
flutter run -d chrome # or connected device
|
||
```
|
||
Expected: App launches showing "SwiftInvoice" centered text.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git init
|
||
git add .
|
||
git commit -m "feat: scaffold Flutter project with dependencies and Material 3 theme"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Drift Database — Table Definitions
|
||
|
||
**Files:**
|
||
- Create: `lib/core/models/enums.dart`
|
||
- Create: `lib/core/database/tables/businesses.dart`
|
||
- Create: `lib/core/database/tables/clients.dart`
|
||
- Create: `lib/core/database/tables/documents.dart`
|
||
- Create: `lib/core/database/tables/line_items.dart`
|
||
- Create: `lib/core/database/tables/payments.dart`
|
||
- Create: `lib/core/database/tables/app_settings.dart`
|
||
|
||
- [ ] **Step 1: Define enums**
|
||
|
||
```dart
|
||
// lib/core/models/enums.dart
|
||
|
||
enum DocumentType {
|
||
invoice,
|
||
estimate;
|
||
|
||
String get value => name;
|
||
|
||
static DocumentType fromString(String s) =>
|
||
DocumentType.values.firstWhere((e) => e.name == s);
|
||
}
|
||
|
||
enum DocumentStatus {
|
||
// Invoice statuses
|
||
draft,
|
||
sent,
|
||
partial,
|
||
paid,
|
||
overdue,
|
||
void_,
|
||
|
||
// Estimate statuses
|
||
accepted,
|
||
declined,
|
||
expired,
|
||
converted;
|
||
|
||
String get value => this == void_ ? 'void' : name;
|
||
|
||
String get displayName => switch (this) {
|
||
void_ => 'Void',
|
||
_ => name[0].toUpperCase() + name.substring(1),
|
||
};
|
||
|
||
static DocumentStatus fromString(String s) {
|
||
if (s == 'void') return DocumentStatus.void_;
|
||
return DocumentStatus.values.firstWhere((e) => e.name == s);
|
||
}
|
||
}
|
||
|
||
enum PaymentMethod {
|
||
cash,
|
||
card,
|
||
bankTransfer,
|
||
check,
|
||
other;
|
||
|
||
String get value => switch (this) {
|
||
bankTransfer => 'bank_transfer',
|
||
_ => name,
|
||
};
|
||
|
||
static PaymentMethod fromString(String s) => switch (s) {
|
||
'bank_transfer' => PaymentMethod.bankTransfer,
|
||
_ => PaymentMethod.values.firstWhere((e) => e.name == s),
|
||
};
|
||
}
|
||
|
||
enum DiscountType {
|
||
percentage,
|
||
fixed;
|
||
|
||
String get value => name;
|
||
|
||
static DiscountType fromString(String s) =>
|
||
DiscountType.values.firstWhere((e) => e.name == s);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Define all Drift table classes**
|
||
|
||
```dart
|
||
// lib/core/database/tables/businesses.dart
|
||
import 'package:drift/drift.dart';
|
||
|
||
class Businesses extends Table {
|
||
TextColumn get id => text()();
|
||
TextColumn get name => text()();
|
||
TextColumn get email => text().nullable()();
|
||
TextColumn get phone => text().nullable()();
|
||
TextColumn get addressLine1 => text().nullable()();
|
||
TextColumn get addressLine2 => text().nullable()();
|
||
TextColumn get city => text().nullable()();
|
||
TextColumn get state => text().nullable()();
|
||
TextColumn get postalCode => text().nullable()();
|
||
TextColumn get countryCode => text().withDefault(const Constant('US'))();
|
||
TextColumn get taxNumber => text().nullable()();
|
||
TextColumn get logoPath => text().nullable()();
|
||
TextColumn get defaultCurrency => text().withDefault(const Constant('USD'))();
|
||
IntColumn get defaultTaxRate => integer().withDefault(const Constant(0))();
|
||
IntColumn get defaultPaymentTermsDays => integer().withDefault(const Constant(30))();
|
||
TextColumn get invoicePrefix => text().withDefault(const Constant('INV'))();
|
||
TextColumn get estimatePrefix => text().withDefault(const Constant('EST'))();
|
||
IntColumn get nextInvoiceNumber => integer().withDefault(const Constant(1))();
|
||
IntColumn get nextEstimateNumber => integer().withDefault(const Constant(1))();
|
||
TextColumn get createdAt => text()();
|
||
TextColumn get updatedAt => text()();
|
||
|
||
@override
|
||
Set<Column> get primaryKey => {id};
|
||
}
|
||
```
|
||
|
||
```dart
|
||
// lib/core/database/tables/clients.dart
|
||
import 'package:drift/drift.dart';
|
||
|
||
class Clients extends Table {
|
||
TextColumn get id => text()();
|
||
TextColumn get businessId => text().references(Businesses, #id)();
|
||
TextColumn get name => text()();
|
||
TextColumn get email => text().nullable()();
|
||
TextColumn get phone => text().nullable()();
|
||
TextColumn get addressLine1 => text().nullable()();
|
||
TextColumn get addressLine2 => text().nullable()();
|
||
TextColumn get city => text().nullable()();
|
||
TextColumn get state => text().nullable()();
|
||
TextColumn get postalCode => text().nullable()();
|
||
TextColumn get countryCode => text().nullable()();
|
||
TextColumn get notes => text().nullable()();
|
||
IntColumn get outstandingBalance => integer().withDefault(const Constant(0))();
|
||
IntColumn get isDeleted => integer().withDefault(const Constant(0))();
|
||
TextColumn get createdAt => text()();
|
||
TextColumn get updatedAt => text()();
|
||
|
||
@override
|
||
Set<Column> get primaryKey => {id};
|
||
}
|
||
```
|
||
|
||
```dart
|
||
// lib/core/database/tables/documents.dart
|
||
import 'package:drift/drift.dart';
|
||
|
||
class Documents extends Table {
|
||
TextColumn get id => text()();
|
||
TextColumn get businessId => text()();
|
||
TextColumn get clientId => text()();
|
||
TextColumn get documentType => text()();
|
||
TextColumn get documentNumber => text().unique()();
|
||
TextColumn get status => text()();
|
||
TextColumn get issueDate => text()();
|
||
TextColumn get dueDate => text().nullable()();
|
||
TextColumn get currencyCode => text().withDefault(const Constant('USD'))();
|
||
IntColumn get subtotal => integer().withDefault(const Constant(0))();
|
||
IntColumn get taxRate => integer().withDefault(const Constant(0))();
|
||
IntColumn get taxAmount => integer().withDefault(const Constant(0))();
|
||
TextColumn get discountType => text().nullable()();
|
||
IntColumn get discountValue => integer().withDefault(const Constant(0))();
|
||
IntColumn get discountAmount => integer().withDefault(const Constant(0))();
|
||
IntColumn get total => integer().withDefault(const Constant(0))();
|
||
IntColumn get amountPaid => integer().withDefault(const Constant(0))();
|
||
IntColumn get amountDue => integer().withDefault(const Constant(0))();
|
||
TextColumn get docNotes => text().nullable()();
|
||
TextColumn get convertedFromId => text().nullable()();
|
||
IntColumn get isDeleted => integer().withDefault(const Constant(0))();
|
||
TextColumn get createdAt => text()();
|
||
TextColumn get updatedAt => text()();
|
||
|
||
@override
|
||
Set<Column> get primaryKey => {id};
|
||
}
|
||
```
|
||
|
||
```dart
|
||
// lib/core/database/tables/line_items.dart
|
||
import 'package:drift/drift.dart';
|
||
|
||
class LineItems extends Table {
|
||
TextColumn get id => text()();
|
||
TextColumn get documentId => text()();
|
||
TextColumn get description => text()();
|
||
RealColumn get quantity => real().withDefault(const Constant(1.0))();
|
||
IntColumn get unitPrice => integer()();
|
||
IntColumn get amount => integer()();
|
||
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
|
||
TextColumn get createdAt => text()();
|
||
TextColumn get updatedAt => text()();
|
||
|
||
@override
|
||
Set<Column> get primaryKey => {id};
|
||
}
|
||
```
|
||
|
||
```dart
|
||
// lib/core/database/tables/payments.dart
|
||
import 'package:drift/drift.dart';
|
||
|
||
class Payments extends Table {
|
||
TextColumn get id => text()();
|
||
TextColumn get documentId => text()();
|
||
IntColumn get amount => integer()();
|
||
TextColumn get method => text()();
|
||
TextColumn get paidAt => text()();
|
||
TextColumn get notes => text().nullable()();
|
||
TextColumn get createdAt => text()();
|
||
TextColumn get updatedAt => text()();
|
||
|
||
@override
|
||
Set<Column> get primaryKey => {id};
|
||
}
|
||
```
|
||
|
||
```dart
|
||
// lib/core/database/tables/app_settings.dart
|
||
import 'package:drift/drift.dart';
|
||
|
||
class AppSettings extends Table {
|
||
TextColumn get key => text()();
|
||
TextColumn get value => text()();
|
||
TextColumn get updatedAt => text()();
|
||
|
||
@override
|
||
Set<Column> get primaryKey => {key};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add lib/core/models/enums.dart lib/core/database/tables/
|
||
git commit -m "feat: define Drift table classes and domain enums for all 6 MVP tables"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Drift Database — AppDatabase & Code Generation
|
||
|
||
**Files:**
|
||
- Create: `lib/core/database/database.dart`
|
||
- Create: `lib/core/providers/database_provider.dart`
|
||
- Create: `lib/core/utils/uuid_generator.dart`
|
||
|
||
- [ ] **Step 1: Create AppDatabase class**
|
||
|
||
```dart
|
||
// lib/core/database/database.dart
|
||
import 'dart:io';
|
||
import 'package:drift/drift.dart';
|
||
import 'package:drift/native.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:path/path.dart' as p;
|
||
|
||
import 'tables/businesses.dart';
|
||
import 'tables/clients.dart';
|
||
import 'tables/documents.dart';
|
||
import 'tables/line_items.dart';
|
||
import 'tables/payments.dart';
|
||
import 'tables/app_settings.dart';
|
||
|
||
part 'database.g.dart';
|
||
|
||
@DriftDatabase(tables: [
|
||
Businesses,
|
||
Clients,
|
||
Documents,
|
||
LineItems,
|
||
Payments,
|
||
AppSettings,
|
||
])
|
||
class AppDatabase extends _$AppDatabase {
|
||
AppDatabase() : super(_openConnection());
|
||
|
||
AppDatabase.forTesting(super.e);
|
||
|
||
@override
|
||
int get schemaVersion => 1;
|
||
|
||
@override
|
||
MigrationStrategy get migration => MigrationStrategy(
|
||
onCreate: (Migrator m) async {
|
||
await m.createAll();
|
||
// Create indexes
|
||
await customStatement(
|
||
'CREATE INDEX IF NOT EXISTS idx_clients_business ON clients(business_id, is_deleted)');
|
||
await customStatement(
|
||
'CREATE INDEX IF NOT EXISTS idx_documents_client ON documents(client_id, document_type, status)');
|
||
await customStatement(
|
||
'CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status, due_date)');
|
||
await customStatement(
|
||
'CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(document_type, is_deleted)');
|
||
await customStatement(
|
||
'CREATE INDEX IF NOT EXISTS idx_line_items_doc ON line_items(document_id, sort_order)');
|
||
await customStatement(
|
||
'CREATE INDEX IF NOT EXISTS idx_payments_doc ON payments(document_id)');
|
||
},
|
||
);
|
||
}
|
||
|
||
LazyDatabase _openConnection() {
|
||
return LazyDatabase(() async {
|
||
final dbFolder = await getApplicationDocumentsDirectory();
|
||
final file = File(p.join(dbFolder.path, 'swift_invoice.sqlite'));
|
||
return NativeDatabase.createInBackground(file);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create UUID utility**
|
||
|
||
```dart
|
||
// lib/core/utils/uuid_generator.dart
|
||
import 'package:uuid/uuid.dart';
|
||
|
||
const _uuid = Uuid();
|
||
|
||
String generateUuid() => _uuid.v4();
|
||
```
|
||
|
||
- [ ] **Step 3: Create database provider**
|
||
|
||
```dart
|
||
// lib/core/providers/database_provider.dart
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../database/database.dart';
|
||
|
||
final databaseProvider = Provider<AppDatabase>((ref) {
|
||
final db = AppDatabase();
|
||
ref.onDispose(() => db.close());
|
||
return db;
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run code generation**
|
||
|
||
```bash
|
||
dart run build_runner build --delete-conflicting-outputs
|
||
```
|
||
Expected: `database.g.dart` generated without errors.
|
||
|
||
- [ ] **Step 5: Write schema validation test**
|
||
|
||
```dart
|
||
// test/core/database/database_test.dart
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/database/database.dart';
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
|
||
setUp(() {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('database creates all 6 tables without error', () async {
|
||
// Verify each table is accessible by selecting from it
|
||
expect(await db.select(db.businesses).get(), isEmpty);
|
||
expect(await db.select(db.clients).get(), isEmpty);
|
||
expect(await db.select(db.documents).get(), isEmpty);
|
||
expect(await db.select(db.lineItems).get(), isEmpty);
|
||
expect(await db.select(db.payments).get(), isEmpty);
|
||
expect(await db.select(db.appSettings).get(), isEmpty);
|
||
});
|
||
|
||
test('schema version is 1', () {
|
||
expect(db.schemaVersion, 1);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run schema test**
|
||
|
||
```bash
|
||
flutter test test/core/database/database_test.dart -v
|
||
```
|
||
Expected: All 2 tests PASS.
|
||
|
||
- [ ] **Step 7: Verify app still compiles**
|
||
|
||
```bash
|
||
flutter build apk --debug 2>&1 | tail -5
|
||
```
|
||
Expected: BUILD SUCCESSFUL
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add lib/core/database/database.dart lib/core/database/database.g.dart \
|
||
lib/core/utils/uuid_generator.dart lib/core/providers/database_provider.dart \
|
||
test/core/database/database_test.dart
|
||
git commit -m "feat: wire up AppDatabase with Drift code generation, indexes, and schema tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: DAOs — Business & AppSettings
|
||
|
||
**Files:**
|
||
- Create: `lib/core/database/daos/business_dao.dart`
|
||
- Create: `lib/core/database/daos/app_settings_dao.dart`
|
||
- Create: `test/core/database/daos/business_dao_test.dart`
|
||
- Create: `test/core/database/daos/app_settings_dao_test.dart`
|
||
|
||
- [ ] **Step 1: Write failing tests for BusinessDao**
|
||
|
||
```dart
|
||
// test/core/database/daos/business_dao_test.dart
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/database/database.dart';
|
||
import 'package:swift_invoice/core/database/daos/business_dao.dart';
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
late BusinessDao dao;
|
||
|
||
setUp(() {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
dao = BusinessDao(db);
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('createBusiness inserts and returns a business', () async {
|
||
final biz = await dao.createBusiness(name: 'Test Co');
|
||
expect(biz.name, 'Test Co');
|
||
expect(biz.id, isNotEmpty);
|
||
expect(biz.defaultCurrency, 'USD');
|
||
expect(biz.defaultPaymentTermsDays, 30);
|
||
});
|
||
|
||
test('getBusiness returns the single business row', () async {
|
||
await dao.createBusiness(name: 'Test Co');
|
||
final biz = await dao.getBusiness();
|
||
expect(biz, isNotNull);
|
||
expect(biz!.name, 'Test Co');
|
||
});
|
||
|
||
test('getBusiness returns null when no business exists', () async {
|
||
final biz = await dao.getBusiness();
|
||
expect(biz, isNull);
|
||
});
|
||
|
||
test('updateBusiness modifies fields', () async {
|
||
final biz = await dao.createBusiness(name: 'Old Name');
|
||
await dao.updateBusiness(biz.id, name: 'New Name', email: 'a@b.com');
|
||
final updated = await dao.getBusiness();
|
||
expect(updated!.name, 'New Name');
|
||
expect(updated.email, 'a@b.com');
|
||
});
|
||
|
||
test('incrementInvoiceNumber returns current and increments', () async {
|
||
final biz = await dao.createBusiness(name: 'Co');
|
||
final num1 = await dao.getAndIncrementInvoiceNumber(biz.id);
|
||
expect(num1, 1);
|
||
final num2 = await dao.getAndIncrementInvoiceNumber(biz.id);
|
||
expect(num2, 2);
|
||
});
|
||
|
||
test('incrementEstimateNumber returns current and increments', () async {
|
||
final biz = await dao.createBusiness(name: 'Co');
|
||
final num1 = await dao.getAndIncrementEstimateNumber(biz.id);
|
||
expect(num1, 1);
|
||
final num2 = await dao.getAndIncrementEstimateNumber(biz.id);
|
||
expect(num2, 2);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
```bash
|
||
flutter test test/core/database/daos/business_dao_test.dart
|
||
```
|
||
Expected: FAIL — `BusinessDao` not found.
|
||
|
||
- [ ] **Step 3: Implement BusinessDao**
|
||
|
||
```dart
|
||
// lib/core/database/daos/business_dao.dart
|
||
import 'package:drift/drift.dart';
|
||
import '../database.dart';
|
||
import '../../utils/uuid_generator.dart';
|
||
|
||
class BusinessDao {
|
||
final AppDatabase _db;
|
||
|
||
BusinessDao(this._db);
|
||
|
||
Future<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**
|
||
|
||
```bash
|
||
flutter test test/core/database/daos/business_dao_test.dart -v
|
||
```
|
||
Expected: All 6 tests PASS.
|
||
|
||
- [ ] **Step 5: Write failing tests for AppSettingsDao**
|
||
|
||
```dart
|
||
// test/core/database/daos/app_settings_dao_test.dart
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/database/database.dart';
|
||
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
late AppSettingsDao dao;
|
||
|
||
setUp(() {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
dao = AppSettingsDao(db);
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('getSetting returns null for non-existent key', () async {
|
||
expect(await dao.getSetting('foo'), isNull);
|
||
});
|
||
|
||
test('setSetting creates and retrieves a setting', () async {
|
||
await dao.setSetting('theme', 'dark');
|
||
expect(await dao.getSetting('theme'), 'dark');
|
||
});
|
||
|
||
test('setSetting overwrites existing value', () async {
|
||
await dao.setSetting('theme', 'dark');
|
||
await dao.setSetting('theme', 'light');
|
||
expect(await dao.getSetting('theme'), 'light');
|
||
});
|
||
|
||
test('isOnboardingComplete returns false by default', () async {
|
||
expect(await dao.isOnboardingComplete(), isFalse);
|
||
});
|
||
|
||
test('markOnboardingComplete sets flag', () async {
|
||
await dao.markOnboardingComplete();
|
||
expect(await dao.isOnboardingComplete(), isTrue);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/core/database/daos/app_settings_dao_test.dart
|
||
```
|
||
Expected: FAIL — `AppSettingsDao` not found.
|
||
|
||
- [ ] **Step 7: Implement AppSettingsDao**
|
||
|
||
```dart
|
||
// lib/core/database/daos/app_settings_dao.dart
|
||
import 'package:drift/drift.dart';
|
||
import '../database.dart';
|
||
|
||
class AppSettingsDao {
|
||
final AppDatabase _db;
|
||
|
||
AppSettingsDao(this._db);
|
||
|
||
Future<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**
|
||
|
||
```bash
|
||
flutter test test/core/database/daos/ -v
|
||
```
|
||
Expected: All 11 tests PASS.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add lib/core/database/daos/business_dao.dart lib/core/database/daos/app_settings_dao.dart \
|
||
test/core/database/daos/
|
||
git commit -m "feat: implement BusinessDao and AppSettingsDao with full test coverage"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: DAOs — Client & Document
|
||
|
||
**Files:**
|
||
- Create: `lib/core/database/daos/client_dao.dart`
|
||
- Create: `lib/core/database/daos/document_dao.dart`
|
||
- Create: `lib/core/database/daos/line_item_dao.dart`
|
||
- Create: `test/core/database/daos/client_dao_test.dart`
|
||
- Create: `test/core/database/daos/document_dao_test.dart`
|
||
- Create: `test/core/database/daos/line_item_dao_test.dart`
|
||
|
||
- [ ] **Step 1: Write failing tests for ClientDao**
|
||
|
||
```dart
|
||
// test/core/database/daos/client_dao_test.dart
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/database/database.dart';
|
||
import 'package:swift_invoice/core/database/daos/business_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/client_dao.dart';
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
late ClientDao dao;
|
||
late String businessId;
|
||
|
||
setUp(() async {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
dao = ClientDao(db);
|
||
final biz = await BusinessDao(db).createBusiness(name: 'Test Co');
|
||
businessId = biz.id;
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('createClient inserts and returns client', () async {
|
||
final client = await dao.createClient(
|
||
businessId: businessId,
|
||
name: 'Jane Doe',
|
||
email: 'jane@example.com',
|
||
);
|
||
expect(client.name, 'Jane Doe');
|
||
expect(client.email, 'jane@example.com');
|
||
expect(client.outstandingBalance, 0);
|
||
});
|
||
|
||
test('getActiveClients returns only non-deleted clients', () async {
|
||
await dao.createClient(businessId: businessId, name: 'Active');
|
||
final deleted = await dao.createClient(businessId: businessId, name: 'Deleted');
|
||
await dao.softDeleteClient(deleted.id);
|
||
|
||
final clients = await dao.getActiveClients(businessId);
|
||
expect(clients.length, 1);
|
||
expect(clients.first.name, 'Active');
|
||
});
|
||
|
||
test('searchClients matches partial name', () async {
|
||
await dao.createClient(businessId: businessId, name: 'Jane Doe');
|
||
await dao.createClient(businessId: businessId, name: 'John Smith');
|
||
|
||
final results = await dao.searchClients(businessId, 'jan');
|
||
expect(results.length, 1);
|
||
expect(results.first.name, 'Jane Doe');
|
||
});
|
||
|
||
test('getActiveClientCount counts non-deleted clients', () async {
|
||
await dao.createClient(businessId: businessId, name: 'A');
|
||
await dao.createClient(businessId: businessId, name: 'B');
|
||
final c = await dao.createClient(businessId: businessId, name: 'C');
|
||
await dao.softDeleteClient(c.id);
|
||
|
||
expect(await dao.getActiveClientCount(businessId), 2);
|
||
});
|
||
|
||
test('updateOutstandingBalance sets correct value', () async {
|
||
final client = await dao.createClient(businessId: businessId, name: 'A');
|
||
await dao.updateOutstandingBalance(client.id, 5000);
|
||
|
||
final updated = await dao.getClient(client.id);
|
||
expect(updated!.outstandingBalance, 5000);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/core/database/daos/client_dao_test.dart
|
||
```
|
||
Expected: FAIL — `ClientDao` not found.
|
||
|
||
- [ ] **Step 3: Implement ClientDao**
|
||
|
||
```dart
|
||
// lib/core/database/daos/client_dao.dart
|
||
import 'package:drift/drift.dart';
|
||
import '../database.dart';
|
||
import '../../utils/uuid_generator.dart';
|
||
|
||
class ClientDao {
|
||
final AppDatabase _db;
|
||
|
||
ClientDao(this._db);
|
||
|
||
Future<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**
|
||
|
||
```bash
|
||
flutter test test/core/database/daos/client_dao_test.dart -v
|
||
```
|
||
Expected: All 5 tests PASS.
|
||
|
||
- [ ] **Step 5: Write failing tests for DocumentDao**
|
||
|
||
```dart
|
||
// test/core/database/daos/document_dao_test.dart
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/database/database.dart';
|
||
import 'package:swift_invoice/core/database/daos/business_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/client_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/document_dao.dart';
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
late DocumentDao dao;
|
||
late String businessId;
|
||
late String clientId;
|
||
|
||
setUp(() async {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
dao = DocumentDao(db);
|
||
final biz = await BusinessDao(db).createBusiness(name: 'Co');
|
||
businessId = biz.id;
|
||
final client = await ClientDao(db).createClient(
|
||
businessId: businessId,
|
||
name: 'Client',
|
||
);
|
||
clientId = client.id;
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('createDocument inserts an invoice', () async {
|
||
final doc = await dao.createDocument(
|
||
businessId: businessId,
|
||
clientId: clientId,
|
||
documentType: 'invoice',
|
||
documentNumber: 'INV-001',
|
||
status: 'draft',
|
||
issueDate: '2026-03-22',
|
||
);
|
||
expect(doc.documentType, 'invoice');
|
||
expect(doc.documentNumber, 'INV-001');
|
||
expect(doc.status, 'draft');
|
||
});
|
||
|
||
test('getInvoices returns only non-deleted invoices', () async {
|
||
await dao.createDocument(
|
||
businessId: businessId, clientId: clientId,
|
||
documentType: 'invoice', documentNumber: 'INV-001',
|
||
status: 'draft', issueDate: '2026-03-22',
|
||
);
|
||
final est = await dao.createDocument(
|
||
businessId: businessId, clientId: clientId,
|
||
documentType: 'estimate', documentNumber: 'EST-001',
|
||
status: 'draft', issueDate: '2026-03-22',
|
||
);
|
||
|
||
final invoices = await dao.getDocumentsByType(businessId, 'invoice');
|
||
expect(invoices.length, 1);
|
||
expect(invoices.first.documentNumber, 'INV-001');
|
||
});
|
||
|
||
test('updateStatus changes document status', () async {
|
||
final doc = await dao.createDocument(
|
||
businessId: businessId, clientId: clientId,
|
||
documentType: 'invoice', documentNumber: 'INV-001',
|
||
status: 'draft', issueDate: '2026-03-22',
|
||
);
|
||
await dao.updateStatus(doc.id, 'sent');
|
||
final updated = await dao.getDocument(doc.id);
|
||
expect(updated!.status, 'sent');
|
||
});
|
||
|
||
test('getOverdueInvoices returns invoices past due date', () async {
|
||
await dao.createDocument(
|
||
businessId: businessId, clientId: clientId,
|
||
documentType: 'invoice', documentNumber: 'INV-001',
|
||
status: 'sent', issueDate: '2026-01-01',
|
||
dueDate: '2026-01-15',
|
||
total: 1000, amountDue: 1000,
|
||
);
|
||
await dao.createDocument(
|
||
businessId: businessId, clientId: clientId,
|
||
documentType: 'invoice', documentNumber: 'INV-002',
|
||
status: 'sent', issueDate: '2026-03-22',
|
||
dueDate: '2099-12-31',
|
||
total: 500, amountDue: 500,
|
||
);
|
||
|
||
final overdue = await dao.getOverdueInvoices(businessId);
|
||
expect(overdue.length, 1);
|
||
expect(overdue.first.documentNumber, 'INV-001');
|
||
});
|
||
|
||
test('getMonthlyInvoiceCount counts current month invoices', () async {
|
||
final now = DateTime.now();
|
||
await dao.createDocument(
|
||
businessId: businessId, clientId: clientId,
|
||
documentType: 'invoice', documentNumber: 'INV-001',
|
||
status: 'draft', issueDate: now.toIso8601String(),
|
||
);
|
||
expect(await dao.getMonthlyInvoiceCount(businessId), 1);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/core/database/daos/document_dao_test.dart
|
||
```
|
||
Expected: FAIL — `DocumentDao` not found.
|
||
|
||
- [ ] **Step 7: Implement DocumentDao**
|
||
|
||
```dart
|
||
// lib/core/database/daos/document_dao.dart
|
||
import 'package:drift/drift.dart';
|
||
import '../database.dart';
|
||
import '../../utils/uuid_generator.dart';
|
||
|
||
class DocumentDao {
|
||
final AppDatabase _db;
|
||
|
||
DocumentDao(this._db);
|
||
|
||
Future<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**
|
||
|
||
```bash
|
||
flutter test test/core/database/daos/document_dao_test.dart -v
|
||
```
|
||
Expected: All 5 tests PASS.
|
||
|
||
- [ ] **Step 9: Write failing tests for LineItemDao, implement, and verify**
|
||
|
||
```dart
|
||
// test/core/database/daos/line_item_dao_test.dart
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/database/database.dart';
|
||
import 'package:swift_invoice/core/database/daos/business_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/client_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/document_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/line_item_dao.dart';
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
late LineItemDao dao;
|
||
late String documentId;
|
||
|
||
setUp(() async {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
dao = LineItemDao(db);
|
||
final biz = await BusinessDao(db).createBusiness(name: 'Co');
|
||
final client = await ClientDao(db).createClient(
|
||
businessId: biz.id, name: 'C');
|
||
final doc = await DocumentDao(db).createDocument(
|
||
businessId: biz.id, clientId: client.id,
|
||
documentType: 'invoice', documentNumber: 'INV-001',
|
||
status: 'draft', issueDate: '2026-03-22',
|
||
);
|
||
documentId = doc.id;
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('addLineItem creates and retrieves line item', () async {
|
||
final item = await dao.addLineItem(
|
||
documentId: documentId,
|
||
description: 'Web Design',
|
||
quantity: 2.0,
|
||
unitPrice: 5000,
|
||
amount: 10000,
|
||
sortOrder: 0,
|
||
);
|
||
expect(item.description, 'Web Design');
|
||
expect(item.amount, 10000);
|
||
});
|
||
|
||
test('getLineItems returns items in sort order', () async {
|
||
await dao.addLineItem(
|
||
documentId: documentId, description: 'B',
|
||
quantity: 1, unitPrice: 100, amount: 100, sortOrder: 1,
|
||
);
|
||
await dao.addLineItem(
|
||
documentId: documentId, description: 'A',
|
||
quantity: 1, unitPrice: 200, amount: 200, sortOrder: 0,
|
||
);
|
||
|
||
final items = await dao.getLineItems(documentId);
|
||
expect(items.length, 2);
|
||
expect(items[0].description, 'A');
|
||
expect(items[1].description, 'B');
|
||
});
|
||
|
||
test('deleteLineItem removes item', () async {
|
||
final item = await dao.addLineItem(
|
||
documentId: documentId, description: 'X',
|
||
quantity: 1, unitPrice: 100, amount: 100, sortOrder: 0,
|
||
);
|
||
await dao.deleteLineItem(item.id);
|
||
final items = await dao.getLineItems(documentId);
|
||
expect(items, isEmpty);
|
||
});
|
||
|
||
test('deleteAllForDocument clears all line items', () async {
|
||
await dao.addLineItem(
|
||
documentId: documentId, description: 'A',
|
||
quantity: 1, unitPrice: 100, amount: 100, sortOrder: 0,
|
||
);
|
||
await dao.addLineItem(
|
||
documentId: documentId, description: 'B',
|
||
quantity: 1, unitPrice: 200, amount: 200, sortOrder: 1,
|
||
);
|
||
await dao.deleteAllForDocument(documentId);
|
||
expect(await dao.getLineItems(documentId), isEmpty);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 10: Implement LineItemDao**
|
||
|
||
```dart
|
||
// lib/core/database/daos/line_item_dao.dart
|
||
import 'package:drift/drift.dart';
|
||
import '../database.dart';
|
||
import '../../utils/uuid_generator.dart';
|
||
|
||
class LineItemDao {
|
||
final AppDatabase _db;
|
||
|
||
LineItemDao(this._db);
|
||
|
||
Future<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**
|
||
|
||
```bash
|
||
flutter test test/core/database/daos/ -v
|
||
```
|
||
Expected: All tests PASS.
|
||
|
||
- [ ] **Step 12: Commit**
|
||
|
||
```bash
|
||
git add lib/core/database/daos/ test/core/database/daos/
|
||
git commit -m "feat: implement Client, Document, and LineItem DAOs with tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Payment DAO & Document Calculator
|
||
|
||
**Files:**
|
||
- Create: `lib/core/database/daos/payment_dao.dart`
|
||
- Create: `lib/core/services/document_calculator.dart`
|
||
- Create: `test/core/database/daos/payment_dao_test.dart`
|
||
- Create: `test/core/services/document_calculator_test.dart`
|
||
|
||
- [ ] **Step 1: Write failing tests for PaymentDao**
|
||
|
||
```dart
|
||
// test/core/database/daos/payment_dao_test.dart
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/database/database.dart';
|
||
import 'package:swift_invoice/core/database/daos/business_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/client_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/document_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/payment_dao.dart';
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
late PaymentDao dao;
|
||
late String documentId;
|
||
|
||
setUp(() async {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
dao = PaymentDao(db);
|
||
final biz = await BusinessDao(db).createBusiness(name: 'Co');
|
||
final client = await ClientDao(db).createClient(
|
||
businessId: biz.id, name: 'C');
|
||
final doc = await DocumentDao(db).createDocument(
|
||
businessId: biz.id, clientId: client.id,
|
||
documentType: 'invoice', documentNumber: 'INV-001',
|
||
status: 'sent', issueDate: '2026-03-22',
|
||
total: 10000, amountDue: 10000,
|
||
);
|
||
documentId = doc.id;
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('recordPayment inserts and returns payment', () async {
|
||
final payment = await dao.recordPayment(
|
||
documentId: documentId,
|
||
amount: 5000,
|
||
method: 'cash',
|
||
paidAt: '2026-03-22T10:00:00',
|
||
);
|
||
expect(payment.amount, 5000);
|
||
expect(payment.method, 'cash');
|
||
});
|
||
|
||
test('getPaymentsForDocument returns all payments', () async {
|
||
await dao.recordPayment(
|
||
documentId: documentId, amount: 3000,
|
||
method: 'cash', paidAt: '2026-03-22T10:00:00',
|
||
);
|
||
await dao.recordPayment(
|
||
documentId: documentId, amount: 2000,
|
||
method: 'card', paidAt: '2026-03-22T11:00:00',
|
||
);
|
||
final payments = await dao.getPaymentsForDocument(documentId);
|
||
expect(payments.length, 2);
|
||
});
|
||
|
||
test('getTotalPaidForDocument sums payments', () async {
|
||
await dao.recordPayment(
|
||
documentId: documentId, amount: 3000,
|
||
method: 'cash', paidAt: '2026-03-22T10:00:00',
|
||
);
|
||
await dao.recordPayment(
|
||
documentId: documentId, amount: 2000,
|
||
method: 'card', paidAt: '2026-03-22T11:00:00',
|
||
);
|
||
expect(await dao.getTotalPaidForDocument(documentId), 5000);
|
||
});
|
||
|
||
test('deletePayment removes payment', () async {
|
||
final payment = await dao.recordPayment(
|
||
documentId: documentId, amount: 5000,
|
||
method: 'cash', paidAt: '2026-03-22T10:00:00',
|
||
);
|
||
await dao.deletePayment(payment.id);
|
||
expect(await dao.getPaymentsForDocument(documentId), isEmpty);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/core/database/daos/payment_dao_test.dart
|
||
```
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement PaymentDao**
|
||
|
||
```dart
|
||
// lib/core/database/daos/payment_dao.dart
|
||
import 'package:drift/drift.dart';
|
||
import '../database.dart';
|
||
import '../../utils/uuid_generator.dart';
|
||
|
||
class PaymentDao {
|
||
final AppDatabase _db;
|
||
|
||
PaymentDao(this._db);
|
||
|
||
Future<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**
|
||
|
||
```bash
|
||
flutter test test/core/database/daos/payment_dao_test.dart -v
|
||
```
|
||
Expected: All 4 tests PASS.
|
||
|
||
- [ ] **Step 5: Write failing tests for DocumentCalculator**
|
||
|
||
```dart
|
||
// test/core/services/document_calculator_test.dart
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/services/document_calculator.dart';
|
||
|
||
void main() {
|
||
test('calculates subtotal from line items', () {
|
||
final items = [
|
||
LineItemInput(quantity: 2.0, unitPriceCents: 5000),
|
||
LineItemInput(quantity: 1.0, unitPriceCents: 3000),
|
||
];
|
||
final result = DocumentCalculator.calculate(
|
||
items: items,
|
||
taxRateBasisPoints: 0,
|
||
);
|
||
expect(result.subtotal, 13000); // (2*5000) + (1*3000)
|
||
});
|
||
|
||
test('calculates tax correctly from basis points', () {
|
||
final items = [
|
||
LineItemInput(quantity: 1.0, unitPriceCents: 10000),
|
||
];
|
||
final result = DocumentCalculator.calculate(
|
||
items: items,
|
||
taxRateBasisPoints: 825, // 8.25%
|
||
);
|
||
expect(result.subtotal, 10000);
|
||
expect(result.taxAmount, 825); // 10000 * 825 / 10000
|
||
expect(result.total, 10825);
|
||
});
|
||
|
||
test('calculates percentage discount', () {
|
||
final items = [
|
||
LineItemInput(quantity: 1.0, unitPriceCents: 10000),
|
||
];
|
||
final result = DocumentCalculator.calculate(
|
||
items: items,
|
||
taxRateBasisPoints: 0,
|
||
discountType: 'percentage',
|
||
discountValueBasisPoints: 1000, // 10%
|
||
);
|
||
expect(result.discountAmount, 1000);
|
||
expect(result.total, 9000);
|
||
});
|
||
|
||
test('calculates fixed discount', () {
|
||
final items = [
|
||
LineItemInput(quantity: 1.0, unitPriceCents: 10000),
|
||
];
|
||
final result = DocumentCalculator.calculate(
|
||
items: items,
|
||
taxRateBasisPoints: 0,
|
||
discountType: 'fixed',
|
||
discountValueBasisPoints: 2500, // $25.00
|
||
);
|
||
expect(result.discountAmount, 2500);
|
||
expect(result.total, 7500);
|
||
});
|
||
|
||
test('tax applied after discount', () {
|
||
final items = [
|
||
LineItemInput(quantity: 1.0, unitPriceCents: 10000),
|
||
];
|
||
final result = DocumentCalculator.calculate(
|
||
items: items,
|
||
taxRateBasisPoints: 1000, // 10%
|
||
discountType: 'fixed',
|
||
discountValueBasisPoints: 2000,
|
||
);
|
||
// subtotal=10000, discount=2000, after_discount=8000, tax=800
|
||
expect(result.subtotal, 10000);
|
||
expect(result.discountAmount, 2000);
|
||
expect(result.taxAmount, 800);
|
||
expect(result.total, 8800);
|
||
});
|
||
|
||
test('handles empty line items', () {
|
||
final result = DocumentCalculator.calculate(
|
||
items: [],
|
||
taxRateBasisPoints: 1000,
|
||
);
|
||
expect(result.subtotal, 0);
|
||
expect(result.total, 0);
|
||
});
|
||
|
||
test('calculates fractional quantities correctly', () {
|
||
final items = [
|
||
LineItemInput(quantity: 2.5, unitPriceCents: 4000),
|
||
];
|
||
final result = DocumentCalculator.calculate(
|
||
items: items,
|
||
taxRateBasisPoints: 0,
|
||
);
|
||
expect(result.subtotal, 10000); // 2.5 * 4000
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/core/services/document_calculator_test.dart
|
||
```
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 7: Implement DocumentCalculator**
|
||
|
||
```dart
|
||
// lib/core/services/document_calculator.dart
|
||
|
||
class LineItemInput {
|
||
final double quantity;
|
||
final int unitPriceCents;
|
||
|
||
const LineItemInput({required this.quantity, required this.unitPriceCents});
|
||
|
||
int get amount => (quantity * unitPriceCents).round();
|
||
}
|
||
|
||
class CalculationResult {
|
||
final int subtotal;
|
||
final int taxAmount;
|
||
final int discountAmount;
|
||
final int total;
|
||
|
||
const CalculationResult({
|
||
required this.subtotal,
|
||
required this.taxAmount,
|
||
required this.discountAmount,
|
||
required this.total,
|
||
});
|
||
}
|
||
|
||
class DocumentCalculator {
|
||
/// Calculate document totals.
|
||
///
|
||
/// [taxRateBasisPoints]: e.g. 825 = 8.25%
|
||
/// [discountType]: 'percentage' or 'fixed'
|
||
/// [discountValueBasisPoints]: for percentage, basis points (1000 = 10%);
|
||
/// for fixed, amount in cents
|
||
static CalculationResult calculate({
|
||
required List<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**
|
||
|
||
```bash
|
||
flutter test test/core/ -v
|
||
```
|
||
Expected: All tests PASS.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add lib/core/database/daos/payment_dao.dart lib/core/services/document_calculator.dart \
|
||
test/core/database/daos/payment_dao_test.dart test/core/services/document_calculator_test.dart
|
||
git commit -m "feat: implement PaymentDao and DocumentCalculator with full test coverage"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Navigation Shell & Onboarding Screen
|
||
|
||
**Files:**
|
||
- Create: `lib/core/utils/currency_formatter.dart`
|
||
- Create: `lib/core/utils/date_formatter.dart`
|
||
- Create: `lib/shared/widgets/app_scaffold.dart`
|
||
- Create: `lib/features/onboarding/onboarding_screen.dart`
|
||
- Create: `lib/features/onboarding/onboarding_notifier.dart`
|
||
- Create: `lib/core/providers/business_provider.dart`
|
||
- Create: `lib/core/providers/settings_provider.dart`
|
||
- Modify: `lib/app.dart` — add GoRouter with onboarding guard
|
||
- Create: `test/core/utils/currency_formatter_test.dart`
|
||
- Create: `test/features/onboarding/onboarding_notifier_test.dart`
|
||
|
||
- [ ] **Step 1: Write failing tests for currency formatter**
|
||
|
||
```dart
|
||
// test/core/utils/currency_formatter_test.dart
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/utils/currency_formatter.dart';
|
||
|
||
void main() {
|
||
test('formats cents to dollar string', () {
|
||
expect(CurrencyFormatter.format(10050), '\$100.50');
|
||
expect(CurrencyFormatter.format(0), '\$0.00');
|
||
expect(CurrencyFormatter.format(99), '\$0.99');
|
||
expect(CurrencyFormatter.format(100000), '\$1,000.00');
|
||
});
|
||
|
||
test('formats basis points to percentage', () {
|
||
expect(CurrencyFormatter.formatTaxRate(825), '8.25%');
|
||
expect(CurrencyFormatter.formatTaxRate(1000), '10.00%');
|
||
expect(CurrencyFormatter.formatTaxRate(0), '0.00%');
|
||
});
|
||
|
||
test('parseCentsToDollars converts string to cents', () {
|
||
expect(CurrencyFormatter.parseToCents('100.50'), 10050);
|
||
expect(CurrencyFormatter.parseToCents('0.99'), 99);
|
||
expect(CurrencyFormatter.parseToCents('1000'), 100000);
|
||
});
|
||
|
||
test('parseTaxRate converts string to basis points', () {
|
||
expect(CurrencyFormatter.parseTaxRate('8.25'), 825);
|
||
expect(CurrencyFormatter.parseTaxRate('10'), 1000);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/core/utils/currency_formatter_test.dart
|
||
```
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement CurrencyFormatter**
|
||
|
||
```dart
|
||
// lib/core/utils/currency_formatter.dart
|
||
import 'package:intl/intl.dart';
|
||
|
||
class CurrencyFormatter {
|
||
static final _currencyFormat = NumberFormat.currency(symbol: '\$');
|
||
|
||
static String format(int cents, {String currencyCode = 'USD'}) {
|
||
return _currencyFormat.format(cents / 100);
|
||
}
|
||
|
||
static String formatTaxRate(int basisPoints) {
|
||
return '${(basisPoints / 100).toStringAsFixed(2)}%';
|
||
}
|
||
|
||
static int parseToCents(String dollars) {
|
||
final value = double.parse(dollars.replaceAll(',', ''));
|
||
return (value * 100).round();
|
||
}
|
||
|
||
static int parseTaxRate(String percentage) {
|
||
final value = double.parse(percentage);
|
||
return (value * 100).round();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create DateFormatter utility**
|
||
|
||
```dart
|
||
// lib/core/utils/date_formatter.dart
|
||
import 'package:intl/intl.dart';
|
||
|
||
class DateFormatter {
|
||
static final _displayFormat = DateFormat('MMM d, yyyy');
|
||
static final _isoFormat = DateFormat('yyyy-MM-dd');
|
||
|
||
static String toDisplay(String isoDate) {
|
||
return _displayFormat.format(DateTime.parse(isoDate));
|
||
}
|
||
|
||
static String toIso(DateTime date) {
|
||
return _isoFormat.format(date);
|
||
}
|
||
|
||
static DateTime addDays(DateTime date, int days) {
|
||
return date.add(Duration(days: days));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run currency formatter tests**
|
||
|
||
```bash
|
||
flutter test test/core/utils/currency_formatter_test.dart -v
|
||
```
|
||
Expected: All 4 tests PASS.
|
||
|
||
- [ ] **Step 6: Create Riverpod providers for business and settings**
|
||
|
||
```dart
|
||
// lib/core/providers/settings_provider.dart
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../database/daos/app_settings_dao.dart';
|
||
import 'database_provider.dart';
|
||
|
||
final appSettingsDaoProvider = Provider<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();
|
||
});
|
||
```
|
||
|
||
```dart
|
||
// lib/core/providers/business_provider.dart
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../database/database.dart';
|
||
import '../database/daos/business_dao.dart';
|
||
import 'database_provider.dart';
|
||
|
||
final businessDaoProvider = Provider<BusinessDao>((ref) {
|
||
return BusinessDao(ref.read(databaseProvider));
|
||
});
|
||
|
||
final businessProvider = FutureProvider<Business?>((ref) async {
|
||
return ref.read(businessDaoProvider).getBusiness();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 7: Create the navigation shell**
|
||
|
||
```dart
|
||
// lib/shared/widgets/app_scaffold.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
class AppScaffold extends StatelessWidget {
|
||
final Widget child;
|
||
|
||
const AppScaffold({super.key, required this.child});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
body: child,
|
||
bottomNavigationBar: NavigationBar(
|
||
selectedIndex: _calculateSelectedIndex(context),
|
||
onDestinationSelected: (index) => _onItemTapped(index, context),
|
||
destinations: const [
|
||
NavigationDestination(icon: Icon(Icons.receipt_long), label: 'Invoices'),
|
||
NavigationDestination(icon: Icon(Icons.description), label: 'Estimates'),
|
||
NavigationDestination(icon: Icon(Icons.people), label: 'Clients'),
|
||
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
int _calculateSelectedIndex(BuildContext context) {
|
||
final location = GoRouterState.of(context).uri.toString();
|
||
if (location.startsWith('/estimates')) return 1;
|
||
if (location.startsWith('/clients')) return 2;
|
||
if (location.startsWith('/settings')) return 3;
|
||
return 0;
|
||
}
|
||
|
||
void _onItemTapped(int index, BuildContext context) {
|
||
switch (index) {
|
||
case 0: context.go('/invoices');
|
||
case 1: context.go('/estimates');
|
||
case 2: context.go('/clients');
|
||
case 3: context.go('/settings');
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Create onboarding notifier and screen**
|
||
|
||
```dart
|
||
// lib/features/onboarding/onboarding_notifier.dart
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../core/database/daos/business_dao.dart';
|
||
import '../../core/database/daos/app_settings_dao.dart';
|
||
import '../../core/providers/database_provider.dart';
|
||
|
||
class OnboardingState {
|
||
final String name;
|
||
final String email;
|
||
final String phone;
|
||
final String defaultCurrency;
|
||
final String taxRateText;
|
||
final String? logoPath;
|
||
final bool isSaving;
|
||
final String? error;
|
||
|
||
const OnboardingState({
|
||
this.name = '',
|
||
this.email = '',
|
||
this.phone = '',
|
||
this.defaultCurrency = 'USD',
|
||
this.taxRateText = '',
|
||
this.logoPath,
|
||
this.isSaving = false,
|
||
this.error,
|
||
});
|
||
|
||
OnboardingState copyWith({
|
||
String? name,
|
||
String? email,
|
||
String? phone,
|
||
String? defaultCurrency,
|
||
String? taxRateText,
|
||
String? logoPath,
|
||
bool? isSaving,
|
||
String? error,
|
||
}) {
|
||
return OnboardingState(
|
||
name: name ?? this.name,
|
||
email: email ?? this.email,
|
||
phone: phone ?? this.phone,
|
||
defaultCurrency: defaultCurrency ?? this.defaultCurrency,
|
||
taxRateText: taxRateText ?? this.taxRateText,
|
||
logoPath: logoPath ?? this.logoPath,
|
||
isSaving: isSaving ?? this.isSaving,
|
||
error: error,
|
||
);
|
||
}
|
||
}
|
||
|
||
class OnboardingNotifier extends StateNotifier<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));
|
||
});
|
||
```
|
||
|
||
```dart
|
||
// lib/features/onboarding/onboarding_screen.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'onboarding_notifier.dart';
|
||
|
||
class OnboardingScreen extends ConsumerWidget {
|
||
const OnboardingScreen({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final state = ref.watch(onboardingNotifierProvider);
|
||
final notifier = ref.read(onboardingNotifierProvider.notifier);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('Set Up Your Business')),
|
||
body: SingleChildScrollView(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
TextField(
|
||
decoration: const InputDecoration(
|
||
labelText: 'Business Name *',
|
||
hintText: 'e.g., Smith Plumbing LLC',
|
||
),
|
||
onChanged: notifier.setName,
|
||
),
|
||
const SizedBox(height: 16),
|
||
TextField(
|
||
decoration: const InputDecoration(
|
||
labelText: 'Email',
|
||
hintText: 'contact@yourbusiness.com',
|
||
),
|
||
keyboardType: TextInputType.emailAddress,
|
||
onChanged: notifier.setEmail,
|
||
),
|
||
const SizedBox(height: 16),
|
||
TextField(
|
||
decoration: const InputDecoration(
|
||
labelText: 'Phone',
|
||
),
|
||
keyboardType: TextInputType.phone,
|
||
onChanged: notifier.setPhone,
|
||
),
|
||
const SizedBox(height: 16),
|
||
TextField(
|
||
decoration: const InputDecoration(
|
||
labelText: 'Default Tax Rate (%)',
|
||
hintText: '8.25',
|
||
),
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
onChanged: notifier.setTaxRate,
|
||
),
|
||
const SizedBox(height: 24),
|
||
if (state.error != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 16),
|
||
child: Text(
|
||
state.error!,
|
||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||
),
|
||
),
|
||
FilledButton(
|
||
onPressed: state.isSaving
|
||
? null
|
||
: () async {
|
||
final success = await notifier.save();
|
||
if (success && context.mounted) {
|
||
context.go('/invoices');
|
||
}
|
||
},
|
||
child: state.isSaving
|
||
? const CircularProgressIndicator()
|
||
: const Text('Save & Start'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9: Wire up GoRouter in app.dart with onboarding guard**
|
||
|
||
```dart
|
||
// lib/app.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'core/theme/app_theme.dart';
|
||
import 'core/providers/settings_provider.dart';
|
||
import 'features/onboarding/onboarding_screen.dart';
|
||
import 'shared/widgets/app_scaffold.dart';
|
||
|
||
final routerProvider = Provider<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**
|
||
|
||
```dart
|
||
// test/features/onboarding/onboarding_notifier_test.dart
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/database/database.dart';
|
||
import 'package:swift_invoice/core/database/daos/business_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';
|
||
import 'package:swift_invoice/features/onboarding/onboarding_notifier.dart';
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
late OnboardingNotifier notifier;
|
||
|
||
setUp(() {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
notifier = OnboardingNotifier(BusinessDao(db), AppSettingsDao(db));
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('save fails with empty name', () async {
|
||
final result = await notifier.save();
|
||
expect(result, isFalse);
|
||
expect(notifier.state.error, 'Business name is required');
|
||
});
|
||
|
||
test('save succeeds with valid name', () async {
|
||
notifier.setName('Test Business');
|
||
final result = await notifier.save();
|
||
expect(result, isTrue);
|
||
expect(notifier.state.error, isNull);
|
||
|
||
// Verify business was created
|
||
final biz = await BusinessDao(db).getBusiness();
|
||
expect(biz, isNotNull);
|
||
expect(biz!.name, 'Test Business');
|
||
|
||
// Verify onboarding marked complete
|
||
expect(await AppSettingsDao(db).isOnboardingComplete(), isTrue);
|
||
});
|
||
|
||
test('save parses tax rate correctly', () async {
|
||
notifier.setName('Co');
|
||
notifier.setTaxRate('8.25');
|
||
await notifier.save();
|
||
|
||
final biz = await BusinessDao(db).getBusiness();
|
||
expect(biz!.defaultTaxRate, 825);
|
||
});
|
||
|
||
test('save trims whitespace from name', () async {
|
||
notifier.setName(' My Company ');
|
||
await notifier.save();
|
||
|
||
final biz = await BusinessDao(db).getBusiness();
|
||
expect(biz!.name, 'My Company');
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 11: Run all tests**
|
||
|
||
```bash
|
||
flutter test -v
|
||
```
|
||
Expected: All tests PASS.
|
||
|
||
- [ ] **Step 12: Run the app and verify onboarding flow**
|
||
|
||
```bash
|
||
flutter run -d chrome
|
||
```
|
||
Expected: App shows onboarding screen. After entering a name and tapping "Save & Start", navigates to invoice dashboard placeholder with bottom nav.
|
||
|
||
- [ ] **Step 13: Commit**
|
||
|
||
```bash
|
||
git add lib/ test/
|
||
git commit -m "feat: add navigation shell, onboarding screen, and Riverpod providers"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2: Core Invoicing (Weeks 3–5)
|
||
|
||
### Task 8: Client List Screen
|
||
|
||
**Files:**
|
||
- Create: `lib/core/providers/client_provider.dart`
|
||
- Create: `lib/features/clients/client_notifier.dart`
|
||
- Create: `lib/features/clients/client_list_screen.dart`
|
||
- Create: `test/features/clients/client_notifier_test.dart`
|
||
|
||
- [ ] **Step 1: Write failing tests for ClientNotifier**
|
||
|
||
```dart
|
||
// test/features/clients/client_notifier_test.dart
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/database/database.dart';
|
||
import 'package:swift_invoice/core/database/daos/business_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/client_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';
|
||
import 'package:swift_invoice/features/clients/client_notifier.dart';
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
late ClientNotifier notifier;
|
||
late String businessId;
|
||
|
||
setUp(() async {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
final biz = await BusinessDao(db).createBusiness(name: 'Co');
|
||
businessId = biz.id;
|
||
notifier = ClientNotifier(ClientDao(db), AppSettingsDao(db), businessId);
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('loadClients returns empty list initially', () async {
|
||
await notifier.loadClients();
|
||
expect(notifier.state.clients, isEmpty);
|
||
});
|
||
|
||
test('addClient adds to list', () async {
|
||
await notifier.addClient(name: 'Jane', email: 'j@x.com');
|
||
expect(notifier.state.clients.length, 1);
|
||
expect(notifier.state.clients.first.name, 'Jane');
|
||
});
|
||
|
||
test('addClient enforces free tier limit of 2', () async {
|
||
await notifier.addClient(name: 'A');
|
||
await notifier.addClient(name: 'B');
|
||
final result = await notifier.addClient(name: 'C');
|
||
expect(result, isFalse);
|
||
expect(notifier.state.showPaywall, isTrue);
|
||
});
|
||
|
||
test('deleteClient soft-deletes and removes from list', () async {
|
||
await notifier.addClient(name: 'Jane');
|
||
final clientId = notifier.state.clients.first.id;
|
||
await notifier.deleteClient(clientId);
|
||
expect(notifier.state.clients, isEmpty);
|
||
});
|
||
|
||
test('searchClients filters by name', () async {
|
||
await notifier.addClient(name: 'Jane Doe');
|
||
await notifier.addClient(name: 'John Smith');
|
||
await notifier.searchClients('jan');
|
||
expect(notifier.state.clients.length, 1);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/features/clients/client_notifier_test.dart
|
||
```
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement ClientNotifier**
|
||
|
||
```dart
|
||
// lib/features/clients/client_notifier.dart
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../core/database/database.dart';
|
||
import '../../core/database/daos/client_dao.dart';
|
||
import '../../core/database/daos/app_settings_dao.dart';
|
||
|
||
class ClientListState {
|
||
final List<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**
|
||
|
||
```bash
|
||
flutter test test/features/clients/client_notifier_test.dart -v
|
||
```
|
||
Expected: All 5 tests PASS.
|
||
|
||
- [ ] **Step 5: Create client list screen**
|
||
|
||
```dart
|
||
// lib/features/clients/client_list_screen.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import '../../core/utils/currency_formatter.dart';
|
||
import '../../core/providers/business_provider.dart';
|
||
import '../../core/providers/client_provider.dart';
|
||
import 'client_notifier.dart';
|
||
|
||
final clientNotifierProvider =
|
||
StateNotifierProvider<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**
|
||
|
||
```dart
|
||
// lib/core/providers/client_provider.dart
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../database/daos/client_dao.dart';
|
||
import '../database/daos/app_settings_dao.dart';
|
||
import 'database_provider.dart';
|
||
|
||
final clientDaoProvider = Provider<ClientDao>((ref) {
|
||
return ClientDao(ref.read(databaseProvider));
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add lib/features/clients/ lib/core/providers/client_provider.dart \
|
||
test/features/clients/
|
||
git commit -m "feat: implement client list with free-tier limit enforcement"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Client Form & Detail Screens
|
||
|
||
**Files:**
|
||
- Create: `lib/features/clients/client_form_screen.dart`
|
||
- Create: `lib/features/clients/client_detail_screen.dart`
|
||
- Create: `lib/core/utils/validators.dart`
|
||
- Create: `test/core/utils/validators_test.dart`
|
||
|
||
- [ ] **Step 1: Write failing tests for validators**
|
||
|
||
```dart
|
||
// test/core/utils/validators_test.dart
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/utils/validators.dart';
|
||
|
||
void main() {
|
||
test('validateRequired rejects empty strings', () {
|
||
expect(Validators.required(''), isNotNull);
|
||
expect(Validators.required(' '), isNotNull);
|
||
expect(Validators.required('abc'), isNull);
|
||
});
|
||
|
||
test('validateEmail accepts valid emails', () {
|
||
expect(Validators.email('a@b.com'), isNull);
|
||
expect(Validators.email('not-an-email'), isNotNull);
|
||
expect(Validators.email(''), isNull); // optional field
|
||
});
|
||
|
||
test('validatePositiveNumber rejects negatives', () {
|
||
expect(Validators.positiveNumber('10'), isNull);
|
||
expect(Validators.positiveNumber('0'), isNull);
|
||
expect(Validators.positiveNumber('-1'), isNotNull);
|
||
expect(Validators.positiveNumber('abc'), isNotNull);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/core/utils/validators_test.dart
|
||
```
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement Validators**
|
||
|
||
```dart
|
||
// lib/core/utils/validators.dart
|
||
|
||
class Validators {
|
||
static String? required(String? value) {
|
||
if (value == null || value.trim().isEmpty) {
|
||
return 'This field is required';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static String? email(String? value) {
|
||
if (value == null || value.isEmpty) return null; // optional
|
||
final regex = RegExp(r'^[\w\-.]+@([\w\-]+\.)+[\w\-]{2,}$');
|
||
if (!regex.hasMatch(value)) return 'Invalid email address';
|
||
return null;
|
||
}
|
||
|
||
static String? positiveNumber(String? value) {
|
||
if (value == null || value.isEmpty) return 'Enter a number';
|
||
final num = double.tryParse(value);
|
||
if (num == null) return 'Invalid number';
|
||
if (num < 0) return 'Must be positive';
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run validators tests**
|
||
|
||
```bash
|
||
flutter test test/core/utils/validators_test.dart -v
|
||
```
|
||
Expected: All 3 tests PASS.
|
||
|
||
- [ ] **Step 5: Create client form screen**
|
||
|
||
```dart
|
||
// lib/features/clients/client_form_screen.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import '../../core/utils/validators.dart';
|
||
import '../../core/database/daos/client_dao.dart';
|
||
import '../../core/providers/database_provider.dart';
|
||
import 'client_list_screen.dart';
|
||
|
||
class ClientFormScreen extends ConsumerStatefulWidget {
|
||
final String? clientId; // null = create, non-null = edit
|
||
|
||
const ClientFormScreen({super.key, this.clientId});
|
||
|
||
@override
|
||
ConsumerState<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**
|
||
|
||
```dart
|
||
// lib/features/clients/client_detail_screen.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import '../../core/utils/currency_formatter.dart';
|
||
import '../../core/utils/date_formatter.dart';
|
||
import '../../core/database/database.dart';
|
||
import '../../core/database/daos/client_dao.dart';
|
||
import '../../core/database/daos/document_dao.dart';
|
||
import '../../core/providers/database_provider.dart';
|
||
import '../invoices/widgets/status_pill.dart';
|
||
import 'client_list_screen.dart';
|
||
|
||
final clientDetailProvider =
|
||
FutureProvider.family<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**
|
||
|
||
```bash
|
||
git add lib/features/clients/ lib/core/utils/validators.dart test/core/utils/
|
||
git commit -m "feat: add client form, detail screen, and input validators"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Invoice Creator — Notifier & Calculator Integration
|
||
|
||
**Files:**
|
||
- Create: `lib/features/invoices/invoice_creator_notifier.dart`
|
||
- Create: `test/features/invoices/invoice_creator_notifier_test.dart`
|
||
|
||
- [ ] **Step 1: Write failing tests for InvoiceCreatorNotifier**
|
||
|
||
```dart
|
||
// test/features/invoices/invoice_creator_notifier_test.dart
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/database/database.dart';
|
||
import 'package:swift_invoice/core/database/daos/business_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/client_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/document_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/line_item_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';
|
||
import 'package:swift_invoice/features/invoices/invoice_creator_notifier.dart';
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
late InvoiceCreatorNotifier notifier;
|
||
late String businessId;
|
||
late String clientId;
|
||
|
||
setUp(() async {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
final biz = await BusinessDao(db).createBusiness(name: 'Co');
|
||
businessId = biz.id;
|
||
final client = await ClientDao(db).createClient(
|
||
businessId: businessId, name: 'Jane');
|
||
clientId = client.id;
|
||
notifier = InvoiceCreatorNotifier(
|
||
businessDao: BusinessDao(db),
|
||
documentDao: DocumentDao(db),
|
||
lineItemDao: LineItemDao(db),
|
||
settingsDao: AppSettingsDao(db),
|
||
businessId: businessId,
|
||
);
|
||
await notifier.initialize();
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('initialize sets auto-generated invoice number', () {
|
||
expect(notifier.state.documentNumber, 'INV-1');
|
||
});
|
||
|
||
test('addLineItem recalculates totals', () {
|
||
notifier.setClientId(clientId);
|
||
notifier.addLineItem(description: 'Work', quantity: 2.0, unitPrice: 5000);
|
||
expect(notifier.state.subtotal, 10000);
|
||
expect(notifier.state.total, 10000);
|
||
expect(notifier.state.lineItems.length, 1);
|
||
});
|
||
|
||
test('tax rate affects total', () {
|
||
notifier.setClientId(clientId);
|
||
notifier.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 10000);
|
||
notifier.setTaxRate(1000); // 10%
|
||
expect(notifier.state.taxAmount, 1000);
|
||
expect(notifier.state.total, 11000);
|
||
});
|
||
|
||
test('save fails without client', () async {
|
||
notifier.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 5000);
|
||
final result = await notifier.save();
|
||
expect(result, isNull);
|
||
expect(notifier.state.error, contains('client'));
|
||
});
|
||
|
||
test('save fails without line items', () async {
|
||
notifier.setClientId(clientId);
|
||
final result = await notifier.save();
|
||
expect(result, isNull);
|
||
expect(notifier.state.error, contains('line item'));
|
||
});
|
||
|
||
test('save creates document and line items', () async {
|
||
notifier.setClientId(clientId);
|
||
notifier.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 5000);
|
||
final docId = await notifier.save();
|
||
expect(docId, isNotNull);
|
||
|
||
final doc = await DocumentDao(db).getDocument(docId!);
|
||
expect(doc!.documentNumber, 'INV-1');
|
||
expect(doc.total, 5000);
|
||
|
||
final items = await LineItemDao(db).getLineItems(docId);
|
||
expect(items.length, 1);
|
||
});
|
||
|
||
test('free tier blocks 4th invoice in same month', () async {
|
||
// Create 3 invoices
|
||
for (var i = 0; i < 3; i++) {
|
||
final n = InvoiceCreatorNotifier(
|
||
businessDao: BusinessDao(db),
|
||
documentDao: DocumentDao(db),
|
||
lineItemDao: LineItemDao(db),
|
||
settingsDao: AppSettingsDao(db),
|
||
businessId: businessId,
|
||
);
|
||
await n.initialize();
|
||
n.setClientId(clientId);
|
||
n.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 1000);
|
||
await n.save();
|
||
}
|
||
|
||
// 4th should be blocked
|
||
final n4 = InvoiceCreatorNotifier(
|
||
businessDao: BusinessDao(db),
|
||
documentDao: DocumentDao(db),
|
||
lineItemDao: LineItemDao(db),
|
||
settingsDao: AppSettingsDao(db),
|
||
businessId: businessId,
|
||
);
|
||
await n4.initialize();
|
||
expect(n4.state.showPaywall, isTrue);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/features/invoices/invoice_creator_notifier_test.dart
|
||
```
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement InvoiceCreatorNotifier**
|
||
|
||
```dart
|
||
// lib/features/invoices/invoice_creator_notifier.dart
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../core/database/daos/business_dao.dart';
|
||
import '../../core/database/daos/document_dao.dart';
|
||
import '../../core/database/daos/line_item_dao.dart';
|
||
import '../../core/database/daos/app_settings_dao.dart';
|
||
import '../../core/services/document_calculator.dart';
|
||
import '../../core/utils/date_formatter.dart';
|
||
|
||
class LineItemFormData {
|
||
final String description;
|
||
final double quantity;
|
||
final int unitPriceCents;
|
||
int get amount => (quantity * unitPriceCents).round();
|
||
|
||
const LineItemFormData({
|
||
required this.description,
|
||
required this.quantity,
|
||
required this.unitPriceCents,
|
||
});
|
||
}
|
||
|
||
class InvoiceCreatorState {
|
||
final String? clientId;
|
||
final String documentNumber;
|
||
final String issueDate;
|
||
final String dueDate;
|
||
final List<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**
|
||
|
||
```bash
|
||
flutter test test/features/invoices/invoice_creator_notifier_test.dart -v
|
||
```
|
||
Expected: All 7 tests PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/features/invoices/invoice_creator_notifier.dart \
|
||
test/features/invoices/invoice_creator_notifier_test.dart
|
||
git commit -m "feat: implement InvoiceCreatorNotifier with calculator integration and tier gating"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Invoice Creator Screen (UI)
|
||
|
||
**Files:**
|
||
- Create: `lib/features/invoices/invoice_creator_screen.dart`
|
||
- Create: `lib/features/invoices/widgets/line_item_row.dart`
|
||
- Create: `lib/shared/widgets/search_dropdown.dart`
|
||
|
||
- [ ] **Step 1: Create line item row widget**
|
||
|
||
```dart
|
||
// lib/features/invoices/widgets/line_item_row.dart
|
||
import 'package:flutter/material.dart';
|
||
import '../../../core/utils/currency_formatter.dart';
|
||
|
||
class LineItemRow extends StatelessWidget {
|
||
final TextEditingController descriptionController;
|
||
final TextEditingController quantityController;
|
||
final TextEditingController rateController;
|
||
final int lineTotal;
|
||
final VoidCallback onDelete;
|
||
final VoidCallback onChanged;
|
||
|
||
const LineItemRow({
|
||
super.key,
|
||
required this.descriptionController,
|
||
required this.quantityController,
|
||
required this.rateController,
|
||
required this.lineTotal,
|
||
required this.onDelete,
|
||
required this.onChanged,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Dismissible(
|
||
key: UniqueKey(),
|
||
direction: DismissDirection.endToStart,
|
||
onDismissed: (_) => onDelete(),
|
||
background: Container(
|
||
color: Theme.of(context).colorScheme.error,
|
||
alignment: Alignment.centerRight,
|
||
padding: const EdgeInsets.only(right: 16),
|
||
child: const Icon(Icons.delete, color: Colors.white),
|
||
),
|
||
child: Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(12),
|
||
child: Column(
|
||
children: [
|
||
TextField(
|
||
controller: descriptionController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Description',
|
||
isDense: true,
|
||
),
|
||
onChanged: (_) => onChanged(),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextField(
|
||
controller: quantityController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Qty',
|
||
isDense: true,
|
||
),
|
||
keyboardType:
|
||
const TextInputType.numberWithOptions(decimal: true),
|
||
onChanged: (_) => onChanged(),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: rateController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Rate',
|
||
isDense: true,
|
||
),
|
||
keyboardType:
|
||
const TextInputType.numberWithOptions(decimal: true),
|
||
onChanged: (_) => onChanged(),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
SizedBox(
|
||
width: 80,
|
||
child: Text(
|
||
CurrencyFormatter.format(lineTotal),
|
||
textAlign: TextAlign.right,
|
||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create invoice creator screen**
|
||
|
||
```dart
|
||
// lib/features/invoices/invoice_creator_screen.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import '../../core/utils/currency_formatter.dart';
|
||
import '../../core/database/daos/business_dao.dart';
|
||
import '../../core/database/daos/document_dao.dart';
|
||
import '../../core/database/daos/line_item_dao.dart';
|
||
import '../../core/database/daos/app_settings_dao.dart';
|
||
import '../../core/database/daos/client_dao.dart';
|
||
import '../../core/database/database.dart';
|
||
import '../../core/providers/database_provider.dart';
|
||
import '../../core/providers/business_provider.dart';
|
||
import 'invoice_creator_notifier.dart';
|
||
import 'widgets/line_item_row.dart';
|
||
|
||
final invoiceCreatorProvider =
|
||
StateNotifierProvider.autoDispose<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:
|
||
```dart
|
||
GoRoute(
|
||
path: '/invoices/new',
|
||
builder: (context, state) => const InvoiceCreatorScreen(),
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 4: Verify the screen renders**
|
||
|
||
```bash
|
||
flutter run -d chrome
|
||
```
|
||
Navigate to `/invoices/new` — screen should render without errors.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/features/invoices/ lib/shared/widgets/search_dropdown.dart
|
||
git commit -m "feat: add invoice creator screen with line item UI components"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Status Pill & Summary Card Widgets
|
||
|
||
**Files:**
|
||
- Create: `lib/features/invoices/widgets/status_pill.dart`
|
||
- Create: `lib/features/invoices/widgets/summary_card.dart`
|
||
|
||
- [ ] **Step 1: Create StatusPill widget**
|
||
|
||
```dart
|
||
// lib/features/invoices/widgets/status_pill.dart
|
||
import 'package:flutter/material.dart';
|
||
import '../../../core/theme/app_colors.dart';
|
||
|
||
class StatusPill extends StatelessWidget {
|
||
final String status;
|
||
|
||
const StatusPill({super.key, required this.status});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: _color.withOpacity(0.15),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Text(
|
||
_label,
|
||
style: TextStyle(
|
||
color: _color,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
String get _label => status[0].toUpperCase() + status.substring(1);
|
||
|
||
Color get _color => switch (status) {
|
||
'draft' => AppColors.statusDraft,
|
||
'sent' => AppColors.statusSent,
|
||
'paid' => AppColors.statusPaid,
|
||
'overdue' => AppColors.statusOverdue,
|
||
'partial' => AppColors.statusPartial,
|
||
'void' => AppColors.statusVoid,
|
||
'accepted' => AppColors.statusPaid,
|
||
'declined' => AppColors.statusOverdue,
|
||
'expired' => AppColors.statusDraft,
|
||
'converted' => AppColors.statusSent,
|
||
_ => AppColors.statusDraft,
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create SummaryCard widget**
|
||
|
||
```dart
|
||
// lib/features/invoices/widgets/summary_card.dart
|
||
import 'package:flutter/material.dart';
|
||
|
||
class SummaryCard extends StatelessWidget {
|
||
final String label;
|
||
final String value;
|
||
final Color? valueColor;
|
||
final IconData? icon;
|
||
|
||
const SummaryCard({
|
||
super.key,
|
||
required this.label,
|
||
required this.value,
|
||
this.valueColor,
|
||
this.icon,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(12),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
if (icon != null) ...[
|
||
Icon(icon, size: 16, color: Theme.of(context).colorScheme.outline),
|
||
const SizedBox(width: 4),
|
||
],
|
||
Text(
|
||
label,
|
||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||
color: Theme.of(context).colorScheme.outline,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
value,
|
||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
color: valueColor,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Write widget tests for StatusPill and SummaryCard**
|
||
|
||
```dart
|
||
// test/features/invoices/widgets/status_pill_test.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/features/invoices/widgets/status_pill.dart';
|
||
|
||
void main() {
|
||
testWidgets('StatusPill displays capitalized label', (tester) async {
|
||
await tester.pumpWidget(
|
||
const MaterialApp(home: Scaffold(body: StatusPill(status: 'draft'))),
|
||
);
|
||
expect(find.text('Draft'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('StatusPill displays Paid label', (tester) async {
|
||
await tester.pumpWidget(
|
||
const MaterialApp(home: Scaffold(body: StatusPill(status: 'paid'))),
|
||
);
|
||
expect(find.text('Paid'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('StatusPill displays Overdue label', (tester) async {
|
||
await tester.pumpWidget(
|
||
const MaterialApp(home: Scaffold(body: StatusPill(status: 'overdue'))),
|
||
);
|
||
expect(find.text('Overdue'), findsOneWidget);
|
||
});
|
||
}
|
||
```
|
||
|
||
```dart
|
||
// test/features/invoices/widgets/summary_card_test.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/features/invoices/widgets/summary_card.dart';
|
||
|
||
void main() {
|
||
testWidgets('SummaryCard displays label and value', (tester) async {
|
||
await tester.pumpWidget(
|
||
const MaterialApp(
|
||
home: Scaffold(
|
||
body: SummaryCard(label: 'Outstanding', value: '\$500.00'),
|
||
),
|
||
),
|
||
);
|
||
expect(find.text('Outstanding'), findsOneWidget);
|
||
expect(find.text('\$500.00'), findsOneWidget);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run widget tests**
|
||
|
||
```bash
|
||
flutter test test/features/invoices/widgets/ -v
|
||
```
|
||
Expected: All 4 tests PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/features/invoices/widgets/ test/features/invoices/widgets/
|
||
git commit -m "feat: add StatusPill and SummaryCard reusable widgets with tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Invoice Dashboard Screen
|
||
|
||
**Files:**
|
||
- Create: `lib/features/invoices/invoice_dashboard_notifier.dart`
|
||
- Create: `lib/features/invoices/invoice_dashboard_screen.dart`
|
||
- Create: `test/features/invoices/invoice_dashboard_notifier_test.dart`
|
||
|
||
- [ ] **Step 1: Write failing tests for InvoiceDashboardNotifier**
|
||
|
||
```dart
|
||
// test/features/invoices/invoice_dashboard_notifier_test.dart
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/database/database.dart';
|
||
import 'package:swift_invoice/core/database/daos/business_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/client_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/document_dao.dart';
|
||
import 'package:swift_invoice/features/invoices/invoice_dashboard_notifier.dart';
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
late InvoiceDashboardNotifier notifier;
|
||
late String businessId;
|
||
late String clientId;
|
||
|
||
setUp(() async {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
final biz = await BusinessDao(db).createBusiness(name: 'Co');
|
||
businessId = biz.id;
|
||
final client = await ClientDao(db).createClient(
|
||
businessId: businessId, name: 'C');
|
||
clientId = client.id;
|
||
notifier = InvoiceDashboardNotifier(DocumentDao(db), businessId);
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('loadDashboard populates invoices', () async {
|
||
await DocumentDao(db).createDocument(
|
||
businessId: businessId, clientId: clientId,
|
||
documentType: 'invoice', documentNumber: 'INV-001',
|
||
status: 'draft', issueDate: '2026-03-22',
|
||
total: 5000, amountDue: 5000,
|
||
);
|
||
await notifier.loadDashboard();
|
||
expect(notifier.state.invoices.length, 1);
|
||
});
|
||
|
||
test('loadDashboard calculates stats', () async {
|
||
await DocumentDao(db).createDocument(
|
||
businessId: businessId, clientId: clientId,
|
||
documentType: 'invoice', documentNumber: 'INV-001',
|
||
status: 'sent', issueDate: '2026-03-22',
|
||
total: 10000, amountDue: 10000,
|
||
);
|
||
await DocumentDao(db).createDocument(
|
||
businessId: businessId, clientId: clientId,
|
||
documentType: 'invoice', documentNumber: 'INV-002',
|
||
status: 'draft', issueDate: '2026-03-22',
|
||
total: 5000, amountDue: 5000,
|
||
);
|
||
await notifier.loadDashboard();
|
||
expect(notifier.state.stats['outstanding'], 10000);
|
||
expect(notifier.state.stats['draftCount'], 1);
|
||
});
|
||
|
||
test('filterByStatus filters invoices', () async {
|
||
await DocumentDao(db).createDocument(
|
||
businessId: businessId, clientId: clientId,
|
||
documentType: 'invoice', documentNumber: 'INV-001',
|
||
status: 'draft', issueDate: '2026-03-22',
|
||
);
|
||
await DocumentDao(db).createDocument(
|
||
businessId: businessId, clientId: clientId,
|
||
documentType: 'invoice', documentNumber: 'INV-002',
|
||
status: 'sent', issueDate: '2026-03-22',
|
||
);
|
||
await notifier.loadDashboard();
|
||
notifier.filterByStatus('draft');
|
||
expect(notifier.state.filteredInvoices.length, 1);
|
||
expect(notifier.state.filteredInvoices.first.status, 'draft');
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/features/invoices/invoice_dashboard_notifier_test.dart
|
||
```
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement InvoiceDashboardNotifier**
|
||
|
||
```dart
|
||
// lib/features/invoices/invoice_dashboard_notifier.dart
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../core/database/database.dart';
|
||
import '../../core/database/daos/document_dao.dart';
|
||
|
||
class DashboardState {
|
||
final List<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**
|
||
|
||
```bash
|
||
flutter test test/features/invoices/invoice_dashboard_notifier_test.dart -v
|
||
```
|
||
Expected: All 3 tests PASS.
|
||
|
||
- [ ] **Step 5: Create Invoice Dashboard Screen**
|
||
|
||
```dart
|
||
// lib/features/invoices/invoice_dashboard_screen.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import '../../core/utils/currency_formatter.dart';
|
||
import '../../core/utils/date_formatter.dart';
|
||
import '../../core/theme/app_colors.dart';
|
||
import '../../core/database/daos/document_dao.dart';
|
||
import '../../core/providers/database_provider.dart';
|
||
import '../../core/providers/business_provider.dart';
|
||
import 'invoice_dashboard_notifier.dart';
|
||
import 'widgets/status_pill.dart';
|
||
import 'widgets/summary_card.dart';
|
||
|
||
final dashboardNotifierProvider =
|
||
StateNotifierProvider<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`:
|
||
```dart
|
||
GoRoute(
|
||
path: '/invoices',
|
||
builder: (context, state) => const InvoiceDashboardScreen(),
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add lib/features/invoices/ test/features/invoices/
|
||
git commit -m "feat: implement invoice dashboard with summary stats and filter chips"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Invoice Detail Screen & Route Wiring
|
||
|
||
**Files:**
|
||
- Create: `lib/features/invoices/invoice_detail_screen.dart`
|
||
- Modify: `lib/app.dart` — add all remaining routes
|
||
- Create: `lib/core/providers/document_provider.dart`
|
||
|
||
- [ ] **Step 1: Create invoice detail screen**
|
||
|
||
```dart
|
||
// lib/features/invoices/invoice_detail_screen.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../core/utils/currency_formatter.dart';
|
||
import '../../core/utils/date_formatter.dart';
|
||
import '../../core/database/database.dart';
|
||
import '../../core/database/daos/document_dao.dart';
|
||
import '../../core/database/daos/line_item_dao.dart';
|
||
import '../../core/database/daos/client_dao.dart';
|
||
import '../../core/database/daos/business_dao.dart';
|
||
import '../../core/database/daos/app_settings_dao.dart';
|
||
import '../../core/providers/database_provider.dart';
|
||
import '../../core/services/pdf_service.dart';
|
||
import '../../features/pdf/pdf_preview_screen.dart';
|
||
import '../../features/payments/payment_bottom_sheet.dart';
|
||
import 'widgets/status_pill.dart';
|
||
|
||
final invoiceDetailProvider =
|
||
FutureProvider.family<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**
|
||
|
||
```dart
|
||
// lib/core/providers/document_provider.dart
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../database/daos/document_dao.dart';
|
||
import '../database/daos/line_item_dao.dart';
|
||
import 'database_provider.dart';
|
||
|
||
final documentDaoProvider = Provider<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:
|
||
```dart
|
||
GoRoute(
|
||
path: '/invoices/:id',
|
||
builder: (context, state) => InvoiceDetailScreen(
|
||
documentId: state.pathParameters['id']!,
|
||
),
|
||
),
|
||
GoRoute(
|
||
path: '/clients/new',
|
||
builder: (context, state) => const ClientFormScreen(),
|
||
),
|
||
GoRoute(
|
||
path: '/clients/:id',
|
||
builder: (context, state) => ClientDetailScreen(
|
||
clientId: state.pathParameters['id']!,
|
||
),
|
||
),
|
||
GoRoute(
|
||
path: '/clients/:id/edit',
|
||
builder: (context, state) => ClientFormScreen(
|
||
clientId: state.pathParameters['id'],
|
||
),
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 4: Verify full navigation flow**
|
||
|
||
```bash
|
||
flutter run -d chrome
|
||
```
|
||
Expected: Can navigate between all 4 tabs. FAB on invoices goes to creator. All routes load without errors.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/features/invoices/invoice_detail_screen.dart \
|
||
lib/core/providers/document_provider.dart lib/app.dart
|
||
git commit -m "feat: add invoice detail screen and wire up all routes"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 3: PDF & Pro Features (Weeks 6–7)
|
||
|
||
### Task 15: PDF Generation Service
|
||
|
||
**Files:**
|
||
- Create: `lib/core/services/pdf_service.dart`
|
||
- Create: `lib/features/pdf/pdf_template.dart`
|
||
- Create: `lib/features/pdf/pdf_preview_screen.dart`
|
||
- Create: `test/core/services/pdf_service_test.dart`
|
||
|
||
- [ ] **Step 1: Write failing tests for PdfService**
|
||
|
||
```dart
|
||
// test/core/services/pdf_service_test.dart
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/services/pdf_service.dart';
|
||
|
||
void main() {
|
||
test('generateInvoicePdf returns non-empty bytes', () async {
|
||
final bytes = await PdfService.generateDocument(
|
||
businessName: 'Test Co',
|
||
businessEmail: 'test@co.com',
|
||
clientName: 'Jane Doe',
|
||
documentTitle: 'INVOICE',
|
||
documentNumber: 'INV-001',
|
||
issueDate: '2026-03-22',
|
||
dueDate: '2026-04-21',
|
||
lineItems: [
|
||
PdfLineItem(description: 'Web Design', quantity: 2.0, rate: 5000, amount: 10000),
|
||
],
|
||
subtotal: 10000,
|
||
taxRate: 825,
|
||
taxAmount: 825,
|
||
discountAmount: 0,
|
||
total: 10825,
|
||
amountDue: 10825,
|
||
notes: 'Payment due in 30 days',
|
||
showWatermark: true,
|
||
);
|
||
expect(bytes, isNotEmpty);
|
||
// PDF magic bytes
|
||
expect(bytes[0], 0x25); // %
|
||
expect(bytes[1], 0x50); // P
|
||
expect(bytes[2], 0x44); // D
|
||
expect(bytes[3], 0x46); // F
|
||
});
|
||
|
||
test('generateDocument without watermark for pro users', () async {
|
||
final bytes = await PdfService.generateDocument(
|
||
businessName: 'Pro Co',
|
||
clientName: 'Client',
|
||
documentTitle: 'INVOICE',
|
||
documentNumber: 'INV-002',
|
||
issueDate: '2026-03-22',
|
||
lineItems: [],
|
||
subtotal: 0,
|
||
taxRate: 0,
|
||
taxAmount: 0,
|
||
discountAmount: 0,
|
||
total: 0,
|
||
amountDue: 0,
|
||
showWatermark: false,
|
||
);
|
||
expect(bytes, isNotEmpty);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/core/services/pdf_service_test.dart
|
||
```
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement PdfService**
|
||
|
||
```dart
|
||
// lib/core/services/pdf_service.dart
|
||
import 'dart:typed_data';
|
||
import 'package:pdf/pdf.dart';
|
||
import 'package:pdf/widgets.dart' as pw;
|
||
|
||
class PdfLineItem {
|
||
final String description;
|
||
final double quantity;
|
||
final int rate;
|
||
final int amount;
|
||
|
||
const PdfLineItem({
|
||
required this.description,
|
||
required this.quantity,
|
||
required this.rate,
|
||
required this.amount,
|
||
});
|
||
}
|
||
|
||
class PdfService {
|
||
static Future<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**
|
||
|
||
```bash
|
||
flutter test test/core/services/pdf_service_test.dart -v
|
||
```
|
||
Expected: All 2 tests PASS.
|
||
|
||
- [ ] **Step 5: Create PDF preview screen**
|
||
|
||
```dart
|
||
// lib/features/pdf/pdf_preview_screen.dart
|
||
import 'dart:typed_data';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:printing/printing.dart';
|
||
import 'package:share_plus/share_plus.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'dart:io';
|
||
import 'package:path/path.dart' as p;
|
||
|
||
class PdfPreviewScreen extends StatelessWidget {
|
||
final Uint8List pdfBytes;
|
||
final String fileName;
|
||
|
||
const PdfPreviewScreen({
|
||
super.key,
|
||
required this.pdfBytes,
|
||
required this.fileName,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('Preview'),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.share),
|
||
onPressed: () => _share(context),
|
||
),
|
||
],
|
||
),
|
||
body: PdfPreview(
|
||
build: (_) => pdfBytes,
|
||
canChangeOrientation: false,
|
||
canChangePageFormat: false,
|
||
canDebug: false,
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<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**
|
||
|
||
```bash
|
||
git add lib/core/services/pdf_service.dart lib/features/pdf/ \
|
||
test/core/services/pdf_service_test.dart
|
||
git commit -m "feat: implement PDF generation with branded template and share flow"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: PDF Integration — Verify Share Flow
|
||
|
||
> **Note:** The `_sharePdf()` method was already implemented in Task 14's `InvoiceDetailScreen`. This task verifies the integration end-to-end.
|
||
|
||
**Files:**
|
||
- No new files — verify existing wiring from Task 14
|
||
|
||
- [ ] **Step 1: Run the app and test PDF flow**
|
||
|
||
```bash
|
||
flutter run -d chrome
|
||
```
|
||
1. Create a client and an invoice with 2+ line items
|
||
2. Open the invoice detail screen
|
||
3. Tap the share icon
|
||
4. Verify PDF preview renders with: business name, client name, line items table, totals, and watermark (free tier)
|
||
5. Tap share — verify system share sheet opens
|
||
|
||
- [ ] **Step 2: Verify status update on share**
|
||
|
||
After sharing a draft invoice, navigate back to dashboard and verify the invoice's status pill changed from "Draft" (gray) to "Sent" (blue).
|
||
|
||
- [ ] **Step 3: Commit (if any fixes were needed)**
|
||
|
||
```bash
|
||
git add lib/features/invoices/invoice_detail_screen.dart
|
||
git commit -m "fix: polish PDF share integration"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: Estimate Creator with Conversion
|
||
|
||
**Files:**
|
||
- Create: `lib/features/estimates/estimate_creator_notifier.dart`
|
||
- Create: `lib/features/estimates/estimate_creator_screen.dart`
|
||
- Create: `lib/features/estimates/estimate_list_screen.dart`
|
||
- Create: `test/features/estimates/estimate_creator_notifier_test.dart`
|
||
|
||
- [ ] **Step 1: Write failing tests for estimate conversion**
|
||
|
||
```dart
|
||
// test/features/estimates/estimate_creator_notifier_test.dart
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/database/database.dart';
|
||
import 'package:swift_invoice/core/database/daos/business_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/client_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/document_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/line_item_dao.dart';
|
||
import 'package:swift_invoice/features/estimates/estimate_creator_notifier.dart';
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
late EstimateCreatorNotifier notifier;
|
||
late String businessId;
|
||
late String clientId;
|
||
|
||
setUp(() async {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
final biz = await BusinessDao(db).createBusiness(name: 'Co');
|
||
businessId = biz.id;
|
||
final client = await ClientDao(db).createClient(
|
||
businessId: businessId, name: 'Jane');
|
||
clientId = client.id;
|
||
notifier = EstimateCreatorNotifier(
|
||
businessDao: BusinessDao(db),
|
||
documentDao: DocumentDao(db),
|
||
lineItemDao: LineItemDao(db),
|
||
businessId: businessId,
|
||
);
|
||
await notifier.initialize();
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('initialize sets estimate number with EST prefix', () {
|
||
expect(notifier.state.documentNumber, startsWith('EST'));
|
||
});
|
||
|
||
test('convertToInvoice creates invoice from estimate', () async {
|
||
// Create and save an estimate
|
||
notifier.setClientId(clientId);
|
||
notifier.addLineItem(description: 'Design', quantity: 3.0, unitPrice: 10000);
|
||
final estimateId = await notifier.save();
|
||
expect(estimateId, isNotNull);
|
||
|
||
// Convert it
|
||
final invoiceId = await notifier.convertToInvoice(estimateId!);
|
||
expect(invoiceId, isNotNull);
|
||
|
||
// Verify the invoice
|
||
final invoice = await DocumentDao(db).getDocument(invoiceId!);
|
||
expect(invoice!.documentType, 'invoice');
|
||
expect(invoice.total, 30000);
|
||
expect(invoice.convertedFromId, estimateId);
|
||
|
||
// Verify estimate status changed
|
||
final estimate = await DocumentDao(db).getDocument(estimateId);
|
||
expect(estimate!.status, 'converted');
|
||
|
||
// Verify line items copied
|
||
final items = await LineItemDao(db).getLineItems(invoiceId);
|
||
expect(items.length, 1);
|
||
expect(items.first.description, 'Design');
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/features/estimates/estimate_creator_notifier_test.dart
|
||
```
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement EstimateCreatorNotifier**
|
||
|
||
This reuses much of InvoiceCreatorNotifier's logic. Key differences:
|
||
- Uses `estimate_prefix` and `next_estimate_number`
|
||
- `documentType = 'estimate'`
|
||
- Adds `convertToInvoice()` method implementing the 5-step conversion logic from the spec
|
||
|
||
```dart
|
||
// lib/features/estimates/estimate_creator_notifier.dart
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../core/database/daos/business_dao.dart';
|
||
import '../../core/database/daos/document_dao.dart';
|
||
import '../../core/database/daos/line_item_dao.dart';
|
||
import '../../core/services/document_calculator.dart';
|
||
import '../../core/utils/date_formatter.dart';
|
||
import '../invoices/invoice_creator_notifier.dart';
|
||
|
||
class EstimateCreatorNotifier extends StateNotifier<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**
|
||
|
||
```bash
|
||
flutter test test/features/estimates/estimate_creator_notifier_test.dart -v
|
||
```
|
||
Expected: All 3 tests PASS.
|
||
|
||
- [ ] **Step 5: Create estimate list screen**
|
||
|
||
```dart
|
||
// lib/features/estimates/estimate_list_screen.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import '../../core/utils/currency_formatter.dart';
|
||
import '../../core/utils/date_formatter.dart';
|
||
import '../../core/database/database.dart';
|
||
import '../../core/database/daos/document_dao.dart';
|
||
import '../../core/providers/database_provider.dart';
|
||
import '../../core/providers/business_provider.dart';
|
||
import '../invoices/widgets/status_pill.dart';
|
||
|
||
final estimatesProvider = FutureProvider<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**
|
||
|
||
```dart
|
||
// lib/features/estimates/estimate_creator_screen.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import '../../core/utils/currency_formatter.dart';
|
||
import '../../core/database/daos/business_dao.dart';
|
||
import '../../core/database/daos/document_dao.dart';
|
||
import '../../core/database/daos/line_item_dao.dart';
|
||
import '../../core/database/daos/client_dao.dart';
|
||
import '../../core/database/database.dart';
|
||
import '../../core/providers/database_provider.dart';
|
||
import '../../core/providers/business_provider.dart';
|
||
import '../invoices/invoice_creator_screen.dart';
|
||
import 'estimate_creator_notifier.dart';
|
||
|
||
final estimateCreatorProvider =
|
||
StateNotifierProvider.autoDispose<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**
|
||
|
||
```dart
|
||
// Add to ShellRoute routes in app.dart:
|
||
GoRoute(
|
||
path: '/estimates/new',
|
||
builder: (context, state) => const EstimateCreatorScreen(),
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add lib/features/estimates/ test/features/estimates/
|
||
git commit -m "feat: implement estimate creator with one-tap invoice conversion"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: Payment Tracker
|
||
|
||
**Files:**
|
||
- Create: `lib/features/payments/payment_notifier.dart`
|
||
- Create: `lib/features/payments/payment_bottom_sheet.dart`
|
||
- Create: `test/features/payments/payment_notifier_test.dart`
|
||
|
||
- [ ] **Step 1: Write failing tests for PaymentNotifier**
|
||
|
||
```dart
|
||
// test/features/payments/payment_notifier_test.dart
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/database/database.dart';
|
||
import 'package:swift_invoice/core/database/daos/business_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/client_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/document_dao.dart';
|
||
import 'package:swift_invoice/core/database/daos/payment_dao.dart';
|
||
import 'package:swift_invoice/features/payments/payment_notifier.dart';
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
late PaymentNotifier notifier;
|
||
late String documentId;
|
||
late String clientId;
|
||
|
||
setUp(() async {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
final biz = await BusinessDao(db).createBusiness(name: 'Co');
|
||
final client = await ClientDao(db).createClient(
|
||
businessId: biz.id, name: 'C');
|
||
clientId = client.id;
|
||
final doc = await DocumentDao(db).createDocument(
|
||
businessId: biz.id, clientId: clientId,
|
||
documentType: 'invoice', documentNumber: 'INV-001',
|
||
status: 'sent', issueDate: '2026-03-22',
|
||
total: 10000, amountDue: 10000,
|
||
);
|
||
documentId = doc.id;
|
||
notifier = PaymentNotifier(
|
||
paymentDao: PaymentDao(db),
|
||
documentDao: DocumentDao(db),
|
||
clientDao: ClientDao(db),
|
||
);
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('recordPayment updates document amounts', () async {
|
||
await notifier.recordPayment(
|
||
documentId: documentId,
|
||
clientId: clientId,
|
||
amount: 5000,
|
||
method: 'cash',
|
||
);
|
||
final doc = await DocumentDao(db).getDocument(documentId);
|
||
expect(doc!.amountPaid, 5000);
|
||
expect(doc.amountDue, 5000);
|
||
expect(doc.status, 'partial');
|
||
});
|
||
|
||
test('full payment marks invoice as paid', () async {
|
||
await notifier.recordPayment(
|
||
documentId: documentId,
|
||
clientId: clientId,
|
||
amount: 10000,
|
||
method: 'card',
|
||
);
|
||
final doc = await DocumentDao(db).getDocument(documentId);
|
||
expect(doc!.status, 'paid');
|
||
expect(doc.amountDue, 0);
|
||
});
|
||
|
||
test('rejects payment exceeding amount due', () async {
|
||
final result = await notifier.recordPayment(
|
||
documentId: documentId,
|
||
clientId: clientId,
|
||
amount: 15000,
|
||
method: 'cash',
|
||
);
|
||
expect(result, isFalse);
|
||
expect(notifier.state.error, contains('exceed'));
|
||
});
|
||
|
||
test('deletePayment reverses amounts', () async {
|
||
await notifier.recordPayment(
|
||
documentId: documentId,
|
||
clientId: clientId,
|
||
amount: 5000,
|
||
method: 'cash',
|
||
);
|
||
final payments = await PaymentDao(db).getPaymentsForDocument(documentId);
|
||
await notifier.deletePayment(
|
||
paymentId: payments.first.id,
|
||
documentId: documentId,
|
||
clientId: clientId,
|
||
);
|
||
final doc = await DocumentDao(db).getDocument(documentId);
|
||
expect(doc!.amountPaid, 0);
|
||
expect(doc.amountDue, 10000);
|
||
});
|
||
|
||
test('cumulative payments cannot exceed total', () async {
|
||
// First payment of 7000 succeeds (10000 total)
|
||
final result1 = await notifier.recordPayment(
|
||
documentId: documentId,
|
||
clientId: clientId,
|
||
amount: 7000,
|
||
method: 'cash',
|
||
);
|
||
expect(result1, isTrue);
|
||
|
||
// Second payment of 5000 would exceed remaining 3000
|
||
final result2 = await notifier.recordPayment(
|
||
documentId: documentId,
|
||
clientId: clientId,
|
||
amount: 5000,
|
||
method: 'card',
|
||
);
|
||
expect(result2, isFalse);
|
||
expect(notifier.state.error, contains('exceed'));
|
||
|
||
// Payment of exactly 3000 should succeed
|
||
final result3 = await notifier.recordPayment(
|
||
documentId: documentId,
|
||
clientId: clientId,
|
||
amount: 3000,
|
||
method: 'card',
|
||
);
|
||
expect(result3, isTrue);
|
||
|
||
final doc = await DocumentDao(db).getDocument(documentId);
|
||
expect(doc!.status, 'paid');
|
||
expect(doc.amountDue, 0);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/features/payments/payment_notifier_test.dart
|
||
```
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement PaymentNotifier**
|
||
|
||
```dart
|
||
// lib/features/payments/payment_notifier.dart
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../core/database/database.dart';
|
||
import '../../core/database/daos/payment_dao.dart';
|
||
import '../../core/database/daos/document_dao.dart';
|
||
import '../../core/database/daos/client_dao.dart';
|
||
|
||
class PaymentState {
|
||
final List<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**
|
||
|
||
```bash
|
||
flutter test test/features/payments/payment_notifier_test.dart -v
|
||
```
|
||
Expected: All 5 tests PASS.
|
||
|
||
- [ ] **Step 5: Create payment bottom sheet UI**
|
||
|
||
```dart
|
||
// lib/features/payments/payment_bottom_sheet.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../core/utils/currency_formatter.dart';
|
||
import '../../core/database/daos/payment_dao.dart';
|
||
import '../../core/database/daos/document_dao.dart';
|
||
import '../../core/database/daos/client_dao.dart';
|
||
import '../../core/providers/database_provider.dart';
|
||
import 'payment_notifier.dart';
|
||
|
||
final paymentNotifierProvider =
|
||
StateNotifierProvider<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**
|
||
|
||
```bash
|
||
git add lib/features/payments/ test/features/payments/
|
||
git commit -m "feat: implement payment tracker with partial payment support"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 19: Overdue Reminders (Local Notifications)
|
||
|
||
**Files:**
|
||
- Create: `lib/core/services/notification_service.dart`
|
||
- Create: `test/core/services/notification_service_test.dart`
|
||
|
||
- [ ] **Step 1: Write failing test for NotificationService**
|
||
|
||
```dart
|
||
// test/core/services/notification_service_test.dart
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/services/notification_service.dart';
|
||
|
||
void main() {
|
||
test('generateNotificationId is deterministic for same documentId', () {
|
||
final id1 = NotificationService.generateNotificationId('doc-123');
|
||
final id2 = NotificationService.generateNotificationId('doc-123');
|
||
expect(id1, id2);
|
||
});
|
||
|
||
test('generateNotificationId differs for different documentIds', () {
|
||
final id1 = NotificationService.generateNotificationId('doc-123');
|
||
final id2 = NotificationService.generateNotificationId('doc-456');
|
||
expect(id1, isNot(id2));
|
||
});
|
||
|
||
test('buildNotificationBody formats correctly', () {
|
||
final body = NotificationService.buildNotificationBody(
|
||
invoiceNumber: 'INV-001',
|
||
clientName: 'Jane Doe',
|
||
);
|
||
expect(body, contains('INV-001'));
|
||
expect(body, contains('Jane Doe'));
|
||
expect(body, contains('overdue'));
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/core/services/notification_service_test.dart
|
||
```
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement NotificationService**
|
||
|
||
```dart
|
||
// lib/core/services/notification_service.dart
|
||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||
import 'package:timezone/timezone.dart' as tz;
|
||
|
||
class NotificationService {
|
||
static final _plugin = FlutterLocalNotificationsPlugin();
|
||
|
||
static Future<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**
|
||
|
||
```bash
|
||
flutter test test/core/services/notification_service_test.dart -v
|
||
```
|
||
Expected: All 3 tests PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/core/services/notification_service.dart \
|
||
test/core/services/notification_service_test.dart
|
||
git commit -m "feat: implement local notification scheduling for overdue invoice reminders"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 20: Wire Notifications into Invoice Lifecycle
|
||
|
||
**Files:**
|
||
- Modify: `lib/features/invoices/invoice_creator_notifier.dart` — schedule notification on save
|
||
- Modify: `lib/features/payments/payment_notifier.dart` — cancel notification on full payment
|
||
|
||
- [ ] **Step 1: Schedule notification when invoice is created with due date**
|
||
|
||
In `InvoiceCreatorNotifier.save()`, after creating the document, add:
|
||
```dart
|
||
if (doc.dueDate != null) {
|
||
await NotificationService.scheduleOverdueReminder(
|
||
documentId: doc.id,
|
||
invoiceNumber: doc.documentNumber,
|
||
clientName: clientName, // fetch from client
|
||
dueDate: DateTime.parse(doc.dueDate!),
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Cancel notification when invoice is fully paid**
|
||
|
||
In `PaymentNotifier.recordPayment()`, after updating document amounts, check:
|
||
```dart
|
||
final updatedDoc = await _documentDao.getDocument(documentId);
|
||
if (updatedDoc?.status == 'paid' || updatedDoc?.status == 'void') {
|
||
await NotificationService.cancelReminder(documentId);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Write tests for notification wiring**
|
||
|
||
The notification service uses platform plugins that can't run in unit tests. Instead, extract the notification calls behind an interface so they can be verified via mock.
|
||
|
||
Add to `test/features/invoices/invoice_creator_notifier_test.dart`:
|
||
```dart
|
||
test('save with due date schedules notification (verify via DAO side effects)', () async {
|
||
notifier.setClientId(clientId);
|
||
notifier.addLineItem(description: 'Work', quantity: 1.0, unitPrice: 5000);
|
||
final docId = await notifier.save();
|
||
expect(docId, isNotNull);
|
||
|
||
// Verify the document has a due date set (notification scheduling depends on this)
|
||
final doc = await DocumentDao(db).getDocument(docId!);
|
||
expect(doc!.dueDate, isNotNull);
|
||
expect(doc.dueDate, isNotEmpty);
|
||
});
|
||
```
|
||
|
||
Add to `test/features/payments/payment_notifier_test.dart`:
|
||
```dart
|
||
test('full payment results in paid status (notification cancel trigger)', () async {
|
||
await notifier.recordPayment(
|
||
documentId: documentId,
|
||
clientId: clientId,
|
||
amount: 10000,
|
||
method: 'card',
|
||
);
|
||
final doc = await DocumentDao(db).getDocument(documentId);
|
||
// Paid status is the trigger for NotificationService.cancelReminder
|
||
expect(doc!.status, 'paid');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
flutter test test/features/invoices/invoice_creator_notifier_test.dart \
|
||
test/features/payments/payment_notifier_test.dart -v
|
||
```
|
||
Expected: All tests PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/features/invoices/invoice_creator_notifier.dart \
|
||
lib/features/payments/payment_notifier.dart \
|
||
test/features/invoices/invoice_creator_notifier_test.dart \
|
||
test/features/payments/payment_notifier_test.dart
|
||
git commit -m "feat: wire notification scheduling into invoice create/payment lifecycle"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 21: Settings Screen
|
||
|
||
**Files:**
|
||
- Create: `lib/features/settings/settings_screen.dart`
|
||
|
||
- [ ] **Step 1: Create settings screen**
|
||
|
||
```dart
|
||
// lib/features/settings/settings_screen.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import '../../core/providers/business_provider.dart';
|
||
|
||
class SettingsScreen extends ConsumerWidget {
|
||
const SettingsScreen({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final businessAsync = ref.watch(businessProvider);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('Settings')),
|
||
body: ListView(
|
||
children: [
|
||
// Business profile section
|
||
businessAsync.when(
|
||
data: (biz) => ListTile(
|
||
leading: const Icon(Icons.business),
|
||
title: Text(biz?.name ?? 'Set up business'),
|
||
subtitle: const Text('Edit business profile'),
|
||
trailing: const Icon(Icons.chevron_right),
|
||
onTap: () => context.push('/onboarding'),
|
||
),
|
||
loading: () => const ListTile(
|
||
leading: CircularProgressIndicator(),
|
||
title: Text('Loading...'),
|
||
),
|
||
error: (e, _) => ListTile(title: Text('Error: $e')),
|
||
),
|
||
|
||
const Divider(),
|
||
|
||
// Subscription
|
||
ListTile(
|
||
leading: const Icon(Icons.star),
|
||
title: const Text('Subscription'),
|
||
subtitle: const Text('Free Plan'),
|
||
trailing: const Icon(Icons.chevron_right),
|
||
onTap: () {
|
||
context.push('/paywall');
|
||
},
|
||
),
|
||
|
||
const Divider(),
|
||
|
||
// About
|
||
const ListTile(
|
||
leading: Icon(Icons.info),
|
||
title: Text('About SwiftInvoice'),
|
||
subtitle: Text('Version 1.0.0'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update router to use SettingsScreen**
|
||
|
||
Replace the `/settings` placeholder in `app.dart`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add lib/features/settings/settings_screen.dart lib/app.dart
|
||
git commit -m "feat: add settings screen with business profile editing"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 4: Monetization & Launch (Weeks 8–9)
|
||
|
||
### Task 22: RevenueCat Integration
|
||
|
||
**Files:**
|
||
- Create: `lib/core/services/subscription_service.dart`
|
||
- Create: `lib/core/providers/subscription_provider.dart`
|
||
- Create: `test/core/services/subscription_service_test.dart`
|
||
|
||
- [ ] **Step 1: Write failing tests for SubscriptionService**
|
||
|
||
```dart
|
||
// test/core/services/subscription_service_test.dart
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/services/subscription_service.dart';
|
||
|
||
void main() {
|
||
test('tierFromEntitlement maps correctly', () {
|
||
expect(SubscriptionService.tierFromEntitlement(null), 'free');
|
||
expect(SubscriptionService.tierFromEntitlement('pro'), 'pro');
|
||
expect(SubscriptionService.tierFromEntitlement('lifetime'), 'lifetime');
|
||
});
|
||
|
||
test('isProOrAbove checks correctly', () {
|
||
expect(SubscriptionService.isProOrAbove('free'), isFalse);
|
||
expect(SubscriptionService.isProOrAbove('pro'), isTrue);
|
||
expect(SubscriptionService.isProOrAbove('lifetime'), isTrue);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/core/services/subscription_service_test.dart
|
||
```
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement SubscriptionService**
|
||
|
||
```dart
|
||
// lib/core/services/subscription_service.dart
|
||
import 'package:purchases_flutter/purchases_flutter.dart';
|
||
import '../database/daos/app_settings_dao.dart';
|
||
|
||
class SubscriptionService {
|
||
final AppSettingsDao _settingsDao;
|
||
|
||
SubscriptionService(this._settingsDao);
|
||
|
||
static const _revenueCatApiKey = String.fromEnvironment(
|
||
'REVENUECAT_API_KEY',
|
||
defaultValue: '', // Set via --dart-define at build time
|
||
);
|
||
|
||
Future<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**
|
||
|
||
```bash
|
||
flutter test test/core/services/subscription_service_test.dart -v
|
||
```
|
||
Expected: All 2 tests PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/core/services/subscription_service.dart \
|
||
test/core/services/subscription_service_test.dart
|
||
git commit -m "feat: implement RevenueCat subscription service with offline-safe tier caching"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 23: Paywall Screen
|
||
|
||
**Files:**
|
||
- Create: `lib/features/paywall/paywall_screen.dart`
|
||
- Create: `lib/features/paywall/paywall_notifier.dart`
|
||
- Create: `test/features/paywall/paywall_notifier_test.dart`
|
||
|
||
- [ ] **Step 1: Write failing tests for PaywallNotifier**
|
||
|
||
```dart
|
||
// test/features/paywall/paywall_notifier_test.dart
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:swift_invoice/core/database/database.dart';
|
||
import 'package:swift_invoice/core/database/daos/app_settings_dao.dart';
|
||
import 'package:swift_invoice/features/paywall/paywall_notifier.dart';
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
late PaywallNotifier notifier;
|
||
|
||
setUp(() {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
notifier = PaywallNotifier(AppSettingsDao(db));
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('initial state is free tier', () async {
|
||
await notifier.loadTier();
|
||
expect(notifier.state.currentTier, 'free');
|
||
expect(notifier.state.isPro, isFalse);
|
||
});
|
||
|
||
test('after upgrade, isPro is true', () async {
|
||
await AppSettingsDao(db).setSubscriptionTier('pro');
|
||
await notifier.loadTier();
|
||
expect(notifier.state.isPro, isTrue);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
flutter test test/features/paywall/paywall_notifier_test.dart
|
||
```
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement PaywallNotifier**
|
||
|
||
```dart
|
||
// lib/features/paywall/paywall_notifier.dart
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../core/database/daos/app_settings_dao.dart';
|
||
import '../../core/services/subscription_service.dart';
|
||
|
||
class PaywallState {
|
||
final String currentTier;
|
||
final bool isPurchasing;
|
||
final String? error;
|
||
|
||
bool get isPro => SubscriptionService.isProOrAbove(currentTier);
|
||
|
||
const PaywallState({
|
||
this.currentTier = 'free',
|
||
this.isPurchasing = false,
|
||
this.error,
|
||
});
|
||
|
||
PaywallState copyWith({
|
||
String? currentTier,
|
||
bool? isPurchasing,
|
||
String? error,
|
||
}) {
|
||
return PaywallState(
|
||
currentTier: currentTier ?? this.currentTier,
|
||
isPurchasing: isPurchasing ?? this.isPurchasing,
|
||
error: error,
|
||
);
|
||
}
|
||
}
|
||
|
||
class PaywallNotifier extends StateNotifier<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**
|
||
|
||
```bash
|
||
flutter test test/features/paywall/paywall_notifier_test.dart -v
|
||
```
|
||
Expected: All 2 tests PASS.
|
||
|
||
- [ ] **Step 5: Create paywall screen UI**
|
||
|
||
```dart
|
||
// lib/features/paywall/paywall_screen.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../core/services/subscription_service.dart';
|
||
import '../../core/database/daos/app_settings_dao.dart';
|
||
import '../../core/providers/database_provider.dart';
|
||
|
||
final subscriptionServiceProvider = Provider<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**
|
||
|
||
```bash
|
||
git add lib/features/paywall/ test/features/paywall/
|
||
git commit -m "feat: implement paywall screen with 3-tier plan selection"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 24: Free Tier Enforcement Integration
|
||
|
||
**Files:**
|
||
- Modify: `lib/features/invoices/invoice_creator_notifier.dart` — show paywall route instead of just flag
|
||
- Modify: `lib/features/clients/client_notifier.dart` — show paywall route
|
||
- Modify: `lib/app.dart` — add paywall route
|
||
|
||
- [ ] **Step 1: Add paywall route**
|
||
|
||
```dart
|
||
// In app.dart, add as a top-level route (not inside ShellRoute):
|
||
GoRoute(
|
||
path: '/paywall',
|
||
builder: (context, state) => const PaywallScreen(),
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 2: Verify tier gating works end-to-end**
|
||
|
||
Manual test:
|
||
1. Create 2 clients — 3rd should show paywall
|
||
2. Create 3 invoices — 4th should show paywall
|
||
3. Dismiss paywall — back to previous screen
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add lib/app.dart lib/features/invoices/invoice_creator_notifier.dart \
|
||
lib/features/clients/client_notifier.dart
|
||
git commit -m "feat: integrate paywall into invoice/client creation flows"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 25: Firebase Analytics Setup
|
||
|
||
**Files:**
|
||
- Modify: `lib/main.dart` — initialize Firebase
|
||
- Create: platform config files (google-services.json, GoogleService-Info.plist)
|
||
|
||
- [ ] **Step 1: Initialize Firebase in main.dart**
|
||
|
||
```dart
|
||
// Update lib/main.dart
|
||
import 'package:firebase_core/firebase_core.dart';
|
||
import 'package:swift_invoice/core/services/notification_service.dart';
|
||
|
||
void main() async {
|
||
WidgetsFlutterBinding.ensureInitialized();
|
||
await Firebase.initializeApp();
|
||
await NotificationService.initialize();
|
||
runApp(const ProviderScope(child: SwiftInvoiceApp()));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Generate Firebase config files using FlutterFire CLI**
|
||
|
||
```bash
|
||
# Install FlutterFire CLI
|
||
dart pub global activate flutterfire_cli
|
||
|
||
# Run the configurator (interactive — select your Firebase project + platforms)
|
||
flutterfire configure --project=your-firebase-project-id
|
||
```
|
||
|
||
This generates:
|
||
- `lib/firebase_options.dart` — platform-specific config (commit this)
|
||
- `android/app/google-services.json` — Android config
|
||
- `ios/Runner/GoogleService-Info.plist` — iOS config
|
||
|
||
Update `main.dart` to use the generated options:
|
||
```dart
|
||
import 'firebase_options.dart';
|
||
|
||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||
```
|
||
|
||
Add to `.gitignore`:
|
||
```
|
||
android/app/google-services.json
|
||
ios/Runner/GoogleService-Info.plist
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add lib/main.dart
|
||
git commit -m "feat: initialize Firebase Analytics and local notifications on app start"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 26: Polish, Testing & Store Submission
|
||
|
||
- [ ] **Step 1: Run full test suite**
|
||
|
||
```bash
|
||
flutter test --coverage
|
||
```
|
||
Expected: All tests pass. Coverage report generated.
|
||
|
||
- [ ] **Step 2: Fix any remaining lint warnings**
|
||
|
||
```bash
|
||
flutter analyze
|
||
```
|
||
Expected: No issues found.
|
||
|
||
- [ ] **Step 3: Test offline workflow**
|
||
|
||
Enable airplane mode. Verify:
|
||
- Create client
|
||
- Create invoice
|
||
- Generate PDF
|
||
- Share via email draft
|
||
All work without errors.
|
||
|
||
- [ ] **Step 4: Performance check**
|
||
|
||
With 100 invoices in DB, dashboard should load in <200ms. Profile with:
|
||
```bash
|
||
flutter run --profile
|
||
```
|
||
|
||
- [ ] **Step 5: App icons and splash screen**
|
||
|
||
Generate icons using `flutter_launcher_icons` package. Add to `pubspec.yaml`:
|
||
```yaml
|
||
dev_dependencies:
|
||
flutter_launcher_icons: ^0.14.0
|
||
|
||
flutter_launcher_icons:
|
||
android: true
|
||
ios: true
|
||
image_path: "assets/icon/app_icon.png"
|
||
```
|
||
|
||
```bash
|
||
dart run flutter_launcher_icons
|
||
```
|
||
|
||
- [ ] **Step 6: Build release APK and IPA**
|
||
|
||
```bash
|
||
flutter build apk --release
|
||
flutter build ipa --release # requires Xcode on macOS
|
||
```
|
||
|
||
- [ ] **Step 7: Submit to stores**
|
||
|
||
- Android: Upload APK to Google Play Console
|
||
- iOS: Upload IPA via Xcode or Codemagic CI
|
||
|
||
- [ ] **Step 8: Final commit**
|
||
|
||
```bash
|
||
git add .
|
||
git commit -m "chore: polish, app icons, and store submission prep"
|
||
```
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
| Phase | Tasks | Key Deliverable |
|
||
|-------|-------|-----------------|
|
||
| 1: Foundation | 1–7 | App boots, DB works, onboarding complete, navigation wired |
|
||
| 2: Core Invoicing | 8–14 | Clients CRUD, invoice creation with live totals, dashboard with stats/filters |
|
||
| 3: PDF & Pro | 15–21 | PDF generation, estimates with conversion, payments, notifications, settings |
|
||
| 4: Monetization | 22–26 | RevenueCat IAP, paywall, Firebase analytics, store submission |
|
||
|
||
**Total tasks:** 26
|
||
**Total test files:** 15
|
||
**Estimated commits:** ~26+
|