feat: Refactor code structure and optimize performance across multiple modules

This commit is contained in:
Achintha Isuru
2025-11-17 23:29:28 -05:00
parent 831570f2e0
commit a64cbd9edf
1508 changed files with 105319 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
const String _staffPaymentFields = '''
id
rate
assignment {
clock_in
clock_out
start_at
end_at
break_in
break_out
position {
shift {
event {
name
date
business {
id
name
avatar
}
}
}
business_skill {
skill {
name
}
}
}
}
work_hours
amount
status
paid_at
created_at
updated_at
''';
const String getWorkSummaryQuerySchema = '''
query GetWorkSummary {
staff_work_summary {
weekly_hours
monthly_hours
weekly_earnings
monthly_earnings
}
}
''';
const String getPaymentsQuerySchema = '''
query GetStaffPayments (\$status: StaffPaymentStatusInput!, \$first: Int!, \$after: String) {
staff_payments(status: \$status, first: \$first, after: \$after) {
pageInfo {
hasNextPage
}
edges {
node {
$_staffPaymentFields
}
cursor
}
}
}
''';
const String confirmPaymentMutationSchema = '''
mutation ConfirmStaffPayment (\$id: ID!) {
confirm_staff_payment(id: \$id) {
$_staffPaymentFields
}
}
''';
const String declinePaymentMutationSchema = '''
mutation DeclineStaffPayment (\$id: ID!, \$reason: String!, \$details: String) {
decline_staff_payment(id: \$id, reason: \$reason, details: \$details) {
$_staffPaymentFields
}
}
''';

View File

@@ -0,0 +1,67 @@
class EarningModel {
EarningModel({
required this.id,
required this.rate,
required this.businessName,
required this.businessAvatar,
required this.businessSkill,
required this.workHours,
required this.amount,
required this.status,
required this.paidAt,
required this.clockInAt,
required this.clockOutAt,
this.breakIn,
this.breakOut,
required this.eventName,
required this.eventDate,
});
factory EarningModel.fromJson(Map<String, dynamic> json) {
final assignment = json['assignment'] as Map<String, dynamic>;
final positionData = assignment['position'] as Map<String, dynamic>;
final eventData = positionData['shift']['event'] as Map<String, dynamic>;
final businessData = eventData['business'] as Map<String, dynamic>;
return EarningModel(
id: json['id'] as String? ?? '',
rate: (json['rate'] as num? ?? 0).toDouble(),
businessName: businessData['name'] as String? ?? '',
businessAvatar: businessData['avatar'] as String? ?? '',
businessSkill:
positionData['business_skill']['skill']['name'] as String? ?? '',
workHours: (json['work_hours'] as num? ?? 0).toDouble(),
amount: (json['amount'] as num? ?? 0).toDouble(),
status: json['status'] as String? ?? 'failed',
paidAt: DateTime.tryParse(
json['paid_at'] as String? ?? '',
),
clockInAt: DateTime.parse(
assignment['clock_in'] ?? assignment['start_at'] as String,
),
clockOutAt: DateTime.parse(
assignment['clock_out'] ?? assignment['end_at'] as String,
),
breakIn: DateTime.tryParse(assignment['break_in'] ?? ''),
breakOut: DateTime.tryParse(assignment['break_out'] ?? ''),
eventName: eventData['name'] as String? ?? '',
eventDate: DateTime.parse(eventData['date'] as String? ?? ''),
);
}
final String id;
final double rate;
final String businessName;
final String businessAvatar;
final String businessSkill;
final double workHours;
final double amount;
final String status;
final DateTime? paidAt;
final DateTime clockInAt;
final DateTime clockOutAt;
final DateTime? breakIn;
final DateTime? breakOut;
final String eventName;
final DateTime eventDate;
}

View File

@@ -0,0 +1,70 @@
class EarningsSummaryModel {
EarningsSummaryModel({
required this.totalEarningsByWeek,
required this.totalEarningsByMonth,
required this.totalWorkedHoursByWeek,
required this.totalWorkedHoursByMonth,
required this.payoutByWeek,
required this.payoutByMonth,
required this.startDatePeriod,
required this.endDatePeriod,
required this.maxEarningInPeriod,
required this.minEarningInPeriod,
});
//TODO: Additional fields that are used in the Earnings History screen are for now returning default values.
factory EarningsSummaryModel.fromJson(Map<String, dynamic> json) {
final time = DateTime.now();
return EarningsSummaryModel(
totalEarningsByWeek: (json['weekly_earnings'] as num?)?.toDouble() ?? 0,
totalEarningsByMonth: (json['monthly_earnings'] as num?)?.toDouble() ?? 0,
totalWorkedHoursByWeek: (json['weekly_hours'] as num?)?.toDouble() ?? 0,
totalWorkedHoursByMonth: (json['monthly_hours'] as num?)?.toDouble() ?? 0,
payoutByWeek: 0,
payoutByMonth: 0,
startDatePeriod: DateTime(time.year, time.month),
endDatePeriod: DateTime(time.year, time.month, 28),
maxEarningInPeriod: 0,
minEarningInPeriod: 0,
);
}
final double totalEarningsByWeek;
final double totalEarningsByMonth;
final double totalWorkedHoursByWeek;
final double totalWorkedHoursByMonth;
final double payoutByWeek;
final double payoutByMonth;
final DateTime? startDatePeriod;
final DateTime? endDatePeriod;
final int maxEarningInPeriod;
final int minEarningInPeriod;
EarningsSummaryModel copyWith({
double? totalEarningsByWeek,
double? totalEarningsByMonth,
double? totalWorkedHoursByWeek,
double? totalWorkedHoursByMonth,
double? payoutByWeek,
double? payoutByMonth,
DateTime? startDatePeriod,
DateTime? endDatePeriod,
int? maxEarningInPeriod,
int? minEarningInPeriod,
}) {
return EarningsSummaryModel(
totalEarningsByWeek: totalEarningsByWeek ?? this.totalEarningsByWeek,
totalEarningsByMonth: totalEarningsByMonth ?? this.totalEarningsByMonth,
totalWorkedHoursByWeek:
totalWorkedHoursByWeek ?? this.totalWorkedHoursByWeek,
totalWorkedHoursByMonth:
totalWorkedHoursByMonth ?? this.totalWorkedHoursByMonth,
payoutByWeek: payoutByWeek ?? this.payoutByWeek,
payoutByMonth: payoutByMonth ?? this.payoutByMonth,
startDatePeriod: startDatePeriod,
endDatePeriod: endDatePeriod,
maxEarningInPeriod: maxEarningInPeriod ?? this.maxEarningInPeriod,
minEarningInPeriod: minEarningInPeriod ?? this.minEarningInPeriod,
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:injectable/injectable.dart';
import 'package:krow/core/application/clients/api/api_client.dart';
import 'package:krow/core/data/models/pagination_wrapper/pagination_wrapper.dart';
import 'package:krow/features/earning/data/earning_qgl.dart';
import 'package:krow/features/earning/data/models/earning_model.dart';
import 'package:krow/features/earning/data/models/earnings_summary_model.dart';
@injectable
class StaffEarningApiProvider {
StaffEarningApiProvider({required ApiClient client}) : _client = client;
final ApiClient _client;
Future<EarningsSummaryModel> fetchStaffEarningsSummary() async {
final QueryResult result = await _client.query(
schema: getWorkSummaryQuerySchema,
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
return EarningsSummaryModel.fromJson(
result.data?['staff_work_summary'] as Map<String, dynamic>? ?? {},
);
}
Future<PaginationWrapper<EarningModel>> fetchEarnings({
required String status,
required int limit,
String? cursor,
}) async {
final QueryResult result = await _client.query(
schema: getPaymentsQuerySchema,
body: {'status': status, 'first': limit, 'after': cursor},
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
return PaginationWrapper<EarningModel>.fromJson(
result.data?['staff_payments'] ?? {},
EarningModel.fromJson,
);
}
Future<EarningModel?> _processPaymentMutation({
required String schema,
required Map<String, dynamic> body,
required String mutationName,
}) async {
final QueryResult result = await _client.mutate(
schema: schema,
body: body,
);
if (result.hasException) throw Exception(result.exception.toString());
if (result.data == null) {
throw Exception('Payment data is missing on mutation $mutationName');
}
final jsonData = result.data?[mutationName] as Map<String, dynamic>;
return EarningModel.fromJson(jsonData);
}
Future<EarningModel?> confirmPayment({required String id}) {
return _processPaymentMutation(
schema: confirmPaymentMutationSchema,
body: {'id': id},
mutationName: 'confirm_staff_payment',
);
}
Future<EarningModel?> declinePayment({
required String id,
required String reason,
String? details,
}) {
return _processPaymentMutation(
schema: declinePaymentMutationSchema,
body: {'id': id, 'reason': reason, 'details': details},
mutationName: 'decline_staff_payment',
);
}
}

View File

@@ -0,0 +1,106 @@
import 'package:injectable/injectable.dart';
import 'package:krow/features/earning/data/models/earning_model.dart';
import 'package:krow/features/earning/data/staff_earning_api_provider.dart';
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
import 'package:krow/features/earning/domain/entities/earnings_batch_entity.dart';
import 'package:krow/features/earning/domain/entities/earnings_summary_entity.dart';
import 'package:krow/features/earning/domain/staff_earning_repository.dart';
@Injectable(as: StaffEarningRepository)
class StaffEarningRepositoryImpl implements StaffEarningRepository {
StaffEarningRepositoryImpl({
required StaffEarningApiProvider apiProvider,
}) : _apiProvider = apiProvider;
final StaffEarningApiProvider _apiProvider;
EarningShiftEntity _convertEarningModel(EarningModel data) {
return EarningShiftEntity(
id: data.id,
status: EarningStatus.fromString(data.status),
businessImageUrl: data.businessAvatar,
skillName: data.businessSkill,
businessName: data.businessName,
totalBreakTime: data.breakIn != null
? data.breakOut?.difference(data.breakIn!).inSeconds
: null,
paymentStatus: 1,
earned: data.amount,
clockIn: data.clockInAt,
clockOut: data.clockOutAt,
eventName: data.eventName,
eventDate: data.eventDate,
);
}
@override
Future<EarningsSummaryEntity> getStaffEarningsData() async {
final data = await _apiProvider.fetchStaffEarningsSummary();
return EarningsSummaryEntity(
totalEarningsByWeek: data.totalEarningsByWeek,
totalEarningsByMonth: data.totalEarningsByMonth,
totalWorkedHoursByWeek: data.totalWorkedHoursByWeek,
totalWorkedHoursByMonth: data.totalWorkedHoursByMonth,
payoutByWeek: data.payoutByWeek,
payoutByMonth: data.payoutByMonth,
startDatePeriod: data.startDatePeriod,
endDatePeriod: data.endDatePeriod,
maxEarningInPeriod: data.maxEarningInPeriod,
minEarningInPeriod: data.minEarningInPeriod,
);
}
@override
Future<EarningsBatchEntity> getEarningsBatch({
required String status,
int limit = 10,
String? lastEntryCursor,
}) async {
final paginationInfo = await _apiProvider.fetchEarnings(
status: status,
limit: limit,
cursor: lastEntryCursor,
);
return EarningsBatchEntity(
batchStatus: status,
hasNextBatch: paginationInfo.pageInfo?.hasNextPage??false,
cursor: paginationInfo.pageInfo?.endCursor ??
paginationInfo.edges.lastOrNull?.cursor,
earnings: paginationInfo.edges.map(
(edgeData) {
return _convertEarningModel(edgeData.node);
},
).toList(),
);
}
@override
Future<EarningShiftEntity?> confirmStaffEarning({
required String earningId,
}) async {
final result = await _apiProvider.confirmPayment(id: earningId);
if (result == null) return null;
return _convertEarningModel(result);
}
@override
Future<EarningShiftEntity?> disputeStaffEarning({
required String id,
required String reason,
String? details,
}) async {
final result = await _apiProvider.declinePayment(
id: id,
reason: reason,
details: details,
);
if (result == null) return null;
return _convertEarningModel(result);
}
}

View File

@@ -0,0 +1,201 @@
import 'dart:developer';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/data/enums/pagination_status.dart';
import 'package:krow/core/data/enums/state_status.dart';
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
import 'package:krow/features/earning/domain/entities/earnings_summary_entity.dart';
import 'package:krow/features/earning/domain/staff_earning_repository.dart';
part 'earnings_data.dart';
part 'earnings_event.dart';
part 'earnings_state.dart';
class EarningsBloc extends Bloc<EarningsEvent, EarningsState> {
EarningsBloc()
: super(const EarningsState.initial()
..copyWith(
tabs: defaultEarningTabs,
)) {
on<EarningsInitEvent>(_onInit);
on<EarningsSwitchBalancePeriodEvent>(_onSwitchBalancePeriod);
on<EarningsTabChangedEvent>(_onTabChanged);
on<LoadAdditionalEarnings>(_onLoadAdditionalEarnings);
on<ReloadCurrentEarnings>(_onReloadCurrentEarnings);
on<ConfirmStaffEarning>(_onConfirmStaffEarning);
on<DisputeStaffEarning>(_onDisputeStaffEarning);
}
final _earningRepository = getIt<StaffEarningRepository>();
Future<void> _onInit(
EarningsInitEvent event,
Emitter<EarningsState> emit,
) async {
add(const LoadAdditionalEarnings());
emit(state.copyWith(status: StateStatus.loading));
EarningsSummaryEntity? earningsData;
try {
earningsData = await _earningRepository.getStaffEarningsData();
} catch (except) {
log('Error in EarningsBloc, on EarningsInitEvent', error: except);
}
emit(state.copyWith(earnings: earningsData, status: StateStatus.idle));
}
void _onSwitchBalancePeriod(
EarningsSwitchBalancePeriodEvent event,
Emitter<EarningsState> emit,
) {
emit(state.copyWith(balancePeriod: event.period));
}
void _onTabChanged(
EarningsTabChangedEvent event,
Emitter<EarningsState> emit,
) {
emit(state.copyWith(tabIndex: event.tabIndex));
if (state.currentTab.status == PaginationStatus.initial) {
add(const LoadAdditionalEarnings());
}
}
Future<void> _onLoadAdditionalEarnings(
LoadAdditionalEarnings event,
Emitter<EarningsState> emit,
) async {
if (!state.currentTab.status.allowLoad) return;
emit(state.updateCurrentTab(status: PaginationStatus.loading));
final tabIndex = state.tabIndex;
try {
final earningsBatch = await _earningRepository.getEarningsBatch(
status: state.currentTab.loadingKey,
lastEntryCursor: state.currentTab.paginationCursor,
);
emit(
state.copyWithTab(
tabIndex: tabIndex,
tab: state.tabs[tabIndex].copyWith(
status: earningsBatch.hasNextBatch
? PaginationStatus.idle
: PaginationStatus.end,
items: [...state.tabs[tabIndex].items, ...earningsBatch.earnings],
hasMoreItems: earningsBatch.hasNextBatch,
),
),
);
} catch (except) {
log(
'Error in EarningsBloc, on LoadAdditionalEarnings',
error: except,
);
emit(
state.copyWithTab(
tabIndex: tabIndex,
tab: state.tabs[tabIndex].copyWith(
status: PaginationStatus.error,
),
),
);
}
if (state.tabs[tabIndex].status == PaginationStatus.loading) {
emit(
state.copyWithTab(
tabIndex: tabIndex,
tab: state.tabs[tabIndex].copyWith(
status: PaginationStatus.idle,
),
),
);
}
}
void _onReloadCurrentEarnings(
ReloadCurrentEarnings event,
Emitter<EarningsState> emit,
) {
emit(
state.copyWithTab(
tab: EarningsTabState.initial(
label: state.currentTab.label,
loadingKey: state.currentTab.loadingKey,
),
),
);
add(const LoadAdditionalEarnings());
}
Future<void> _onEarningAction(
Future<EarningShiftEntity?> earningFuture,
String eventName,
Emitter<EarningsState> emit,
) async {
emit(state.copyWith(status: StateStatus.loading));
EarningShiftEntity? earning;
try {
earning = await earningFuture;
} catch (except) {
log(
'Error in EarningsBloc, on $eventName',
error: except,
);
}
var tabData = state.currentTab;
if (earning != null) {
final earningIndex =
state.currentTab.items.indexWhere((item) => item.id == earning?.id);
if (earningIndex >= 0) {
tabData = state.currentTab.copyWith(
items: List.from(state.currentTab.items)..[earningIndex] = earning,
);
}
}
emit(
state.copyWithTab(
status: StateStatus.idle,
tab: tabData,
),
);
}
Future<void> _onConfirmStaffEarning(
ConfirmStaffEarning event,
Emitter<EarningsState> emit,
) {
return _onEarningAction(
_earningRepository.confirmStaffEarning(
earningId: event.earningId,
),
'ConfirmStaffEarning',
emit,
);
}
Future<void> _onDisputeStaffEarning(
DisputeStaffEarning event,
Emitter<EarningsState> emit,
) async {
return _onEarningAction(
_earningRepository.disputeStaffEarning(
id: event.earningId,
reason: event.reason,
details: event.details,
),
'DeclineStaffEarning',
emit,
);
}
}

View File

@@ -0,0 +1,85 @@
part of 'earnings_bloc.dart';
enum BalancePeriod {
week,
month,
}
class EarningsTabState {
const EarningsTabState({
required this.label,
required this.loadingKey,
required this.items,
this.status = PaginationStatus.idle,
this.hasMoreItems = true,
this.paginationCursor,
});
const EarningsTabState.initial({
required this.label,
required this.loadingKey,
this.items = const [],
this.status = PaginationStatus.initial,
this.hasMoreItems = true,
this.paginationCursor,
});
final String label;
final String loadingKey;
final List<EarningShiftEntity> items;
final PaginationStatus status;
final bool hasMoreItems;
final String? paginationCursor;
EarningsTabState copyWith({
List<EarningShiftEntity>? items,
PaginationStatus? status,
bool? hasMoreItems,
String? label,
String? loadingKey,
String? paginationCursor,
}) {
return EarningsTabState(
loadingKey: loadingKey ?? this.loadingKey,
items: items ?? this.items,
status: status ?? this.status,
hasMoreItems: hasMoreItems ?? this.hasMoreItems,
label: label ?? this.label,
paginationCursor: paginationCursor ?? this.paginationCursor,
);
}
}
const defaultEarningTabs = [
EarningsTabState.initial(
label: 'new_earning',
loadingKey: 'new' // Confirmed by admin
),
EarningsTabState.initial(
label: 'confirmed',
loadingKey: 'confirmed', // Confirmed by staff
),
EarningsTabState.initial(
label: 'disputed',
loadingKey: 'disputed', // Declined by staff
),
EarningsTabState.initial(
label: 'sent',
loadingKey: 'sent', // Should remain sent
),
EarningsTabState.initial(
label: 'received',
loadingKey: 'paid', // Should remain paid
),
];
// $of = match ($status) {
// 'new' => fn(Builder $q) => $q->whereIn('status', [StaffPaymentStatus::new]),
// 'confirmed' => fn(Builder $q) => $q->whereIn('status', [StaffPaymentStatus::confirmed_by_admin]),
// 'pending' => fn(Builder $q) => $q->whereIn('status', [
// StaffPaymentStatus::decline_by_staff,
// StaffPaymentStatus::confirmed_by_staff,
// ]),
// 'sent' => fn(Builder $q) => $q->whereIn('status', [StaffPaymentStatus::sent]),
// 'paid' => fn(Builder $q) => $q->whereIn('status', [StaffPaymentStatus::paid]),
// };

View File

@@ -0,0 +1,48 @@
part of 'earnings_bloc.dart';
@immutable
sealed class EarningsEvent {
const EarningsEvent();
}
class EarningsInitEvent extends EarningsEvent {
const EarningsInitEvent();
}
class EarningsSwitchBalancePeriodEvent extends EarningsEvent {
final BalancePeriod period;
const EarningsSwitchBalancePeriodEvent({required this.period});
}
class EarningsTabChangedEvent extends EarningsEvent {
final int tabIndex;
const EarningsTabChangedEvent({required this.tabIndex});
}
class LoadAdditionalEarnings extends EarningsEvent {
const LoadAdditionalEarnings();
}
class ReloadCurrentEarnings extends EarningsEvent {
const ReloadCurrentEarnings();
}
class ConfirmStaffEarning extends EarningsEvent {
const ConfirmStaffEarning(this.earningId);
final String earningId;
}
class DisputeStaffEarning extends EarningsEvent {
const DisputeStaffEarning({
required this.earningId,
required this.reason,
required this.details,
});
final String earningId;
final String reason;
final String details;
}

View File

@@ -0,0 +1,92 @@
part of 'earnings_bloc.dart';
@immutable
class EarningsState {
const EarningsState({
required this.status,
required this.tabIndex,
required this.balancePeriod,
required this.earnings,
required this.tabs,
});
const EarningsState.initial({
this.status = StateStatus.idle,
this.tabIndex = 0,
this.balancePeriod = BalancePeriod.week,
this.earnings = const EarningsSummaryEntity.empty(),
this.tabs = defaultEarningTabs,
});
final StateStatus status;
final int tabIndex;
final BalancePeriod balancePeriod;
final EarningsSummaryEntity earnings;
final List<EarningsTabState> tabs;
EarningsTabState get currentTab => tabs[tabIndex];
double get totalEarnings {
return balancePeriod == BalancePeriod.week
? earnings.totalEarningsByWeek
: earnings.totalEarningsByMonth;
}
double get totalHours {
return balancePeriod == BalancePeriod.week
? earnings.totalWorkedHoursByWeek
: earnings.totalWorkedHoursByMonth;
}
double get totalPayout {
return balancePeriod == BalancePeriod.week
? earnings.payoutByWeek
: earnings.payoutByMonth;
}
EarningsState copyWith({
StateStatus? status,
int? tabIndex,
BalancePeriod? balancePeriod,
EarningsSummaryEntity? earnings,
List<EarningsTabState>? tabs,
}) {
return EarningsState(
status: status ?? this.status,
tabIndex: tabIndex ?? this.tabIndex,
balancePeriod: balancePeriod ?? this.balancePeriod,
earnings: earnings ?? this.earnings,
tabs: tabs ?? this.tabs,
);
}
EarningsState copyWithTab({
required EarningsTabState tab,
int? tabIndex,
StateStatus? status,
}) {
return copyWith(
tabs: List.from(tabs)..[tabIndex ?? this.tabIndex] = tab,
status: status,
);
}
EarningsState updateCurrentTab({
List<EarningShiftEntity>? items,
PaginationStatus? status,
bool? hasMoreItems,
String? label,
String? paginationCursor,
}) {
return copyWith(
tabs: List.from(tabs)
..[tabIndex] = currentTab.copyWith(
items: items,
status: status,
hasMoreItems: hasMoreItems,
label: label,
paginationCursor: paginationCursor,
),
);
}
}

View File

@@ -0,0 +1,4 @@
class EarningDisputeInfo {
String reason = '';
String details = '';
}

View File

@@ -0,0 +1,64 @@
class EarningShiftEntity {
final String id;
final EarningStatus status;
final String businessImageUrl;
final String skillName;
final String businessName;
final int? totalBreakTime;
final int? paymentStatus;
final double? earned;
final DateTime? clockIn;
final DateTime? clockOut;
final String eventName;
final DateTime eventDate;
EarningShiftEntity({
required this.id,
required this.status,
required this.businessImageUrl,
required this.skillName,
required this.businessName,
required this.totalBreakTime,
required this.paymentStatus,
required this.earned,
required this.clockIn,
required this.clockOut,
required this.eventName,
required this.eventDate,
});
int get paymentProgressStep {
return switch (status) {
EarningStatus.pending => 0, // corresponds to Pending Sent
EarningStatus.processing => 1, // corresponds to Pending Payment
EarningStatus.paid => 2, // corresponds to Payment Received
_ => -1, // don't show active step
};
}
}
enum EarningStatus {
new_,
pending,
confirmedByAdmin,
confirmedByStaff,
declineByStaff,
processing,
paid,
failed,
canceled;
static EarningStatus fromString(value) {
return switch (value) {
'new' => new_,
'confirmed_by_admin' => confirmedByAdmin,
'confirmed_by_staff' => confirmedByStaff,
'decline_by_staff' => declineByStaff,
'pending' => pending,
'paid' => paid,
'failed' => failed,
'canceled' => canceled,
_ => canceled,
};
}
}

View File

@@ -0,0 +1,15 @@
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
class EarningsBatchEntity {
EarningsBatchEntity({
required this.batchStatus,
required this.hasNextBatch,
required this.earnings,
this.cursor,
});
final String batchStatus;
final bool hasNextBatch;
final List<EarningShiftEntity> earnings;
final String? cursor;
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/foundation.dart';
@immutable
class EarningsSummaryEntity {
const EarningsSummaryEntity({
required this.totalEarningsByWeek,
required this.totalEarningsByMonth,
required this.totalWorkedHoursByWeek,
required this.totalWorkedHoursByMonth,
required this.payoutByWeek,
required this.payoutByMonth,
required this.startDatePeriod,
required this.endDatePeriod,
required this.maxEarningInPeriod,
required this.minEarningInPeriod,
});
const EarningsSummaryEntity.empty({
this.totalEarningsByWeek = 0,
this.totalEarningsByMonth = 0,
this.totalWorkedHoursByWeek = 0,
this.totalWorkedHoursByMonth = 0,
this.payoutByWeek = 0,
this.payoutByMonth = 0,
this.startDatePeriod,
this.endDatePeriod,
this.maxEarningInPeriod = 0,
this.minEarningInPeriod = 0,
});
final double totalEarningsByWeek;
final double totalEarningsByMonth;
final double totalWorkedHoursByWeek;
final double totalWorkedHoursByMonth;
final double payoutByWeek;
final double payoutByMonth;
final DateTime? startDatePeriod;
final DateTime? endDatePeriod;
final int maxEarningInPeriod;
final int minEarningInPeriod;
EarningsSummaryEntity copyWith({
double? totalEarningsByWeek,
double? totalEarningsByMonth,
double? totalWorkedHoursByWeek,
double? totalWorkedHoursByMonth,
double? payoutByWeek,
double? payoutByMonth,
DateTime? startDatePeriod,
DateTime? endDatePeriod,
int? maxEarningInPeriod,
int? minEarningInPeriod,
}) {
return EarningsSummaryEntity(
totalEarningsByWeek: totalEarningsByWeek ?? this.totalEarningsByWeek,
totalEarningsByMonth: totalEarningsByMonth ?? this.totalEarningsByMonth,
totalWorkedHoursByWeek:
totalWorkedHoursByWeek ?? this.totalWorkedHoursByWeek,
totalWorkedHoursByMonth:
totalWorkedHoursByMonth ?? this.totalWorkedHoursByMonth,
payoutByWeek: payoutByWeek ?? this.payoutByWeek,
payoutByMonth: payoutByMonth ?? this.payoutByMonth,
startDatePeriod: startDatePeriod,
endDatePeriod: endDatePeriod,
maxEarningInPeriod: maxEarningInPeriod ?? this.maxEarningInPeriod,
minEarningInPeriod: minEarningInPeriod ?? this.minEarningInPeriod,
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
import 'package:krow/features/earning/domain/entities/earnings_batch_entity.dart';
import 'package:krow/features/earning/domain/entities/earnings_summary_entity.dart';
abstract interface class StaffEarningRepository {
Future<EarningsSummaryEntity> getStaffEarningsData();
Future<EarningsBatchEntity> getEarningsBatch({
required String status,
int limit = 10,
String? lastEntryCursor,
});
Future<EarningShiftEntity?> confirmStaffEarning({required String earningId});
Future<EarningShiftEntity?> disputeStaffEarning({
required String id,
required String reason,
String? details,
});
}

View File

@@ -0,0 +1,18 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
@RoutePage()
class EarningsFlowScreen extends StatelessWidget {
const EarningsFlowScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (BuildContext context) =>
EarningsBloc()..add(const EarningsInitEvent()),
child: const AutoRouter(),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:auto_route/annotations.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
import 'package:krow/features/earning/presentation/widget/earnings_appbar.dart';
import 'package:krow/features/earning/presentation/widget/list_item/earning_card_compact_widget.dart';
import '../widget/earnings_history_header.dart';
@RoutePage()
class EarningsHistoryScreen extends StatelessWidget {
const EarningsHistoryScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: SafeArea(
top: false,
child: BlocBuilder<EarningsBloc, EarningsState>(
builder: (context, state) {
List<EarningShiftEntity> items = state.currentTab.items;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const EarningsAppBar(
leading: true,
child: EarningsHistoryHeader(),
),
const Gap(24),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
'your_shifts'.tr(),
style: AppTextStyles.bodySmallMed,
),
),
_buildListView(items),
],
);
},
),
),
),
);
}
ListView _buildListView(List<EarningShiftEntity> items) {
return ListView.builder(
padding: const EdgeInsets.only(top: 24),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: items.length,
itemBuilder: (context, index) {
return EarningCardCompactWidget(
earningEntity: items[index],
);
},
);
}
}

View File

@@ -0,0 +1,78 @@
import 'package:auto_route/annotations.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
import 'package:krow/features/earning/presentation/slivers/bottom_earnings_sliver.dart';
import 'package:krow/features/earning/presentation/slivers/earnings_header_sliver.dart';
import 'package:krow/features/earning/presentation/slivers/earnings_list_sliver.dart';
@RoutePage()
class EarningsScreen extends StatefulWidget {
const EarningsScreen({super.key});
@override
State<EarningsScreen> createState() => _EarningsScreenState();
}
class _EarningsScreenState extends State<EarningsScreen> {
final _scrollController = ScrollController();
bool allowPagination = true;
@override
void initState() {
super.initState();
_scrollController.addListener(
() {
if (allowPagination &&
_scrollController.position.userScrollDirection ==
ScrollDirection.reverse &&
_scrollController.position.extentAfter < 50) {
context.read<EarningsBloc>().add(const LoadAdditionalEarnings());
allowPagination = false;
}
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
extendBody: true,
body: RefreshIndicator(
displacement: 80,
backgroundColor: AppColors.bgColorLight,
color: AppColors.bgColorDark,
triggerMode: RefreshIndicatorTriggerMode.anywhere,
onRefresh: () {
context.read<EarningsBloc>().add(const ReloadCurrentEarnings());
return Future.delayed(const Duration(seconds: 1));
},
child: BlocListener<EarningsBloc, EarningsState>(
listenWhen: (previous, current) =>
previous.currentTab.status != current.currentTab.status,
listener: (context, state) {
allowPagination = state.currentTab.status.allowLoad;
},
child: CustomScrollView(
controller: _scrollController,
physics: const ClampingScrollPhysics(),
slivers: [
const EarningsHeaderSliver(),
const EarningsListSliver(),
const BottomEarningsSliver(),
],
),
),
),
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,51 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/data/enums/pagination_status.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
class BottomEarningsSliver extends StatelessWidget {
const BottomEarningsSliver({super.key});
@override
Widget build(BuildContext context) {
return SliverSafeArea(
top: false,
sliver: BlocBuilder<EarningsBloc, EarningsState>(
buildWhen: (previous, current) =>
previous.currentTab != current.currentTab ||
current.currentTab.status != previous.currentTab.status,
builder: (context, state) {
return SliverToBoxAdapter(
child: Column(
children: [
SizedBox(
height: 60,
child: Center(
child: switch (state.currentTab.status) {
PaginationStatus.initial ||
PaginationStatus.loading =>
const CircularProgressIndicator(),
PaginationStatus.empty => Text(
'no_history_section'.tr(),
style: AppTextStyles.bodyMediumReg,
),
PaginationStatus.end => Text(
'end_of_payments_history'.tr(),
style: AppTextStyles.bodyMediumReg,
),
_ => null,
},
),
),
const Gap(20),
],
),
);
},
),
);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_tabs.dart';
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
import 'package:krow/features/earning/presentation/widget/earnings_appbar.dart';
import 'package:krow/features/earning/presentation/widget/earnings_header.dart';
class EarningsHeaderSliver extends StatelessWidget {
const EarningsHeaderSliver({super.key});
@override
Widget build(BuildContext context) {
return SliverList.list(
children: [
const EarningsAppBar(child: EarningsHeader()),
const Gap(24),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
'your_shifts'.tr(),
style: AppTextStyles.bodySmallMed,
),
),
const Gap(24),
BlocBuilder<EarningsBloc, EarningsState>(
buildWhen: (previous, current) =>
previous.tabs != current.tabs,
builder: (context, state) {
return KwTabBar(
key: const Key('earnings_tab_bar'),
forceScroll: true,
tabs: [for (final tab in state.tabs) tab.label.tr()],
onTap: (int index) {
context
.read<EarningsBloc>()
.add(EarningsTabChangedEvent(tabIndex: index));
},
);
},
),
],
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
import 'package:krow/features/earning/presentation/widget/list_item/earning_card_widget.dart';
class EarningsListSliver extends StatelessWidget {
const EarningsListSliver({super.key});
@override
Widget build(BuildContext context) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
sliver: BlocBuilder<EarningsBloc, EarningsState>(
buildWhen: (previous, current) =>
previous.tabIndex != current.tabIndex ||
previous.currentTab.items != current.currentTab.items,
builder: (context, state) {
return SliverList.separated(
itemCount: state.currentTab.items.length,
separatorBuilder: (context, index) => const Gap(12),
itemBuilder: (context, index) {
return EarningCardWidget(
earningEntity: state.currentTab.items[index],
);
},
);
},
),
);
}
}

View File

@@ -0,0 +1,98 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
class AnimatedSlider extends StatefulWidget {
const AnimatedSlider({super.key});
@override
State<AnimatedSlider> createState() => _AnimatedSliderState();
}
class _AnimatedSliderState extends State<AnimatedSlider> {
@override
Widget build(BuildContext context) {
return Container(
width: 200, // Adjust the width as needed
height: 36, // Adjust the height as needed
decoration: BoxDecoration(
color: Colors.blueGrey.shade400, // Background color
borderRadius: BorderRadius.circular(18),
),
child: Stack(
children: [
BlocBuilder<EarningsBloc, EarningsState>(
builder: (context, state) {
bool isThisWeekSelected =
state.balancePeriod == BalancePeriod.week;
return AnimatedAlign(
duration: const Duration(milliseconds: 150),
alignment: isThisWeekSelected
? Alignment.centerLeft
: Alignment.centerRight,
child: Container(
width: 90,
margin: const EdgeInsets.all(4),
// Half the width of the container
height: 36,
decoration: BoxDecoration(
color: Colors.blueGrey.shade900, // Active slider color
borderRadius: BorderRadius.circular(14),
),
),
);
},
),
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () {
BlocProvider.of<EarningsBloc>(context)
.add(const EarningsSwitchBalancePeriodEvent(
period: BalancePeriod.week,
));
},
child: Container(
color: Colors.transparent,
child: Center(
child: Text(
'this_week'.tr(),
style: AppTextStyles.bodySmallMed
.copyWith(color: AppColors.grayWhite),
),
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () {
BlocProvider.of<EarningsBloc>(context)
.add(const EarningsSwitchBalancePeriodEvent(
period: BalancePeriod.month,
));
},
child: Container(
color: Colors.transparent,
child: Center(
child: Text(
'this_month'.tr(),
style: AppTextStyles.bodySmallMed
.copyWith(color: AppColors.grayWhite),
),
),
),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_dropdown.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_input.dart';
import 'package:krow/features/earning/domain/entities/earning_dispute_info.dart';
class DisputeFormWidget extends StatelessWidget {
const DisputeFormWidget({super.key, required this.disputeInfo});
final EarningDisputeInfo disputeInfo;
@override
Widget build(BuildContext context) {
return Column(
children: [
KwDropdown<String>(
title: 'reason'.tr(),
hintText: 'select_reason_from_list'.tr(),
horizontalPadding: 24,
items: [
for (final reason in _disputeReasonsData)
KwDropDownItem(data: reason, title: reason),
],
onSelected: (reason) => disputeInfo.reason = reason,
),
const Gap(8),
KwTextInput(
title: 'additional_reasons'.tr(),
minHeight: 114,
controller: TextEditingController(),
onChanged: (details) => disputeInfo.details = details,
),
],
);
}
}
final _disputeReasonsData = [
'incorrect_hours'.tr(),
'incorrect_charges'.tr(),
'other'.tr()
];

View File

@@ -0,0 +1,110 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/theme.dart';
class EarningsAppBar extends StatelessWidget {
final Widget child;
final bool leading;
const EarningsAppBar({
super.key,
required this.child,
this.leading = false,
});
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.loose,
children: [
_buildProfileBackground(context),
SafeArea(
bottom: false,
child: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Gap(16),
_buildAppBar(context),
const Gap(24),
child,
],
),
),
)
],
);
}
Widget _buildProfileBackground(BuildContext context) {
return Positioned.fill(
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
child: Container(
width: MediaQuery.of(context).size.width,
color: AppColors.bgColorDark,
child: Assets.images.bg.svg(
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
),
),
),
);
}
Widget _buildAppBar(BuildContext context) {
return Container(
height: 48,
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (leading) ...[
GestureDetector(
onTap: () {
Navigator.of(context).pop();
},
child: Container(
width: 48,
height: 48,
color: Colors.transparent,
alignment: Alignment.center,
child: Assets.images.appBar.appbarLeading.svg(
colorFilter: const ColorFilter.mode(
Colors.white,
BlendMode.srcIn,
),
),
),
),
const Gap(8),
],
Text(
'Earnings'.tr(),
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(color: Colors.white),
),
const Spacer(),
Container(
width: 48,
height: 48,
alignment: Alignment.center,
child: Assets.images.appBar.notification.svg(
colorFilter: const ColorFilter.mode(
Colors.white,
BlendMode.srcIn,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/earning/presentation/widget/earnings_charts_widget.dart';
class EarningsCharts extends StatefulWidget {
const EarningsCharts({super.key});
@override
State<EarningsCharts> createState() => _EarningsChartsState();
}
class _EarningsChartsState extends State<EarningsCharts> {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: KwBoxDecorations.primaryDark
.copyWith(color: AppColors.darkBgStroke),
height: 218,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'12.01 - 12.31.2024 period:',
style: AppTextStyles.captionReg
.copyWith(color: AppColors.bgColorLight),
),
const Gap(8),
Text('\$1490',style: AppTextStyles.headingH3.copyWith(color: AppColors.primaryYellow),),
const SizedBox(
height: 150,
child: EarningsChartsWidget(),
),
],
));
}
}

View File

@@ -0,0 +1,162 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
class EarningsChartsWidget extends StatefulWidget {
const EarningsChartsWidget({super.key});
@override
State<EarningsChartsWidget> createState() => _EarningsChartsWidgetState();
}
class _EarningsChartsWidgetState extends State<EarningsChartsWidget> {
var touchedIndex = -1;
var values = [300, 99, 300, 9];
@override
Widget build(BuildContext context) {
var maxValue = values.reduce((value, element) => value > element ? value : element);
return Padding(
padding: const EdgeInsets.only(top:8.0),
child: BarChart(
BarChartData(
alignment: BarChartAlignment.center,
maxY: maxValue.toDouble(),
minY: 0,
groupsSpace: 12,
barTouchData: buildBarTouchData(),
borderData: FlBorderData(
show: true,
border: const Border(
bottom: BorderSide(
color: AppColors.darkBgInactive,
width: 1,
),
top: BorderSide(
color: AppColors.darkBgInactive,
width: 1,
),
),
),
titlesData: _buildTiles(),
gridData: buildFlGridData(),
barGroups: getData()),
),
);
}
FlGridData buildFlGridData() {
return FlGridData(
show: true,
checkToShowHorizontalLine: (value) {
return true;
},
getDrawingHorizontalLine: (value) => const FlLine(
color: AppColors.darkBgInactive,
dashArray: [4, 4],
strokeWidth: 1,
),
drawVerticalLine: false,
);
}
FlTitlesData _buildTiles() {
return FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 16,
getTitlesWidget: _buildBottomTiles,
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
maxIncluded: true,
showTitles: true,
reservedSize: 28,
getTitlesWidget: _buildLeftTitles,
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
);
}
Widget _buildLeftTitles(double value, TitleMeta meta) {
return Padding(
padding: EdgeInsets.only(bottom: value == 0?8.0:0),
child: Text(
meta.formattedValue,
style:
AppTextStyles.bodyTinyReg.copyWith(color: AppColors.darkBgInactive),
),
);
}
Widget _buildBottomTiles(double value, TitleMeta meta) {
var diap = '1-8 9-15 16-23 24-30'.split(' ');
return Padding(
padding: const EdgeInsets.only(top:4.0),
child: Text(
diap[value.toInt()],
style:
AppTextStyles.bodyTinyReg.copyWith(color: AppColors.darkBgInactive),
),
);
}
BarTouchData buildBarTouchData() {
return BarTouchData(
allowTouchBarBackDraw: true,
touchExtraThreshold: const EdgeInsets.only(top:16),
touchCallback: (FlTouchEvent event, barTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions ||
barTouchResponse == null ||
barTouchResponse.spot == null) {
touchedIndex = -1;
return;
}
touchedIndex = barTouchResponse.spot!.touchedBarGroupIndex;
});
},
touchTooltipData: BarTouchTooltipData(
getTooltipColor: (group) => AppColors.primaryMint,
tooltipRoundedRadius: 24,
tooltipPadding:
const EdgeInsets.only(left: 12, right: 12, top: 12, bottom: 8),
getTooltipItem: (group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'\$${rod.toY.round()}',
AppTextStyles.bodyMediumMed,
);
},
),
);
}
List<BarChartGroupData> getData() {
double width = (MediaQuery.of(context).size.width-140)/4;
return List.generate(4, (i)=>
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: values[i].toDouble(),
borderRadius: const BorderRadius.all(Radius.circular(12)),
color: touchedIndex == i?AppColors.primaryMint:AppColors.primaryYellow,
width: width),
],
),);
}
}

View File

@@ -0,0 +1,100 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
import 'package:krow/features/earning/presentation/widget/animated_slider.dart';
class EarningsHeader extends StatelessWidget {
const EarningsHeader({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(
bottom: 24,
left: 12,
right: 12,
),
decoration: KwBoxDecorations.primaryDark,
child: BlocBuilder<EarningsBloc, EarningsState>(
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'your_earnings'.tr(),
textAlign: TextAlign.center,
style: AppTextStyles.bodyMediumMed
.copyWith(color: AppColors.grayWhite),
),
const Gap(12),
const AnimatedSlider(),
const Gap(24),
Text(
'\$${state.totalEarnings.toStringAsFixed(2)}',
style: AppTextStyles.headingH0
.copyWith(color: AppColors.grayWhite),
),
const Gap(24),
Container(
padding: const EdgeInsets.all(12),
decoration: KwBoxDecorations.primaryDark.copyWith(
color: AppColors.darkBgStroke,
),
child: Column(
children: [
_buildInfoRow(
'total_worked_hours'.tr(),
'${state.totalHours.toInt()}',
),
// const Gap(12),
// _buildInfoRow(
// 'Payout:',
// '\$${state.totalPayout.toStringAsFixed(2)}',
// ),
],
),
),
// const Gap(24),
// KwButton.accent(
// label: 'Earnings History',
// onPressed: () {
// context.router.push(const EarningsHistoryRoute());
// },
// )
],
);
},
),
),
),
],
);
}
Row _buildInfoRow(String title, String? value) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style:
AppTextStyles.bodyMediumMed.copyWith(color: AppColors.grayWhite),
),
Text(
value ?? '',
style:
AppTextStyles.bodyMediumMed.copyWith(color: AppColors.grayWhite),
),
],
);
}
}

View File

@@ -0,0 +1,131 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_popup_menu.dart';
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
import 'package:krow/features/earning/presentation/widget/earnings_charts_card.dart';
class EarningsHistoryHeader extends StatelessWidget {
const EarningsHistoryHeader({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 24, left: 12, right: 12),
decoration: KwBoxDecorations.primaryDark,
child: BlocBuilder<EarningsBloc, EarningsState>(
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPeriodRow(),
const Gap(12),
const EarningsCharts(),
const Gap(8),
_buildMinMaxRow(
state.earnings.minEarningInPeriod,
state.earnings.maxEarningInPeriod,
),
],
);
},
),
),
),
],
);
}
_buildMinMaxRow(int min, int max) {
return Container(
padding: const EdgeInsets.all(12),
decoration:
KwBoxDecorations.primaryDark.copyWith(color: AppColors.darkBgStroke),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildInfoRow('max_earning'.tr(), '\$$max'),
const Gap(12),
_buildInfoRow('min_earning'.tr(), '\$$min'),
],
),
);
}
Row _buildInfoRow(String title, String? value) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style:
AppTextStyles.bodyMediumMed.copyWith(color: AppColors.grayWhite),
),
Text(
value ?? '',
style:
AppTextStyles.bodyMediumMed.copyWith(color: AppColors.grayWhite),
),
],
);
}
Row _buildPeriodRow() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'earnings_history'.tr(),
textAlign: TextAlign.center,
style:
AppTextStyles.bodyMediumMed.copyWith(color: AppColors.grayWhite),
),
KwPopupMenu(
customButtonBuilder: (context, isOpen) {
return Row(
children: [
Assets.images.icons.calendar.svg(
width: 12,
height: 12,
colorFilter: const ColorFilter.mode(
AppColors.grayWhite, BlendMode.srcIn)),
const Gap(2),
Text(
'period'.tr(),
style: AppTextStyles.bodyMediumMed
.copyWith(color: AppColors.grayWhite),
),
const Gap(12),
AnimatedRotation(
duration: const Duration(milliseconds: 150),
turns: isOpen ? -0.5 : 0,
child: Assets.images.icons.caretDown.svg(
width: 16,
height: 16,
colorFilter: const ColorFilter.mode(
AppColors.darkBgInactive, BlendMode.srcIn),
),
),
],
);
},
menuItems: [
KwPopupMenuItem(title: 'week'.tr(), onTap: () {}),
KwPopupMenuItem(title: 'month'.tr(), onTap: () {}),
KwPopupMenuItem(title: 'range'.tr(), onTap: () {}),
])
],
);
}
}

View File

@@ -0,0 +1,106 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/widgets/ui_kit/dialogs/kw_dialog.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
import 'package:krow/features/earning/domain/entities/earning_dispute_info.dart';
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
import 'package:krow/features/earning/presentation/widget/dispute_form_widget.dart';
class EarningActionWidget extends StatefulWidget {
const EarningActionWidget({
super.key,
required this.earningShiftEntity,
});
final EarningShiftEntity earningShiftEntity;
@override
State<EarningActionWidget> createState() => _EarningActionWidgetState();
}
class _EarningActionWidgetState extends State<EarningActionWidget> {
CrossFadeState _crossFadeState = CrossFadeState.showFirst;
Future<void> _onConfirmPaymentPressed() async {
final bloc = context.read<EarningsBloc>();
await KwDialog.show<void>(
context: context,
icon: Assets.images.icons.moneyRecive,
state: KwDialogState.positive,
title: 'your_earnings_are_in'.tr(),
message: '${'you_earned_for_shift'.tr(args: [
widget.earningShiftEntity.earned.toString(),
widget.earningShiftEntity.eventName,
DateFormat('d MMM yyyy', context.locale.languageCode).format(
widget.earningShiftEntity.eventDate,
)
])}\n\n${'total_earnings_added'.tr(args: [
widget.earningShiftEntity.earned.toString()
])}',
primaryButtonLabel: 'confirm_earning'.tr(),
secondaryButtonLabel: 'dispute_contact_support'.tr(),
onPrimaryButtonPressed: (dialogContext) {
Navigator.pop(dialogContext);
bloc.add(ConfirmStaffEarning(widget.earningShiftEntity.id));
setState(() => _crossFadeState = CrossFadeState.showSecond);
},
onSecondaryButtonPressed: (dialogContext) {
Navigator.pop(dialogContext);
_onDisputePaymentPressed();
},
);
}
Future<void> _onDisputePaymentPressed() async {
final bloc = context.read<EarningsBloc>();
final disputeInfo = EarningDisputeInfo();
await KwDialog.show<void>(
context: context,
icon: Assets.images.icons.moneyRecive,
state: KwDialogState.negative,
title: 'dispute_earnings'.tr(),
message: 'dispute_message'.tr(),
primaryButtonLabel: 'Submit Dispute',
secondaryButtonLabel: 'cancel'.tr(),
child: DisputeFormWidget(disputeInfo: disputeInfo),
onPrimaryButtonPressed: (dialogContext) {
Navigator.pop(dialogContext);
bloc.add(
DisputeStaffEarning(
earningId: widget.earningShiftEntity.id,
reason: disputeInfo.reason,
details: disputeInfo.details,
),
);
setState(() => _crossFadeState = CrossFadeState.showSecond);
},
onSecondaryButtonPressed: (dialogContext) {
Navigator.pop(dialogContext);
},
);
}
@override
Widget build(BuildContext context) {
return AnimatedCrossFade(
duration: Durations.medium4,
crossFadeState: _crossFadeState,
secondChild: const SizedBox.shrink(),
firstChild: Padding(
padding: const EdgeInsets.only(left: 20, right: 20, top: 24),
child: KwButton.outlinedPrimary(
label: 'confirm'.tr(),
onPressed: _onConfirmPaymentPressed,
),
),
);
}
}

View File

@@ -0,0 +1,77 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
class EarningCardCompactWidget extends StatelessWidget {
final EarningShiftEntity earningEntity;
const EarningCardCompactWidget({
super.key,
required this.earningEntity,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: KwBoxDecorations.primaryLight12,
height: 72,
margin: const EdgeInsets.only(bottom: 12, left: 16, right: 16),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildContent(),
],
),
);
}
Widget _buildContent() {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipOval(
child: CachedNetworkImage(
imageUrl: earningEntity.businessImageUrl,
fit: BoxFit.cover,
height: 48,
width: 48,
)),
const Gap(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
const Gap(5),
Text(
earningEntity.skillName,
style: AppTextStyles.bodyMediumMed,
),
const Gap(4),
Text(
earningEntity.businessName,
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray),
),
const Gap(5),
],
),
),
const Gap(24),
Padding(
padding: const EdgeInsets.only(top: 5),
child: Text(
'\$${earningEntity.earned}',
style: AppTextStyles.bodyMediumMed,
),
),
],
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/widgets/shift_payment_step_widget.dart';
import 'package:krow/core/presentation/widgets/shift_total_time_spend_widget.dart';
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
import 'package:krow/features/earning/presentation/widget/list_item/earning_action_widget.dart';
import 'package:krow/features/earning/presentation/widget/list_item/shift_item_header_widget.dart';
class EarningCardWidget extends StatelessWidget {
const EarningCardWidget({
super.key,
required this.earningEntity,
});
final EarningShiftEntity earningEntity;
@override
Widget build(BuildContext context) {
return Container(
decoration: KwBoxDecorations.primaryLight12,
padding: const EdgeInsets.only(top: 24, bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShiftItemHeaderWidget(earningEntity),
ShiftTotalTimeSpendWidget(
startTime: earningEntity.clockIn!,
endTime: earningEntity.clockOut!,
totalBreakTime: earningEntity.totalBreakTime ?? 0,
),
ShiftPaymentStepWidget(
currentIndex: earningEntity.paymentProgressStep,
),
if (earningEntity.status == EarningStatus.confirmedByAdmin)
EarningActionWidget(earningShiftEntity: earningEntity),
],
),
);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
import 'package:krow/features/earning/presentation/widget/list_item/shift_status_label_widget.dart';
class ShiftItemHeaderWidget extends StatelessWidget {
final EarningShiftEntity viewModel;
const ShiftItemHeaderWidget(
this.viewModel, {
super.key,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipOval(
child: CachedNetworkImage(
imageUrl: viewModel.businessImageUrl,
fit: BoxFit.cover,
height: 48,
width: 48,
),
),
EarnedShiftStatusLabelWidget(viewModel),
],
),
const Gap(12),
Row(
children: [
Expanded(
child: Text(
viewModel.skillName,
style: AppTextStyles.bodyMediumMed,
),
),
const Gap(24),
Text(
'\$${viewModel.earned}',
style: AppTextStyles.bodyMediumMed,
)
],
),
const Gap(4),
Text(
viewModel.businessName,
style: AppTextStyles.bodySmallReg.copyWith(
color: AppColors.blackGray,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
class EarnedShiftStatusLabelWidget extends StatefulWidget {
final EarningShiftEntity viewModel;
const EarnedShiftStatusLabelWidget(this.viewModel, {super.key});
@override
State<EarnedShiftStatusLabelWidget> createState() =>
_EarnedShiftStatusLabelWidgetState();
}
class _EarnedShiftStatusLabelWidgetState
extends State<EarnedShiftStatusLabelWidget> {
Color getColor() {
return AppColors.bgColorDark;
// switch (widget.viewModel.status) {
// case EventShiftRoleStaffStatus.assigned:
// return AppColors.primaryBlue;
// case EventShiftRoleStaffStatus.confirmed:
// return AppColors.statusWarning;
// case EventShiftRoleStaffStatus.ongoing:
// return AppColors.statusSuccess;
// case EventShiftRoleStaffStatus.completed:
// return AppColors.bgColorDark;
// case EventShiftRoleStaffStatus.canceledByAdmin:
// case EventShiftRoleStaffStatus.canceledByBusiness:
// case EventShiftRoleStaffStatus.canceledByStaff:
// case EventShiftRoleStaffStatus.requestedReplace:
// return AppColors.statusError;
// }
}
String getText() {
return 'completed'.tr();
// switch (widget.viewModel.status) {
// case EventShiftRoleStaffStatus.assigned:
// return _getAssignedAgo();
// case EventShiftRoleStaffStatus.confirmed:
// return _getStartIn();
// case EventShiftRoleStaffStatus.ongoing:
// return 'Ongoing';
// case EventShiftRoleStaffStatus.completed:
// return 'Completed';
// case EventShiftRoleStaffStatus.canceledByAdmin:
// case EventShiftRoleStaffStatus.canceledByBusiness:
// case EventShiftRoleStaffStatus.canceledByStaff:
// case EventShiftRoleStaffStatus.requestedReplace:
// return 'Canceled';
// }
}
@override
Widget build(BuildContext context) {
return Container(
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: getColor(),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
getText(),
style: AppTextStyles.bodySmallMed
.copyWith(color: AppColors.grayWhite, height: 0.7),
),
),
);
}
}