Add client billing feature module

Introduces the client billing feature with domain, data, and presentation layers. Adds billing BLoC, navigation, UI widgets, and integrates with mock financial repository. Updates localization files for billing-related strings, adds new icons and typography, and registers url_launcher plugins for Linux, macOS, and Windows platforms.
This commit is contained in:
Achintha Isuru
2026-01-23 14:18:56 -05:00
parent 45d6710183
commit ff9ad58b8c
37 changed files with 1588 additions and 9 deletions

View File

@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -9,10 +9,12 @@ import firebase_app_check
import firebase_auth
import firebase_core
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View File

@@ -8,10 +8,13 @@
#include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FirebaseAuthPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
firebase_auth
firebase_core
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -56,6 +56,7 @@
"location": {
"title": "Where do you want to work?",
"subtitle": "Add your preferred work locations",
"full_name_label": "Full Name",
"add_location_label": "Add Location *",
"add_location_hint": "City or ZIP code",
"add_button": "Add",
@@ -332,7 +333,7 @@
"get_direction": "Get direction",
"total": "Total",
"hrs": "HRS",
"workers": "workers",
"workers": "$count workers",
"clock_in": "CLOCK IN",
"clock_out": "CLOCK OUT",
"coverage": "Coverage",
@@ -340,6 +341,28 @@
"confirmed_workers": "Workers Confirmed",
"no_workers": "No workers confirmed yet."
}
},
"client_billing": {
"title": "Billing",
"current_period": "Current Period",
"saved_amount": "$amount saved",
"awaiting_approval": "Awaiting Approval",
"payment_method": "Payment Method",
"add_payment": "Add",
"default_badge": "Default",
"expires": "Expires $date",
"period_breakdown": "This Period Breakdown",
"week": "Week",
"month": "Month",
"total": "Total",
"hours": "$count hours",
"rate_optimization_title": "Rate Optimization",
"rate_optimization_body": "Save $amount/month by switching 3 shifts",
"view_details": "View Details",
"invoice_history": "Invoice History",
"view_all": "View all",
"export_button": "Export All Invoices",
"pending_badge": "PENDING APPROVAL",
"paid_badge": "PAID"
}
}

View File

@@ -56,6 +56,7 @@
"location": {
"title": "¿Dónde quieres trabajar?",
"subtitle": "Agrega tus ubicaciones de trabajo preferidas",
"full_name_label": "Nombre completo",
"add_location_label": "Agregar ubicación *",
"add_location_hint": "Ciudad o código postal",
"add_button": "Agregar",
@@ -332,7 +333,7 @@
"get_direction": "Obtener dirección",
"total": "Total",
"hrs": "HRS",
"workers": "trabajadores",
"workers": "$count trabajadores",
"clock_in": "ENTRADA",
"clock_out": "SALIDA",
"coverage": "Cobertura",
@@ -340,5 +341,28 @@
"confirmed_workers": "Trabajadores Confirmados",
"no_workers": "Ningún trabajador confirmado aún."
}
},
"client_billing": {
"title": "Facturación",
"current_period": "Período Actual",
"saved_amount": "$amount ahorrado",
"awaiting_approval": "Esperando Aprobación",
"payment_method": "Método de Pago",
"add_payment": "Añadir",
"default_badge": "Predeterminado",
"expires": "Expira $date",
"period_breakdown": "Desglose de este Período",
"week": "Semana",
"month": "Mes",
"total": "Total",
"hours": "$count horas",
"rate_optimization_title": "Optimización de Tarifas",
"rate_optimization_body": "Ahorra $amount/mes cambiando 3 turnos",
"view_details": "Ver Detalles",
"invoice_history": "Historial de Facturas",
"view_all": "Ver todo",
"export_button": "Exportar Todas las Facturas",
"pending_badge": "PENDIENTE APROBACIÓN",
"paid_badge": "PAGADO"
}
}

View File

@@ -30,4 +30,26 @@ class FinancialRepositoryMock {
),
];
}
}
Future<List<InvoiceItem>> getInvoiceItems(String invoiceId) async {
await Future.delayed(const Duration(milliseconds: 500));
return <InvoiceItem>[
const InvoiceItem(
id: 'item_1',
invoiceId: 'inv_1',
staffId: 'staff_1',
workHours: 8.0,
rate: 25.0,
amount: 200.0,
),
const InvoiceItem(
id: 'item_2',
invoiceId: 'inv_1',
staffId: 'staff_2',
workHours: 6.0,
rate: 30.0,
amount: 180.0,
),
];
}
}

View File

@@ -198,4 +198,7 @@ class UiIcons {
/// Chart icon for reports
static const IconData chart = _IconLib.barChart3;
/// Download icon
static const IconData download = _IconLib.download;
}

View File

@@ -258,6 +258,15 @@ class UiTypography {
color: UiColors.textPrimary,
);
/// Title Uppercase 4 Bold - Font: Instrument Sans, Size: 11, Height: 1.5, Spacing: 2.2 (#121826)
static final TextStyle titleUppercase4b = _primaryBase.copyWith(
fontWeight: FontWeight.w700,
fontSize: 11,
height: 1.5,
letterSpacing: 2.2,
color: UiColors.textPrimary,
);
// --- 1.5 Body ---
/// Body 1 Bold - Font: Instrument Sans, Size: 16, Height: 1.5 (#121826)

View File

@@ -0,0 +1,4 @@
library;
export 'src/presentation/navigation/billing_navigator.dart';
export 'src/billing_module.dart';

View File

@@ -0,0 +1,51 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'data/repositories_impl/billing_repository_impl.dart';
import 'domain/repositories/i_billing_repository.dart';
import 'domain/usecases/get_current_bill_amount.dart';
import 'domain/usecases/get_invoice_history.dart';
import 'domain/usecases/get_pending_invoices.dart';
import 'domain/usecases/get_savings_amount.dart';
import 'domain/usecases/get_spending_breakdown.dart';
import 'presentation/blocs/billing_bloc.dart';
import 'presentation/pages/billing_page.dart';
/// Modular module for the billing feature.
class BillingModule extends Module {
@override
void binds(Injector i) {
// External Dependencies (Mocks from data_connect)
// In a real app, these would likely be provided by a Core module or similar.
i.addSingleton(FinancialRepositoryMock.new);
// Repositories
i.addSingleton<IBillingRepository>(
() => BillingRepositoryImpl(
financialRepository: i.get<FinancialRepositoryMock>(),
),
);
// Use Cases
i.addSingleton(GetCurrentBillAmountUseCase.new);
i.addSingleton(GetSavingsAmountUseCase.new);
i.addSingleton(GetPendingInvoicesUseCase.new);
i.addSingleton(GetInvoiceHistoryUseCase.new);
i.addSingleton(GetSpendingBreakdownUseCase.new);
// BLoCs
i.addSingleton<BillingBloc>(
() => BillingBloc(
getCurrentBillAmount: i.get<GetCurrentBillAmountUseCase>(),
getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
),
);
}
@override
void routes(RouteManager r) {
r.child('/', child: (_) => const BillingPage());
}
}

View File

@@ -0,0 +1,73 @@
import 'package:krow_data_connect/krow_data_connect.dart' as data_connect;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/i_billing_repository.dart';
/// Implementation of [IBillingRepository].
///
/// Delegates data access to [FinancialRepositoryMock] from the data connect package.
///
/// In a real implementation, this would likely inject `krow_data_connect` classes.
/// Since we are using mocks exposed by `krow_data_connect`, we use them directly.
class BillingRepositoryImpl implements IBillingRepository {
/// Creates a [BillingRepositoryImpl].
///
/// Requires the [financialRepository] to fetch financial data.
BillingRepositoryImpl({
required data_connect.FinancialRepositoryMock financialRepository,
}) : _financialRepository = financialRepository;
final data_connect.FinancialRepositoryMock _financialRepository;
@override
Future<double> getCurrentBillAmount() async {
// In a real app, this might be an aggregate query.
// Simulating fetching invoices and summing up.
final List<Invoice> invoices = await _financialRepository.getInvoices(
'current_business',
);
return invoices
.where((Invoice i) => i.status == InvoiceStatus.open)
.fold<double>(
0.0,
(double sum, Invoice item) => sum + item.totalAmount,
);
}
@override
Future<List<Invoice>> getInvoiceHistory() async {
final List<Invoice> invoices = await _financialRepository.getInvoices(
'current_business',
);
return invoices
.where((Invoice i) => i.status == InvoiceStatus.paid)
.toList();
}
@override
Future<List<Invoice>> getPendingInvoices() async {
final List<Invoice> invoices = await _financialRepository.getInvoices(
'current_business',
);
return invoices
.where(
(Invoice i) =>
i.status == InvoiceStatus.open ||
i.status == InvoiceStatus.disputed,
)
.toList();
}
@override
Future<double> getSavingsAmount() async {
// Simulating savings calculation (e.g., comparing to market rates).
await Future<void>.delayed(const Duration(milliseconds: 500));
return 320.00;
}
@override
Future<List<InvoiceItem>> getSpendingBreakdown() async {
// Assuming breakdown is based on the current period's invoice items.
// We fetch items for a dummy invoice ID representing the current period.
return _financialRepository.getInvoiceItems('current_period_invoice');
}
}

View File

@@ -0,0 +1,21 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for billing related operations.
///
/// This repository handles fetching invoices, financial summaries, and breakdowns.
abstract class IBillingRepository {
/// Fetches invoices that are pending approval or payment.
Future<List<Invoice>> getPendingInvoices();
/// Fetches historically paid invoices.
Future<List<Invoice>> getInvoiceHistory();
/// Fetches the current bill amount for the period.
Future<double> getCurrentBillAmount();
/// Fetches the savings amount.
Future<double> getSavingsAmount();
/// Fetches invoice items for spending breakdown analysis.
Future<List<InvoiceItem>> getSpendingBreakdown();
}

View File

@@ -0,0 +1,13 @@
import 'package:krow_core/core.dart';
import '../repositories/i_billing_repository.dart';
/// Use case for fetching the current bill amount.
class GetCurrentBillAmountUseCase extends NoInputUseCase<double> {
/// Creates a [GetCurrentBillAmountUseCase].
GetCurrentBillAmountUseCase(this._repository);
final IBillingRepository _repository;
@override
Future<double> call() => _repository.getCurrentBillAmount();
}

View File

@@ -0,0 +1,14 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/i_billing_repository.dart';
/// Use case for fetching the invoice history.
class GetInvoiceHistoryUseCase extends NoInputUseCase<List<Invoice>> {
/// Creates a [GetInvoiceHistoryUseCase].
GetInvoiceHistoryUseCase(this._repository);
final IBillingRepository _repository;
@override
Future<List<Invoice>> call() => _repository.getInvoiceHistory();
}

View File

@@ -0,0 +1,14 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/i_billing_repository.dart';
/// Use case for fetching the pending invoices.
class GetPendingInvoicesUseCase extends NoInputUseCase<List<Invoice>> {
/// Creates a [GetPendingInvoicesUseCase].
GetPendingInvoicesUseCase(this._repository);
final IBillingRepository _repository;
@override
Future<List<Invoice>> call() => _repository.getPendingInvoices();
}

View File

@@ -0,0 +1,13 @@
import 'package:krow_core/core.dart';
import '../repositories/i_billing_repository.dart';
/// Use case for fetching the savings amount.
class GetSavingsAmountUseCase extends NoInputUseCase<double> {
/// Creates a [GetSavingsAmountUseCase].
GetSavingsAmountUseCase(this._repository);
final IBillingRepository _repository;
@override
Future<double> call() => _repository.getSavingsAmount();
}

View File

@@ -0,0 +1,14 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/i_billing_repository.dart';
/// Use case for fetching the spending breakdown items.
class GetSpendingBreakdownUseCase extends NoInputUseCase<List<InvoiceItem>> {
/// Creates a [GetSpendingBreakdownUseCase].
GetSpendingBreakdownUseCase(this._repository);
final IBillingRepository _repository;
@override
Future<List<InvoiceItem>> call() => _repository.getSpendingBreakdown();
}

View File

@@ -0,0 +1,135 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_current_bill_amount.dart';
import '../../domain/usecases/get_invoice_history.dart';
import '../../domain/usecases/get_pending_invoices.dart';
import '../../domain/usecases/get_savings_amount.dart';
import '../../domain/usecases/get_spending_breakdown.dart';
import '../models/billing_invoice_model.dart';
import '../models/spending_breakdown_model.dart';
import 'billing_event.dart';
import 'billing_state.dart';
/// BLoC for managing billing state and data loading.
class BillingBloc extends Bloc<BillingEvent, BillingState> {
/// Creates a [BillingBloc] with the given use cases.
BillingBloc({
required GetCurrentBillAmountUseCase getCurrentBillAmount,
required GetSavingsAmountUseCase getSavingsAmount,
required GetPendingInvoicesUseCase getPendingInvoices,
required GetInvoiceHistoryUseCase getInvoiceHistory,
required GetSpendingBreakdownUseCase getSpendingBreakdown,
}) : _getCurrentBillAmount = getCurrentBillAmount,
_getSavingsAmount = getSavingsAmount,
_getPendingInvoices = getPendingInvoices,
_getInvoiceHistory = getInvoiceHistory,
_getSpendingBreakdown = getSpendingBreakdown,
super(const BillingState()) {
on<BillingLoadStarted>(_onLoadStarted);
}
final GetCurrentBillAmountUseCase _getCurrentBillAmount;
final GetSavingsAmountUseCase _getSavingsAmount;
final GetPendingInvoicesUseCase _getPendingInvoices;
final GetInvoiceHistoryUseCase _getInvoiceHistory;
final GetSpendingBreakdownUseCase _getSpendingBreakdown;
Future<void> _onLoadStarted(
BillingLoadStarted event,
Emitter<BillingState> emit,
) async {
emit(state.copyWith(status: BillingStatus.loading));
try {
final List<dynamic> results = await Future.wait<dynamic>(<Future<dynamic>>[
_getCurrentBillAmount.call(),
_getSavingsAmount.call(),
_getPendingInvoices.call(),
_getInvoiceHistory.call(),
_getSpendingBreakdown.call(),
]);
final double currentBill = results[0] as double;
final double savings = results[1] as double;
final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
final List<Invoice> invoiceHistory = results[3] as List<Invoice>;
final List<InvoiceItem> spendingItems = results[4] as List<InvoiceItem>;
// Map Domain Entities to Presentation Models
final List<BillingInvoice> uiPendingInvoices = pendingInvoices
.map(_mapInvoiceToUiModel)
.toList();
final List<BillingInvoice> uiInvoiceHistory = invoiceHistory
.map(_mapInvoiceToUiModel)
.toList();
final List<SpendingBreakdownItem> uiSpendingBreakdown = _mapSpendingItemsToUiModel(spendingItems);
emit(
state.copyWith(
status: BillingStatus.success,
currentBill: currentBill,
savings: savings,
pendingInvoices: uiPendingInvoices,
invoiceHistory: uiInvoiceHistory,
spendingBreakdown: uiSpendingBreakdown,
),
);
} catch (e) {
emit(
state.copyWith(
status: BillingStatus.failure,
errorMessage: e.toString(),
),
);
}
}
BillingInvoice _mapInvoiceToUiModel(Invoice invoice) {
// In a real app, fetches related Event/Business names via ID.
// For now, mapping available fields and hardcoding missing UI placeholders.
// Preserving "Existing Behavior" means we show something.
return BillingInvoice(
id: invoice.id,
title: 'Invoice #${invoice.id}', // Placeholder as Invoice lacks title
locationAddress:
'Location for ${invoice.eventId}', // Placeholder for address
clientName: 'Client ${invoice.businessId}', // Placeholder for client name
date: '2024-01-24', // Placeholder date
totalAmount: invoice.totalAmount,
workersCount: 5, // Placeholder count
totalHours: invoice.workAmount / 25.0, // Estimating hours from amount
status: invoice.status.name,
);
}
List<SpendingBreakdownItem> _mapSpendingItemsToUiModel(
List<InvoiceItem> items,
) {
// Aggregating items by some logic.
// Since InvoiceItem doesn't have category, we mock it based on staffId or similar.
final Map<String, SpendingBreakdownItem> aggregation = <String, SpendingBreakdownItem>{};
for (final InvoiceItem item in items) {
// Mocking category derivation
final String category = item.staffId.hashCode % 2 == 0
? 'Server Staff'
: 'Bar Staff';
final SpendingBreakdownItem? existing = aggregation[category];
if (existing != null) {
aggregation[category] = SpendingBreakdownItem(
category: category,
hours: existing.hours + item.workHours.round(),
amount: existing.amount + item.amount,
);
} else {
aggregation[category] = SpendingBreakdownItem(
category: category,
hours: item.workHours.round(),
amount: item.amount,
);
}
}
return aggregation.values.toList();
}
}

View File

@@ -0,0 +1,16 @@
import 'package:equatable/equatable.dart';
/// Base class for all billing events.
abstract class BillingEvent extends Equatable {
/// Creates a [BillingEvent].
const BillingEvent();
@override
List<Object?> get props => <Object?>[];
}
/// Event triggered when billing data needs to be loaded.
class BillingLoadStarted extends BillingEvent {
/// Creates a [BillingLoadStarted] event.
const BillingLoadStarted();
}

View File

@@ -0,0 +1,85 @@
import 'package:equatable/equatable.dart';
import '../models/billing_invoice_model.dart';
import '../models/spending_breakdown_model.dart';
/// The loading status of the billing feature.
enum BillingStatus {
/// Page hasn't started loading.
initial,
/// Data is currently being fetched.
loading,
/// Data loaded successfully.
success,
/// Loading failed.
failure,
}
/// Represents the state of the billing feature.
class BillingState extends Equatable {
/// Creates a [BillingState].
const BillingState({
this.status = BillingStatus.initial,
this.currentBill = 0.0,
this.savings = 0.0,
this.pendingInvoices = const <BillingInvoice>[],
this.invoiceHistory = const <BillingInvoice>[],
this.spendingBreakdown = const <SpendingBreakdownItem>[],
this.errorMessage,
});
/// The current feature status.
final BillingStatus status;
/// The total amount for the current billing period.
final double currentBill;
/// Total savings achieved compared to traditional agencies.
final double savings;
/// Invoices awaiting client approval.
final List<BillingInvoice> pendingInvoices;
/// History of paid invoices.
final List<BillingInvoice> invoiceHistory;
/// Breakdown of spending by category.
final List<SpendingBreakdownItem> spendingBreakdown;
/// Error message if loading failed.
final String? errorMessage;
/// Creates a copy of this state with updated fields.
BillingState copyWith({
BillingStatus? status,
double? currentBill,
double? savings,
List<BillingInvoice>? pendingInvoices,
List<BillingInvoice>? invoiceHistory,
List<SpendingBreakdownItem>? spendingBreakdown,
String? errorMessage,
}) {
return BillingState(
status: status ?? this.status,
currentBill: currentBill ?? this.currentBill,
savings: savings ?? this.savings,
pendingInvoices: pendingInvoices ?? this.pendingInvoices,
invoiceHistory: invoiceHistory ?? this.invoiceHistory,
spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => <Object?>[
status,
currentBill,
savings,
pendingInvoices,
invoiceHistory,
spendingBreakdown,
errorMessage,
];
}

View File

@@ -0,0 +1,38 @@
import 'package:equatable/equatable.dart';
class BillingInvoice extends Equatable {
const BillingInvoice({
required this.id,
required this.title,
required this.locationAddress,
required this.clientName,
required this.date,
required this.totalAmount,
required this.workersCount,
required this.totalHours,
required this.status,
});
final String id;
final String title;
final String locationAddress;
final String clientName;
final String date;
final double totalAmount;
final int workersCount;
final double totalHours;
final String status;
@override
List<Object?> get props => <Object?>[
id,
title,
locationAddress,
clientName,
date,
totalAmount,
workersCount,
totalHours,
status,
];
}

View File

@@ -0,0 +1,23 @@
import 'package:equatable/equatable.dart';
/// Represents a single item in the spending breakdown.
class SpendingBreakdownItem extends Equatable {
/// Creates a [SpendingBreakdownItem].
const SpendingBreakdownItem({
required this.category,
required this.hours,
required this.amount,
});
/// The category name (e.g., "Server Staff").
final String category;
/// The total hours worked in this category.
final int hours;
/// The total amount spent in this category.
final double amount;
@override
List<Object?> get props => <Object?>[category, hours, amount];
}

View File

@@ -0,0 +1,7 @@
import 'package:flutter_modular/flutter_modular.dart';
/// Extension on [IModularNavigator] to provide typed navigation for the billing feature.
extension BillingNavigator on IModularNavigator {
/// Navigates to the billing page.
void pushBilling() => pushNamed('/billing/');
}

View File

@@ -0,0 +1,99 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart';
import '../blocs/billing_state.dart';
import '../widgets/billing_header.dart';
import '../widgets/pending_invoices_section.dart';
import '../widgets/payment_method_card.dart';
import '../widgets/spending_breakdown_card.dart';
import '../widgets/savings_card.dart';
import '../widgets/invoice_history_section.dart';
import '../widgets/export_invoices_button.dart';
/// The entry point page for the client billing feature.
///
/// This page initializes the [BillingBloc] and provides it to the [BillingView].
class BillingPage extends StatelessWidget {
/// Creates a [BillingPage].
const BillingPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<BillingBloc>(
create: (BuildContext context) =>
Modular.get<BillingBloc>()..add(const BillingLoadStarted()),
child: const BillingView(),
);
}
}
/// The main view for the client billing feature.
///
/// This widget displays the billing dashboard content based on the current
/// state of the [BillingBloc].
class BillingView extends StatelessWidget {
/// Creates a [BillingView].
const BillingView({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<BillingBloc, BillingState>(
builder: (BuildContext context, BillingState state) {
return Scaffold(
backgroundColor: UiColors.bgPrimary,
body: Column(
children: <Widget>[
BillingHeader(
currentBill: state.currentBill,
savings: state.savings,
onBack: () => Modular.to.pop(),
),
Expanded(child: _buildContent(context, state)),
],
),
);
},
);
}
Widget _buildContent(BuildContext context, BillingState state) {
if (state.status == BillingStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == BillingStatus.failure) {
return Center(
child: Text(
state.errorMessage ?? 'An error occurred',
style: UiTypography.body1r.textError,
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
PendingInvoicesSection(invoices: state.pendingInvoices),
const SizedBox(height: UiConstants.space4),
],
const PaymentMethodCard(),
const SizedBox(height: UiConstants.space4),
const SpendingBreakdownCard(),
const SizedBox(height: UiConstants.space4),
SavingsCard(savings: state.savings),
const SizedBox(height: UiConstants.space6),
InvoiceHistorySection(invoices: state.invoiceHistory),
const SizedBox(height: UiConstants.space6),
const ExportInvoicesButton(),
const SizedBox(height: UiConstants.space6),
],
),
);
}
}

View File

@@ -0,0 +1,97 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Header for the billing page showing current period total and savings.
class BillingHeader extends StatelessWidget {
/// Creates a [BillingHeader].
const BillingHeader({
required this.currentBill,
required this.savings,
required this.onBack,
super.key,
});
/// The amount of the current bill.
final double currentBill;
/// The amount saved in the current period.
final double savings;
/// Callback when the back button is pressed.
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(
UiConstants.space5,
MediaQuery.of(context).padding.top + UiConstants.space4,
UiConstants.space5,
UiConstants.space5,
),
color: UiColors.primary,
child: Column(
children: <Widget>[
Row(
children: <Widget>[
UiIconButton.secondary(icon: UiIcons.arrowLeft, onTap: onBack),
const SizedBox(width: UiConstants.space3),
Text(
t.client_billing.title,
style: UiTypography.headline4m.copyWith(color: UiColors.white),
),
],
),
const SizedBox(height: UiConstants.space5),
Column(
children: <Widget>[
Text(
t.client_billing.current_period,
style: UiTypography.footnote2r.copyWith(
color: UiColors.white.withValues(alpha: 0.7),
),
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${currentBill.toStringAsFixed(2)}',
style: UiTypography.display1b.copyWith(color: UiColors.white),
),
const SizedBox(height: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: UiColors.accent,
borderRadius: BorderRadius.circular(100),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Icon(
UiIcons.trendingDown,
size: 12,
color: UiColors.foreground,
),
const SizedBox(width: UiConstants.space1),
Text(
t.client_billing.saved_amount(
amount: savings.toStringAsFixed(0),
),
style: UiTypography.footnote2b.copyWith(
color: UiColors.foreground,
),
),
],
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,22 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Button to export all invoices.
class ExportInvoicesButton extends StatelessWidget {
/// Creates an [ExportInvoicesButton].
const ExportInvoicesButton({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: UiButton.secondary(
text: t.client_billing.export_button,
onPressed: () {},
leadingIcon: UiIcons.download,
size: UiButtonSize.large,
),
);
}
}

View File

@@ -0,0 +1,147 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../models/billing_invoice_model.dart';
/// Section showing the history of paid invoices.
class InvoiceHistorySection extends StatelessWidget {
/// Creates an [InvoiceHistorySection].
const InvoiceHistorySection({required this.invoices, super.key});
/// The list of historical invoices.
final List<BillingInvoice> invoices;
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.client_billing.invoice_history,
style: UiTypography.title2b.textPrimary,
),
GestureDetector(
onTap: () {},
child: Row(
children: <Widget>[
Text(
t.client_billing.view_all,
style: UiTypography.footnote2b.textPrimary,
),
const Icon(
UiIcons.chevronRight,
size: 16,
color: UiColors.primary,
),
],
),
),
],
),
const SizedBox(height: UiConstants.space2),
Container(
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: invoices.asMap().entries.map((
MapEntry<int, BillingInvoice> entry,
) {
final int index = entry.key;
final BillingInvoice invoice = entry.value;
return Column(
children: <Widget>[
if (index > 0)
const Divider(height: 1, color: UiColors.border),
_InvoiceItem(invoice: invoice),
],
);
}).toList(),
),
),
],
);
}
}
class _InvoiceItem extends StatelessWidget {
const _InvoiceItem({required this.invoice});
final BillingInvoice invoice;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Row(
children: <Widget>[
Container(
padding: const EdgeInsets.all(UiConstants.space2),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusMd,
),
child: const Icon(UiIcons.file, color: UiColors.primary, size: 20),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(invoice.id, style: UiTypography.body2b.textPrimary),
Text(
invoice.date,
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
style: UiTypography.body2b.textPrimary,
),
const _PaidBadge(),
],
),
const SizedBox(width: UiConstants.space2),
const Icon(UiIcons.download, size: 16, color: UiColors.iconSecondary),
],
),
);
}
}
class _PaidBadge extends StatelessWidget {
const _PaidBadge();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: UiColors.tagSuccess,
borderRadius: BorderRadius.circular(4),
),
child: Text(
t.client_billing.paid_badge,
style: UiTypography.titleUppercase4b.copyWith(
color: UiColors.iconSuccess,
),
),
);
}
}

View File

@@ -0,0 +1,111 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Card showing the current payment method.
class PaymentMethodCard extends StatelessWidget {
/// Creates a [PaymentMethodCard].
const PaymentMethodCard({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.client_billing.payment_method,
style: UiTypography.title2b.textPrimary,
),
GestureDetector(
onTap: () {},
child: Row(
children: <Widget>[
const Icon(UiIcons.add, size: 14, color: UiColors.primary),
const SizedBox(width: 4),
Text(
t.client_billing.add_payment,
style: UiTypography.footnote2b.textPrimary,
),
],
),
),
],
),
const SizedBox(height: UiConstants.space3),
Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusMd,
),
child: Row(
children: <Widget>[
Container(
width: 40,
height: 28,
decoration: BoxDecoration(
color: UiColors.primary,
borderRadius: BorderRadius.circular(4),
),
child: const Center(
child: Text(
'VISA',
style: TextStyle(
color: UiColors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('•••• 4242', style: UiTypography.body2b.textPrimary),
Text(
t.client_billing.expires(date: '12/25'),
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: UiColors.accent,
borderRadius: BorderRadius.circular(4),
),
child: Text(
t.client_billing.default_badge,
style: UiTypography.titleUppercase4b.textPrimary,
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,208 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../models/billing_invoice_model.dart';
/// Section showing invoices awaiting approval.
class PendingInvoicesSection extends StatelessWidget {
/// Creates a [PendingInvoicesSection].
const PendingInvoicesSection({required this.invoices, super.key});
/// The list of pending invoices.
final List<BillingInvoice> invoices;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: UiColors.textWarning,
shape: BoxShape.circle,
),
),
const SizedBox(width: UiConstants.space2),
Text(
t.client_billing.awaiting_approval,
style: UiTypography.title2b.textPrimary,
),
const SizedBox(width: UiConstants.space2),
Container(
width: 24,
height: 24,
decoration: const BoxDecoration(
color: UiColors.accent,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${invoices.length}',
style: UiTypography.footnote2b.textPrimary,
),
),
),
],
),
const SizedBox(height: UiConstants.space3),
...invoices.map(
(BillingInvoice invoice) => Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: _PendingInvoiceCard(invoice: invoice),
),
),
],
);
}
}
class _PendingInvoiceCard extends StatelessWidget {
const _PendingInvoiceCard({required this.invoice});
final BillingInvoice invoice;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: 14,
color: UiColors.iconSecondary,
),
const SizedBox(width: UiConstants.space1),
Expanded(
child: Text(
invoice.locationAddress,
style: UiTypography.footnote2r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: UiConstants.space1),
Text(invoice.title, style: UiTypography.body2b.textPrimary),
const SizedBox(height: UiConstants.space1),
Row(
children: <Widget>[
Text(
invoice.clientName,
style: UiTypography.footnote2r.textSecondary,
),
const SizedBox(width: UiConstants.space2),
Text('', style: UiTypography.footnote2r.textInactive),
const SizedBox(width: UiConstants.space2),
Text(
invoice.date,
style: UiTypography.footnote2r.textSecondary,
),
],
),
const SizedBox(height: UiConstants.space3),
Row(
children: <Widget>[
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: UiColors.textWarning,
shape: BoxShape.circle,
),
),
const SizedBox(width: UiConstants.space2),
Text(
t.client_billing.pending_badge,
style: UiTypography.titleUppercase4b.copyWith(
color: UiColors.textWarning,
),
),
],
),
const SizedBox(height: UiConstants.space4),
Container(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space3),
decoration: const BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(color: UiColors.border),
),
),
child: Row(
children: <Widget>[
Expanded(
child: _buildStatItem(
UiIcons.dollar,
'\$${invoice.totalAmount.toStringAsFixed(2)}',
'Total',
),
),
Container(width: 1, height: 30, color: UiColors.border),
Expanded(
child: _buildStatItem(
UiIcons.users,
'${invoice.workersCount}',
'Workers',
),
),
Container(width: 1, height: 30, color: UiColors.border),
Expanded(
child: _buildStatItem(
UiIcons.clock,
invoice.totalHours.toStringAsFixed(1),
'HRS',
),
),
],
),
),
const SizedBox(height: UiConstants.space4),
SizedBox(
width: double.infinity,
child: UiButton.primary(
text: 'Review & Approve',
onPressed: () {},
size: UiButtonSize.small,
),
),
],
),
),
);
}
Widget _buildStatItem(IconData icon, String value, String label) {
return Column(
children: <Widget>[
Icon(icon, size: 14, color: UiColors.iconSecondary),
const SizedBox(height: 2),
Text(value, style: UiTypography.body2b.textPrimary),
Text(
label.toUpperCase(),
style: UiTypography.titleUppercase4m.textSecondary,
),
],
);
}
}

View File

@@ -0,0 +1,79 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Card showing savings information and rate optimization suggestions.
class SavingsCard extends StatelessWidget {
/// Creates a [SavingsCard].
const SavingsCard({required this.savings, super.key});
/// The estimated savings amount.
final double savings;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.accent.withValues(alpha: 0.2),
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.accent),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
padding: const EdgeInsets.all(UiConstants.space2),
decoration: BoxDecoration(
color: UiColors.accent,
borderRadius: UiConstants.radiusMd,
),
child: const Icon(
UiIcons.trendingDown,
size: 16,
color: UiColors.textPrimary,
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.client_billing.rate_optimization_title,
style: UiTypography.body2b.textPrimary,
),
const SizedBox(height: UiConstants.space1),
Text(
// Using a hardcoded 180 here to match prototype mock or derived value
t.client_billing.rate_optimization_body(amount: 180),
style: UiTypography.footnote2r.textSecondary,
),
const SizedBox(height: UiConstants.space2),
SizedBox(
height: 28,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusMd,
),
textStyle: UiTypography.footnote2b,
),
child: Text(t.client_billing.view_details),
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,154 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
import '../models/spending_breakdown_model.dart';
/// Card showing the spending breakdown for the current period.
class SpendingBreakdownCard extends StatefulWidget {
/// Creates a [SpendingBreakdownCard].
const SpendingBreakdownCard({super.key});
@override
State<SpendingBreakdownCard> createState() => _SpendingBreakdownCardState();
}
class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<BillingBloc, BillingState>(
builder: (BuildContext context, BillingState state) {
final double total = state.spendingBreakdown.fold(
0.0,
(double sum, SpendingBreakdownItem item) => sum + item.amount,
);
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.client_billing.period_breakdown,
style: UiTypography.title2b.textPrimary,
),
Container(
height: 28,
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: BorderRadius.circular(6),
),
child: TabBar(
controller: _tabController,
isScrollable: true,
indicator: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: BorderRadius.circular(4),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.05),
blurRadius: 1,
),
],
),
labelColor: UiColors.textPrimary,
unselectedLabelColor: UiColors.textSecondary,
labelStyle: UiTypography.titleUppercase4b,
padding: const EdgeInsets.all(2),
indicatorSize: TabBarIndicatorSize.tab,
labelPadding: const EdgeInsets.symmetric(horizontal: 12),
dividerColor: Colors.transparent,
tabs: <Widget>[
Tab(text: t.client_billing.week),
Tab(text: t.client_billing.month),
],
),
),
],
),
const SizedBox(height: UiConstants.space4),
...state.spendingBreakdown.map(
(SpendingBreakdownItem item) => _buildBreakdownRow(item),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: UiConstants.space2),
child: Divider(height: 1, color: UiColors.border),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.client_billing.total,
style: UiTypography.body2b.textPrimary,
),
Text(
'\$${total.toStringAsFixed(2)}',
style: UiTypography.body2b.textPrimary,
),
],
),
],
),
);
},
);
}
Widget _buildBreakdownRow(SpendingBreakdownItem item) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(item.category, style: UiTypography.body2r.textPrimary),
Text(
t.client_billing.hours(count: item.hours),
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
Text(
'\$${item.amount.toStringAsFixed(2)}',
style: UiTypography.body2m.textPrimary,
),
],
),
);
}
}

View File

@@ -0,0 +1,42 @@
name: billing
description: Client Billing feature package
publish_to: 'none'
version: 1.0.0+1
resolution: workspace
environment:
sdk: '>=3.10.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
# Architecture
flutter_modular: ^6.3.2
flutter_bloc: ^8.1.3
equatable: ^2.0.5
# Shared packages
design_system:
path: ../../../design_system
core_localization:
path: ../../../core_localization
krow_domain:
path: ../../../domain
krow_core:
path: ../../../core
# UI
lucide_icons: ^0.257.0
intl: ^0.20.1
krow_data_connect: any
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
bloc_test: ^9.1.5
mocktail: ^1.0.1
flutter:
uses-material-design: true

View File

@@ -1,3 +1,4 @@
import 'package:billing/billing.dart';
import 'package:client_home/client_home.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
@@ -26,11 +27,7 @@ class ClientMainModule extends Module {
child: (BuildContext context) =>
const PlaceholderPage(title: 'Coverage'),
),
ChildRoute<dynamic>(
'/billing',
child: (BuildContext context) =>
const PlaceholderPage(title: 'Billing'),
),
ModuleRoute<dynamic>('/billing', module: BillingModule()),
ModuleRoute<dynamic>('/orders', module: ViewOrdersModule()),
ChildRoute<dynamic>(
'/reports',

View File

@@ -25,6 +25,9 @@ dependencies:
path: ../home
view_orders:
path: ../view_orders
billing:
path: ../billing
# Intentionally commenting these out as they might not exist yet
# client_settings:
# path: ../settings

View File

@@ -65,6 +65,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
billing:
dependency: transitive
description:
path: "packages/features/client/billing"
relative: true
source: path
version: "1.0.0+1"
bloc:
dependency: transitive
description: