Merge pull request #291 from Oloodi/208-p0-auth-05-get-started-screen

feat: Client Shifts Screen
This commit is contained in:
Boris-Wilfried
2026-01-25 22:50:32 -05:00
committed by GitHub
35 changed files with 1013 additions and 83 deletions

View File

@@ -704,6 +704,21 @@
"immediate_start": "Immediate start",
"no_experience": "No experience"
}
},
"staff_time_card": {
"title": "Timecard",
"hours_worked": "Hours Worked",
"total_earnings": "Total Earnings",
"shift_history": "Shift History",
"no_shifts": "No shifts for this month",
"hours": "hours",
"per_hr": "/hr",
"status": {
"approved": "Approved",
"disputed": "Disputed",
"paid": "Paid",
"pending": "Pending"
}
}
}

View File

@@ -703,5 +703,20 @@
"immediate_start": "Immediate start",
"no_experience": "No experience"
}
},
"staff_time_card": {
"title": "Tarjeta de tiempo",
"hours_worked": "Horas trabajadas",
"total_earnings": "Ganancias totales",
"shift_history": "Historial de turnos",
"no_shifts": "No hay turnos para este mes",
"hours": "horas",
"per_hr": "/hr",
"status": {
"approved": "Aprobado",
"disputed": "Disputado",
"paid": "Pagado",
"pending": "Pendiente"
}
}
}

View File

@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 2
/// Strings: 1004 (502 per locale)
/// Strings: 1026 (513 per locale)
///
/// Built on 2026-01-25 at 22:04 UTC
/// Built on 2026-01-26 at 02:23 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View File

@@ -56,6 +56,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final TranslationsStaffCertificatesEn staff_certificates = TranslationsStaffCertificatesEn._(_root);
late final TranslationsStaffProfileAttireEn staff_profile_attire = TranslationsStaffProfileAttireEn._(_root);
late final TranslationsStaffShiftsEn staff_shifts = TranslationsStaffShiftsEn._(_root);
late final TranslationsStaffTimeCardEn staff_time_card = TranslationsStaffTimeCardEn._(_root);
}
// Path: common
@@ -387,6 +388,38 @@ class TranslationsStaffShiftsEn {
late final TranslationsStaffShiftsTagsEn tags = TranslationsStaffShiftsTagsEn._(_root);
}
// Path: staff_time_card
class TranslationsStaffTimeCardEn {
TranslationsStaffTimeCardEn._(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Timecard'
String get title => 'Timecard';
/// en: 'Hours Worked'
String get hours_worked => 'Hours Worked';
/// en: 'Total Earnings'
String get total_earnings => 'Total Earnings';
/// en: 'Shift History'
String get shift_history => 'Shift History';
/// en: 'No shifts for this month'
String get no_shifts => 'No shifts for this month';
/// en: 'hours'
String get hours => 'hours';
/// en: '/hr'
String get per_hr => '/hr';
late final TranslationsStaffTimeCardStatusEn status = TranslationsStaffTimeCardStatusEn._(_root);
}
// Path: staff_authentication.get_started_page
class TranslationsStaffAuthenticationGetStartedPageEn {
TranslationsStaffAuthenticationGetStartedPageEn._(this._root);
@@ -1691,6 +1724,27 @@ class TranslationsStaffShiftsTagsEn {
String get no_experience => 'No experience';
}
// Path: staff_time_card.status
class TranslationsStaffTimeCardStatusEn {
TranslationsStaffTimeCardStatusEn._(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Approved'
String get approved => 'Approved';
/// en: 'Disputed'
String get disputed => 'Disputed';
/// en: 'Paid'
String get paid => 'Paid';
/// en: 'Pending'
String get pending => 'Pending';
}
// Path: staff_authentication.profile_setup_page.steps
class TranslationsStaffAuthenticationProfileSetupPageStepsEn {
TranslationsStaffAuthenticationProfileSetupPageStepsEn._(this._root);
@@ -3113,6 +3167,19 @@ extension on Translations {
'staff_shifts.details.pending_time' => ({required Object time}) => 'Pending ${time} ago',
'staff_shifts.tags.immediate_start' => 'Immediate start',
'staff_shifts.tags.no_experience' => 'No experience',
'staff_time_card.title' => 'Timecard',
'staff_time_card.hours_worked' => 'Hours Worked',
'staff_time_card.total_earnings' => 'Total Earnings',
'staff_time_card.shift_history' => 'Shift History',
'staff_time_card.no_shifts' => 'No shifts for this month',
'staff_time_card.hours' => 'hours',
'staff_time_card.per_hr' => '/hr',
'staff_time_card.status.approved' => 'Approved',
'staff_time_card.status.disputed' => 'Disputed',
'staff_time_card.status.paid' => 'Paid',
_ => null,
} ?? switch (path) {
'staff_time_card.status.pending' => 'Pending',
_ => null,
};
}

View File

@@ -53,6 +53,7 @@ class TranslationsEs with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsStaffCertificatesEs staff_certificates = _TranslationsStaffCertificatesEs._(_root);
@override late final _TranslationsStaffProfileAttireEs staff_profile_attire = _TranslationsStaffProfileAttireEs._(_root);
@override late final _TranslationsStaffShiftsEs staff_shifts = _TranslationsStaffShiftsEs._(_root);
@override late final _TranslationsStaffTimeCardEs staff_time_card = _TranslationsStaffTimeCardEs._(_root);
}
// Path: common
@@ -292,6 +293,23 @@ class _TranslationsStaffShiftsEs implements TranslationsStaffShiftsEn {
@override late final _TranslationsStaffShiftsTagsEs tags = _TranslationsStaffShiftsTagsEs._(_root);
}
// Path: staff_time_card
class _TranslationsStaffTimeCardEs implements TranslationsStaffTimeCardEn {
_TranslationsStaffTimeCardEs._(this._root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Tarjeta de tiempo';
@override String get hours_worked => 'Horas trabajadas';
@override String get total_earnings => 'Ganancias totales';
@override String get shift_history => 'Historial de turnos';
@override String get no_shifts => 'No hay turnos para este mes';
@override String get hours => 'horas';
@override String get per_hr => '/hr';
@override late final _TranslationsStaffTimeCardStatusEs status = _TranslationsStaffTimeCardStatusEs._(_root);
}
// Path: staff_authentication.get_started_page
class _TranslationsStaffAuthenticationGetStartedPageEs implements TranslationsStaffAuthenticationGetStartedPageEn {
_TranslationsStaffAuthenticationGetStartedPageEs._(this._root);
@@ -1049,6 +1067,19 @@ class _TranslationsStaffShiftsTagsEs implements TranslationsStaffShiftsTagsEn {
@override String get no_experience => 'No experience';
}
// Path: staff_time_card.status
class _TranslationsStaffTimeCardStatusEs implements TranslationsStaffTimeCardStatusEn {
_TranslationsStaffTimeCardStatusEs._(this._root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get approved => 'Aprobado';
@override String get disputed => 'Disputado';
@override String get paid => 'Pagado';
@override String get pending => 'Pendiente';
}
// Path: staff_authentication.profile_setup_page.steps
class _TranslationsStaffAuthenticationProfileSetupPageStepsEs implements TranslationsStaffAuthenticationProfileSetupPageStepsEn {
_TranslationsStaffAuthenticationProfileSetupPageStepsEs._(this._root);
@@ -2091,6 +2122,19 @@ extension on TranslationsEs {
'staff_shifts.details.pending_time' => ({required Object time}) => 'Pending ${time} ago',
'staff_shifts.tags.immediate_start' => 'Immediate start',
'staff_shifts.tags.no_experience' => 'No experience',
'staff_time_card.title' => 'Tarjeta de tiempo',
'staff_time_card.hours_worked' => 'Horas trabajadas',
'staff_time_card.total_earnings' => 'Ganancias totales',
'staff_time_card.shift_history' => 'Historial de turnos',
'staff_time_card.no_shifts' => 'No hay turnos para este mes',
'staff_time_card.hours' => 'horas',
'staff_time_card.per_hr' => '/hr',
'staff_time_card.status.approved' => 'Aprobado',
'staff_time_card.status.disputed' => 'Disputado',
'staff_time_card.status.paid' => 'Pagado',
_ => null,
} ?? switch (path) {
'staff_time_card.status.pending' => 'Pendiente',
_ => null,
};
}

View File

@@ -18,6 +18,7 @@ export 'src/entities/business/business.dart';
export 'src/entities/business/business_setting.dart';
export 'src/entities/business/hub.dart';
export 'src/entities/business/hub_department.dart';
export 'src/entities/business/vendor.dart';
// Events & Assignments
export 'src/entities/events/event.dart';

View File

@@ -16,6 +16,6 @@ extension AuthNavigator on IModularNavigator {
/// Navigates to the worker home (external to this module).
void pushWorkerHome() {
pushNamed('/worker-main/home/');
pushNamed('/worker-main/home');
}
}

View File

@@ -2,9 +2,16 @@ import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/payments_repository.dart';
/// Implementation of [PaymentsRepository].
///
/// This class handles the retrieval of payment data by delegating to the
/// [FinancialRepositoryMock] from the data connect package.
///
/// It resides in the data layer and depends on the domain layer for the repository interface.
class PaymentsRepositoryImpl implements PaymentsRepository {
final FinancialRepositoryMock financialRepository;
/// Creates a [PaymentsRepositoryImpl] with the given [financialRepository].
PaymentsRepositoryImpl({required this.financialRepository});
@override

View File

@@ -0,0 +1,12 @@
import 'package:krow_core/core.dart';
/// Arguments for getting payment history.
class GetPaymentHistoryArguments extends UseCaseArgument {
/// The period to filter by (e.g., "monthly", "weekly").
final String period;
const GetPaymentHistoryArguments(this.period);
@override
List<Object?> get props => [period];
}

View File

@@ -1,23 +0,0 @@
import 'package:equatable/equatable.dart';
class PaymentSummary extends Equatable {
final double weeklyEarnings;
final double monthlyEarnings;
final double pendingEarnings;
final double totalEarnings;
const PaymentSummary({
required this.weeklyEarnings,
required this.monthlyEarnings,
required this.pendingEarnings,
required this.totalEarnings,
});
@override
List<Object?> get props => [
weeklyEarnings,
monthlyEarnings,
pendingEarnings,
totalEarnings,
];
}

View File

@@ -1,41 +0,0 @@
import 'package:equatable/equatable.dart';
class PaymentTransaction extends Equatable {
final String id;
final String title;
final String location;
final String address;
final DateTime date;
final String workedTime;
final double amount;
final String status;
final int hours;
final double rate;
const PaymentTransaction({
required this.id,
required this.title,
required this.location,
required this.address,
required this.date,
required this.workedTime,
required this.amount,
required this.status,
required this.hours,
required this.rate,
});
@override
List<Object?> get props => [
id,
title,
location,
address,
date,
workedTime,
amount,
status,
hours,
rate,
];
}

View File

@@ -1,6 +1,10 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for Payments feature.
///
/// Defines the contract for data access related to staff payments.
/// Implementations of this interface should reside in the data layer.
abstract class PaymentsRepository {
/// Fetches the list of payments.
/// Fetches the list of payments for the current staff member.
Future<List<StaffPayment>> getPayments();
}

View File

@@ -1,14 +1,19 @@
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';
class GetPaymentHistoryUseCase extends UseCase<String, List<StaffPayment>> {
/// 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>> {
final PaymentsRepository repository;
/// Creates a [GetPaymentHistoryUseCase].
GetPaymentHistoryUseCase(this.repository);
@override
Future<List<StaffPayment>> call(String period) async {
Future<List<StaffPayment>> call(GetPaymentHistoryArguments arguments) async {
// TODO: Implement filtering by period
return await repository.getPayments();
}

View File

@@ -2,9 +2,14 @@ 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.
///
/// It fetches the full list of payments, which ideally should be aggregated
/// by the presentation layer or a specific data source method.
class GetPaymentSummaryUseCase extends NoInputUseCase<List<StaffPayment>> {
final PaymentsRepository repository;
/// Creates a [GetPaymentSummaryUseCase].
GetPaymentSummaryUseCase(this.repository);
@override

View File

@@ -1,5 +1,6 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../../domain/arguments/get_payment_history_arguments.dart';
import '../../../domain/usecases/get_payment_summary_usecase.dart';
import '../../../domain/usecases/get_payment_history_usecase.dart';
import '../../models/payment_stats.dart';
@@ -27,7 +28,9 @@ class PaymentsBloc extends Bloc<PaymentsEvent, PaymentsState> {
final List<StaffPayment> allPayments = await getPaymentSummary();
final PaymentStats stats = _calculateStats(allPayments);
final List<StaffPayment> history = await getPaymentHistory('week');
final List<StaffPayment> history = await getPaymentHistory(
const GetPaymentHistoryArguments('week'),
);
emit(PaymentsLoaded(
summary: stats,
history: history,
@@ -44,10 +47,10 @@ class PaymentsBloc extends Bloc<PaymentsEvent, PaymentsState> {
) async {
final PaymentsState currentState = state;
if (currentState is PaymentsLoaded) {
if (currentState.activePeriod == event.period) return;
try {
final List<StaffPayment> newHistory = await getPaymentHistory(event.period);
final List<StaffPayment> newHistory = await getPaymentHistory(
GetPaymentHistoryArguments(event.period),
);
emit(currentState.copyWith(
history: newHistory,
activePeriod: event.period,

View File

@@ -63,7 +63,7 @@ extension ProfileNavigator on IModularNavigator {
/// Navigates to the timecard page.
void pushTimecard() {
pushNamed('/time-card');
pushNamed('../time-card');
}
/// Navigates to the FAQs page.

View File

@@ -0,0 +1,64 @@
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/entities/time_card.dart';
import '../../domain/repositories/time_card_repository.dart';
class TimeCardRepositoryImpl implements TimeCardRepository {
final ShiftsRepositoryMock shiftsRepository;
TimeCardRepositoryImpl({required this.shiftsRepository});
@override
Future<List<TimeCard>> getTimeCards(DateTime month) async {
// We use ShiftsRepositoryMock as it contains shift details (title, client, etc).
// In a real app, we might query 'TimeCards' directly or join Shift+Payment.
// For now, we simulate TimeCards from Shifts.
final List<Shift> shifts = await shiftsRepository.getMyShifts();
// Map to TimeCard and filter by the requested month.
return shifts
.map((Shift shift) {
double hours = 8.0;
// Simple parse for mock
try {
// Assuming HH:mm
final int start = int.parse(shift.startTime.split(':')[0]);
final int end = int.parse(shift.endTime.split(':')[0]);
hours = (end - start).abs().toDouble();
if (hours == 0) hours = 8.0;
} catch (_) {}
return TimeCard(
id: shift.id,
shiftTitle: shift.title,
clientName: shift.clientName,
date: DateTime.tryParse(shift.date) ?? DateTime.now(),
startTime: shift.startTime,
endTime: shift.endTime,
totalHours: hours,
hourlyRate: shift.hourlyRate,
totalPay: hours * shift.hourlyRate,
status: _mapStatus(shift.status),
location: shift.location,
);
})
.where((TimeCard tc) =>
tc.date.year == month.year && tc.date.month == month.month)
.toList();
}
TimeCardStatus _mapStatus(String? shiftStatus) {
if (shiftStatus == null) return TimeCardStatus.pending;
// Map shift status to TimeCardStatus
switch (shiftStatus.toLowerCase()) {
case 'confirmed':
return TimeCardStatus.pending;
case 'completed':
return TimeCardStatus.approved;
case 'paid':
return TimeCardStatus.paid;
default:
return TimeCardStatus.pending;
}
}
}

View File

@@ -0,0 +1,12 @@
import 'package:krow_core/core.dart';
/// Arguments for the GetTimeCardsUseCase.
class GetTimeCardsArguments extends UseCaseArgument {
/// The month to fetch time cards for.
final DateTime month;
const GetTimeCardsArguments(this.month);
@override
List<Object?> get props => [month];
}

View File

@@ -0,0 +1,56 @@
import 'package:equatable/equatable.dart';
enum TimeCardStatus {
pending,
approved,
paid,
disputed;
bool get isApproved => this == TimeCardStatus.approved;
bool get isPaid => this == TimeCardStatus.paid;
bool get isDisputed => this == TimeCardStatus.disputed;
bool get isPending => this == TimeCardStatus.pending;
}
class TimeCard extends Equatable {
final String id;
final String shiftTitle;
final String clientName;
final DateTime date;
final String startTime;
final String endTime;
final double totalHours;
final double hourlyRate;
final double totalPay;
final TimeCardStatus status;
final String? location;
const TimeCard({
required this.id,
required this.shiftTitle,
required this.clientName,
required this.date,
required this.startTime,
required this.endTime,
required this.totalHours,
required this.hourlyRate,
required this.totalPay,
required this.status,
this.location,
});
@override
List<Object?> get props => [
id,
shiftTitle,
clientName,
date,
startTime,
endTime,
totalHours,
hourlyRate,
totalPay,
status,
location,
];
}

View File

@@ -0,0 +1,12 @@
import '../entities/time_card.dart';
/// Repository interface for accessing time card data.
///
/// This repository handles fetching time cards and related financial data
/// for the staff member.
abstract class TimeCardRepository {
/// Retrieves a list of [TimeCard]s for a specific month.
///
/// [month] is a [DateTime] representing the month to filter by.
Future<List<TimeCard>> getTimeCards(DateTime month);
}

View File

@@ -0,0 +1,19 @@
import 'package:krow_core/core.dart';
import '../../domain/entities/time_card.dart';
import '../arguments/get_time_cards_arguments.dart';
import '../repositories/time_card_repository.dart';
/// UseCase to retrieve time cards for a given month.
class GetTimeCardsUseCase extends UseCase<GetTimeCardsArguments, List<TimeCard>> {
final TimeCardRepository repository;
GetTimeCardsUseCase(this.repository);
/// Executes the use case.
///
/// Returns a list of [TimeCard]s for the specified month in [arguments].
@override
Future<List<TimeCard>> call(GetTimeCardsArguments arguments) {
return repository.getTimeCards(arguments.month);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import '../../domain/entities/time_card.dart';
import '../../domain/arguments/get_time_cards_arguments.dart';
import '../../domain/usecases/get_time_cards_usecase.dart';
part 'time_card_event.dart';
part 'time_card_state.dart';
/// BLoC to manage Time Card state.
class TimeCardBloc extends Bloc<TimeCardEvent, TimeCardState> {
final GetTimeCardsUseCase getTimeCards;
TimeCardBloc({required this.getTimeCards}) : super(TimeCardInitial()) {
on<LoadTimeCards>(_onLoadTimeCards);
on<ChangeMonth>(_onChangeMonth);
}
/// Handles fetching time cards for the requested month.
Future<void> _onLoadTimeCards(LoadTimeCards event, Emitter<TimeCardState> emit) async {
emit(TimeCardLoading());
try {
final List<TimeCard> cards = await getTimeCards(GetTimeCardsArguments(event.month));
final double totalHours = cards.fold(0.0, (double sum, TimeCard t) => sum + t.totalHours);
final double totalEarnings = cards.fold(0.0, (double sum, TimeCard t) => sum + t.totalPay);
emit(TimeCardLoaded(
timeCards: cards,
selectedMonth: event.month,
totalHours: totalHours,
totalEarnings: totalEarnings,
));
} catch (e) {
emit(TimeCardError(e.toString()));
}
}
Future<void> _onChangeMonth(ChangeMonth event, Emitter<TimeCardState> emit) async {
add(LoadTimeCards(event.month));
}
}

View File

@@ -0,0 +1,23 @@
part of 'time_card_bloc.dart';
abstract class TimeCardEvent extends Equatable {
const TimeCardEvent();
@override
List<Object?> get props => [];
}
class LoadTimeCards extends TimeCardEvent {
final DateTime month;
const LoadTimeCards(this.month);
@override
List<Object?> get props => [month];
}
class ChangeMonth extends TimeCardEvent {
final DateTime month;
const ChangeMonth(this.month);
@override
List<Object?> get props => [month];
}

View File

@@ -0,0 +1,32 @@
part of 'time_card_bloc.dart';
abstract class TimeCardState extends Equatable {
const TimeCardState();
@override
List<Object?> get props => [];
}
class TimeCardInitial extends TimeCardState {}
class TimeCardLoading extends TimeCardState {}
class TimeCardLoaded extends TimeCardState {
final List<TimeCard> timeCards;
final DateTime selectedMonth;
final double totalHours;
final double totalEarnings;
const TimeCardLoaded({
required this.timeCards,
required this.selectedMonth,
required this.totalHours,
required this.totalEarnings,
});
@override
List<Object?> get props => [timeCards, selectedMonth, totalHours, totalEarnings];
}
class TimeCardError extends TimeCardState {
final String message;
const TimeCardError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import '../blocs/time_card_bloc.dart';
import '../widgets/month_selector.dart';
import '../widgets/shift_history_list.dart';
import '../widgets/time_card_summary.dart';
/// The main page for displaying the staff time card.
class TimeCardPage extends StatefulWidget {
const TimeCardPage({super.key});
@override
State<TimeCardPage> createState() => _TimeCardPageState();
}
class _TimeCardPageState extends State<TimeCardPage> {
final TimeCardBloc _bloc = Modular.get<TimeCardBloc>();
@override
void initState() {
super.initState();
_bloc.add(LoadTimeCards(DateTime.now()));
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _bloc,
child: Scaffold(
backgroundColor: UiColors.bgPrimary,
appBar: AppBar(
backgroundColor: UiColors.bgPopup,
elevation: 0,
leading: IconButton(
icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconSecondary),
onPressed: () => Modular.to.pop(),
),
title: Text(
t.staff_time_card.title,
style: UiTypography.headline4m.copyWith(
color: UiColors.textPrimary,
),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1.0),
child: Container(color: UiColors.border, height: 1.0),
),
),
body: BlocBuilder<TimeCardBloc, TimeCardState>(
builder: (context, state) {
if (state is TimeCardLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is TimeCardError) {
return Center(child: Text('Error: ${state.message}'));
} else if (state is TimeCardLoaded) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
vertical: UiConstants.space6,
),
child: Column(
children: [
MonthSelector(
selectedDate: state.selectedMonth,
onPreviousMonth: () => _bloc.add(ChangeMonth(
DateTime(state.selectedMonth.year, state.selectedMonth.month - 1),
)),
onNextMonth: () => _bloc.add(ChangeMonth(
DateTime(state.selectedMonth.year, state.selectedMonth.month + 1),
)),
),
const SizedBox(height: UiConstants.space6),
TimeCardSummary(
totalHours: state.totalHours,
totalEarnings: state.totalEarnings,
),
const SizedBox(height: UiConstants.space6),
ShiftHistoryList(timesheets: state.timeCards),
],
),
);
}
return const SizedBox.shrink();
},
),
),
);
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:design_system/design_system.dart';
/// A widget that allows the user to navigate between months.
class MonthSelector extends StatelessWidget {
final DateTime selectedDate;
final VoidCallback onPreviousMonth;
final VoidCallback onNextMonth;
const MonthSelector({
super.key,
required this.selectedDate,
required this.onPreviousMonth,
required this.onNextMonth,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space1),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconSecondary),
onPressed: onPreviousMonth,
),
Text(
DateFormat('MMM yyyy').format(selectedDate),
style: UiTypography.title2b.copyWith(
color: UiColors.textPrimary,
),
),
IconButton(
icon: const Icon(
UiIcons.chevronRight,
color: UiColors.iconSecondary,
),
onPressed: onNextMonth,
),
],
),
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
import '../../domain/entities/time_card.dart';
import 'timesheet_card.dart';
/// Displays the list of shift history or an empty state.
class ShiftHistoryList extends StatelessWidget {
final List<TimeCard> timesheets;
const ShiftHistoryList({super.key, required this.timesheets});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.staff_time_card.shift_history,
style: UiTypography.title2b.copyWith(
color: UiColors.textPrimary,
),
),
const SizedBox(height: UiConstants.space3),
if (timesheets.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space12),
child: Column(
children: [
const Icon(UiIcons.clock, size: 48, color: UiColors.iconSecondary),
const SizedBox(height: UiConstants.space3),
Text(
t.staff_time_card.no_shifts,
style: UiTypography.body1r.copyWith(color: UiColors.textSecondary),
),
],
),
),
)
else
...timesheets.map((ts) => TimesheetCard(timesheet: ts)),
],
);
}
}

View File

@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
/// Displays the total hours worked and total earnings for the selected month.
class TimeCardSummary extends StatelessWidget {
final double totalHours;
final double totalEarnings;
const TimeCardSummary({
super.key,
required this.totalHours,
required this.totalEarnings,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _SummaryCard(
icon: UiIcons.clock,
label: t.staff_time_card.hours_worked,
value: totalHours.toStringAsFixed(1),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: _SummaryCard(
icon: UiIcons.dollar,
label: t.staff_time_card.total_earnings,
value: '\$${totalEarnings.toStringAsFixed(2)}',
),
),
],
);
}
}
class _SummaryCard extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const _SummaryCard({
required this.icon,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 16, color: UiColors.primary),
const SizedBox(width: UiConstants.space2),
Text(
label,
style: UiTypography.body2m.copyWith(color: UiColors.textSecondary),
),
],
),
const SizedBox(height: UiConstants.space2),
Text(
value,
style: UiTypography.headline1m.copyWith(
color: UiColors.textPrimary,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
import '../../domain/entities/time_card.dart';
/// A card widget displaying details of a single shift/timecard.
class TimesheetCard extends StatelessWidget {
final TimeCard timesheet;
const TimesheetCard({super.key, required this.timesheet});
@override
Widget build(BuildContext context) {
final status = timesheet.status;
Color statusBg;
Color statusColor;
String statusText;
switch (status) {
case TimeCardStatus.approved:
statusBg = UiColors.textSuccess.withOpacity(0.12);
statusColor = UiColors.textSuccess;
statusText = t.staff_time_card.status.approved;
break;
case TimeCardStatus.disputed:
statusBg = UiColors.destructive.withOpacity(0.12);
statusColor = UiColors.destructive;
statusText = t.staff_time_card.status.disputed;
break;
case TimeCardStatus.paid:
statusBg = UiColors.primary.withOpacity(0.12);
statusColor = UiColors.primary;
statusText = t.staff_time_card.status.paid;
break;
case TimeCardStatus.pending:
statusBg = UiColors.textWarning.withOpacity(0.12);
statusColor = UiColors.textWarning;
statusText = t.staff_time_card.status.pending;
break;
}
final dateStr = DateFormat('EEE, MMM d').format(timesheet.date);
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
timesheet.shiftTitle,
style: UiTypography.body1m.copyWith(
color: UiColors.textPrimary,
),
),
Text(
timesheet.clientName,
style: UiTypography.body2r.copyWith(
color: UiColors.textSecondary,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: statusBg,
borderRadius: UiConstants.radiusFull,
),
child: Text(
statusText,
style: UiTypography.titleUppercase4b.copyWith(
color: statusColor,
),
),
),
],
),
const SizedBox(height: UiConstants.space3),
Wrap(
spacing: UiConstants.space3,
runSpacing: UiConstants.space1,
children: [
_IconText(icon: UiIcons.calendar, text: dateStr),
_IconText(
icon: UiIcons.clock,
text: '${_formatTime(timesheet.startTime)} - ${_formatTime(timesheet.endTime)}',
),
if (timesheet.location != null)
_IconText(icon: UiIcons.mapPin, text: timesheet.location!),
],
),
const SizedBox(height: UiConstants.space3),
Container(
padding: const EdgeInsets.only(top: UiConstants.space3),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: UiColors.border)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${timesheet.totalHours.toStringAsFixed(1)} ${t.staff_time_card.hours} @ \$${timesheet.hourlyRate.toStringAsFixed(2)}${t.staff_time_card.per_hr}',
style: UiTypography.body2r.copyWith(color: UiColors.textSecondary),
),
Text(
'\$${timesheet.totalPay.toStringAsFixed(2)}',
style: UiTypography.title2b.copyWith(
color: UiColors.primary,
),
),
],
),
),
],
),
);
}
// Helper to safely format time strings like "HH:mm"
String _formatTime(String t) {
if (t.isEmpty) return '--:--';
try {
final parts = t.split(':');
if (parts.length >= 2) {
final dt = DateTime(2000, 1, 1, int.parse(parts[0]), int.parse(parts[1]));
return DateFormat('h:mm a').format(dt);
}
return t;
} catch (_) {
return t;
}
}
}
class _IconText extends StatelessWidget {
final IconData icon;
final String text;
const _IconText({required this.icon, required this.text});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space1),
Text(
text,
style: UiTypography.body2r.copyWith(color: UiColors.textSecondary),
),
],
);
}
}

View File

@@ -0,0 +1,35 @@
library staff_time_card;
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'data/repositories_impl/time_card_repository_impl.dart';
import 'domain/repositories/time_card_repository.dart';
import 'domain/usecases/get_time_cards_usecase.dart';
import 'presentation/blocs/time_card_bloc.dart';
import 'presentation/pages/time_card_page.dart';
export 'presentation/pages/time_card_page.dart';
class StaffTimeCardModule extends Module {
@override
void binds(Injector i) {
// Repositories
// In a real app, ShiftsRepository might be provided by a Core Data Module.
// For this self-contained feature/mock, we instantiate it here if not available globally.
// Assuming we need a local instance for the mock to work or it's stateless.
i.add<ShiftsRepositoryMock>(ShiftsRepositoryMock.new);
i.add<TimeCardRepository>(TimeCardRepositoryImpl.new);
// UseCases
i.add<GetTimeCardsUseCase>(GetTimeCardsUseCase.new);
// Blocs
i.add<TimeCardBloc>(TimeCardBloc.new);
}
@override
void routes(RouteManager r) {
r.child('/', child: (context) => const TimeCardPage());
}
}

View File

@@ -0,0 +1 @@
export 'src/staff_time_card_module.dart';

View File

@@ -0,0 +1,31 @@
name: staff_time_card
description: Staff Time Card Feature
version: 0.0.1
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
flutter: ">=3.0.0"
dependencies:
flutter:
sdk: flutter
flutter_modular: ^6.3.2
flutter_bloc: ^8.1.3
equatable: ^2.0.5
lucide_icons: ^0.257.0
intl: ^0.20.0
design_system:
path: ../../../../../design_system
core_localization:
path: ../../../../../core_localization
krow_core:
path: ../../../../../core
krow_domain:
path: ../../../../../domain
krow_data_connect:
path: ../../../../../data_connect
dev_dependencies:
flutter_test:
sdk: flutter

View File

@@ -12,6 +12,7 @@ import 'package:staff_certificates/staff_certificates.dart';
import 'package:staff_attire/staff_attire.dart';
import 'package:staff_shifts/staff_shifts.dart';
import 'package:staff_payments/staff_payements.dart';
import 'package:staff_time_card/staff_time_card.dart';
import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart';
import 'package:staff_main/src/presentation/constants/staff_main_routes.dart';
@@ -30,27 +31,58 @@ class StaffMainModule extends Module {
'/',
child: (BuildContext context) => const StaffMainPage(),
children: <ParallelRoute<dynamic>>[
ModuleRoute<dynamic>(
/// TOODO: FEATURE_NOT_YET_IMPLEMENTED
/// Replace PlaceholderPage with actual module when implemented
ChildRoute<dynamic>(
StaffMainRoutes.shifts,
module: StaffShiftsModule(),
child: (BuildContext context) => const PlaceholderPage(title: 'Shifts'),
),
ModuleRoute<dynamic>(
// ModuleRoute<dynamic>(
// StaffMainRoutes.shifts,
// module: StaffShiftsModule(),
// ),
/// TOODO: FEATURE_NOT_YET_IMPLEMENTED
/// Replace PlaceholderPage with actual module when implemented
ChildRoute<dynamic>(
StaffMainRoutes.payments,
module: StaffPaymentsModule(),
child: (BuildContext context) => const PlaceholderPage(title: 'Payments'),
),
ModuleRoute<dynamic>(
// ModuleRoute<dynamic>(
// StaffMainRoutes.payments,
// module: StaffPaymentsModule(),
// ),
/// TOODO: FEATURE_NOT_YET_IMPLEMENTED
/// Replace PlaceholderPage with actual module when implemented
ChildRoute<dynamic>(
StaffMainRoutes.home,
module: StaffHomeModule(),
child: (BuildContext context) => const PlaceholderPage(title: 'Home'),
),
// ModuleRoute<dynamic>(
// StaffMainRoutes.home,
// module: StaffHomeModule(),
// ),
ChildRoute<dynamic>(
StaffMainRoutes.clockIn,
child: (BuildContext context) =>
const PlaceholderPage(title: 'Clock In'),
),
ModuleRoute<dynamic>(
/// TODO: FEATURE_NOT_YET_IMPLEMENTED
/// Replace PlaceholderPage with actual module when implemented
ChildRoute<dynamic>(
StaffMainRoutes.profile,
module: StaffProfileModule(),
child: (BuildContext context) => const PlaceholderPage(title: 'Profile'),
),
// ModuleRoute<dynamic>(
// StaffMainRoutes.profile,
// module: StaffProfileModule(),
// ),
],
);
r.module('/onboarding', module: StaffProfileInfoModule());
@@ -67,5 +99,9 @@ class StaffMainModule extends Module {
'/certificates',
module: StaffCertificatesModule(),
);
r.module(
'/time-card',
module: StaffTimeCardModule(),
);
}
}

View File

@@ -47,6 +47,8 @@ dependencies:
path: ../shifts
staff_payments:
path: ../payments
staff_time_card:
path: ../profile_sections/finances/time_card
dev_dependencies:
flutter_test:

View File

@@ -1121,6 +1121,13 @@ packages:
relative: true
source: path
version: "0.0.1"
staff_time_card:
dependency: transitive
description:
path: "packages/features/staff/profile_sections/finances/time_card"
relative: true
source: path
version: "0.0.1"
stream_channel:
dependency: transitive
description: