feat: Migrate staff profile features from Data Connect to V2 REST API

- Removed data_connect package from mobile pubspec.yaml.
- Added documentation for V2 profile migration status and QA findings.
- Implemented new session management with ClientSessionStore and StaffSessionStore.
- Created V2SessionService for handling user sessions via the V2 API.
- Developed use cases for cancelling late worker assignments and submitting worker reviews.
- Added arguments and use cases for payment chart retrieval and profile completion checks.
- Implemented repository interfaces and their implementations for staff main and profile features.
- Ensured proper error handling and validation in use cases.
This commit is contained in:
Achintha Isuru
2026-03-16 22:45:06 -04:00
parent 4834266986
commit b31a615092
478 changed files with 10512 additions and 19854 deletions

View File

@@ -1,30 +1,37 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'data/repositories_impl/billing_repository_impl.dart';
import 'domain/repositories/billing_repository.dart';
import 'domain/usecases/get_bank_accounts.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 'domain/usecases/approve_invoice.dart';
import 'domain/usecases/dispute_invoice.dart';
import 'presentation/blocs/billing_bloc.dart';
import 'presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
import 'presentation/models/billing_invoice_model.dart';
import 'presentation/pages/billing_page.dart';
import 'presentation/pages/completion_review_page.dart';
import 'presentation/pages/invoice_ready_page.dart';
import 'presentation/pages/pending_invoices_page.dart';
import 'package:billing/src/data/repositories_impl/billing_repository_impl.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
import 'package:billing/src/domain/usecases/approve_invoice.dart';
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
import 'package:billing/src/domain/usecases/get_current_bill_amount.dart';
import 'package:billing/src/domain/usecases/get_invoice_history.dart';
import 'package:billing/src/domain/usecases/get_pending_invoices.dart';
import 'package:billing/src/domain/usecases/get_savings_amount.dart';
import 'package:billing/src/domain/usecases/get_spending_breakdown.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
import 'package:billing/src/presentation/pages/billing_page.dart';
import 'package:billing/src/presentation/pages/completion_review_page.dart';
import 'package:billing/src/presentation/pages/invoice_ready_page.dart';
import 'package:billing/src/presentation/pages/pending_invoices_page.dart';
/// Modular module for the billing feature.
///
/// Uses [BaseApiService] for all backend access via V2 REST API.
class BillingModule extends Module {
@override
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<BillingRepository>(BillingRepositoryImpl.new);
i.addLazySingleton<BillingRepository>(
() => BillingRepositoryImpl(apiService: i.get<BaseApiService>()),
);
// Use Cases
i.addLazySingleton(GetBankAccountsUseCase.new);
@@ -32,7 +39,7 @@ class BillingModule extends Module {
i.addLazySingleton(GetSavingsAmountUseCase.new);
i.addLazySingleton(GetPendingInvoicesUseCase.new);
i.addLazySingleton(GetInvoiceHistoryUseCase.new);
i.addLazySingleton(GetSpendingBreakdownUseCase.new);
i.addLazySingleton(GetSpendBreakdownUseCase.new);
i.addLazySingleton(ApproveInvoiceUseCase.new);
i.addLazySingleton(DisputeInvoiceUseCase.new);
@@ -44,7 +51,7 @@ class BillingModule extends Module {
getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
getSpendBreakdown: i.get<GetSpendBreakdownUseCase>(),
),
);
i.add<ShiftCompletionReviewBloc>(
@@ -62,16 +69,20 @@ class BillingModule extends Module {
child: (_) => const BillingPage(),
);
r.child(
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.completionReview),
child: (_) =>
ShiftCompletionReviewPage(invoice: r.args.data as BillingInvoice?),
ClientPaths.childRoute(
ClientPaths.billing, ClientPaths.completionReview),
child: (_) => ShiftCompletionReviewPage(
invoice:
r.args.data is Invoice ? r.args.data as Invoice : null,
),
);
r.child(
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.invoiceReady),
child: (_) => const InvoiceReadyPage(),
);
r.child(
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.awaitingApproval),
ClientPaths.childRoute(
ClientPaths.billing, ClientPaths.awaitingApproval),
child: (_) => const PendingInvoicesPage(),
);
}

View File

@@ -1,70 +1,103 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/billing_repository.dart';
/// Implementation of [BillingRepository] that delegates to [dc.BillingConnectorRepository].
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Implementation of [BillingRepository] using the V2 REST API.
///
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
/// All backend calls go through [BaseApiService] with [V2ApiEndpoints].
class BillingRepositoryImpl implements BillingRepository {
/// Creates a [BillingRepositoryImpl].
BillingRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
BillingRepositoryImpl({
dc.BillingConnectorRepository? connectorRepository,
dc.DataConnectService? service,
}) : _connectorRepository = connectorRepository ??
dc.DataConnectService.instance.getBillingRepository(),
_service = service ?? dc.DataConnectService.instance;
final dc.BillingConnectorRepository _connectorRepository;
final dc.DataConnectService _service;
/// The API service used for all HTTP requests.
final BaseApiService _apiService;
@override
Future<List<BusinessBankAccount>> getBankAccounts() async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getBankAccounts(businessId: businessId);
}
@override
Future<double> getCurrentBillAmount() async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getCurrentBillAmount(businessId: businessId);
}
@override
Future<List<Invoice>> getInvoiceHistory() async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getInvoiceHistory(businessId: businessId);
Future<List<BillingAccount>> getBankAccounts() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingAccounts);
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map((dynamic json) =>
BillingAccount.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<List<Invoice>> getPendingInvoices() async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getPendingInvoices(businessId: businessId);
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingInvoicesPending);
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map(
(dynamic json) => Invoice.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<double> getSavingsAmount() async {
// Simulating savings calculation
return 0.0;
Future<List<Invoice>> getInvoiceHistory() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingInvoicesHistory);
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map(
(dynamic json) => Invoice.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period) async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getSpendingBreakdown(
businessId: businessId,
period: period,
Future<int> getCurrentBillCents() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingCurrentBill);
final Map<String, dynamic> data =
response.data as Map<String, dynamic>;
return (data['currentBillCents'] as num).toInt();
}
@override
Future<int> getSavingsCents() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingSavings);
final Map<String, dynamic> data =
response.data as Map<String, dynamic>;
return (data['savingsCents'] as num).toInt();
}
@override
Future<List<SpendItem>> getSpendBreakdown({
required String startDate,
required String endDate,
}) async {
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientBillingSpendBreakdown,
params: <String, dynamic>{
'startDate': startDate,
'endDate': endDate,
},
);
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map((dynamic json) =>
SpendItem.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<void> approveInvoice(String id) async {
return _connectorRepository.approveInvoice(id: id);
await _apiService.post(V2ApiEndpoints.clientInvoiceApprove(id));
}
@override
Future<void> disputeInvoice(String id, String reason) async {
return _connectorRepository.disputeInvoice(id: id, reason: reason);
await _apiService.post(
V2ApiEndpoints.clientInvoiceDispute(id),
data: <String, dynamic>{'reason': reason},
);
}
}

View File

@@ -7,7 +7,7 @@ import 'package:krow_domain/krow_domain.dart';
/// It allows the Domain layer to remain independent of specific data sources.
abstract class BillingRepository {
/// Fetches bank accounts associated with the business.
Future<List<BusinessBankAccount>> getBankAccounts();
Future<List<BillingAccount>> getBankAccounts();
/// Fetches invoices that are pending approval or payment.
Future<List<Invoice>> getPendingInvoices();
@@ -15,14 +15,17 @@ abstract class BillingRepository {
/// Fetches historically paid invoices.
Future<List<Invoice>> getInvoiceHistory();
/// Fetches the current bill amount for the period.
Future<double> getCurrentBillAmount();
/// Fetches the current bill amount in cents for the period.
Future<int> getCurrentBillCents();
/// Fetches the savings amount.
Future<double> getSavingsAmount();
/// Fetches the savings amount in cents.
Future<int> getSavingsCents();
/// Fetches invoice items for spending breakdown analysis.
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period);
/// Fetches spending breakdown by category for a date range.
Future<List<SpendItem>> getSpendBreakdown({
required String startDate,
required String endDate,
});
/// Approves an invoice.
Future<void> approveInvoice(String id);

View File

@@ -1,11 +1,13 @@
import 'package:krow_core/core.dart';
import '../repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for approving an invoice.
class ApproveInvoiceUseCase extends UseCase<String, void> {
/// Creates an [ApproveInvoiceUseCase].
ApproveInvoiceUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override

View File

@@ -1,10 +1,16 @@
import 'package:krow_core/core.dart';
import '../repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Params for [DisputeInvoiceUseCase].
class DisputeInvoiceParams {
/// Creates [DisputeInvoiceParams].
const DisputeInvoiceParams({required this.id, required this.reason});
/// The invoice ID to dispute.
final String id;
/// The reason for the dispute.
final String reason;
}
@@ -13,6 +19,7 @@ class DisputeInvoiceUseCase extends UseCase<DisputeInvoiceParams, void> {
/// Creates a [DisputeInvoiceUseCase].
DisputeInvoiceUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override

View File

@@ -1,14 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for fetching the bank accounts associated with the business.
class GetBankAccountsUseCase extends NoInputUseCase<List<BusinessBankAccount>> {
class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> {
/// Creates a [GetBankAccountsUseCase].
GetBankAccountsUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override
Future<List<BusinessBankAccount>> call() => _repository.getBankAccounts();
Future<List<BillingAccount>> call() => _repository.getBankAccounts();
}

View File

@@ -1,16 +1,17 @@
import 'package:krow_core/core.dart';
import '../repositories/billing_repository.dart';
/// Use case for fetching the current bill amount.
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for fetching the current bill amount in cents.
///
/// This use case encapsulates the logic for retrieving the total amount due for the current billing period.
/// It delegates the data retrieval to the [BillingRepository].
class GetCurrentBillAmountUseCase extends NoInputUseCase<double> {
/// Delegates data retrieval to the [BillingRepository].
class GetCurrentBillAmountUseCase extends NoInputUseCase<int> {
/// Creates a [GetCurrentBillAmountUseCase].
GetCurrentBillAmountUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override
Future<double> call() => _repository.getCurrentBillAmount();
Future<int> call() => _repository.getCurrentBillCents();
}

View File

@@ -1,15 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for fetching the invoice history.
///
/// This use case encapsulates the logic for retrieving the list of past paid invoices.
/// It delegates the data retrieval to the [BillingRepository].
/// Retrieves the list of past paid invoices.
class GetInvoiceHistoryUseCase extends NoInputUseCase<List<Invoice>> {
/// Creates a [GetInvoiceHistoryUseCase].
GetInvoiceHistoryUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override

View File

@@ -1,15 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for fetching the pending invoices.
///
/// This use case encapsulates the logic for retrieving invoices that are currently open or disputed.
/// It delegates the data retrieval to the [BillingRepository].
/// Retrieves invoices that are currently open or disputed.
class GetPendingInvoicesUseCase extends NoInputUseCase<List<Invoice>> {
/// Creates a [GetPendingInvoicesUseCase].
GetPendingInvoicesUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override

View File

@@ -1,16 +1,17 @@
import 'package:krow_core/core.dart';
import '../repositories/billing_repository.dart';
/// Use case for fetching the savings amount.
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for fetching the savings amount in cents.
///
/// This use case encapsulates the logic for retrieving the estimated savings for the client.
/// It delegates the data retrieval to the [BillingRepository].
class GetSavingsAmountUseCase extends NoInputUseCase<double> {
/// Delegates data retrieval to the [BillingRepository].
class GetSavingsAmountUseCase extends NoInputUseCase<int> {
/// Creates a [GetSavingsAmountUseCase].
GetSavingsAmountUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override
Future<double> call() => _repository.getSavingsAmount();
Future<int> call() => _repository.getSavingsCents();
}

View File

@@ -1,19 +1,38 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/billing_repository.dart';
/// Use case for fetching the spending breakdown items.
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Parameters for [GetSpendBreakdownUseCase].
class SpendBreakdownParams {
/// Creates [SpendBreakdownParams].
const SpendBreakdownParams({
required this.startDate,
required this.endDate,
});
/// ISO-8601 start date for the range.
final String startDate;
/// ISO-8601 end date for the range.
final String endDate;
}
/// Use case for fetching the spending breakdown by category.
///
/// This use case encapsulates the logic for retrieving the spending breakdown by category or item.
/// It delegates the data retrieval to the [BillingRepository].
class GetSpendingBreakdownUseCase
extends UseCase<BillingPeriod, List<InvoiceItem>> {
/// Creates a [GetSpendingBreakdownUseCase].
GetSpendingBreakdownUseCase(this._repository);
/// Delegates data retrieval to the [BillingRepository].
class GetSpendBreakdownUseCase
extends UseCase<SpendBreakdownParams, List<SpendItem>> {
/// Creates a [GetSpendBreakdownUseCase].
GetSpendBreakdownUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override
Future<List<InvoiceItem>> call(BillingPeriod period) =>
_repository.getSpendingBreakdown(period);
Future<List<SpendItem>> call(SpendBreakdownParams input) =>
_repository.getSpendBreakdown(
startDate: input.startDate,
endDate: input.endDate,
);
}

View File

@@ -1,17 +1,17 @@
import 'dart:developer' as developer;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_bank_accounts.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';
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
import 'package:billing/src/domain/usecases/get_current_bill_amount.dart';
import 'package:billing/src/domain/usecases/get_invoice_history.dart';
import 'package:billing/src/domain/usecases/get_pending_invoices.dart';
import 'package:billing/src/domain/usecases/get_savings_amount.dart';
import 'package:billing/src/domain/usecases/get_spending_breakdown.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
/// BLoC for managing billing state and data loading.
class BillingBloc extends Bloc<BillingEvent, BillingState>
@@ -23,14 +23,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
required GetSavingsAmountUseCase getSavingsAmount,
required GetPendingInvoicesUseCase getPendingInvoices,
required GetInvoiceHistoryUseCase getInvoiceHistory,
required GetSpendingBreakdownUseCase getSpendingBreakdown,
}) : _getBankAccounts = getBankAccounts,
_getCurrentBillAmount = getCurrentBillAmount,
_getSavingsAmount = getSavingsAmount,
_getPendingInvoices = getPendingInvoices,
_getInvoiceHistory = getInvoiceHistory,
_getSpendingBreakdown = getSpendingBreakdown,
super(const BillingState()) {
required GetSpendBreakdownUseCase getSpendBreakdown,
}) : _getBankAccounts = getBankAccounts,
_getCurrentBillAmount = getCurrentBillAmount,
_getSavingsAmount = getSavingsAmount,
_getPendingInvoices = getPendingInvoices,
_getInvoiceHistory = getInvoiceHistory,
_getSpendBreakdown = getSpendBreakdown,
super(const BillingState()) {
on<BillingLoadStarted>(_onLoadStarted);
on<BillingPeriodChanged>(_onPeriodChanged);
}
@@ -40,61 +40,60 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
final GetSavingsAmountUseCase _getSavingsAmount;
final GetPendingInvoicesUseCase _getPendingInvoices;
final GetInvoiceHistoryUseCase _getInvoiceHistory;
final GetSpendingBreakdownUseCase _getSpendingBreakdown;
final GetSpendBreakdownUseCase _getSpendBreakdown;
/// Executes [loader] and returns null on failure, logging the error.
Future<T?> _loadSafe<T>(Future<T> Function() loader) async {
try {
return await loader();
} catch (e, stackTrace) {
developer.log(
'Partial billing load failed: $e',
name: 'BillingBloc',
error: e,
stackTrace: stackTrace,
);
return null;
}
}
Future<void> _onLoadStarted(
BillingLoadStarted event,
Emitter<BillingState> emit,
) async {
emit(state.copyWith(status: BillingStatus.loading));
await handleError(
emit: emit.call,
action: () async {
final List<dynamic> results =
await Future.wait<dynamic>(<Future<dynamic>>[
_getCurrentBillAmount.call(),
_getSavingsAmount.call(),
_getPendingInvoices.call(),
_getInvoiceHistory.call(),
_getSpendingBreakdown.call(state.period),
_getBankAccounts.call(),
]);
final double savings = results[1] as double;
final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
final List<Invoice> invoiceHistory = results[3] as List<Invoice>;
final List<InvoiceItem> spendingItems = results[4] as List<InvoiceItem>;
final List<BusinessBankAccount> bankAccounts =
results[5] as List<BusinessBankAccount>;
final SpendBreakdownParams spendParams = _dateRangeFor(state.periodTab);
// Map Domain Entities to Presentation Models
final List<BillingInvoice> uiPendingInvoices = pendingInvoices
.map(_mapInvoiceToUiModel)
.toList();
final List<BillingInvoice> uiInvoiceHistory = invoiceHistory
.map(_mapInvoiceToUiModel)
.toList();
final List<SpendingBreakdownItem> uiSpendingBreakdown =
_mapSpendingItemsToUiModel(spendingItems);
final double periodTotal = uiSpendingBreakdown.fold(
0.0,
(double sum, SpendingBreakdownItem item) => sum + item.amount,
);
final List<Object?> results = await Future.wait<Object?>(
<Future<Object?>>[
_loadSafe<int>(() => _getCurrentBillAmount.call()),
_loadSafe<int>(() => _getSavingsAmount.call()),
_loadSafe<List<Invoice>>(() => _getPendingInvoices.call()),
_loadSafe<List<Invoice>>(() => _getInvoiceHistory.call()),
_loadSafe<List<SpendItem>>(() => _getSpendBreakdown.call(spendParams)),
_loadSafe<List<BillingAccount>>(() => _getBankAccounts.call()),
],
);
emit(
state.copyWith(
status: BillingStatus.success,
currentBill: periodTotal,
savings: savings,
pendingInvoices: uiPendingInvoices,
invoiceHistory: uiInvoiceHistory,
spendingBreakdown: uiSpendingBreakdown,
bankAccounts: bankAccounts,
),
);
},
onError: (String errorKey) =>
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
final int? currentBillCents = results[0] as int?;
final int? savingsCents = results[1] as int?;
final List<Invoice>? pendingInvoices = results[2] as List<Invoice>?;
final List<Invoice>? invoiceHistory = results[3] as List<Invoice>?;
final List<SpendItem>? spendBreakdown = results[4] as List<SpendItem>?;
final List<BillingAccount>? bankAccounts =
results[5] as List<BillingAccount>?;
emit(
state.copyWith(
status: BillingStatus.success,
currentBillCents: currentBillCents ?? state.currentBillCents,
savingsCents: savingsCents ?? state.savingsCents,
pendingInvoices: pendingInvoices ?? state.pendingInvoices,
invoiceHistory: invoiceHistory ?? state.invoiceHistory,
spendBreakdown: spendBreakdown ?? state.spendBreakdown,
bankAccounts: bankAccounts ?? state.bankAccounts,
),
);
}
@@ -105,19 +104,15 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
await handleError(
emit: emit.call,
action: () async {
final List<InvoiceItem> spendingItems = await _getSpendingBreakdown
.call(event.period);
final List<SpendingBreakdownItem> uiSpendingBreakdown =
_mapSpendingItemsToUiModel(spendingItems);
final double periodTotal = uiSpendingBreakdown.fold(
0.0,
(double sum, SpendingBreakdownItem item) => sum + item.amount,
);
final SpendBreakdownParams params =
_dateRangeFor(event.periodTab);
final List<SpendItem> spendBreakdown =
await _getSpendBreakdown.call(params);
emit(
state.copyWith(
period: event.period,
spendingBreakdown: uiSpendingBreakdown,
currentBill: periodTotal,
periodTab: event.periodTab,
spendBreakdown: spendBreakdown,
),
);
},
@@ -126,98 +121,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
);
}
BillingInvoice _mapInvoiceToUiModel(Invoice invoice) {
final DateFormat formatter = DateFormat('EEEE, MMMM d');
final String dateLabel = invoice.issueDate == null
? 'N/A'
: formatter.format(invoice.issueDate!);
final List<BillingWorkerRecord> workers = invoice.workers.map((
InvoiceWorker w,
) {
final DateFormat timeFormat = DateFormat('h:mm a');
return BillingWorkerRecord(
workerName: w.name,
roleName: w.role,
totalAmount: w.amount,
hours: w.hours,
rate: w.rate,
startTime: w.checkIn != null ? timeFormat.format(w.checkIn!) : '--:--',
endTime: w.checkOut != null ? timeFormat.format(w.checkOut!) : '--:--',
breakMinutes: w.breakMinutes,
workerAvatarUrl: w.avatarUrl,
);
}).toList();
String? overallStart;
String? overallEnd;
// Find valid times from actual DateTime checks to ensure chronological sorting
final List<DateTime> validCheckIns = invoice.workers
.where((InvoiceWorker w) => w.checkIn != null)
.map((InvoiceWorker w) => w.checkIn!)
.toList();
final List<DateTime> validCheckOuts = invoice.workers
.where((InvoiceWorker w) => w.checkOut != null)
.map((InvoiceWorker w) => w.checkOut!)
.toList();
final DateFormat timeFormat = DateFormat('h:mm a');
if (validCheckIns.isNotEmpty) {
validCheckIns.sort();
overallStart = timeFormat.format(validCheckIns.first);
} else if (workers.isNotEmpty) {
overallStart = workers.first.startTime;
}
if (validCheckOuts.isNotEmpty) {
validCheckOuts.sort();
overallEnd = timeFormat.format(validCheckOuts.last);
} else if (workers.isNotEmpty) {
overallEnd = workers.first.endTime;
}
return BillingInvoice(
id: invoice.id,
title: invoice.title ?? 'N/A',
locationAddress: invoice.locationAddress ?? 'Remote',
clientName: invoice.clientName ?? 'N/A',
date: dateLabel,
totalAmount: invoice.totalAmount,
workersCount: invoice.staffCount ?? 0,
totalHours: invoice.totalHours ?? 0.0,
status: invoice.status.name.toUpperCase(),
workers: workers,
startTime: overallStart,
endTime: overallEnd,
/// Computes ISO-8601 date range for the selected period tab.
SpendBreakdownParams _dateRangeFor(BillingPeriodTab tab) {
final DateTime now = DateTime.now().toUtc();
final int days = tab == BillingPeriodTab.week ? 7 : 30;
final DateTime start = now.subtract(Duration(days: days));
return SpendBreakdownParams(
startDate: start.toIso8601String(),
endDate: now.toIso8601String(),
);
}
List<SpendingBreakdownItem> _mapSpendingItemsToUiModel(
List<InvoiceItem> items,
) {
final Map<String, SpendingBreakdownItem> aggregation =
<String, SpendingBreakdownItem>{};
for (final InvoiceItem item in items) {
final String category = item.staffId;
final SpendingBreakdownItem? existing = aggregation[category];
if (existing != null) {
aggregation[category] = SpendingBreakdownItem(
category: category,
hours: existing.hours + item.workHours.round(),
amount: existing.amount + item.amount,
);
} else {
aggregation[category] = SpendingBreakdownItem(
category: category,
hours: item.workHours.round(),
amount: item.amount,
);
}
}
return aggregation.values.toList();
}
}

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
/// Base class for all billing events.
abstract class BillingEvent extends Equatable {
@@ -16,11 +17,14 @@ class BillingLoadStarted extends BillingEvent {
const BillingLoadStarted();
}
/// Event triggered when the spend breakdown period tab changes.
class BillingPeriodChanged extends BillingEvent {
const BillingPeriodChanged(this.period);
/// Creates a [BillingPeriodChanged] event.
const BillingPeriodChanged(this.periodTab);
final BillingPeriod period;
/// The selected period tab.
final BillingPeriodTab periodTab;
@override
List<Object?> get props => <Object?>[period];
List<Object?> get props => <Object?>[periodTab];
}

View File

@@ -1,7 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import '../models/billing_invoice_model.dart';
import '../models/spending_breakdown_model.dart';
/// The loading status of the billing feature.
enum BillingStatus {
@@ -18,83 +16,104 @@ enum BillingStatus {
failure,
}
/// Which period the spend breakdown covers.
enum BillingPeriodTab {
/// Last 7 days.
week,
/// Last 30 days.
month,
}
/// Represents the state of the billing feature.
class BillingState extends Equatable {
/// Creates a [BillingState].
const BillingState({
this.status = BillingStatus.initial,
this.currentBill = 0.0,
this.savings = 0.0,
this.pendingInvoices = const <BillingInvoice>[],
this.invoiceHistory = const <BillingInvoice>[],
this.spendingBreakdown = const <SpendingBreakdownItem>[],
this.bankAccounts = const <BusinessBankAccount>[],
this.period = BillingPeriod.week,
this.currentBillCents = 0,
this.savingsCents = 0,
this.pendingInvoices = const <Invoice>[],
this.invoiceHistory = const <Invoice>[],
this.spendBreakdown = const <SpendItem>[],
this.bankAccounts = const <BillingAccount>[],
this.periodTab = BillingPeriodTab.week,
this.errorMessage,
});
/// The current feature status.
final BillingStatus status;
/// The total amount for the current billing period.
final double currentBill;
/// The total amount for the current billing period in cents.
final int currentBillCents;
/// Total savings achieved compared to traditional agencies.
final double savings;
/// Total savings in cents.
final int savingsCents;
/// Invoices awaiting client approval.
final List<BillingInvoice> pendingInvoices;
final List<Invoice> pendingInvoices;
/// History of paid invoices.
final List<BillingInvoice> invoiceHistory;
final List<Invoice> invoiceHistory;
/// Breakdown of spending by category.
final List<SpendingBreakdownItem> spendingBreakdown;
final List<SpendItem> spendBreakdown;
/// Bank accounts associated with the business.
final List<BusinessBankAccount> bankAccounts;
final List<BillingAccount> bankAccounts;
/// Selected period for the breakdown.
final BillingPeriod period;
/// Selected period tab for the breakdown.
final BillingPeriodTab periodTab;
/// Error message if loading failed.
final String? errorMessage;
/// Current bill formatted as dollars.
double get currentBillDollars => currentBillCents / 100.0;
/// Savings formatted as dollars.
double get savingsDollars => savingsCents / 100.0;
/// Total spend across the breakdown in cents.
int get spendTotalCents => spendBreakdown.fold(
0,
(int sum, SpendItem item) => sum + item.amountCents,
);
/// Creates a copy of this state with updated fields.
BillingState copyWith({
BillingStatus? status,
double? currentBill,
double? savings,
List<BillingInvoice>? pendingInvoices,
List<BillingInvoice>? invoiceHistory,
List<SpendingBreakdownItem>? spendingBreakdown,
List<BusinessBankAccount>? bankAccounts,
BillingPeriod? period,
int? currentBillCents,
int? savingsCents,
List<Invoice>? pendingInvoices,
List<Invoice>? invoiceHistory,
List<SpendItem>? spendBreakdown,
List<BillingAccount>? bankAccounts,
BillingPeriodTab? periodTab,
String? errorMessage,
}) {
return BillingState(
status: status ?? this.status,
currentBill: currentBill ?? this.currentBill,
savings: savings ?? this.savings,
currentBillCents: currentBillCents ?? this.currentBillCents,
savingsCents: savingsCents ?? this.savingsCents,
pendingInvoices: pendingInvoices ?? this.pendingInvoices,
invoiceHistory: invoiceHistory ?? this.invoiceHistory,
spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown,
spendBreakdown: spendBreakdown ?? this.spendBreakdown,
bankAccounts: bankAccounts ?? this.bankAccounts,
period: period ?? this.period,
periodTab: periodTab ?? this.periodTab,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => <Object?>[
status,
currentBill,
savings,
pendingInvoices,
invoiceHistory,
spendingBreakdown,
bankAccounts,
period,
errorMessage,
];
status,
currentBillCents,
savingsCents,
pendingInvoices,
invoiceHistory,
spendBreakdown,
bankAccounts,
periodTab,
errorMessage,
];
}

View File

@@ -1,19 +1,22 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import '../../../domain/usecases/approve_invoice.dart';
import '../../../domain/usecases/dispute_invoice.dart';
import 'shift_completion_review_event.dart';
import 'shift_completion_review_state.dart';
import 'package:billing/src/domain/usecases/approve_invoice.dart';
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart';
/// BLoC for approving or disputing an invoice from the review page.
class ShiftCompletionReviewBloc
extends Bloc<ShiftCompletionReviewEvent, ShiftCompletionReviewState>
with BlocErrorHandler<ShiftCompletionReviewState> {
/// Creates a [ShiftCompletionReviewBloc].
ShiftCompletionReviewBloc({
required ApproveInvoiceUseCase approveInvoice,
required DisputeInvoiceUseCase disputeInvoice,
}) : _approveInvoice = approveInvoice,
_disputeInvoice = disputeInvoice,
super(const ShiftCompletionReviewState()) {
}) : _approveInvoice = approveInvoice,
_disputeInvoice = disputeInvoice,
super(const ShiftCompletionReviewState()) {
on<ShiftCompletionReviewApproved>(_onApproved);
on<ShiftCompletionReviewDisputed>(_onDisputed);
}

View File

@@ -1,84 +0,0 @@
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,
this.workers = const <BillingWorkerRecord>[],
this.startTime,
this.endTime,
});
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;
final List<BillingWorkerRecord> workers;
final String? startTime;
final String? endTime;
@override
List<Object?> get props => <Object?>[
id,
title,
locationAddress,
clientName,
date,
totalAmount,
workersCount,
totalHours,
status,
workers,
startTime,
endTime,
];
}
class BillingWorkerRecord extends Equatable {
const BillingWorkerRecord({
required this.workerName,
required this.roleName,
required this.totalAmount,
required this.hours,
required this.rate,
required this.startTime,
required this.endTime,
required this.breakMinutes,
this.workerAvatarUrl,
});
final String workerName;
final String roleName;
final double totalAmount;
final double hours;
final double rate;
final String startTime;
final String endTime;
final int breakMinutes;
final String? workerAvatarUrl;
@override
List<Object?> get props => <Object?>[
workerName,
roleName,
totalAmount,
hours,
rate,
startTime,
endTime,
breakMinutes,
workerAvatarUrl,
];
}

View File

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

View File

@@ -5,13 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart';
import '../blocs/billing_state.dart';
import '../widgets/billing_page_skeleton.dart';
import '../widgets/invoice_history_section.dart';
import '../widgets/pending_invoices_section.dart';
import '../widgets/spending_breakdown_card.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
import 'package:billing/src/presentation/widgets/billing_page_skeleton.dart';
import 'package:billing/src/presentation/widgets/invoice_history_section.dart';
import 'package:billing/src/presentation/widgets/pending_invoices_section.dart';
import 'package:billing/src/presentation/widgets/spending_breakdown_card.dart';
/// The entry point page for the client billing feature.
///
@@ -32,8 +32,7 @@ class BillingPage extends StatelessWidget {
/// The main view for the client billing feature.
///
/// This widget displays the billing dashboard content based on the current
/// state of the [BillingBloc].
/// Displays the billing dashboard content based on the current [BillingState].
class BillingView extends StatefulWidget {
/// Creates a [BillingView].
const BillingView({super.key});
@@ -125,7 +124,7 @@ class _BillingViewState extends State<BillingView> {
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${state.currentBill.toStringAsFixed(2)}',
'\$${state.currentBillDollars.toStringAsFixed(2)}',
style: UiTypography.displayM.copyWith(
color: UiColors.white,
fontSize: 40,
@@ -152,7 +151,8 @@ class _BillingViewState extends State<BillingView> {
const SizedBox(width: UiConstants.space2),
Text(
t.client_billing.saved_amount(
amount: state.savings.toStringAsFixed(0),
amount: state.savingsDollars
.toStringAsFixed(0),
),
style: UiTypography.footnote2b.copyWith(
color: UiColors.accentForeground,
@@ -221,7 +221,6 @@ class _BillingViewState extends State<BillingView> {
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
PendingInvoicesSection(invoices: state.pendingInvoices),
],
// const PaymentMethodCard(),
const SpendingBreakdownCard(),
if (state.invoiceHistory.isNotEmpty)
InvoiceHistorySection(invoices: state.invoiceHistory),

View File

@@ -1,19 +1,21 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import '../models/billing_invoice_model.dart';
import '../widgets/completion_review/completion_review_actions.dart';
import '../widgets/completion_review/completion_review_amount.dart';
import '../widgets/completion_review/completion_review_info.dart';
import '../widgets/completion_review/completion_review_search_and_tabs.dart';
import '../widgets/completion_review/completion_review_worker_card.dart';
import '../widgets/completion_review/completion_review_workers_header.dart';
import 'package:billing/src/presentation/widgets/completion_review/completion_review_actions.dart';
import 'package:billing/src/presentation/widgets/completion_review/completion_review_amount.dart';
import 'package:billing/src/presentation/widgets/completion_review/completion_review_info.dart';
/// Page for reviewing and approving/disputing an invoice.
class ShiftCompletionReviewPage extends StatefulWidget {
/// Creates a [ShiftCompletionReviewPage].
const ShiftCompletionReviewPage({this.invoice, super.key});
final BillingInvoice? invoice;
/// The invoice to review.
final Invoice? invoice;
@override
State<ShiftCompletionReviewPage> createState() =>
@@ -21,31 +23,45 @@ class ShiftCompletionReviewPage extends StatefulWidget {
}
class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
late BillingInvoice invoice;
String searchQuery = '';
int selectedTab = 0; // 0: Needs Review (mocked as empty), 1: All
/// The resolved invoice, or null if route data is missing/invalid.
late final Invoice? invoice;
@override
void initState() {
super.initState();
// Use widget.invoice if provided, else try to get from arguments
invoice = widget.invoice ?? Modular.args.data as BillingInvoice;
invoice = widget.invoice ??
(Modular.args.data is Invoice
? Modular.args.data as Invoice
: null);
}
@override
Widget build(BuildContext context) {
final List<BillingWorkerRecord> filteredWorkers = invoice.workers.where((
BillingWorkerRecord w,
) {
if (searchQuery.isEmpty) return true;
return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) ||
w.roleName.toLowerCase().contains(searchQuery.toLowerCase());
}).toList();
final Invoice? resolvedInvoice = invoice;
if (resolvedInvoice == null) {
return Scaffold(
appBar: UiAppBar(
title: t.client_billing.review_and_approve,
showBackButton: true,
),
body: Center(
child: Text(
t.errors.generic.unknown,
style: UiTypography.body1m.textError,
),
),
);
}
final DateFormat formatter = DateFormat('EEEE, MMMM d');
final String dateLabel = resolvedInvoice.dueDate != null
? formatter.format(resolvedInvoice.dueDate!)
: 'N/A';
return Scaffold(
appBar: UiAppBar(
title: invoice.title,
subtitle: invoice.clientName,
title: resolvedInvoice.invoiceNumber,
subtitle: resolvedInvoice.vendorName ?? '',
showBackButton: true,
),
body: SafeArea(
@@ -55,26 +71,13 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(height: UiConstants.space4),
CompletionReviewInfo(invoice: invoice),
CompletionReviewInfo(
dateLabel: dateLabel,
vendorName: resolvedInvoice.vendorName,
),
const SizedBox(height: UiConstants.space4),
CompletionReviewAmount(invoice: invoice),
CompletionReviewAmount(amountCents: resolvedInvoice.amountCents),
const SizedBox(height: UiConstants.space6),
// CompletionReviewWorkersHeader(workersCount: invoice.workersCount),
// const SizedBox(height: UiConstants.space4),
// CompletionReviewSearchAndTabs(
// selectedTab: selectedTab,
// workersCount: invoice.workersCount,
// onTabChanged: (int index) =>
// setState(() => selectedTab = index),
// onSearchChanged: (String val) =>
// setState(() => searchQuery = val),
// ),
// const SizedBox(height: UiConstants.space4),
// ...filteredWorkers.map(
// (BillingWorkerRecord worker) =>
// CompletionReviewWorkerCard(worker: worker),
// ),
// const SizedBox(height: UiConstants.space4),
],
),
),
@@ -87,7 +90,9 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
),
),
child: SafeArea(child: CompletionReviewActions(invoiceId: invoice.id)),
child: SafeArea(
child: CompletionReviewActions(invoiceId: resolvedInvoice.invoiceId),
),
),
);
}

View File

@@ -2,14 +2,17 @@ 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 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart';
import '../blocs/billing_state.dart';
import '../models/billing_invoice_model.dart';
import '../widgets/invoices_list_skeleton.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart';
/// Page displaying invoices that are ready.
class InvoiceReadyPage extends StatelessWidget {
/// Creates an [InvoiceReadyPage].
const InvoiceReadyPage({super.key});
@override
@@ -21,7 +24,9 @@ class InvoiceReadyPage extends StatelessWidget {
}
}
/// View for the invoice ready page.
class InvoiceReadyView extends StatelessWidget {
/// Creates an [InvoiceReadyView].
const InvoiceReadyView({super.key});
@override
@@ -60,7 +65,7 @@ class InvoiceReadyView extends StatelessWidget {
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(height: 16),
itemBuilder: (BuildContext context, int index) {
final BillingInvoice invoice = state.invoiceHistory[index];
final Invoice invoice = state.invoiceHistory[index];
return _InvoiceSummaryCard(invoice: invoice);
},
);
@@ -72,10 +77,17 @@ class InvoiceReadyView extends StatelessWidget {
class _InvoiceSummaryCard extends StatelessWidget {
const _InvoiceSummaryCard({required this.invoice});
final BillingInvoice invoice;
final Invoice invoice;
@override
Widget build(BuildContext context) {
final DateFormat formatter = DateFormat('MMM d, yyyy');
final String dateLabel = invoice.dueDate != null
? formatter.format(invoice.dueDate!)
: 'N/A';
final double amountDollars = invoice.amountCents / 100.0;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
@@ -106,22 +118,26 @@ class _InvoiceSummaryCard extends StatelessWidget {
borderRadius: BorderRadius.circular(20),
),
child: Text(
'READY',
invoice.status.value.toUpperCase(),
style: UiTypography.titleUppercase4b.copyWith(
color: UiColors.success,
),
),
),
Text(invoice.date, style: UiTypography.footnote2r.textTertiary),
Text(dateLabel, style: UiTypography.footnote2r.textTertiary),
],
),
const SizedBox(height: 16),
Text(invoice.title, style: UiTypography.title2b.textPrimary),
const SizedBox(height: 8),
Text(
invoice.locationAddress,
style: UiTypography.body2r.textSecondary,
invoice.invoiceNumber,
style: UiTypography.title2b.textPrimary,
),
const SizedBox(height: 8),
if (invoice.vendorName != null)
Text(
invoice.vendorName!,
style: UiTypography.body2r.textSecondary,
),
const Divider(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -134,7 +150,7 @@ class _InvoiceSummaryCard extends StatelessWidget {
style: UiTypography.titleUppercase4m.textSecondary,
),
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
'\$${amountDollars.toStringAsFixed(2)}',
style: UiTypography.title2b.primary,
),
],

View File

@@ -5,12 +5,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
import '../widgets/invoices_list_skeleton.dart';
import '../widgets/pending_invoices_section.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart';
import 'package:billing/src/presentation/widgets/pending_invoices_section.dart';
/// Page listing all invoices awaiting client approval.
class PendingInvoicesPage extends StatelessWidget {
/// Creates a [PendingInvoicesPage].
const PendingInvoicesPage({super.key});
@override
@@ -44,7 +46,7 @@ class PendingInvoicesPage extends StatelessWidget {
UiConstants.space5,
UiConstants.space5,
UiConstants.space5,
100, // Bottom padding for scroll clearance
100,
),
itemCount: state.pendingInvoices.length,
itemBuilder: (BuildContext context, int index) {
@@ -87,6 +89,3 @@ class PendingInvoicesPage extends StatelessWidget {
);
}
}
// We need to export the card widget from the section file if we want to reuse it,
// or move it to its own file. I'll move it to a shared file or just make it public in the section file.

View File

@@ -6,23 +6,26 @@ import 'package:flutter/material.dart';
class BillingHeader extends StatelessWidget {
/// Creates a [BillingHeader].
const BillingHeader({
required this.currentBill,
required this.savings,
required this.currentBillCents,
required this.savingsCents,
required this.onBack,
super.key,
});
/// The amount of the current bill.
final double currentBill;
/// The amount of the current bill in cents.
final int currentBillCents;
/// The amount saved in the current period.
final double savings;
/// The savings amount in cents.
final int savingsCents;
/// Callback when the back button is pressed.
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
final double billDollars = currentBillCents / 100.0;
final double savingsDollars = savingsCents / 100.0;
return Container(
padding: EdgeInsets.fromLTRB(
UiConstants.space5,
@@ -54,10 +57,9 @@ class BillingHeader extends StatelessWidget {
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${currentBill.toStringAsFixed(2)}',
'\$${billDollars.toStringAsFixed(2)}',
style: UiTypography.display1b.copyWith(color: UiColors.white),
),
const SizedBox(height: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(
@@ -79,7 +81,7 @@ class BillingHeader extends StatelessWidget {
const SizedBox(width: UiConstants.space1),
Text(
t.client_billing.saved_amount(
amount: savings.toStringAsFixed(0),
amount: savingsDollars.toStringAsFixed(0),
),
style: UiTypography.footnote2b.copyWith(
color: UiColors.foreground,

View File

@@ -5,87 +5,91 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../../blocs/shift_completion_review/shift_completion_review_bloc.dart';
import '../../blocs/shift_completion_review/shift_completion_review_event.dart';
import '../../blocs/shift_completion_review/shift_completion_review_state.dart';
import '../../blocs/billing_bloc.dart';
import '../../blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart';
/// Action buttons (approve / flag) at the bottom of the review page.
class CompletionReviewActions extends StatelessWidget {
/// Creates a [CompletionReviewActions].
const CompletionReviewActions({required this.invoiceId, super.key});
/// The invoice ID to act upon.
final String invoiceId;
@override
Widget build(BuildContext context) {
return BlocProvider<ShiftCompletionReviewBloc>.value(
value: Modular.get<ShiftCompletionReviewBloc>(),
return BlocProvider<ShiftCompletionReviewBloc>(
create: (_) => Modular.get<ShiftCompletionReviewBloc>(),
child:
BlocConsumer<ShiftCompletionReviewBloc, ShiftCompletionReviewState>(
listener: (BuildContext context, ShiftCompletionReviewState state) {
if (state.status == ShiftCompletionReviewStatus.success) {
final String message = state.message == 'approved'
? t.client_billing.approved_success
: t.client_billing.flagged_success;
final UiSnackbarType type = state.message == 'approved'
? UiSnackbarType.success
: UiSnackbarType.warning;
listener: (BuildContext context, ShiftCompletionReviewState state) {
if (state.status == ShiftCompletionReviewStatus.success) {
final String message = state.message == 'approved'
? t.client_billing.approved_success
: t.client_billing.flagged_success;
final UiSnackbarType type = state.message == 'approved'
? UiSnackbarType.success
: UiSnackbarType.warning;
UiSnackbar.show(context, message: message, type: type);
Modular.get<BillingBloc>().add(const BillingLoadStarted());
Modular.to.toAwaitingApproval();
} else if (state.status == ShiftCompletionReviewStatus.failure) {
UiSnackbar.show(
context,
message: state.errorMessage ?? t.errors.generic.unknown,
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ShiftCompletionReviewState state) {
final bool isLoading =
state.status == ShiftCompletionReviewStatus.loading;
UiSnackbar.show(context, message: message, type: type);
Modular.get<BillingBloc>().add(const BillingLoadStarted());
Modular.to.toAwaitingApproval();
} else if (state.status == ShiftCompletionReviewStatus.failure) {
UiSnackbar.show(
context,
message: state.errorMessage ?? t.errors.generic.unknown,
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ShiftCompletionReviewState state) {
final bool isLoading =
state.status == ShiftCompletionReviewStatus.loading;
return Row(
spacing: UiConstants.space2,
children: <Widget>[
Expanded(
child: UiButton.secondary(
text: t.client_billing.actions.flag_review,
leadingIcon: UiIcons.warning,
onPressed: isLoading
? null
: () => _showFlagDialog(context, state),
size: UiButtonSize.large,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
side: BorderSide.none,
),
),
return Row(
spacing: UiConstants.space2,
children: <Widget>[
Expanded(
child: UiButton.secondary(
text: t.client_billing.actions.flag_review,
leadingIcon: UiIcons.warning,
onPressed: isLoading
? null
: () => _showFlagDialog(context, state),
size: UiButtonSize.large,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
side: BorderSide.none,
),
Expanded(
child: UiButton.primary(
text: t.client_billing.actions.approve_pay,
leadingIcon: isLoading ? null : UiIcons.checkCircle,
isLoading: isLoading,
onPressed: isLoading
? null
: () {
BlocProvider.of<ShiftCompletionReviewBloc>(
context,
).add(ShiftCompletionReviewApproved(invoiceId));
},
size: UiButtonSize.large,
),
),
],
);
},
),
),
),
Expanded(
child: UiButton.primary(
text: t.client_billing.actions.approve_pay,
leadingIcon: isLoading ? null : UiIcons.checkCircle,
isLoading: isLoading,
onPressed: isLoading
? null
: () {
BlocProvider.of<ShiftCompletionReviewBloc>(
context,
).add(ShiftCompletionReviewApproved(invoiceId));
},
size: UiButtonSize.large,
),
),
],
);
},
),
);
}
void _showFlagDialog(BuildContext context, ShiftCompletionReviewState state) {
void _showFlagDialog(
BuildContext context, ShiftCompletionReviewState state) {
final TextEditingController controller = TextEditingController();
showDialog(
context: context,

View File

@@ -2,15 +2,18 @@ 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';
/// Displays the total invoice amount on the review page.
class CompletionReviewAmount extends StatelessWidget {
const CompletionReviewAmount({required this.invoice, super.key});
/// Creates a [CompletionReviewAmount].
const CompletionReviewAmount({required this.amountCents, super.key});
final BillingInvoice invoice;
/// The invoice total in cents.
final int amountCents;
@override
Widget build(BuildContext context) {
final double amountDollars = amountCents / 100.0;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(UiConstants.space6),
@@ -27,13 +30,9 @@ class CompletionReviewAmount extends StatelessWidget {
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
'\$${amountDollars.toStringAsFixed(2)}',
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
),
Text(
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix}\$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
style: UiTypography.footnote2b.textSecondary,
),
],
),
);

View File

@@ -1,12 +1,20 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../models/billing_invoice_model.dart';
/// Displays invoice metadata (date, vendor) on the review page.
class CompletionReviewInfo extends StatelessWidget {
const CompletionReviewInfo({required this.invoice, super.key});
/// Creates a [CompletionReviewInfo].
const CompletionReviewInfo({
required this.dateLabel,
this.vendorName,
super.key,
});
final BillingInvoice invoice;
/// Formatted date string.
final String dateLabel;
/// Vendor name, if available.
final String? vendorName;
@override
Widget build(BuildContext context) {
@@ -14,12 +22,9 @@ class CompletionReviewInfo extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space1,
children: <Widget>[
_buildInfoRow(UiIcons.calendar, invoice.date),
_buildInfoRow(
UiIcons.clock,
'${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}',
),
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
_buildInfoRow(UiIcons.calendar, dateLabel),
if (vendorName != null)
_buildInfoRow(UiIcons.building, vendorName!),
],
);
}

View File

@@ -1,126 +1,18 @@
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';
/// Card showing a single worker's details in the completion review.
///
/// Currently unused -- the V2 Invoice entity does not include per-worker
/// breakdown data. This widget is retained as a placeholder for when the
/// backend adds worker-level invoice detail endpoints.
class CompletionReviewWorkerCard extends StatelessWidget {
const CompletionReviewWorkerCard({required this.worker, super.key});
final BillingWorkerRecord worker;
/// Creates a [CompletionReviewWorkerCard].
const CompletionReviewWorkerCard({super.key});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
),
child: Column(
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
CircleAvatar(
radius: 20,
backgroundColor: UiColors.bgSecondary,
backgroundImage: worker.workerAvatarUrl != null
? NetworkImage(worker.workerAvatarUrl!)
: null,
child: worker.workerAvatarUrl == null
? const Icon(
UiIcons.user,
size: 20,
color: UiColors.iconSecondary,
)
: null,
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
worker.workerName,
style: UiTypography.body1b.textPrimary,
),
Text(
worker.roleName,
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'\$${worker.totalAmount.toStringAsFixed(2)}',
style: UiTypography.body1b.textPrimary,
),
Text(
'${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr',
style: UiTypography.footnote2r.textSecondary,
),
],
),
],
),
const SizedBox(height: UiConstants.space4),
Row(
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Text(
'${worker.startTime} - ${worker.endTime}',
style: UiTypography.footnote2b.textPrimary,
),
),
const SizedBox(width: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Row(
children: <Widget>[
const Icon(
UiIcons.coffee,
size: 12,
color: UiColors.iconSecondary,
),
const SizedBox(width: 4),
Text(
'${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}',
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
const Spacer(),
UiIconButton.secondary(icon: UiIcons.edit, onTap: () {}),
const SizedBox(width: UiConstants.space2),
UiIconButton.secondary(icon: UiIcons.warning, onTap: () {}),
],
),
],
),
);
// Placeholder until V2 API provides worker-level invoice data.
return const SizedBox.shrink();
}
}

View File

@@ -1,7 +1,8 @@
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';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
/// Section showing the history of paid invoices.
class InvoiceHistorySection extends StatelessWidget {
@@ -9,7 +10,7 @@ class InvoiceHistorySection extends StatelessWidget {
const InvoiceHistorySection({required this.invoices, super.key});
/// The list of historical invoices.
final List<BillingInvoice> invoices;
final List<Invoice> invoices;
@override
Widget build(BuildContext context) {
@@ -36,10 +37,10 @@ class InvoiceHistorySection extends StatelessWidget {
),
child: Column(
children: invoices.asMap().entries.map((
MapEntry<int, BillingInvoice> entry,
MapEntry<int, Invoice> entry,
) {
final int index = entry.key;
final BillingInvoice invoice = entry.value;
final Invoice invoice = entry.value;
return Column(
children: <Widget>[
if (index > 0)
@@ -58,10 +59,18 @@ class InvoiceHistorySection extends StatelessWidget {
class _InvoiceItem extends StatelessWidget {
const _InvoiceItem({required this.invoice});
final BillingInvoice invoice;
final Invoice invoice;
@override
Widget build(BuildContext context) {
final DateFormat formatter = DateFormat('MMM d, yyyy');
final String dateLabel = invoice.paymentDate != null
? formatter.format(invoice.paymentDate!)
: invoice.dueDate != null
? formatter.format(invoice.dueDate!)
: 'N/A';
final double amountDollars = invoice.amountCents / 100.0;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
@@ -86,11 +95,11 @@ class _InvoiceItem extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(invoice.title, style: UiTypography.body1r.textPrimary),
Text(
invoice.date,
style: UiTypography.footnote2r.textSecondary,
invoice.invoiceNumber,
style: UiTypography.body1r.textPrimary,
),
Text(dateLabel, style: UiTypography.footnote2r.textSecondary),
],
),
),
@@ -98,7 +107,7 @@ class _InvoiceItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
'\$${amountDollars.toStringAsFixed(2)}',
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
),
_StatusBadge(status: invoice.status),
@@ -113,11 +122,11 @@ class _InvoiceItem extends StatelessWidget {
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.status});
final String status;
final InvoiceStatus status;
@override
Widget build(BuildContext context) {
final bool isPaid = status.toUpperCase() == 'PAID';
final bool isPaid = status == InvoiceStatus.paid;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space1 + 2,

View File

@@ -3,8 +3,9 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
/// Card showing the current payment method.
class PaymentMethodCard extends StatelessWidget {
@@ -15,8 +16,8 @@ class PaymentMethodCard extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<BillingBloc, BillingState>(
builder: (BuildContext context, BillingState state) {
final List<BusinessBankAccount> accounts = state.bankAccounts;
final BusinessBankAccount? account =
final List<BillingAccount> accounts = state.bankAccounts;
final BillingAccount? account =
accounts.isNotEmpty ? accounts.first : null;
if (account == null) {
@@ -24,11 +25,10 @@ class PaymentMethodCard extends StatelessWidget {
}
final String bankLabel =
account.bankName.isNotEmpty == true ? account.bankName : '----';
account.bankName.isNotEmpty ? account.bankName : '----';
final String last4 =
account.last4.isNotEmpty == true ? account.last4 : '----';
account.last4?.isNotEmpty == true ? account.last4! : '----';
final bool isPrimary = account.isPrimary;
final String expiryLabel = _formatExpiry(account.expiryTime);
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
@@ -87,11 +87,11 @@ class PaymentMethodCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'•••• $last4',
'\u2022\u2022\u2022\u2022 $last4',
style: UiTypography.body2b.textPrimary,
),
Text(
t.client_billing.expires(date: expiryLabel),
account.accountType.name.toUpperCase(),
style: UiTypography.footnote2r.textSecondary,
),
],
@@ -121,13 +121,4 @@ class PaymentMethodCard extends StatelessWidget {
},
);
}
String _formatExpiry(DateTime? expiryTime) {
if (expiryTime == null) {
return 'N/A';
}
final String month = expiryTime.month.toString().padLeft(2, '0');
final String year = (expiryTime.year % 100).toString().padLeft(2, '0');
return '$month/$year';
}
}

View File

@@ -2,9 +2,9 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import '../models/billing_invoice_model.dart';
import 'package:krow_domain/krow_domain.dart';
/// Section showing a banner for invoices awaiting approval.
class PendingInvoicesSection extends StatelessWidget {
@@ -12,7 +12,7 @@ class PendingInvoicesSection extends StatelessWidget {
const PendingInvoicesSection({required this.invoices, super.key});
/// The list of pending invoices.
final List<BillingInvoice> invoices;
final List<Invoice> invoices;
@override
Widget build(BuildContext context) {
@@ -93,10 +93,17 @@ class PendingInvoiceCard extends StatelessWidget {
/// Creates a [PendingInvoiceCard].
const PendingInvoiceCard({required this.invoice, super.key});
final BillingInvoice invoice;
/// The invoice to display.
final Invoice invoice;
@override
Widget build(BuildContext context) {
final DateFormat formatter = DateFormat('EEEE, MMMM d');
final String dateLabel = invoice.dueDate != null
? formatter.format(invoice.dueDate!)
: 'N/A';
final double amountDollars = invoice.amountCents / 100.0;
return Container(
decoration: BoxDecoration(
color: UiColors.white,
@@ -108,42 +115,33 @@ class PendingInvoiceCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
Text(
invoice.invoiceNumber,
style: UiTypography.headline4b.textPrimary,
),
const SizedBox(height: UiConstants.space3),
Row(
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: 16,
color: UiColors.iconSecondary,
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
invoice.locationAddress,
style: UiTypography.footnote2r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
if (invoice.vendorName != null) ...<Widget>[
Row(
children: <Widget>[
const Icon(
UiIcons.building,
size: 16,
color: UiColors.iconSecondary,
),
),
],
),
const SizedBox(height: UiConstants.space2),
Row(
children: <Widget>[
Text(
invoice.clientName,
style: UiTypography.footnote2r.textSecondary,
),
const SizedBox(width: UiConstants.space2),
Text('', style: UiTypography.footnote2r.textInactive),
const SizedBox(width: UiConstants.space2),
Text(
invoice.date,
style: UiTypography.footnote2r.textSecondary,
),
],
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
invoice.vendorName!,
style: UiTypography.footnote2r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: UiConstants.space2),
],
Text(dateLabel, style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space3),
Row(
children: <Widget>[
@@ -157,7 +155,7 @@ class PendingInvoiceCard extends StatelessWidget {
),
const SizedBox(width: UiConstants.space2),
Text(
t.client_billing.pending_badge.toUpperCase(),
invoice.status.value.toUpperCase(),
style: UiTypography.titleUppercase4b.copyWith(
color: UiColors.textWarning,
),
@@ -168,40 +166,10 @@ class PendingInvoiceCard extends StatelessWidget {
const Divider(height: 1, color: UiColors.border),
Padding(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
child: Row(
children: <Widget>[
Expanded(
child: _buildStatItem(
UiIcons.dollar,
'\$${invoice.totalAmount.toStringAsFixed(2)}',
t.client_billing.stats.total,
),
),
Container(
width: 1,
height: 32,
color: UiColors.border.withValues(alpha: 0.3),
),
Expanded(
child: _buildStatItem(
UiIcons.users,
'${invoice.workersCount}',
t.client_billing.stats.workers,
),
),
Container(
width: 1,
height: 32,
color: UiColors.border.withValues(alpha: 0.3),
),
Expanded(
child: _buildStatItem(
UiIcons.clock,
invoice.totalHours.toStringAsFixed(1),
t.client_billing.stats.hrs,
),
),
],
child: _buildStatItem(
UiIcons.dollar,
'\$${amountDollars.toStringAsFixed(2)}',
t.client_billing.stats.total,
),
),
const Divider(height: 1, color: UiColors.border),

View File

@@ -3,10 +3,10 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
import '../blocs/billing_event.dart';
import '../models/spending_breakdown_model.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
/// Card showing the spending breakdown for the current period.
class SpendingBreakdownCard extends StatefulWidget {
@@ -37,10 +37,7 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
Widget build(BuildContext context) {
return BlocBuilder<BillingBloc, BillingState>(
builder: (BuildContext context, BillingState state) {
final double total = state.spendingBreakdown.fold(
0.0,
(double sum, SpendingBreakdownItem item) => sum + item.amount,
);
final double totalDollars = state.spendTotalCents / 100.0;
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
@@ -97,11 +94,12 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
),
dividerColor: UiColors.transparent,
onTap: (int index) {
final BillingPeriod period =
index == 0 ? BillingPeriod.week : BillingPeriod.month;
ReadContext(context).read<BillingBloc>().add(
BillingPeriodChanged(period),
);
final BillingPeriodTab tab = index == 0
? BillingPeriodTab.week
: BillingPeriodTab.month;
ReadContext(context)
.read<BillingBloc>()
.add(BillingPeriodChanged(tab));
},
tabs: <Widget>[
Tab(text: t.client_billing.week),
@@ -112,8 +110,8 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
],
),
const SizedBox(height: UiConstants.space4),
...state.spendingBreakdown.map(
(SpendingBreakdownItem item) => _buildBreakdownRow(item),
...state.spendBreakdown.map(
(SpendItem item) => _buildBreakdownRow(item),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: UiConstants.space2),
@@ -127,7 +125,7 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
style: UiTypography.body2b.textPrimary,
),
Text(
'\$${total.toStringAsFixed(2)}',
'\$${totalDollars.toStringAsFixed(2)}',
style: UiTypography.body2b.textPrimary,
),
],
@@ -139,7 +137,8 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
);
}
Widget _buildBreakdownRow(SpendingBreakdownItem item) {
Widget _buildBreakdownRow(SpendItem item) {
final double amountDollars = item.amountCents / 100.0;
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Row(
@@ -151,14 +150,14 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
children: <Widget>[
Text(item.category, style: UiTypography.body2r.textPrimary),
Text(
t.client_billing.hours(count: item.hours),
'${item.percentage.toStringAsFixed(1)}%',
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
Text(
'\$${item.amount.toStringAsFixed(2)}',
'\$${amountDollars.toStringAsFixed(2)}',
style: UiTypography.body2m.textPrimary,
),
],

View File

@@ -10,12 +10,12 @@ environment:
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
@@ -25,12 +25,10 @@ dependencies:
path: ../../../domain
krow_core:
path: ../../../core
krow_data_connect:
path: ../../../data_connect
# UI
intl: ^0.20.0
firebase_data_connect: ^0.2.2+1
dev_dependencies:
flutter_test:
sdk: flutter