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,98 +1,76 @@
import 'package:firebase_data_connect/src/core/ref.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/payments_repository.dart';
import 'package:staff_payments/src/domain/repositories/payments_repository.dart';
class PaymentsRepositoryImpl
implements PaymentsRepository {
/// V2 REST API implementation of [PaymentsRepository].
///
/// Calls the staff payments endpoints via [BaseApiService].
class PaymentsRepositoryImpl implements PaymentsRepository {
/// Creates a [PaymentsRepositoryImpl] with the given [apiService].
PaymentsRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
PaymentsRepositoryImpl() : _service = DataConnectService.instance;
final DataConnectService _service;
/// The API service used for HTTP requests.
final BaseApiService _apiService;
@override
Future<PaymentSummary> getPaymentSummary() async {
return _service.run(() async {
final String currentStaffId = await _service.getStaffId();
// Fetch recent payments with a limit
final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> response = await _service.connector.listRecentPaymentsByStaffId(
staffId: currentStaffId,
).limit(100).execute();
final List<dc.ListRecentPaymentsByStaffIdRecentPayments> payments = response.data.recentPayments;
double weekly = 0;
double monthly = 0;
double pending = 0;
double total = 0;
final DateTime now = DateTime.now();
final DateTime startOfWeek = now.subtract(const Duration(days: 7));
final DateTime startOfMonth = DateTime(now.year, now.month, 1);
for (final dc.ListRecentPaymentsByStaffIdRecentPayments p in payments) {
final DateTime? date = _service.toDateTime(p.invoice.issueDate) ?? _service.toDateTime(p.createdAt);
final double amount = p.invoice.amount;
final String? status = p.status?.stringValue;
if (status == 'PENDING') {
pending += amount;
} else if (status == 'PAID') {
total += amount;
if (date != null) {
if (date.isAfter(startOfWeek)) weekly += amount;
if (date.isAfter(startOfMonth)) monthly += amount;
}
}
}
return PaymentSummary(
weeklyEarnings: weekly,
monthlyEarnings: monthly,
pendingEarnings: pending,
totalEarnings: total,
);
});
Future<PaymentSummary> getPaymentSummary({
String? startDate,
String? endDate,
}) async {
final Map<String, dynamic> params = <String, dynamic>{
if (startDate != null) 'startDate': startDate,
if (endDate != null) 'endDate': endDate,
};
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.staffPaymentsSummary,
params: params.isEmpty ? null : params,
);
return PaymentSummary.fromJson(response.data as Map<String, dynamic>);
}
@override
Future<List<StaffPayment>> getPaymentHistory(String period) async {
return _service.run(() async {
final String currentStaffId = await _service.getStaffId();
final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> response = await _service.connector
.listRecentPaymentsByStaffId(staffId: currentStaffId)
.execute();
Future<List<PaymentRecord>> getPaymentHistory({
String? startDate,
String? endDate,
}) async {
final Map<String, dynamic> params = <String, dynamic>{
if (startDate != null) 'startDate': startDate,
if (endDate != null) 'endDate': endDate,
};
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.staffPaymentsHistory,
params: params.isEmpty ? null : params,
);
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
final List<dynamic> items = body['items'] as List<dynamic>;
return items
.map((dynamic json) =>
PaymentRecord.fromJson(json as Map<String, dynamic>))
.toList();
}
return response.data.recentPayments.map((dc.ListRecentPaymentsByStaffIdRecentPayments payment) {
// Extract shift details from nested application structure
final String? shiftTitle = payment.application.shiftRole.shift.title;
final String? locationAddress = payment.application.shiftRole.shift.locationAddress;
final double? hoursWorked = payment.application.shiftRole.hours;
final double? hourlyRate = payment.application.shiftRole.role.costPerHour;
// Extract hub details from order
final String? locationHub = payment.invoice.order.teamHub.hubName;
final String? hubAddress = payment.invoice.order.teamHub.address;
final String? shiftLocation = locationAddress ?? hubAddress;
return StaffPayment(
id: payment.id,
staffId: payment.staffId,
assignmentId: payment.applicationId,
amount: payment.invoice.amount,
status: PaymentAdapter.toPaymentStatus(payment.status?.stringValue ?? 'UNKNOWN'),
paidAt: _service.toDateTime(payment.invoice.issueDate),
shiftTitle: shiftTitle,
shiftLocation: locationHub,
locationAddress: shiftLocation,
hoursWorked: hoursWorked,
hourlyRate: hourlyRate,
workedTime: payment.workedTime,
);
}).toList();
});
@override
Future<List<PaymentChartPoint>> getPaymentChart({
String? startDate,
String? endDate,
String bucket = 'day',
}) async {
final Map<String, dynamic> params = <String, dynamic>{
'bucket': bucket,
if (startDate != null) 'startDate': startDate,
if (endDate != null) 'endDate': endDate,
};
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.staffPaymentsChart,
params: params,
);
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
final List<dynamic> items = body['items'] as List<dynamic>;
return items
.map((dynamic json) =>
PaymentChartPoint.fromJson(json as Map<String, dynamic>))
.toList();
}
}

View File

@@ -0,0 +1,23 @@
import 'package:krow_core/core.dart';
/// Arguments for retrieving payment chart data.
class GetPaymentChartArguments extends UseCaseArgument {
/// Creates [GetPaymentChartArguments] with the [bucket] granularity.
const GetPaymentChartArguments({
this.bucket = 'day',
this.startDate,
this.endDate,
});
/// Time bucket granularity: `day`, `week`, or `month`.
final String bucket;
/// ISO-8601 start date for the range filter.
final String? startDate;
/// ISO-8601 end date for the range filter.
final String? endDate;
@override
List<Object?> get props => <Object?>[bucket, startDate, endDate];
}

View File

@@ -1,12 +1,19 @@
import 'package:krow_core/core.dart';
/// Arguments for getting payment history.
/// Arguments for retrieving payment history.
class GetPaymentHistoryArguments extends UseCaseArgument {
/// Creates [GetPaymentHistoryArguments] with optional date range.
const GetPaymentHistoryArguments({
this.startDate,
this.endDate,
});
const GetPaymentHistoryArguments(this.period);
/// The period to filter by (e.g., "monthly", "weekly").
final String period;
/// ISO-8601 start date for the range filter.
final String? startDate;
/// ISO-8601 end date for the range filter.
final String? endDate;
@override
List<Object?> get props => <Object?>[period];
List<Object?> get props => <Object?>[startDate, endDate];
}

View File

@@ -1,13 +1,25 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for Payments feature.
/// Repository interface for the staff payments feature.
///
/// Defines the contract for data access related to staff payments.
/// Implementations of this interface should reside in the data layer.
/// Implementations live in the data layer and call the V2 REST API.
abstract class PaymentsRepository {
/// Fetches the payment summary (earnings).
Future<PaymentSummary> getPaymentSummary();
/// Fetches the aggregated payment summary for the given date range.
Future<PaymentSummary> getPaymentSummary({
String? startDate,
String? endDate,
});
/// Fetches the payment history for a specific period.
Future<List<StaffPayment>> getPaymentHistory(String period);
/// Fetches payment history records for the given date range.
Future<List<PaymentRecord>> getPaymentHistory({
String? startDate,
String? endDate,
});
/// Fetches aggregated chart data points for the given date range and bucket.
Future<List<PaymentChartPoint>> getPaymentChart({
String? startDate,
String? endDate,
String bucket,
});
}

View File

@@ -0,0 +1,26 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_payments/src/domain/arguments/get_payment_chart_arguments.dart';
import 'package:staff_payments/src/domain/repositories/payments_repository.dart';
/// Retrieves aggregated chart data for the current staff member's payments.
class GetPaymentChartUseCase
extends UseCase<GetPaymentChartArguments, List<PaymentChartPoint>> {
/// Creates a [GetPaymentChartUseCase].
GetPaymentChartUseCase(this._repository);
/// The payments repository.
final PaymentsRepository _repository;
@override
Future<List<PaymentChartPoint>> call(
GetPaymentChartArguments arguments,
) async {
return _repository.getPaymentChart(
startDate: arguments.startDate,
endDate: arguments.endDate,
bucket: arguments.bucket,
);
}
}

View File

@@ -1,19 +1,25 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../arguments/get_payment_history_arguments.dart';
import '../repositories/payments_repository.dart';
/// Use case to retrieve payment history filtered by a period.
///
/// This use case delegates the data retrieval to [PaymentsRepository].
class GetPaymentHistoryUseCase extends UseCase<GetPaymentHistoryArguments, List<StaffPayment>> {
import 'package:staff_payments/src/domain/arguments/get_payment_history_arguments.dart';
import 'package:staff_payments/src/domain/repositories/payments_repository.dart';
/// Retrieves payment history records for the current staff member.
class GetPaymentHistoryUseCase
extends UseCase<GetPaymentHistoryArguments, List<PaymentRecord>> {
/// Creates a [GetPaymentHistoryUseCase].
GetPaymentHistoryUseCase(this.repository);
final PaymentsRepository repository;
GetPaymentHistoryUseCase(this._repository);
/// The payments repository.
final PaymentsRepository _repository;
@override
Future<List<StaffPayment>> call(GetPaymentHistoryArguments arguments) async {
return await repository.getPaymentHistory(arguments.period);
Future<List<PaymentRecord>> call(
GetPaymentHistoryArguments arguments,
) async {
return _repository.getPaymentHistory(
startDate: arguments.startDate,
endDate: arguments.endDate,
);
}
}

View File

@@ -1,16 +1,18 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/payments_repository.dart';
/// Use case to retrieve payment summary information.
import 'package:staff_payments/src/domain/repositories/payments_repository.dart';
/// Retrieves the aggregated payment summary for the current staff member.
class GetPaymentSummaryUseCase extends NoInputUseCase<PaymentSummary> {
/// Creates a [GetPaymentSummaryUseCase].
GetPaymentSummaryUseCase(this.repository);
final PaymentsRepository repository;
GetPaymentSummaryUseCase(this._repository);
/// The payments repository.
final PaymentsRepository _repository;
@override
Future<PaymentSummary> call() async {
return await repository.getPaymentSummary();
return _repository.getPaymentSummary();
}
}

View File

@@ -1,26 +1,50 @@
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'domain/repositories/payments_repository.dart';
import 'domain/usecases/get_payment_summary_usecase.dart';
import 'domain/usecases/get_payment_history_usecase.dart';
import 'data/repositories/payments_repository_impl.dart';
import 'presentation/blocs/payments/payments_bloc.dart';
import 'presentation/pages/payments_page.dart';
import 'presentation/pages/early_pay_page.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_payments/src/data/repositories/payments_repository_impl.dart';
import 'package:staff_payments/src/domain/repositories/payments_repository.dart';
import 'package:staff_payments/src/domain/usecases/get_payment_chart_usecase.dart';
import 'package:staff_payments/src/domain/usecases/get_payment_history_usecase.dart';
import 'package:staff_payments/src/domain/usecases/get_payment_summary_usecase.dart';
import 'package:staff_payments/src/presentation/blocs/payments/payments_bloc.dart';
import 'package:staff_payments/src/presentation/pages/early_pay_page.dart';
import 'package:staff_payments/src/presentation/pages/payments_page.dart';
/// Module for the staff payments feature.
class StaffPaymentsModule extends Module {
@override
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.add<PaymentsRepository>(PaymentsRepositoryImpl.new);
i.add<PaymentsRepository>(
() => PaymentsRepositoryImpl(
apiService: i.get<BaseApiService>(),
),
);
// Use Cases
i.add(GetPaymentSummaryUseCase.new);
i.add(GetPaymentHistoryUseCase.new);
i.add<GetPaymentSummaryUseCase>(
() => GetPaymentSummaryUseCase(i.get<PaymentsRepository>()),
);
i.add<GetPaymentHistoryUseCase>(
() => GetPaymentHistoryUseCase(i.get<PaymentsRepository>()),
);
i.add<GetPaymentChartUseCase>(
() => GetPaymentChartUseCase(i.get<PaymentsRepository>()),
);
// Blocs
i.add(PaymentsBloc.new);
i.add<PaymentsBloc>(
() => PaymentsBloc(
getPaymentSummary: i.get<GetPaymentSummaryUseCase>(),
getPaymentHistory: i.get<GetPaymentHistoryUseCase>(),
getPaymentChart: i.get<GetPaymentChartUseCase>(),
),
);
}
@override

View File

@@ -1,24 +1,38 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../../domain/arguments/get_payment_history_arguments.dart';
import '../../../domain/usecases/get_payment_history_usecase.dart';
import '../../../domain/usecases/get_payment_summary_usecase.dart';
import 'payments_event.dart';
import 'payments_state.dart';
import 'package:staff_payments/src/domain/arguments/get_payment_chart_arguments.dart';
import 'package:staff_payments/src/domain/arguments/get_payment_history_arguments.dart';
import 'package:staff_payments/src/domain/usecases/get_payment_chart_usecase.dart';
import 'package:staff_payments/src/domain/usecases/get_payment_history_usecase.dart';
import 'package:staff_payments/src/domain/usecases/get_payment_summary_usecase.dart';
import 'package:staff_payments/src/presentation/blocs/payments/payments_event.dart';
import 'package:staff_payments/src/presentation/blocs/payments/payments_state.dart';
/// BLoC that manages loading and displaying staff payment data.
class PaymentsBloc extends Bloc<PaymentsEvent, PaymentsState>
with BlocErrorHandler<PaymentsState> {
/// Creates a [PaymentsBloc] injecting the required use cases.
PaymentsBloc({
required this.getPaymentSummary,
required this.getPaymentHistory,
required this.getPaymentChart,
}) : super(PaymentsInitial()) {
on<LoadPaymentsEvent>(_onLoadPayments);
on<ChangePeriodEvent>(_onChangePeriod);
}
/// Use case for fetching the earnings summary.
final GetPaymentSummaryUseCase getPaymentSummary;
/// Use case for fetching payment history records.
final GetPaymentHistoryUseCase getPaymentHistory;
/// Use case for fetching chart data points.
final GetPaymentChartUseCase getPaymentChart;
/// Handles the initial load of all payment data.
Future<void> _onLoadPayments(
LoadPaymentsEvent event,
Emitter<PaymentsState> emit,
@@ -27,15 +41,28 @@ class PaymentsBloc extends Bloc<PaymentsEvent, PaymentsState>
await handleError(
emit: emit.call,
action: () async {
final PaymentSummary currentSummary = await getPaymentSummary();
final List<StaffPayment> history = await getPaymentHistory(
const GetPaymentHistoryArguments('week'),
);
final _DateRange range = _dateRangeFor('week');
final List<Object> results = await Future.wait(<Future<Object>>[
getPaymentSummary(),
getPaymentHistory(
GetPaymentHistoryArguments(
startDate: range.start,
endDate: range.end,
),
),
getPaymentChart(
GetPaymentChartArguments(
startDate: range.start,
endDate: range.end,
bucket: 'day',
),
),
]);
emit(
PaymentsLoaded(
summary: currentSummary,
history: history,
summary: results[0] as PaymentSummary,
history: results[1] as List<PaymentRecord>,
chartPoints: results[2] as List<PaymentChartPoint>,
activePeriod: 'week',
),
);
@@ -44,6 +71,7 @@ class PaymentsBloc extends Bloc<PaymentsEvent, PaymentsState>
);
}
/// Handles switching the active period tab.
Future<void> _onChangePeriod(
ChangePeriodEvent event,
Emitter<PaymentsState> emit,
@@ -53,12 +81,27 @@ class PaymentsBloc extends Bloc<PaymentsEvent, PaymentsState>
await handleError(
emit: emit.call,
action: () async {
final List<StaffPayment> newHistory = await getPaymentHistory(
GetPaymentHistoryArguments(event.period),
);
final _DateRange range = _dateRangeFor(event.period);
final String bucket = _bucketFor(event.period);
final List<Object> results = await Future.wait(<Future<Object>>[
getPaymentHistory(
GetPaymentHistoryArguments(
startDate: range.start,
endDate: range.end,
),
),
getPaymentChart(
GetPaymentChartArguments(
startDate: range.start,
endDate: range.end,
bucket: bucket,
),
),
]);
emit(
currentState.copyWith(
history: newHistory,
history: results[0] as List<PaymentRecord>,
chartPoints: results[1] as List<PaymentChartPoint>,
activePeriod: event.period,
),
);
@@ -67,5 +110,46 @@ class PaymentsBloc extends Bloc<PaymentsEvent, PaymentsState>
);
}
}
/// Computes start and end ISO-8601 date strings for a given period.
static _DateRange _dateRangeFor(String period) {
final DateTime now = DateTime.now();
final DateTime end = now;
late final DateTime start;
switch (period) {
case 'week':
start = now.subtract(const Duration(days: 7));
case 'month':
start = DateTime(now.year, now.month - 1, now.day);
case 'year':
start = DateTime(now.year - 1, now.month, now.day);
default:
start = now.subtract(const Duration(days: 7));
}
return _DateRange(
start: start.toIso8601String(),
end: end.toIso8601String(),
);
}
/// Maps a period identifier to the chart bucket granularity.
static String _bucketFor(String period) {
switch (period) {
case 'week':
return 'day';
case 'month':
return 'week';
case 'year':
return 'month';
default:
return 'day';
}
}
}
/// Internal helper for holding a date range pair.
class _DateRange {
const _DateRange({required this.start, required this.end});
final String start;
final String end;
}

View File

@@ -1,17 +1,23 @@
import 'package:equatable/equatable.dart';
/// Base event for the payments feature.
abstract class PaymentsEvent extends Equatable {
/// Creates a [PaymentsEvent].
const PaymentsEvent();
@override
List<Object?> get props => <Object?>[];
}
/// Triggered on initial load to fetch summary, history, and chart data.
class LoadPaymentsEvent extends PaymentsEvent {}
/// Triggered when the user switches the period tab (week, month, year).
class ChangePeriodEvent extends PaymentsEvent {
/// Creates a [ChangePeriodEvent] for the given [period].
const ChangePeriodEvent(this.period);
/// The selected period identifier.
final String period;
@override

View File

@@ -1,47 +1,69 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Base state for the payments feature.
abstract class PaymentsState extends Equatable {
/// Creates a [PaymentsState].
const PaymentsState();
@override
List<Object?> get props => <Object?>[];
}
/// Initial state before any data has been requested.
class PaymentsInitial extends PaymentsState {}
/// Data is being loaded from the backend.
class PaymentsLoading extends PaymentsState {}
/// Data loaded successfully.
class PaymentsLoaded extends PaymentsState {
/// Creates a [PaymentsLoaded] state.
const PaymentsLoaded({
required this.summary,
required this.history,
required this.chartPoints,
this.activePeriod = 'week',
});
/// Aggregated payment summary.
final PaymentSummary summary;
final List<StaffPayment> history;
/// List of individual payment records.
final List<PaymentRecord> history;
/// Chart data points for the earnings trend graph.
final List<PaymentChartPoint> chartPoints;
/// Currently selected period tab (week, month, year).
final String activePeriod;
/// Creates a copy with optional overrides.
PaymentsLoaded copyWith({
PaymentSummary? summary,
List<StaffPayment>? history,
List<PaymentRecord>? history,
List<PaymentChartPoint>? chartPoints,
String? activePeriod,
}) {
return PaymentsLoaded(
summary: summary ?? this.summary,
history: history ?? this.history,
chartPoints: chartPoints ?? this.chartPoints,
activePeriod: activePeriod ?? this.activePeriod,
);
}
@override
List<Object?> get props => <Object?>[summary, history, activePeriod];
List<Object?> get props =>
<Object?>[summary, history, chartPoints, activePeriod];
}
/// An error occurred while loading payments data.
class PaymentsError extends PaymentsState {
/// Creates a [PaymentsError] with the given [message].
const PaymentsError(this.message);
/// The error key or message.
final String message;
@override

View File

@@ -5,15 +5,18 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:core_localization/core_localization.dart';
import '../blocs/payments/payments_bloc.dart';
import '../blocs/payments/payments_event.dart';
import '../blocs/payments/payments_state.dart';
import '../widgets/payments_page_skeleton.dart';
import '../widgets/payment_stats_card.dart';
import '../widgets/payment_history_item.dart';
import '../widgets/earnings_graph.dart';
import 'package:staff_payments/src/presentation/blocs/payments/payments_bloc.dart';
import 'package:staff_payments/src/presentation/blocs/payments/payments_event.dart';
import 'package:staff_payments/src/presentation/blocs/payments/payments_state.dart';
import 'package:staff_payments/src/presentation/widgets/payments_page_skeleton.dart';
import 'package:staff_payments/src/presentation/widgets/payment_stats_card.dart';
import 'package:staff_payments/src/presentation/widgets/payment_history_item.dart';
import 'package:staff_payments/src/presentation/widgets/earnings_graph.dart';
/// Main page for the staff payments feature.
class PaymentsPage extends StatefulWidget {
/// Creates a [PaymentsPage].
const PaymentsPage({super.key});
@override
@@ -38,12 +41,11 @@ class _PaymentsPageState extends State<PaymentsPage> {
backgroundColor: UiColors.background,
body: BlocConsumer<PaymentsBloc, PaymentsState>(
listener: (BuildContext context, PaymentsState state) {
// Error is already shown on the page itself (lines 53-63), no need for snackbar
// Error is rendered inline, no snackbar needed.
},
builder: (BuildContext context, PaymentsState state) {
if (state is PaymentsLoading) {
return const PaymentsPageSkeleton();
} else if (state is PaymentsError) {
return Center(
child: Padding(
@@ -51,7 +53,8 @@ class _PaymentsPageState extends State<PaymentsPage> {
child: Text(
translateErrorKey(state.message),
textAlign: TextAlign.center,
style: UiTypography.body2r.copyWith(color: UiColors.textSecondary),
style: UiTypography.body2r
.copyWith(color: UiColors.textSecondary),
),
),
);
@@ -65,7 +68,10 @@ class _PaymentsPageState extends State<PaymentsPage> {
);
}
/// Builds the loaded content layout.
Widget _buildContent(BuildContext context, PaymentsLoaded state) {
final String totalFormatted =
_formatCents(state.summary.totalEarningsCents);
return SingleChildScrollView(
child: Column(
children: <Widget>[
@@ -91,7 +97,7 @@ class _PaymentsPageState extends State<PaymentsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
"Earnings",
'Earnings',
style: UiTypography.displayMb.white,
),
const SizedBox(height: UiConstants.space6),
@@ -101,14 +107,14 @@ class _PaymentsPageState extends State<PaymentsPage> {
child: Column(
children: <Widget>[
Text(
"Total Earnings",
'Total Earnings',
style: UiTypography.body2r.copyWith(
color: UiColors.accent,
),
),
const SizedBox(height: UiConstants.space1),
Text(
"\$${state.summary.totalEarnings.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]},')}",
totalFormatted,
style: UiTypography.displayL.white,
),
],
@@ -121,13 +127,14 @@ class _PaymentsPageState extends State<PaymentsPage> {
padding: const EdgeInsets.all(UiConstants.space1),
decoration: BoxDecoration(
color: UiColors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderRadius:
BorderRadius.circular(UiConstants.radiusBase),
),
child: Row(
children: <Widget>[
_buildTab("Week", 'week', state.activePeriod),
_buildTab("Month", 'month', state.activePeriod),
_buildTab("Year", 'year', state.activePeriod),
_buildTab('Week', 'week', state.activePeriod),
_buildTab('Month', 'month', state.activePeriod),
_buildTab('Year', 'year', state.activePeriod),
],
),
),
@@ -139,16 +146,18 @@ class _PaymentsPageState extends State<PaymentsPage> {
Transform.translate(
offset: const Offset(0, -UiConstants.space4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
padding:
const EdgeInsets.symmetric(horizontal: UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Earnings Graph
EarningsGraph(
payments: state.history,
chartPoints: state.chartPoints,
period: state.activePeriod,
),
const SizedBox(height: UiConstants.space6),
// Quick Stats
Row(
children: <Widget>[
@@ -156,8 +165,8 @@ class _PaymentsPageState extends State<PaymentsPage> {
child: PaymentStatsCard(
icon: UiIcons.chart,
iconColor: UiColors.success,
label: "This Week",
amount: "\$${state.summary.weeklyEarnings}",
label: 'Total Earnings',
amount: totalFormatted,
),
),
const SizedBox(width: UiConstants.space3),
@@ -165,8 +174,14 @@ class _PaymentsPageState extends State<PaymentsPage> {
child: PaymentStatsCard(
icon: UiIcons.calendar,
iconColor: UiColors.primary,
label: "This Month",
amount: "\$${state.summary.monthlyEarnings.toStringAsFixed(0)}",
label: '${state.history.length} Payments',
amount: _formatCents(
state.history.fold<int>(
0,
(int sum, PaymentRecord r) =>
sum + r.amountCents,
),
),
),
),
],
@@ -179,28 +194,26 @@ class _PaymentsPageState extends State<PaymentsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
"Recent Payments",
'Recent Payments',
style: UiTypography.body1b,
),
const SizedBox(height: UiConstants.space3),
Column(
children: state.history.map((StaffPayment payment) {
children:
state.history.map((PaymentRecord payment) {
return Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space2),
child: PaymentHistoryItem(
amount: payment.amount,
title: payment.shiftTitle ?? "Shift Payment",
location: payment.shiftLocation ?? "Varies",
address: payment.locationAddress ?? payment.id,
date: payment.paidAt != null
? DateFormat('E, MMM d')
.format(payment.paidAt!)
: 'Pending',
workedTime: payment.workedTime ?? "Completed",
hours: (payment.hoursWorked ?? 0).toInt(),
rate: payment.hourlyRate ?? 0.0,
status: payment.status.name.toUpperCase(),
amountCents: payment.amountCents,
title: payment.shiftName ?? 'Shift Payment',
location: payment.location ?? 'Varies',
date:
DateFormat('E, MMM d').format(payment.date),
minutesWorked: payment.minutesWorked ?? 0,
hourlyRateCents:
payment.hourlyRateCents ?? 0,
status: payment.status,
),
);
}).toList(),
@@ -218,16 +231,19 @@ class _PaymentsPageState extends State<PaymentsPage> {
);
}
/// Builds a period tab widget.
Widget _buildTab(String label, String value, String activePeriod) {
final bool isSelected = activePeriod == value;
return Expanded(
child: GestureDetector(
onTap: () => _bloc.add(ChangePeriodEvent(value)),
child: Container(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space2),
padding:
const EdgeInsets.symmetric(vertical: UiConstants.space2),
decoration: BoxDecoration(
color: isSelected ? UiColors.white : UiColors.transparent,
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderRadius:
BorderRadius.circular(UiConstants.radiusMdValue),
),
child: Center(
child: Text(
@@ -241,5 +257,14 @@ class _PaymentsPageState extends State<PaymentsPage> {
),
);
}
}
/// Formats an amount in cents to a dollar string (e.g. `$1,234.56`).
static String _formatCents(int cents) {
final double dollars = cents / 100;
final NumberFormat formatter = NumberFormat.currency(
symbol: r'$',
decimalDigits: 2,
);
return formatter.format(dollars);
}
}

View File

@@ -4,25 +4,24 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
/// Displays an earnings trend line chart from backend chart data.
class EarningsGraph extends StatelessWidget {
/// Creates an [EarningsGraph].
const EarningsGraph({
super.key,
required this.payments,
required this.chartPoints,
required this.period,
});
final List<StaffPayment> payments;
/// Pre-aggregated chart data points from the V2 API.
final List<PaymentChartPoint> chartPoints;
/// The currently selected period (week, month, year).
final String period;
@override
Widget build(BuildContext context) {
// Basic data processing for the graph
// We'll aggregate payments by date
final List<StaffPayment> validPayments = payments.where((StaffPayment p) => p.paidAt != null).toList()
..sort((StaffPayment a, StaffPayment b) => a.paidAt!.compareTo(b.paidAt!));
// If no data, show empty state or simple placeholder
if (validPayments.isEmpty) {
if (chartPoints.isEmpty) {
return Container(
height: 200,
decoration: BoxDecoration(
@@ -31,15 +30,23 @@ class EarningsGraph extends StatelessWidget {
),
child: Center(
child: Text(
"No sufficient data for graph",
'No sufficient data for graph',
style: UiTypography.body2r.textSecondary,
),
),
);
}
final List<FlSpot> spots = _generateSpots(validPayments);
final double maxY = spots.isNotEmpty ? spots.map((FlSpot s) => s.y).reduce((double a, double b) => a > b ? a : b) : 0.0;
final List<PaymentChartPoint> sorted = List<PaymentChartPoint>.of(chartPoints)
..sort((PaymentChartPoint a, PaymentChartPoint b) =>
a.bucket.compareTo(b.bucket));
final List<FlSpot> spots = _generateSpots(sorted);
final double maxY = spots.isNotEmpty
? spots
.map((FlSpot s) => s.y)
.reduce((double a, double b) => a > b ? a : b)
: 0.0;
return Container(
height: 220,
@@ -59,7 +66,7 @@ class EarningsGraph extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
"Earnings Trend",
'Earnings Trend',
style: UiTypography.body2b.textPrimary,
),
const SizedBox(height: UiConstants.space4),
@@ -71,26 +78,31 @@ class EarningsGraph extends StatelessWidget {
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (double value, TitleMeta meta) {
// Simple logic to show a few dates
if (value % 2 != 0) return const SizedBox();
final int index = value.toInt();
if (index >= 0 && index < validPayments.length) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
DateFormat('d').format(validPayments[index].paidAt!),
style: UiTypography.footnote1r.textSecondary,
),
);
}
return const SizedBox();
getTitlesWidget:
(double value, TitleMeta meta) {
if (value % 2 != 0) return const SizedBox();
final int index = value.toInt();
if (index >= 0 && index < sorted.length) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
_formatBucketLabel(
sorted[index].bucket, period),
style:
UiTypography.footnote1r.textSecondary,
),
);
}
return const SizedBox();
},
),
),
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: false),
lineBarsData: <LineChartBarData>[
@@ -119,20 +131,32 @@ class EarningsGraph extends StatelessWidget {
);
}
List<FlSpot> _generateSpots(List<StaffPayment> data) {
if (data.isEmpty) return [];
// If only one data point, add a dummy point at the start to create a horizontal line
/// Converts chart points to [FlSpot] values (dollars).
List<FlSpot> _generateSpots(List<PaymentChartPoint> data) {
if (data.isEmpty) return <FlSpot>[];
if (data.length == 1) {
return [
FlSpot(0, data[0].amount),
FlSpot(1, data[0].amount),
final double dollars = data[0].amountCents / 100;
return <FlSpot>[
FlSpot(0, dollars),
FlSpot(1, dollars),
];
}
// Generate spots based on index in the list for simplicity in this demo
return List<FlSpot>.generate(data.length, (int index) {
return FlSpot(index.toDouble(), data[index].amount);
return FlSpot(index.toDouble(), data[index].amountCents / 100);
});
}
/// Returns a short label for a chart bucket date.
String _formatBucketLabel(DateTime bucket, String period) {
switch (period) {
case 'year':
return DateFormat('MMM').format(bucket);
case 'month':
return DateFormat('d').format(bucket);
default:
return DateFormat('d').format(bucket);
}
}
}

View File

@@ -1,32 +1,53 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
/// Displays a single payment record in the history list.
class PaymentHistoryItem extends StatelessWidget {
/// Creates a [PaymentHistoryItem].
const PaymentHistoryItem({
super.key,
required this.amount,
required this.amountCents,
required this.title,
required this.location,
required this.address,
required this.date,
required this.workedTime,
required this.hours,
required this.rate,
required this.minutesWorked,
required this.hourlyRateCents,
required this.status,
});
final double amount;
/// Payment amount in cents.
final int amountCents;
/// Shift or payment title.
final String title;
/// Location / hub name.
final String location;
final String address;
/// Formatted date string.
final String date;
final String workedTime;
final int hours;
final double rate;
final String status;
/// Total minutes worked.
final int minutesWorked;
/// Hourly rate in cents.
final int hourlyRateCents;
/// Payment processing status.
final PaymentStatus status;
@override
Widget build(BuildContext context) {
final String dollarAmount = _centsToDollars(amountCents);
final String rateDisplay = _centsToDollars(hourlyRateCents);
final int hours = minutesWorked ~/ 60;
final int mins = minutesWorked % 60;
final String timeDisplay =
mins > 0 ? '${hours}h ${mins}m' : '${hours}h';
final Color statusColor = _statusColor(status);
final String statusLabel = status.value;
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
@@ -43,16 +64,16 @@ class PaymentHistoryItem extends StatelessWidget {
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: UiColors.primary,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
"PAID",
statusLabel,
style: UiTypography.titleUppercase4b.copyWith(
color: UiColors.primary,
color: statusColor,
),
),
],
@@ -68,7 +89,8 @@ class PaymentHistoryItem extends StatelessWidget {
height: 44,
decoration: BoxDecoration(
color: UiColors.secondary,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderRadius:
BorderRadius.circular(UiConstants.radiusBase),
),
child: const Icon(
UiIcons.dollar,
@@ -90,10 +112,7 @@ class PaymentHistoryItem extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: UiTypography.body2m,
),
Text(title, style: UiTypography.body2m),
Text(
location,
style: UiTypography.body3r.textSecondary,
@@ -105,12 +124,13 @@ class PaymentHistoryItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
"\$${amount.toStringAsFixed(0)}",
dollarAmount,
style: UiTypography.headline4b,
),
Text(
"\$${rate.toStringAsFixed(0)}/hr · ${hours}h",
style: UiTypography.footnote1r.textSecondary,
'$rateDisplay/hr \u00B7 $timeDisplay',
style:
UiTypography.footnote1r.textSecondary,
),
],
),
@@ -118,7 +138,7 @@ class PaymentHistoryItem extends StatelessWidget {
),
const SizedBox(height: UiConstants.space2),
// Date and Time
// Date
Row(
children: <Widget>[
const Icon(
@@ -139,32 +159,11 @@ class PaymentHistoryItem extends StatelessWidget {
),
const SizedBox(width: UiConstants.space2),
Text(
workedTime,
timeDisplay,
style: UiTypography.body3r.textSecondary,
),
],
),
const SizedBox(height: 1),
// Address
Row(
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: 12,
color: UiColors.mutedForeground,
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
address,
style: UiTypography.body3r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
@@ -174,4 +173,26 @@ class PaymentHistoryItem extends StatelessWidget {
),
);
}
/// Converts cents to a formatted dollar string.
static String _centsToDollars(int cents) {
final double dollars = cents / 100;
return '\$${dollars.toStringAsFixed(2)}';
}
/// Returns a colour for the given payment status.
static Color _statusColor(PaymentStatus status) {
switch (status) {
case PaymentStatus.paid:
return UiColors.primary;
case PaymentStatus.pending:
return UiColors.textWarning;
case PaymentStatus.processing:
return UiColors.primary;
case PaymentStatus.failed:
return UiColors.error;
case PaymentStatus.unknown:
return UiColors.mutedForeground;
}
}
}

View File

@@ -18,13 +18,9 @@ dependencies:
path: ../../../domain
krow_core:
path: ../../../core
krow_data_connect:
path: ../../../data_connect
flutter:
sdk: flutter
firebase_data_connect: ^0.2.2+2
firebase_auth: ^6.1.4
flutter_modular: ^6.3.2
intl: ^0.20.0
fl_chart: ^0.66.0