From ff9ad58b8cb2007ad9fb147e77523f3c78e8c8ee Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 23 Jan 2026 14:18:56 -0500 Subject: [PATCH] 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. --- .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + .../lib/src/l10n/en.i18n.json | 27 ++- .../lib/src/l10n/es.i18n.json | 26 ++- .../src/mocks/financial_repository_mock.dart | 24 +- .../design_system/lib/src/ui_icons.dart | 3 + .../design_system/lib/src/ui_typography.dart | 9 + .../features/client/billing/lib/billing.dart | 4 + .../billing/lib/src/billing_module.dart | 51 +++++ .../billing_repository_impl.dart | 73 ++++++ .../repositories/i_billing_repository.dart | 21 ++ .../usecases/get_current_bill_amount.dart | 13 ++ .../domain/usecases/get_invoice_history.dart | 14 ++ .../domain/usecases/get_pending_invoices.dart | 14 ++ .../domain/usecases/get_savings_amount.dart | 13 ++ .../usecases/get_spending_breakdown.dart | 14 ++ .../src/presentation/blocs/billing_bloc.dart | 135 ++++++++++++ .../src/presentation/blocs/billing_event.dart | 16 ++ .../src/presentation/blocs/billing_state.dart | 85 +++++++ .../models/billing_invoice_model.dart | 38 ++++ .../models/spending_breakdown_model.dart | 23 ++ .../navigation/billing_navigator.dart | 7 + .../src/presentation/pages/billing_page.dart | 99 +++++++++ .../presentation/widgets/billing_header.dart | 97 ++++++++ .../widgets/export_invoices_button.dart | 22 ++ .../widgets/invoice_history_section.dart | 147 +++++++++++++ .../widgets/payment_method_card.dart | 111 ++++++++++ .../widgets/pending_invoices_section.dart | 208 ++++++++++++++++++ .../presentation/widgets/savings_card.dart | 79 +++++++ .../widgets/spending_breakdown_card.dart | 154 +++++++++++++ .../features/client/billing/pubspec.yaml | 42 ++++ .../lib/src/client_main_module.dart | 7 +- .../features/client/client_main/pubspec.yaml | 3 + apps/mobile/pubspec.lock | 7 + 37 files changed, 1588 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/packages/features/client/billing/lib/billing.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/billing_module.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/domain/repositories/i_billing_repository.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/models/spending_breakdown_model.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/navigation/billing_navigator.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_header.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/export_invoices_button.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/spending_breakdown_card.dart create mode 100644 apps/mobile/packages/features/client/billing/pubspec.yaml diff --git a/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc index e71a16d2..f6f23bfe 100644 --- a/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include 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); } diff --git a/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake b/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake index 2e1de87a..f16b4c34 100644 --- a/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift index 8bd29968..c4ba9dcf 100644 --- a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc index d141b74f..869eecae 100644 --- a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,13 @@ #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FirebaseAuthPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake index 29944d5b..7ba8383b 100644 --- a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST firebase_auth firebase_core + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index f97c1777..9ce4cea3 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -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" } } - diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index c141a406..9f4e6f46 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -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" } } diff --git a/apps/mobile/packages/data_connect/lib/src/mocks/financial_repository_mock.dart b/apps/mobile/packages/data_connect/lib/src/mocks/financial_repository_mock.dart index 0711463a..0ae0c513 100644 --- a/apps/mobile/packages/data_connect/lib/src/mocks/financial_repository_mock.dart +++ b/apps/mobile/packages/data_connect/lib/src/mocks/financial_repository_mock.dart @@ -30,4 +30,26 @@ class FinancialRepositoryMock { ), ]; } -} \ No newline at end of file + + Future> getInvoiceItems(String invoiceId) async { + await Future.delayed(const Duration(milliseconds: 500)); + return [ + 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, + ), + ]; + } +} diff --git a/apps/mobile/packages/design_system/lib/src/ui_icons.dart b/apps/mobile/packages/design_system/lib/src/ui_icons.dart index 6b04f468..df7f72d2 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_icons.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_icons.dart @@ -198,4 +198,7 @@ class UiIcons { /// Chart icon for reports static const IconData chart = _IconLib.barChart3; + + /// Download icon + static const IconData download = _IconLib.download; } diff --git a/apps/mobile/packages/design_system/lib/src/ui_typography.dart b/apps/mobile/packages/design_system/lib/src/ui_typography.dart index dc795923..faf15943 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_typography.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_typography.dart @@ -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) diff --git a/apps/mobile/packages/features/client/billing/lib/billing.dart b/apps/mobile/packages/features/client/billing/lib/billing.dart new file mode 100644 index 00000000..a4a659c9 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/billing.dart @@ -0,0 +1,4 @@ +library; + +export 'src/presentation/navigation/billing_navigator.dart'; +export 'src/billing_module.dart'; diff --git a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart new file mode 100644 index 00000000..082a33f4 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart @@ -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( + () => BillingRepositoryImpl( + financialRepository: i.get(), + ), + ); + + // 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( + getCurrentBillAmount: i.get(), + getSavingsAmount: i.get(), + getPendingInvoices: i.get(), + getInvoiceHistory: i.get(), + getSpendingBreakdown: i.get(), + ), + ); + } + + @override + void routes(RouteManager r) { + r.child('/', child: (_) => const BillingPage()); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart new file mode 100644 index 00000000..7f9ba7d7 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart @@ -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 getCurrentBillAmount() async { + // In a real app, this might be an aggregate query. + // Simulating fetching invoices and summing up. + final List invoices = await _financialRepository.getInvoices( + 'current_business', + ); + return invoices + .where((Invoice i) => i.status == InvoiceStatus.open) + .fold( + 0.0, + (double sum, Invoice item) => sum + item.totalAmount, + ); + } + + @override + Future> getInvoiceHistory() async { + final List invoices = await _financialRepository.getInvoices( + 'current_business', + ); + return invoices + .where((Invoice i) => i.status == InvoiceStatus.paid) + .toList(); + } + + @override + Future> getPendingInvoices() async { + final List invoices = await _financialRepository.getInvoices( + 'current_business', + ); + return invoices + .where( + (Invoice i) => + i.status == InvoiceStatus.open || + i.status == InvoiceStatus.disputed, + ) + .toList(); + } + + @override + Future getSavingsAmount() async { + // Simulating savings calculation (e.g., comparing to market rates). + await Future.delayed(const Duration(milliseconds: 500)); + return 320.00; + } + + @override + Future> 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'); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/i_billing_repository.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/i_billing_repository.dart new file mode 100644 index 00000000..fc2094e4 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/i_billing_repository.dart @@ -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> getPendingInvoices(); + + /// Fetches historically paid invoices. + Future> getInvoiceHistory(); + + /// Fetches the current bill amount for the period. + Future getCurrentBillAmount(); + + /// Fetches the savings amount. + Future getSavingsAmount(); + + /// Fetches invoice items for spending breakdown analysis. + Future> getSpendingBreakdown(); +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart new file mode 100644 index 00000000..c416fa08 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart @@ -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 { + /// Creates a [GetCurrentBillAmountUseCase]. + GetCurrentBillAmountUseCase(this._repository); + + final IBillingRepository _repository; + + @override + Future call() => _repository.getCurrentBillAmount(); +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart new file mode 100644 index 00000000..7c93c5b8 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart @@ -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> { + /// Creates a [GetInvoiceHistoryUseCase]. + GetInvoiceHistoryUseCase(this._repository); + + final IBillingRepository _repository; + + @override + Future> call() => _repository.getInvoiceHistory(); +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart new file mode 100644 index 00000000..ba680dee --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart @@ -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> { + /// Creates a [GetPendingInvoicesUseCase]. + GetPendingInvoicesUseCase(this._repository); + + final IBillingRepository _repository; + + @override + Future> call() => _repository.getPendingInvoices(); +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart new file mode 100644 index 00000000..da4b703f --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart @@ -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 { + /// Creates a [GetSavingsAmountUseCase]. + GetSavingsAmountUseCase(this._repository); + + final IBillingRepository _repository; + + @override + Future call() => _repository.getSavingsAmount(); +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart new file mode 100644 index 00000000..ce1a84d1 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart @@ -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> { + /// Creates a [GetSpendingBreakdownUseCase]. + GetSpendingBreakdownUseCase(this._repository); + + final IBillingRepository _repository; + + @override + Future> call() => _repository.getSpendingBreakdown(); +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart new file mode 100644 index 00000000..e9553796 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart @@ -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 { + /// 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(_onLoadStarted); + } + + final GetCurrentBillAmountUseCase _getCurrentBillAmount; + final GetSavingsAmountUseCase _getSavingsAmount; + final GetPendingInvoicesUseCase _getPendingInvoices; + final GetInvoiceHistoryUseCase _getInvoiceHistory; + final GetSpendingBreakdownUseCase _getSpendingBreakdown; + + Future _onLoadStarted( + BillingLoadStarted event, + Emitter emit, + ) async { + emit(state.copyWith(status: BillingStatus.loading)); + try { + final List results = await Future.wait(>[ + _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 pendingInvoices = results[2] as List; + final List invoiceHistory = results[3] as List; + final List spendingItems = results[4] as List; + + // Map Domain Entities to Presentation Models + final List uiPendingInvoices = pendingInvoices + .map(_mapInvoiceToUiModel) + .toList(); + final List uiInvoiceHistory = invoiceHistory + .map(_mapInvoiceToUiModel) + .toList(); + final List 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 _mapSpendingItemsToUiModel( + List items, + ) { + // Aggregating items by some logic. + // Since InvoiceItem doesn't have category, we mock it based on staffId or similar. + final Map aggregation = {}; + + 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(); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart new file mode 100644 index 00000000..661ecd9e --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart @@ -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 get props => []; +} + +/// Event triggered when billing data needs to be loaded. +class BillingLoadStarted extends BillingEvent { + /// Creates a [BillingLoadStarted] event. + const BillingLoadStarted(); +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart new file mode 100644 index 00000000..c2da7008 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart @@ -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 [], + this.invoiceHistory = const [], + this.spendingBreakdown = const [], + 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 pendingInvoices; + + /// History of paid invoices. + final List invoiceHistory; + + /// Breakdown of spending by category. + final List 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? pendingInvoices, + List? invoiceHistory, + List? 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 get props => [ + status, + currentBill, + savings, + pendingInvoices, + invoiceHistory, + spendingBreakdown, + errorMessage, + ]; +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart new file mode 100644 index 00000000..b44c7367 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart @@ -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 get props => [ + id, + title, + locationAddress, + clientName, + date, + totalAmount, + workersCount, + totalHours, + status, + ]; +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/models/spending_breakdown_model.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/models/spending_breakdown_model.dart new file mode 100644 index 00000000..4fc32313 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/models/spending_breakdown_model.dart @@ -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 get props => [category, hours, amount]; +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/navigation/billing_navigator.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/navigation/billing_navigator.dart new file mode 100644 index 00000000..a0fee8aa --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/navigation/billing_navigator.dart @@ -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/'); +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart new file mode 100644 index 00000000..8fb39115 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart @@ -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( + create: (BuildContext context) => + Modular.get()..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( + builder: (BuildContext context, BillingState state) { + return Scaffold( + backgroundColor: UiColors.bgPrimary, + body: Column( + children: [ + 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: [ + if (state.pendingInvoices.isNotEmpty) ...[ + 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), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_header.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_header.dart new file mode 100644 index 00000000..0ae3bc32 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_header.dart @@ -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: [ + Row( + children: [ + 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: [ + 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: [ + 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, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/export_invoices_button.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/export_invoices_button.dart new file mode 100644 index 00000000..4019ff02 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/export_invoices_button.dart @@ -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, + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart new file mode 100644 index 00000000..19f97c47 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart @@ -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 invoices; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + t.client_billing.invoice_history, + style: UiTypography.title2b.textPrimary, + ), + GestureDetector( + onTap: () {}, + child: Row( + children: [ + 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( + color: UiColors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: invoices.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final BillingInvoice invoice = entry.value; + return Column( + children: [ + 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: [ + 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: [ + Text(invoice.id, style: UiTypography.body2b.textPrimary), + Text( + invoice.date, + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + 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, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart new file mode 100644 index 00000000..531dc4cf --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart @@ -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( + color: UiColors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + t.client_billing.payment_method, + style: UiTypography.title2b.textPrimary, + ), + GestureDetector( + onTap: () {}, + child: Row( + children: [ + 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: [ + 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: [ + 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, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart new file mode 100644 index 00000000..5580589f --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart @@ -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 invoices; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + 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( + 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: [ + Row( + children: [ + 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: [ + 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: [ + 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: [ + 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: [ + Icon(icon, size: 14, color: UiColors.iconSecondary), + const SizedBox(height: 2), + Text(value, style: UiTypography.body2b.textPrimary), + Text( + label.toUpperCase(), + style: UiTypography.titleUppercase4m.textSecondary, + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart new file mode 100644 index 00000000..18ea1dfd --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart @@ -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: [ + 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: [ + 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), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/spending_breakdown_card.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/spending_breakdown_card.dart new file mode 100644 index 00000000..33d52e17 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/spending_breakdown_card.dart @@ -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 createState() => _SpendingBreakdownCardState(); +} + +class _SpendingBreakdownCardState extends State + 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( + 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( + color: UiColors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + 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( + 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: [ + 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: [ + 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: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/pubspec.yaml b/apps/mobile/packages/features/client/billing/pubspec.yaml new file mode 100644 index 00000000..7b8ad3dc --- /dev/null +++ b/apps/mobile/packages/features/client/billing/pubspec.yaml @@ -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 diff --git a/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart b/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart index 2569e7fd..8759d57c 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart @@ -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( - '/billing', - child: (BuildContext context) => - const PlaceholderPage(title: 'Billing'), - ), + ModuleRoute('/billing', module: BillingModule()), ModuleRoute('/orders', module: ViewOrdersModule()), ChildRoute( '/reports', diff --git a/apps/mobile/packages/features/client/client_main/pubspec.yaml b/apps/mobile/packages/features/client/client_main/pubspec.yaml index c2ac9a6f..cbaaf1e9 100644 --- a/apps/mobile/packages/features/client/client_main/pubspec.yaml +++ b/apps/mobile/packages/features/client/client_main/pubspec.yaml @@ -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 diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 22b2d153..96681ac2 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -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: