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: