Merge pull request #336 from Oloodi/312-feature-integrate-google-maps-places-autocomplete-for-hub-address-validation
Continuation of the mobile app developement
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
name: krowwithus_client
|
name: krowwithus_client
|
||||||
description: "Krow Client Application"
|
description: "Krow Client Application"
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 0.0.1-M3+2
|
version: 0.0.1-M3+3
|
||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -517,6 +517,8 @@
|
|||||||
"secure_subtitle": "Your account details are encrypted and safe.",
|
"secure_subtitle": "Your account details are encrypted and safe.",
|
||||||
"primary": "Primary",
|
"primary": "Primary",
|
||||||
"add_new_account": "Add New Account",
|
"add_new_account": "Add New Account",
|
||||||
|
"bank_name": "Bank Name",
|
||||||
|
"bank_hint": "Enter bank name",
|
||||||
"routing_number": "Routing Number",
|
"routing_number": "Routing Number",
|
||||||
"routing_hint": "Enter routing number",
|
"routing_hint": "Enter routing number",
|
||||||
"account_number": "Account Number",
|
"account_number": "Account Number",
|
||||||
@@ -526,7 +528,8 @@
|
|||||||
"savings": "Savings",
|
"savings": "Savings",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"account_ending": "Ending in $last4"
|
"account_ending": "Ending in $last4",
|
||||||
|
"account_added_success": "Bank account added successfully!"
|
||||||
},
|
},
|
||||||
"logout": {
|
"logout": {
|
||||||
"button": "Sign Out"
|
"button": "Sign Out"
|
||||||
|
|||||||
@@ -515,6 +515,8 @@
|
|||||||
"secure_title": "Seguro y Cifrado",
|
"secure_title": "Seguro y Cifrado",
|
||||||
"secure_subtitle": "Su información bancaria está cifrada y almacenada de forma segura. Nunca compartimos sus detalles.",
|
"secure_subtitle": "Su información bancaria está cifrada y almacenada de forma segura. Nunca compartimos sus detalles.",
|
||||||
"add_new_account": "Agregar Nueva Cuenta",
|
"add_new_account": "Agregar Nueva Cuenta",
|
||||||
|
"bank_name": "Nombre del Banco",
|
||||||
|
"bank_hint": "Ingrese nombre del banco",
|
||||||
"routing_number": "Número de Ruta",
|
"routing_number": "Número de Ruta",
|
||||||
"routing_hint": "9 dígitos",
|
"routing_hint": "9 dígitos",
|
||||||
"account_number": "Número de Cuenta",
|
"account_number": "Número de Cuenta",
|
||||||
@@ -525,7 +527,8 @@
|
|||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"primary": "Principal",
|
"primary": "Principal",
|
||||||
"account_ending": "Termina en $last4"
|
"account_ending": "Termina en $last4",
|
||||||
|
"account_added_success": "¡Cuenta bancaria agregada exitosamente!"
|
||||||
},
|
},
|
||||||
"logout": {
|
"logout": {
|
||||||
"button": "Cerrar Sesión"
|
"button": "Cerrar Sesión"
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 2
|
/// Locales: 2
|
||||||
/// Strings: 1038 (519 per locale)
|
/// Strings: 1044 (522 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2026-01-29 at 15:50 UTC
|
/// Built on 2026-01-30 at 23:09 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|||||||
@@ -2253,6 +2253,12 @@ class TranslationsStaffProfileBankAccountPageEn {
|
|||||||
/// en: 'Add New Account'
|
/// en: 'Add New Account'
|
||||||
String get add_new_account => 'Add New Account';
|
String get add_new_account => 'Add New Account';
|
||||||
|
|
||||||
|
/// en: 'Bank Name'
|
||||||
|
String get bank_name => 'Bank Name';
|
||||||
|
|
||||||
|
/// en: 'Enter bank name'
|
||||||
|
String get bank_hint => 'Enter bank name';
|
||||||
|
|
||||||
/// en: 'Routing Number'
|
/// en: 'Routing Number'
|
||||||
String get routing_number => 'Routing Number';
|
String get routing_number => 'Routing Number';
|
||||||
|
|
||||||
@@ -2282,6 +2288,9 @@ class TranslationsStaffProfileBankAccountPageEn {
|
|||||||
|
|
||||||
/// en: 'Ending in $last4'
|
/// en: 'Ending in $last4'
|
||||||
String account_ending({required Object last4}) => 'Ending in ${last4}';
|
String account_ending({required Object last4}) => 'Ending in ${last4}';
|
||||||
|
|
||||||
|
/// en: 'Bank account added successfully!'
|
||||||
|
String get account_added_success => 'Bank account added successfully!';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: staff.profile.logout
|
// Path: staff.profile.logout
|
||||||
@@ -3058,6 +3067,8 @@ extension on Translations {
|
|||||||
'staff.profile.bank_account_page.secure_subtitle' => 'Your account details are encrypted and safe.',
|
'staff.profile.bank_account_page.secure_subtitle' => 'Your account details are encrypted and safe.',
|
||||||
'staff.profile.bank_account_page.primary' => 'Primary',
|
'staff.profile.bank_account_page.primary' => 'Primary',
|
||||||
'staff.profile.bank_account_page.add_new_account' => 'Add New Account',
|
'staff.profile.bank_account_page.add_new_account' => 'Add New Account',
|
||||||
|
'staff.profile.bank_account_page.bank_name' => 'Bank Name',
|
||||||
|
'staff.profile.bank_account_page.bank_hint' => 'Enter bank name',
|
||||||
'staff.profile.bank_account_page.routing_number' => 'Routing Number',
|
'staff.profile.bank_account_page.routing_number' => 'Routing Number',
|
||||||
'staff.profile.bank_account_page.routing_hint' => 'Enter routing number',
|
'staff.profile.bank_account_page.routing_hint' => 'Enter routing number',
|
||||||
'staff.profile.bank_account_page.account_number' => 'Account Number',
|
'staff.profile.bank_account_page.account_number' => 'Account Number',
|
||||||
@@ -3068,6 +3079,7 @@ extension on Translations {
|
|||||||
'staff.profile.bank_account_page.cancel' => 'Cancel',
|
'staff.profile.bank_account_page.cancel' => 'Cancel',
|
||||||
'staff.profile.bank_account_page.save' => 'Save',
|
'staff.profile.bank_account_page.save' => 'Save',
|
||||||
'staff.profile.bank_account_page.account_ending' => ({required Object last4}) => 'Ending in ${last4}',
|
'staff.profile.bank_account_page.account_ending' => ({required Object last4}) => 'Ending in ${last4}',
|
||||||
|
'staff.profile.bank_account_page.account_added_success' => 'Bank account added successfully!',
|
||||||
'staff.profile.logout.button' => 'Sign Out',
|
'staff.profile.logout.button' => 'Sign Out',
|
||||||
'staff.onboarding.personal_info.title' => 'Personal Info',
|
'staff.onboarding.personal_info.title' => 'Personal Info',
|
||||||
'staff.onboarding.personal_info.change_photo_hint' => 'Tap to change photo',
|
'staff.onboarding.personal_info.change_photo_hint' => 'Tap to change photo',
|
||||||
@@ -3192,11 +3204,11 @@ extension on Translations {
|
|||||||
'staff_shifts.tags.immediate_start' => 'Immediate start',
|
'staff_shifts.tags.immediate_start' => 'Immediate start',
|
||||||
'staff_shifts.tags.no_experience' => 'No experience',
|
'staff_shifts.tags.no_experience' => 'No experience',
|
||||||
'staff_time_card.title' => 'Timecard',
|
'staff_time_card.title' => 'Timecard',
|
||||||
|
_ => null,
|
||||||
|
} ?? switch (path) {
|
||||||
'staff_time_card.hours_worked' => 'Hours Worked',
|
'staff_time_card.hours_worked' => 'Hours Worked',
|
||||||
'staff_time_card.total_earnings' => 'Total Earnings',
|
'staff_time_card.total_earnings' => 'Total Earnings',
|
||||||
'staff_time_card.shift_history' => 'Shift History',
|
'staff_time_card.shift_history' => 'Shift History',
|
||||||
_ => null,
|
|
||||||
} ?? switch (path) {
|
|
||||||
'staff_time_card.no_shifts' => 'No shifts for this month',
|
'staff_time_card.no_shifts' => 'No shifts for this month',
|
||||||
'staff_time_card.hours' => 'hours',
|
'staff_time_card.hours' => 'hours',
|
||||||
'staff_time_card.per_hr' => '/hr',
|
'staff_time_card.per_hr' => '/hr',
|
||||||
|
|||||||
@@ -1381,6 +1381,8 @@ class _TranslationsStaffProfileBankAccountPageEs implements TranslationsStaffPro
|
|||||||
@override String get secure_title => 'Seguro y Cifrado';
|
@override String get secure_title => 'Seguro y Cifrado';
|
||||||
@override String get secure_subtitle => 'Su información bancaria está cifrada y almacenada de forma segura. Nunca compartimos sus detalles.';
|
@override String get secure_subtitle => 'Su información bancaria está cifrada y almacenada de forma segura. Nunca compartimos sus detalles.';
|
||||||
@override String get add_new_account => 'Agregar Nueva Cuenta';
|
@override String get add_new_account => 'Agregar Nueva Cuenta';
|
||||||
|
@override String get bank_name => 'Nombre del Banco';
|
||||||
|
@override String get bank_hint => 'Ingrese nombre del banco';
|
||||||
@override String get routing_number => 'Número de Ruta';
|
@override String get routing_number => 'Número de Ruta';
|
||||||
@override String get routing_hint => '9 dígitos';
|
@override String get routing_hint => '9 dígitos';
|
||||||
@override String get account_number => 'Número de Cuenta';
|
@override String get account_number => 'Número de Cuenta';
|
||||||
@@ -1392,6 +1394,7 @@ class _TranslationsStaffProfileBankAccountPageEs implements TranslationsStaffPro
|
|||||||
@override String get save => 'Guardar';
|
@override String get save => 'Guardar';
|
||||||
@override String get primary => 'Principal';
|
@override String get primary => 'Principal';
|
||||||
@override String account_ending({required Object last4}) => 'Termina en ${last4}';
|
@override String account_ending({required Object last4}) => 'Termina en ${last4}';
|
||||||
|
@override String get account_added_success => '¡Cuenta bancaria agregada exitosamente!';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: staff.profile.logout
|
// Path: staff.profile.logout
|
||||||
@@ -2000,6 +2003,8 @@ extension on TranslationsEs {
|
|||||||
'staff.profile.bank_account_page.secure_title' => 'Seguro y Cifrado',
|
'staff.profile.bank_account_page.secure_title' => 'Seguro y Cifrado',
|
||||||
'staff.profile.bank_account_page.secure_subtitle' => 'Su información bancaria está cifrada y almacenada de forma segura. Nunca compartimos sus detalles.',
|
'staff.profile.bank_account_page.secure_subtitle' => 'Su información bancaria está cifrada y almacenada de forma segura. Nunca compartimos sus detalles.',
|
||||||
'staff.profile.bank_account_page.add_new_account' => 'Agregar Nueva Cuenta',
|
'staff.profile.bank_account_page.add_new_account' => 'Agregar Nueva Cuenta',
|
||||||
|
'staff.profile.bank_account_page.bank_name' => 'Nombre del Banco',
|
||||||
|
'staff.profile.bank_account_page.bank_hint' => 'Ingrese nombre del banco',
|
||||||
'staff.profile.bank_account_page.routing_number' => 'Número de Ruta',
|
'staff.profile.bank_account_page.routing_number' => 'Número de Ruta',
|
||||||
'staff.profile.bank_account_page.routing_hint' => '9 dígitos',
|
'staff.profile.bank_account_page.routing_hint' => '9 dígitos',
|
||||||
'staff.profile.bank_account_page.account_number' => 'Número de Cuenta',
|
'staff.profile.bank_account_page.account_number' => 'Número de Cuenta',
|
||||||
@@ -2011,6 +2016,7 @@ extension on TranslationsEs {
|
|||||||
'staff.profile.bank_account_page.save' => 'Guardar',
|
'staff.profile.bank_account_page.save' => 'Guardar',
|
||||||
'staff.profile.bank_account_page.primary' => 'Principal',
|
'staff.profile.bank_account_page.primary' => 'Principal',
|
||||||
'staff.profile.bank_account_page.account_ending' => ({required Object last4}) => 'Termina en ${last4}',
|
'staff.profile.bank_account_page.account_ending' => ({required Object last4}) => 'Termina en ${last4}',
|
||||||
|
'staff.profile.bank_account_page.account_added_success' => '¡Cuenta bancaria agregada exitosamente!',
|
||||||
'staff.profile.logout.button' => 'Cerrar Sesión',
|
'staff.profile.logout.button' => 'Cerrar Sesión',
|
||||||
'staff.onboarding.personal_info.title' => 'Información Personal',
|
'staff.onboarding.personal_info.title' => 'Información Personal',
|
||||||
'staff.onboarding.personal_info.change_photo_hint' => 'Toca para cambiar foto',
|
'staff.onboarding.personal_info.change_photo_hint' => 'Toca para cambiar foto',
|
||||||
@@ -2135,11 +2141,11 @@ extension on TranslationsEs {
|
|||||||
'staff_shifts.tags.immediate_start' => 'Immediate start',
|
'staff_shifts.tags.immediate_start' => 'Immediate start',
|
||||||
'staff_shifts.tags.no_experience' => 'No experience',
|
'staff_shifts.tags.no_experience' => 'No experience',
|
||||||
'staff_time_card.title' => 'Tarjeta de tiempo',
|
'staff_time_card.title' => 'Tarjeta de tiempo',
|
||||||
|
_ => null,
|
||||||
|
} ?? switch (path) {
|
||||||
'staff_time_card.hours_worked' => 'Horas trabajadas',
|
'staff_time_card.hours_worked' => 'Horas trabajadas',
|
||||||
'staff_time_card.total_earnings' => 'Ganancias totales',
|
'staff_time_card.total_earnings' => 'Ganancias totales',
|
||||||
'staff_time_card.shift_history' => 'Historial de turnos',
|
'staff_time_card.shift_history' => 'Historial de turnos',
|
||||||
_ => null,
|
|
||||||
} ?? switch (path) {
|
|
||||||
'staff_time_card.no_shifts' => 'No hay turnos para este mes',
|
'staff_time_card.no_shifts' => 'No hay turnos para este mes',
|
||||||
'staff_time_card.hours' => 'horas',
|
'staff_time_card.hours' => 'horas',
|
||||||
'staff_time_card.per_hr' => '/hr',
|
'staff_time_card.per_hr' => '/hr',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export 'src/entities/events/work_session.dart';
|
|||||||
|
|
||||||
// Shifts
|
// Shifts
|
||||||
export 'src/entities/shifts/shift.dart';
|
export 'src/entities/shifts/shift.dart';
|
||||||
|
export 'src/adapters/shifts/shift_adapter.dart';
|
||||||
|
|
||||||
// Orders & Requests
|
// Orders & Requests
|
||||||
export 'src/entities/orders/order_type.dart';
|
export 'src/entities/orders/order_type.dart';
|
||||||
@@ -45,9 +46,11 @@ export 'src/entities/skills/skill_kit.dart';
|
|||||||
|
|
||||||
// Financial & Payroll
|
// Financial & Payroll
|
||||||
export 'src/entities/financial/invoice.dart';
|
export 'src/entities/financial/invoice.dart';
|
||||||
|
export 'src/entities/financial/time_card.dart';
|
||||||
export 'src/entities/financial/invoice_item.dart';
|
export 'src/entities/financial/invoice_item.dart';
|
||||||
export 'src/entities/financial/invoice_decline.dart';
|
export 'src/entities/financial/invoice_decline.dart';
|
||||||
export 'src/entities/financial/staff_payment.dart';
|
export 'src/entities/financial/staff_payment.dart';
|
||||||
|
export 'src/entities/financial/payment_summary.dart';
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
export 'src/entities/profile/staff_document.dart';
|
export 'src/entities/profile/staff_document.dart';
|
||||||
@@ -78,6 +81,9 @@ export 'src/entities/home/home_dashboard_data.dart';
|
|||||||
export 'src/entities/home/reorder_item.dart';
|
export 'src/entities/home/reorder_item.dart';
|
||||||
|
|
||||||
// Availability
|
// Availability
|
||||||
|
export 'src/adapters/availability/availability_adapter.dart';
|
||||||
|
export 'src/entities/clock_in/attendance_status.dart';
|
||||||
|
export 'src/adapters/clock_in/clock_in_adapter.dart';
|
||||||
export 'src/entities/availability/availability_slot.dart';
|
export 'src/entities/availability/availability_slot.dart';
|
||||||
export 'src/entities/availability/day_availability.dart';
|
export 'src/entities/availability/day_availability.dart';
|
||||||
|
|
||||||
@@ -87,3 +93,4 @@ export 'src/adapters/profile/experience_adapter.dart';
|
|||||||
export 'src/entities/profile/experience_skill.dart';
|
export 'src/entities/profile/experience_skill.dart';
|
||||||
export 'src/adapters/profile/bank_account_adapter.dart';
|
export 'src/adapters/profile/bank_account_adapter.dart';
|
||||||
export 'src/adapters/profile/tax_form_adapter.dart';
|
export 'src/adapters/profile/tax_form_adapter.dart';
|
||||||
|
export 'src/adapters/financial/payment_adapter.dart';
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import '../../entities/availability/availability_slot.dart';
|
||||||
|
|
||||||
|
/// Adapter for [AvailabilitySlot] domain entity.
|
||||||
|
class AvailabilityAdapter {
|
||||||
|
static const Map<String, Map<String, String>> _slotDefinitions = {
|
||||||
|
'MORNING': {
|
||||||
|
'id': 'morning',
|
||||||
|
'label': 'Morning',
|
||||||
|
'timeRange': '4:00 AM - 12:00 PM',
|
||||||
|
},
|
||||||
|
'AFTERNOON': {
|
||||||
|
'id': 'afternoon',
|
||||||
|
'label': 'Afternoon',
|
||||||
|
'timeRange': '12:00 PM - 6:00 PM',
|
||||||
|
},
|
||||||
|
'EVENING': {
|
||||||
|
'id': 'evening',
|
||||||
|
'label': 'Evening',
|
||||||
|
'timeRange': '6:00 PM - 12:00 AM',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Converts a backend slot name (e.g. 'MORNING') to a Domain [AvailabilitySlot].
|
||||||
|
static AvailabilitySlot fromPrimitive(String slotName, {bool isAvailable = false}) {
|
||||||
|
final def = _slotDefinitions[slotName.toUpperCase()] ?? _slotDefinitions['MORNING']!;
|
||||||
|
return AvailabilitySlot(
|
||||||
|
id: def['id']!,
|
||||||
|
label: def['label']!,
|
||||||
|
timeRange: def['timeRange']!,
|
||||||
|
isAvailable: isAvailable,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import '../../entities/shifts/shift.dart';
|
||||||
|
import '../../entities/clock_in/attendance_status.dart';
|
||||||
|
|
||||||
|
/// Adapter for Clock In related data.
|
||||||
|
class ClockInAdapter {
|
||||||
|
|
||||||
|
/// Converts primitive attendance data to [AttendanceStatus].
|
||||||
|
static AttendanceStatus toAttendanceStatus({
|
||||||
|
required String status,
|
||||||
|
DateTime? checkInTime,
|
||||||
|
DateTime? checkOutTime,
|
||||||
|
String? activeShiftId,
|
||||||
|
}) {
|
||||||
|
final bool isCheckedIn = status == 'CHECKED_IN' || status == 'LATE'; // Assuming LATE is also checked in?
|
||||||
|
|
||||||
|
// Statuses that imply active attendance: CHECKED_IN, LATE.
|
||||||
|
// Statuses that imply completed: CHECKED_OUT.
|
||||||
|
|
||||||
|
return AttendanceStatus(
|
||||||
|
isCheckedIn: isCheckedIn,
|
||||||
|
checkInTime: checkInTime,
|
||||||
|
checkOutTime: checkOutTime,
|
||||||
|
activeShiftId: activeShiftId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import '../../entities/financial/staff_payment.dart';
|
||||||
|
|
||||||
|
/// Adapter for Payment related data.
|
||||||
|
class PaymentAdapter {
|
||||||
|
|
||||||
|
/// Converts string status to [PaymentStatus].
|
||||||
|
static PaymentStatus toPaymentStatus(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'PAID':
|
||||||
|
return PaymentStatus.paid;
|
||||||
|
case 'PENDING':
|
||||||
|
return PaymentStatus.pending;
|
||||||
|
case 'FAILED':
|
||||||
|
return PaymentStatus.failed;
|
||||||
|
default:
|
||||||
|
return PaymentStatus.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import '../../entities/financial/time_card.dart';
|
||||||
|
|
||||||
|
/// Adapter for [TimeCard] to map data layer values to domain entity.
|
||||||
|
class TimeCardAdapter {
|
||||||
|
/// Maps primitive values to [TimeCard].
|
||||||
|
static TimeCard fromPrimitives({
|
||||||
|
required String id,
|
||||||
|
required String shiftTitle,
|
||||||
|
required String clientName,
|
||||||
|
required DateTime date,
|
||||||
|
required String startTime,
|
||||||
|
required String endTime,
|
||||||
|
required double totalHours,
|
||||||
|
required double hourlyRate,
|
||||||
|
required double totalPay,
|
||||||
|
required String status,
|
||||||
|
String? location,
|
||||||
|
}) {
|
||||||
|
return TimeCard(
|
||||||
|
id: id,
|
||||||
|
shiftTitle: shiftTitle,
|
||||||
|
clientName: clientName,
|
||||||
|
date: date,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
totalHours: totalHours,
|
||||||
|
hourlyRate: hourlyRate,
|
||||||
|
totalPay: totalPay,
|
||||||
|
status: _stringToStatus(status),
|
||||||
|
location: location,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static TimeCardStatus _stringToStatus(String status) {
|
||||||
|
switch (status.toUpperCase()) {
|
||||||
|
case 'CHECKED_OUT':
|
||||||
|
case 'COMPLETED':
|
||||||
|
return TimeCardStatus.approved; // Assuming completed = approved for now
|
||||||
|
case 'PAID':
|
||||||
|
return TimeCardStatus.paid; // If this status exists
|
||||||
|
case 'DISPUTED':
|
||||||
|
return TimeCardStatus.disputed;
|
||||||
|
case 'CHECKED_IN':
|
||||||
|
case 'ACCEPTED':
|
||||||
|
case 'CONFIRMED':
|
||||||
|
default:
|
||||||
|
return TimeCardStatus.pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import '../../entities/shifts/shift.dart';
|
||||||
|
|
||||||
|
/// Adapter for Shift related data.
|
||||||
|
class ShiftAdapter {
|
||||||
|
|
||||||
|
// Note: Conversion logic will likely live in RepoImpl or here if we pass raw objects.
|
||||||
|
// Given we are dealing with generated types that aren't exported by domain,
|
||||||
|
// we might put the logic in Repo or make this accept dynamic/Map if strictly required.
|
||||||
|
// For now, placeholders or simple status helpers.
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Simple entity to hold attendance state
|
||||||
|
class AttendanceStatus extends Equatable {
|
||||||
|
final bool isCheckedIn;
|
||||||
|
final DateTime? checkInTime;
|
||||||
|
final DateTime? checkOutTime;
|
||||||
|
final String? activeShiftId;
|
||||||
|
|
||||||
|
const AttendanceStatus({
|
||||||
|
this.isCheckedIn = false,
|
||||||
|
this.checkInTime,
|
||||||
|
this.checkOutTime,
|
||||||
|
this.activeShiftId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [isCheckedIn, checkInTime, checkOutTime, activeShiftId];
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Summary of staff earnings.
|
||||||
class PaymentSummary extends Equatable {
|
class PaymentSummary extends Equatable {
|
||||||
final double weeklyEarnings;
|
final double weeklyEarnings;
|
||||||
final double monthlyEarnings;
|
final double monthlyEarnings;
|
||||||
@@ -13,6 +13,9 @@ enum PaymentStatus {
|
|||||||
|
|
||||||
/// Transfer failed.
|
/// Transfer failed.
|
||||||
failed,
|
failed,
|
||||||
|
|
||||||
|
/// Status unknown.
|
||||||
|
unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a payout to a [Staff] member for a completed [Assignment].
|
/// Represents a payout to a [Staff] member for a completed [Assignment].
|
||||||
|
|||||||
@@ -1,30 +1,52 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Status of a time card.
|
||||||
enum TimeCardStatus {
|
enum TimeCardStatus {
|
||||||
|
/// Waiting for approval or payment.
|
||||||
pending,
|
pending,
|
||||||
|
/// Approved by manager.
|
||||||
approved,
|
approved,
|
||||||
|
/// Payment has been issued.
|
||||||
paid,
|
paid,
|
||||||
|
/// Disputed by staff or client.
|
||||||
disputed;
|
disputed;
|
||||||
|
|
||||||
|
/// Whether the card is approved.
|
||||||
bool get isApproved => this == TimeCardStatus.approved;
|
bool get isApproved => this == TimeCardStatus.approved;
|
||||||
|
/// Whether the card is paid.
|
||||||
bool get isPaid => this == TimeCardStatus.paid;
|
bool get isPaid => this == TimeCardStatus.paid;
|
||||||
|
/// Whether the card is disputed.
|
||||||
bool get isDisputed => this == TimeCardStatus.disputed;
|
bool get isDisputed => this == TimeCardStatus.disputed;
|
||||||
|
/// Whether the card is pending.
|
||||||
bool get isPending => this == TimeCardStatus.pending;
|
bool get isPending => this == TimeCardStatus.pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents a time card for a staff member.
|
||||||
class TimeCard extends Equatable {
|
class TimeCard extends Equatable {
|
||||||
|
/// Unique identifier of the time card (often matches Application ID).
|
||||||
final String id;
|
final String id;
|
||||||
|
/// Title of the shift.
|
||||||
final String shiftTitle;
|
final String shiftTitle;
|
||||||
|
/// Name of the client business.
|
||||||
final String clientName;
|
final String clientName;
|
||||||
|
/// Date of the shift.
|
||||||
final DateTime date;
|
final DateTime date;
|
||||||
|
/// Actual or scheduled start time.
|
||||||
final String startTime;
|
final String startTime;
|
||||||
|
/// Actual or scheduled end time.
|
||||||
final String endTime;
|
final String endTime;
|
||||||
|
/// Total hours worked.
|
||||||
final double totalHours;
|
final double totalHours;
|
||||||
|
/// Hourly pay rate.
|
||||||
final double hourlyRate;
|
final double hourlyRate;
|
||||||
|
/// Total pay amount.
|
||||||
final double totalPay;
|
final double totalPay;
|
||||||
|
/// Current status of the time card.
|
||||||
final TimeCardStatus status;
|
final TimeCardStatus status;
|
||||||
|
/// Location name.
|
||||||
final String? location;
|
final String? location;
|
||||||
|
|
||||||
|
/// Creates a [TimeCard].
|
||||||
const TimeCard({
|
const TimeCard({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.shiftTitle,
|
required this.shiftTitle,
|
||||||
@@ -39,10 +39,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
return _getUserProfile(
|
return _getUserProfile(
|
||||||
firebaseUserId: firebaseUser.uid,
|
firebaseUserId: firebaseUser.uid,
|
||||||
fallbackEmail: firebaseUser.email ?? email,
|
fallbackEmail: firebaseUser.email ?? email,
|
||||||
|
requireBusinessRole: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
//TO-DO: validate that user is business role and has business account
|
|
||||||
|
|
||||||
} on firebase.FirebaseAuthException catch (e) {
|
} on firebase.FirebaseAuthException catch (e) {
|
||||||
if (e.code == 'invalid-credential' || e.code == 'wrong-password') {
|
if (e.code == 'invalid-credential' || e.code == 'wrong-password') {
|
||||||
throw Exception('Incorrect email or password.');
|
throw Exception('Incorrect email or password.');
|
||||||
@@ -138,12 +137,18 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
Future<domain.User> _getUserProfile({
|
Future<domain.User> _getUserProfile({
|
||||||
required String firebaseUserId,
|
required String firebaseUserId,
|
||||||
required String? fallbackEmail,
|
required String? fallbackEmail,
|
||||||
|
bool requireBusinessRole = false,
|
||||||
}) async {
|
}) async {
|
||||||
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response = await _dataConnect.getUserById(id: firebaseUserId).execute();
|
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response = await _dataConnect.getUserById(id: firebaseUserId).execute();
|
||||||
final dc.GetUserByIdUser? user = response.data?.user;
|
final dc.GetUserByIdUser? user = response.data?.user;
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
throw Exception('Authenticated user profile not found in database.');
|
throw Exception('Authenticated user profile not found in database.');
|
||||||
}
|
}
|
||||||
|
if (requireBusinessRole && user.userRole != 'BUSINESS') {
|
||||||
|
await _firebaseAuth.signOut();
|
||||||
|
dc.ClientSessionStore.instance.clear();
|
||||||
|
throw Exception('User is not authorized for this app.');
|
||||||
|
}
|
||||||
|
|
||||||
final String? email = user.email ?? fallbackEmail;
|
final String? email = user.email ?? fallbackEmail;
|
||||||
if (email == null || email.isEmpty) {
|
if (email == null || email.isEmpty) {
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ import 'presentation/pages/billing_page.dart';
|
|||||||
class BillingModule extends Module {
|
class BillingModule extends Module {
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// External Dependencies (Mocks from data_connect)
|
|
||||||
// In a real app, these would likely be provided by a Core module or similar.
|
|
||||||
i.addSingleton(FinancialRepositoryMock.new);
|
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addSingleton<BillingRepository>(
|
i.addSingleton<BillingRepository>(
|
||||||
() => BillingRepositoryImpl(
|
() => BillingRepositoryImpl(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -6,11 +7,9 @@ import 'package:flutter_modular/flutter_modular.dart';
|
|||||||
import '../blocs/billing_bloc.dart';
|
import '../blocs/billing_bloc.dart';
|
||||||
import '../blocs/billing_event.dart';
|
import '../blocs/billing_event.dart';
|
||||||
import '../blocs/billing_state.dart';
|
import '../blocs/billing_state.dart';
|
||||||
import '../widgets/billing_header.dart';
|
|
||||||
import '../widgets/invoice_history_section.dart';
|
import '../widgets/invoice_history_section.dart';
|
||||||
import '../widgets/payment_method_card.dart';
|
import '../widgets/payment_method_card.dart';
|
||||||
import '../widgets/pending_invoices_section.dart';
|
import '../widgets/pending_invoices_section.dart';
|
||||||
import '../widgets/savings_card.dart';
|
|
||||||
import '../widgets/spending_breakdown_card.dart';
|
import '../widgets/spending_breakdown_card.dart';
|
||||||
|
|
||||||
/// The entry point page for the client billing feature.
|
/// The entry point page for the client billing feature.
|
||||||
@@ -34,23 +33,136 @@ class BillingPage extends StatelessWidget {
|
|||||||
///
|
///
|
||||||
/// This widget displays the billing dashboard content based on the current
|
/// This widget displays the billing dashboard content based on the current
|
||||||
/// state of the [BillingBloc].
|
/// state of the [BillingBloc].
|
||||||
class BillingView extends StatelessWidget {
|
class BillingView extends StatefulWidget {
|
||||||
/// Creates a [BillingView].
|
/// Creates a [BillingView].
|
||||||
const BillingView({super.key});
|
const BillingView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BillingView> createState() => _BillingViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BillingViewState extends State<BillingView> {
|
||||||
|
late ScrollController _scrollController;
|
||||||
|
bool _isScrolled = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController = ScrollController();
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
if (_scrollController.offset > 140 && !_isScrolled) {
|
||||||
|
setState(() => _isScrolled = true);
|
||||||
|
} else if (_scrollController.offset <= 140 && _isScrolled) {
|
||||||
|
setState(() => _isScrolled = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<BillingBloc, BillingState>(
|
return BlocBuilder<BillingBloc, BillingState>(
|
||||||
builder: (BuildContext context, BillingState state) {
|
builder: (BuildContext context, BillingState state) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Column(
|
body: CustomScrollView(
|
||||||
children: <Widget>[
|
controller: _scrollController,
|
||||||
BillingHeader(
|
slivers: <Widget>[
|
||||||
currentBill: state.currentBill,
|
SliverAppBar(
|
||||||
savings: state.savings,
|
pinned: true,
|
||||||
onBack: () => Modular.to.pop(),
|
expandedHeight: 200.0,
|
||||||
|
backgroundColor: UiColors.primary,
|
||||||
|
leading: Center(
|
||||||
|
child: UiIconButton.secondary(
|
||||||
|
icon: UiIcons.arrowLeft,
|
||||||
|
onTap: () => Modular.to.pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: Text(
|
||||||
|
_isScrolled
|
||||||
|
? '\$${state.currentBill.toStringAsFixed(2)}'
|
||||||
|
: t.client_billing.title,
|
||||||
|
key: ValueKey<bool>(_isScrolled),
|
||||||
|
style: UiTypography.headline4m.copyWith(
|
||||||
|
color: UiColors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: UiConstants.space0,
|
||||||
|
left: UiConstants.space5,
|
||||||
|
right: UiConstants.space5,
|
||||||
|
bottom: UiConstants.space10,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
t.client_billing.current_period,
|
||||||
|
style: UiTypography.footnote2r.copyWith(
|
||||||
|
color: UiColors.white.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
'\$${state.currentBill.toStringAsFixed(2)}',
|
||||||
|
style: UiTypography.display1b
|
||||||
|
.copyWith(color: UiColors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space2,
|
||||||
|
vertical: UiConstants.space1,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.accent,
|
||||||
|
borderRadius: BorderRadius.circular(100),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(
|
||||||
|
UiIcons.trendingDown,
|
||||||
|
size: 12,
|
||||||
|
color: UiColors.foreground,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
t.client_billing.saved_amount(
|
||||||
|
amount: state.savings.toStringAsFixed(0),
|
||||||
|
),
|
||||||
|
style: UiTypography.footnote2b.copyWith(
|
||||||
|
color: UiColors.foreground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverList(
|
||||||
|
delegate: SliverChildListDelegate(
|
||||||
|
<Widget>[
|
||||||
|
_buildContent(context, state),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Expanded(child: _buildContent(context, state)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -60,7 +172,10 @@ class BillingView extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildContent(BuildContext context, BillingState state) {
|
Widget _buildContent(BuildContext context, BillingState state) {
|
||||||
if (state.status == BillingStatus.loading) {
|
if (state.status == BillingStatus.loading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(UiConstants.space10),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.status == BillingStatus.failure) {
|
if (state.status == BillingStatus.failure) {
|
||||||
@@ -72,23 +187,49 @@ class BillingView extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(UiConstants.space5),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: UiConstants.space4,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
|
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
|
||||||
PendingInvoicesSection(invoices: state.pendingInvoices),
|
PendingInvoicesSection(invoices: state.pendingInvoices),
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
],
|
],
|
||||||
const PaymentMethodCard(),
|
const PaymentMethodCard(),
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
const SpendingBreakdownCard(),
|
const SpendingBreakdownCard(),
|
||||||
|
if (state.invoiceHistory.isEmpty) _buildEmptyState(context)
|
||||||
|
else InvoiceHistorySection(invoices: state.invoiceHistory),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
const SizedBox(height: UiConstants.space12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.bgPopup,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: UiColors.border),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
UiIcons.file,
|
||||||
|
size: 48,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
SavingsCard(savings: state.savings),
|
Text(
|
||||||
const SizedBox(height: UiConstants.space6),
|
'No Invoices for the selected period',
|
||||||
InvoiceHistorySection(invoices: state.invoiceHistory),
|
style: UiTypography.body1m.textSecondary,
|
||||||
const SizedBox(height: UiConstants.space24),
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ class _PaymentMethodCardState extends State<PaymentMethodCard> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final fdc.QueryResult<dc.GetAccountsByOwnerIdData,
|
final fdc.QueryResult<
|
||||||
dc.GetAccountsByOwnerIdVariables> result =
|
dc.GetAccountsByOwnerIdData,
|
||||||
await dc.ExampleConnector.instance
|
dc.GetAccountsByOwnerIdVariables
|
||||||
.getAccountsByOwnerId(ownerId: businessId)
|
>
|
||||||
.execute();
|
result = await dc.ExampleConnector.instance
|
||||||
|
.getAccountsByOwnerId(ownerId: businessId)
|
||||||
|
.execute();
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,115 +38,123 @@ class _PaymentMethodCardState extends State<PaymentMethodCard> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FutureBuilder<dc.GetAccountsByOwnerIdData?>(
|
return FutureBuilder<dc.GetAccountsByOwnerIdData?>(
|
||||||
future: _accountsFuture,
|
future: _accountsFuture,
|
||||||
builder: (BuildContext context,
|
builder:
|
||||||
AsyncSnapshot<dc.GetAccountsByOwnerIdData?> snapshot) {
|
(
|
||||||
final List<dc.GetAccountsByOwnerIdAccounts> accounts =
|
BuildContext context,
|
||||||
snapshot.data?.accounts ??
|
AsyncSnapshot<dc.GetAccountsByOwnerIdData?> snapshot,
|
||||||
<dc.GetAccountsByOwnerIdAccounts>[];
|
) {
|
||||||
final dc.GetAccountsByOwnerIdAccounts? account =
|
final List<dc.GetAccountsByOwnerIdAccounts> accounts =
|
||||||
accounts.isNotEmpty ? accounts.first : null;
|
snapshot.data?.accounts ?? <dc.GetAccountsByOwnerIdAccounts>[];
|
||||||
final String bankLabel =
|
final dc.GetAccountsByOwnerIdAccounts? account = accounts.isNotEmpty
|
||||||
account?.bank.isNotEmpty == true ? account!.bank : '----';
|
? accounts.first
|
||||||
final String last4 =
|
: null;
|
||||||
account?.last4.isNotEmpty == true ? account!.last4 : '----';
|
|
||||||
final bool isPrimary = account?.isPrimary ?? false;
|
|
||||||
final String expiryLabel = _formatExpiry(account?.expiryTime);
|
|
||||||
|
|
||||||
return Container(
|
if (account == null) {
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
return const SizedBox.shrink();
|
||||||
decoration: BoxDecoration(
|
}
|
||||||
color: UiColors.white,
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
final String bankLabel = account.bank.isNotEmpty == true
|
||||||
border: Border.all(color: UiColors.border),
|
? account.bank
|
||||||
boxShadow: <BoxShadow>[
|
: '----';
|
||||||
BoxShadow(
|
final String last4 = account.last4.isNotEmpty == true
|
||||||
color: UiColors.black.withValues(alpha: 0.04),
|
? account.last4
|
||||||
blurRadius: 8,
|
: '----';
|
||||||
offset: const Offset(0, 2),
|
final bool isPrimary = account.isPrimary ?? false;
|
||||||
),
|
final String expiryLabel = _formatExpiry(account.expiryTime);
|
||||||
],
|
|
||||||
),
|
return Container(
|
||||||
child: Column(
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
children: <Widget>[
|
decoration: BoxDecoration(
|
||||||
Row(
|
color: UiColors.white,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
borderRadius: UiConstants.radiusLg,
|
||||||
children: <Widget>[
|
border: Border.all(color: UiColors.border),
|
||||||
Text(
|
boxShadow: <BoxShadow>[
|
||||||
t.client_billing.payment_method,
|
BoxShadow(
|
||||||
style: UiTypography.title2b.textPrimary,
|
color: UiColors.black.withValues(alpha: 0.04),
|
||||||
),
|
blurRadius: 8,
|
||||||
const SizedBox.shrink(),
|
offset: const Offset(0, 2),
|
||||||
],
|
|
||||||
),
|
|
||||||
if (account != null) ...<Widget>[
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space3),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.bgSecondary,
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
),
|
),
|
||||||
child: Row(
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Container(
|
Text(
|
||||||
width: 40,
|
t.client_billing.payment_method,
|
||||||
height: 28,
|
style: UiTypography.title2b.textPrimary,
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.primary,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
bankLabel,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: UiColors.white,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space3),
|
const SizedBox.shrink(),
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
'•••• $last4',
|
|
||||||
style: UiTypography.body2b.textPrimary,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
t.client_billing.expires(date: expiryLabel),
|
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isPrimary)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 6,
|
|
||||||
vertical: 2,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.accent,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
t.client_billing.default_badge,
|
|
||||||
style: UiTypography.titleUppercase4b.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: UiConstants.space3),
|
||||||
],
|
Container(
|
||||||
],
|
padding: const EdgeInsets.all(UiConstants.space3),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
);
|
color: UiColors.bgSecondary,
|
||||||
},
|
borderRadius: UiConstants.radiusMd,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.primary,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
bankLabel,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: UiColors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
'•••• $last4',
|
||||||
|
style: UiTypography.body2b.textPrimary,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
t.client_billing.expires(date: expiryLabel),
|
||||||
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isPrimary)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.accent,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
t.client_billing.default_badge,
|
||||||
|
style: UiTypography.titleUppercase4b.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import 'package:design_system/design_system.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import '../blocs/coverage_bloc.dart';
|
import '../blocs/coverage_bloc.dart';
|
||||||
import '../blocs/coverage_event.dart';
|
import '../blocs/coverage_event.dart';
|
||||||
import '../blocs/coverage_state.dart';
|
import '../blocs/coverage_state.dart';
|
||||||
|
|
||||||
import '../widgets/coverage_header.dart';
|
import '../widgets/coverage_calendar_selector.dart';
|
||||||
import '../widgets/coverage_quick_stats.dart';
|
import '../widgets/coverage_quick_stats.dart';
|
||||||
import '../widgets/coverage_shift_list.dart';
|
import '../widgets/coverage_shift_list.dart';
|
||||||
import '../widgets/late_workers_alert.dart';
|
import '../widgets/late_workers_alert.dart';
|
||||||
@@ -14,10 +15,41 @@ import '../widgets/late_workers_alert.dart';
|
|||||||
/// Page for displaying daily coverage information.
|
/// Page for displaying daily coverage information.
|
||||||
///
|
///
|
||||||
/// Shows shifts, worker statuses, and coverage statistics for a selected date.
|
/// Shows shifts, worker statuses, and coverage statistics for a selected date.
|
||||||
class CoveragePage extends StatelessWidget {
|
class CoveragePage extends StatefulWidget {
|
||||||
/// Creates a [CoveragePage].
|
/// Creates a [CoveragePage].
|
||||||
const CoveragePage({super.key});
|
const CoveragePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CoveragePage> createState() => _CoveragePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CoveragePageState extends State<CoveragePage> {
|
||||||
|
late ScrollController _scrollController;
|
||||||
|
bool _isScrolled = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController = ScrollController();
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
if (_scrollController.offset > 180 && !_isScrolled) {
|
||||||
|
setState(() => _isScrolled = true);
|
||||||
|
} else if (_scrollController.offset <= 180 && _isScrolled) {
|
||||||
|
setState(() => _isScrolled = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<CoverageBloc>(
|
return BlocProvider<CoverageBloc>(
|
||||||
@@ -26,26 +58,159 @@ class CoveragePage extends StatelessWidget {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: BlocBuilder<CoverageBloc, CoverageState>(
|
body: BlocBuilder<CoverageBloc, CoverageState>(
|
||||||
builder: (BuildContext context, CoverageState state) {
|
builder: (BuildContext context, CoverageState state) {
|
||||||
return Column(
|
final DateTime selectedDate = state.selectedDate ?? DateTime.now();
|
||||||
children: <Widget>[
|
|
||||||
CoverageHeader(
|
return CustomScrollView(
|
||||||
selectedDate: state.selectedDate ?? DateTime.now(),
|
controller: _scrollController,
|
||||||
coveragePercent: state.stats?.coveragePercent ?? 0,
|
slivers: <Widget>[
|
||||||
totalConfirmed: state.stats?.totalConfirmed ?? 0,
|
SliverAppBar(
|
||||||
totalNeeded: state.stats?.totalNeeded ?? 0,
|
pinned: true,
|
||||||
onDateSelected: (DateTime date) {
|
expandedHeight: 300.0,
|
||||||
BlocProvider.of<CoverageBloc>(context).add(
|
backgroundColor: UiColors.primary,
|
||||||
CoverageLoadRequested(date: date),
|
leading: IconButton(
|
||||||
);
|
onPressed: () => Modular.to.pop(),
|
||||||
},
|
icon: Container(
|
||||||
onRefresh: () {
|
padding: const EdgeInsets.all(UiConstants.space2),
|
||||||
BlocProvider.of<CoverageBloc>(context).add(
|
decoration: BoxDecoration(
|
||||||
const CoverageRefreshRequested(),
|
color: UiColors.primaryForeground.withOpacity(0.2),
|
||||||
);
|
shape: BoxShape.circle,
|
||||||
},
|
),
|
||||||
|
child: const Icon(
|
||||||
|
UiIcons.arrowLeft,
|
||||||
|
color: UiColors.primaryForeground,
|
||||||
|
size: UiConstants.space4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: Text(
|
||||||
|
_isScrolled
|
||||||
|
? DateFormat('MMMM d').format(selectedDate)
|
||||||
|
: 'Daily Coverage',
|
||||||
|
key: ValueKey<bool>(_isScrolled),
|
||||||
|
style: UiTypography.title2m.copyWith(
|
||||||
|
color: UiColors.primaryForeground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
BlocProvider.of<CoverageBloc>(context).add(
|
||||||
|
const CoverageRefreshRequested(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: Container(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.primaryForeground.withOpacity(0.2),
|
||||||
|
borderRadius: UiConstants.radiusMd,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
UiIcons.rotateCcw,
|
||||||
|
color: UiColors.primaryForeground,
|
||||||
|
size: UiConstants.space4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space4),
|
||||||
|
],
|
||||||
|
flexibleSpace: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: <Color>[
|
||||||
|
UiColors.primary,
|
||||||
|
UiColors.primary,
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: FlexibleSpaceBar(
|
||||||
|
background: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
UiConstants.space5,
|
||||||
|
100, // Top padding to clear AppBar
|
||||||
|
UiConstants.space5,
|
||||||
|
UiConstants.space4,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: <Widget>[
|
||||||
|
CoverageCalendarSelector(
|
||||||
|
selectedDate: selectedDate,
|
||||||
|
onDateSelected: (DateTime date) {
|
||||||
|
BlocProvider.of<CoverageBloc>(context).add(
|
||||||
|
CoverageLoadRequested(date: date),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
// Coverage Stats Container
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
UiColors.primaryForeground.withOpacity(0.1),
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceBetween,
|
||||||
|
children: <Widget>[
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
'Coverage Status',
|
||||||
|
style: UiTypography.body2r.copyWith(
|
||||||
|
color: UiColors.primaryForeground
|
||||||
|
.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${state.stats?.coveragePercent ?? 0}%',
|
||||||
|
style: UiTypography.display1b.copyWith(
|
||||||
|
color: UiColors.primaryForeground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
'Workers',
|
||||||
|
style: UiTypography.body2r.copyWith(
|
||||||
|
color: UiColors.primaryForeground
|
||||||
|
.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${state.stats?.totalConfirmed ?? 0}/${state.stats?.totalNeeded ?? 0}',
|
||||||
|
style: UiTypography.title2m.copyWith(
|
||||||
|
color: UiColors.primaryForeground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
SliverList(
|
||||||
child: _buildBody(context: context, state: state),
|
delegate: SliverChildListDelegate(
|
||||||
|
<Widget>[
|
||||||
|
_buildBody(context: context, state: state),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -99,7 +264,7 @@ class CoveragePage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(UiConstants.space5),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -120,7 +285,9 @@ class CoveragePage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
CoverageShiftList(shifts: state.shifts),
|
CoverageShiftList(shifts: state.shifts),
|
||||||
const SizedBox(height: 100),
|
const SizedBox(
|
||||||
|
height: UiConstants.space24,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ class OneTimeOrderView extends StatelessWidget {
|
|||||||
title: labels.success_title,
|
title: labels.success_title,
|
||||||
message: labels.success_message,
|
message: labels.success_message,
|
||||||
buttonLabel: labels.back_to_orders,
|
buttonLabel: labels.back_to_orders,
|
||||||
onDone: () => Modular.to.navigate(
|
onDone: () => Modular.to.pushNamedAndRemoveUntil(
|
||||||
'/client-main/orders/',
|
'/client-main/orders/',
|
||||||
|
(_) => false,
|
||||||
arguments: <String, dynamic>{
|
arguments: <String, dynamic>{
|
||||||
'initialDate': state.date.toIso8601String(),
|
'initialDate': state.date.toIso8601String(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -74,23 +74,39 @@ class ClientHomePage extends StatelessWidget {
|
|||||||
|
|
||||||
/// Builds the widget list in normal mode with visibility filters.
|
/// Builds the widget list in normal mode with visibility filters.
|
||||||
Widget _buildNormalModeList(ClientHomeState state) {
|
Widget _buildNormalModeList(ClientHomeState state) {
|
||||||
return ListView(
|
final List<String> visibleWidgets = state.widgetOrder.where((String id) {
|
||||||
padding: const EdgeInsets.fromLTRB(
|
if (id == 'reorder' && state.reorderItems.isEmpty) {
|
||||||
UiConstants.space4,
|
return false;
|
||||||
0,
|
}
|
||||||
UiConstants.space4,
|
return state.widgetVisibility[id] ?? true;
|
||||||
100,
|
}).toList();
|
||||||
),
|
|
||||||
children: state.widgetOrder.map((String id) {
|
return ListView.separated(
|
||||||
return Padding(
|
padding: const EdgeInsets.only(bottom: 100),
|
||||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
separatorBuilder: (BuildContext context, int index) {
|
||||||
child: DashboardWidgetBuilder(
|
return const Divider(color: UiColors.border, height: 0.2);
|
||||||
id: id,
|
},
|
||||||
state: state,
|
itemCount: visibleWidgets.length,
|
||||||
isEditMode: false,
|
itemBuilder: (BuildContext context, int index) {
|
||||||
),
|
final String id = visibleWidgets[index];
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
if (index != 0) const SizedBox(height: UiConstants.space8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space4,
|
||||||
|
),
|
||||||
|
child: DashboardWidgetBuilder(
|
||||||
|
id: id,
|
||||||
|
state: state,
|
||||||
|
isEditMode: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space8),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}).toList(),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ class ActionsWidget extends StatelessWidget {
|
|||||||
/// Callback when Create Order is pressed.
|
/// Callback when Create Order is pressed.
|
||||||
final VoidCallback onCreateOrderPressed;
|
final VoidCallback onCreateOrderPressed;
|
||||||
|
|
||||||
|
/// Optional subtitle for the section.
|
||||||
|
final String? subtitle;
|
||||||
|
|
||||||
/// Creates an [ActionsWidget].
|
/// Creates an [ActionsWidget].
|
||||||
const ActionsWidget({
|
const ActionsWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.onRapidPressed,
|
required this.onRapidPressed,
|
||||||
required this.onCreateOrderPressed,
|
required this.onCreateOrderPressed,
|
||||||
|
this.subtitle,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -22,8 +26,11 @@ class ActionsWidget extends StatelessWidget {
|
|||||||
// Check if client_home exists in t
|
// Check if client_home exists in t
|
||||||
final TranslationsClientHomeActionsEn i18n = t.client_home.actions;
|
final TranslationsClientHomeActionsEn i18n = t.client_home.actions;
|
||||||
|
|
||||||
return Row(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
/// TODO: FEATURE_NOT_YET_IMPLEMENTED
|
/// TODO: FEATURE_NOT_YET_IMPLEMENTED
|
||||||
// Expanded(
|
// Expanded(
|
||||||
// child: _ActionCard(
|
// child: _ActionCard(
|
||||||
@@ -55,6 +62,8 @@ class ActionsWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,17 +12,36 @@ class CoverageWidget extends StatelessWidget {
|
|||||||
/// The percentage of coverage (0-100).
|
/// The percentage of coverage (0-100).
|
||||||
final int coveragePercent;
|
final int coveragePercent;
|
||||||
|
|
||||||
|
/// Optional subtitle for the section.
|
||||||
|
final String? subtitle;
|
||||||
|
|
||||||
/// Creates a [CoverageWidget].
|
/// Creates a [CoverageWidget].
|
||||||
const CoverageWidget({
|
const CoverageWidget({
|
||||||
super.key,
|
super.key,
|
||||||
this.totalNeeded = 0,
|
this.totalNeeded = 0,
|
||||||
this.totalConfirmed = 0,
|
this.totalConfirmed = 0,
|
||||||
this.coveragePercent = 0,
|
this.coveragePercent = 0,
|
||||||
|
this.subtitle,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
Color backgroundColor;
|
||||||
|
Color textColor;
|
||||||
|
|
||||||
|
if (coveragePercent == 100) {
|
||||||
|
backgroundColor = UiColors.tagActive;
|
||||||
|
textColor = UiColors.textSuccess;
|
||||||
|
} else if (coveragePercent >= 40) {
|
||||||
|
backgroundColor = UiColors.tagPending;
|
||||||
|
textColor = UiColors.textWarning;
|
||||||
|
} else {
|
||||||
|
backgroundColor = UiColors.tagError;
|
||||||
|
textColor = UiColors.textError;
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -41,19 +60,25 @@ class CoverageWidget extends StatelessWidget {
|
|||||||
2, // 2px is not in metrics, using hardcoded for small tweaks or space0/space1
|
2, // 2px is not in metrics, using hardcoded for small tweaks or space0/space1
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.tagActive,
|
color: backgroundColor,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'$coveragePercent% Covered',
|
'$coveragePercent% Covered',
|
||||||
style: UiTypography.footnote2b.copyWith(
|
style: UiTypography.footnote2b.copyWith(
|
||||||
color: UiColors.textSuccess,
|
color: textColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space2),
|
if (subtitle != null) ...<Widget>[
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: UiTypography.body2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -65,7 +90,7 @@ class CoverageWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
Expanded(
|
if (totalConfirmed != 0) Expanded(
|
||||||
child: _MetricCard(
|
child: _MetricCard(
|
||||||
icon: UiIcons.success,
|
icon: UiIcons.success,
|
||||||
iconColor: UiColors.iconSuccess,
|
iconColor: UiColors.iconSuccess,
|
||||||
|
|||||||
@@ -56,11 +56,15 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
|||||||
|
|
||||||
/// Builds the actual widget content based on the widget ID.
|
/// Builds the actual widget content based on the widget ID.
|
||||||
Widget _buildWidgetContent(BuildContext context) {
|
Widget _buildWidgetContent(BuildContext context) {
|
||||||
|
// Only show subtitle in normal mode
|
||||||
|
final String? subtitle = !isEditMode ? _getWidgetSubtitle(id) : null;
|
||||||
|
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'actions':
|
case 'actions':
|
||||||
return ActionsWidget(
|
return ActionsWidget(
|
||||||
onRapidPressed: () => Modular.to.pushRapidOrder(),
|
onRapidPressed: () => Modular.to.pushRapidOrder(),
|
||||||
onCreateOrderPressed: () => Modular.to.pushCreateOrder(),
|
onCreateOrderPressed: () => Modular.to.pushCreateOrder(),
|
||||||
|
subtitle: subtitle,
|
||||||
);
|
);
|
||||||
case 'reorder':
|
case 'reorder':
|
||||||
return ReorderWidget(
|
return ReorderWidget(
|
||||||
@@ -88,6 +92,7 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
subtitle: subtitle,
|
||||||
);
|
);
|
||||||
case 'spending':
|
case 'spending':
|
||||||
return SpendingWidget(
|
return SpendingWidget(
|
||||||
@@ -95,6 +100,7 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
|||||||
next7DaysSpending: state.dashboardData.next7DaysSpending,
|
next7DaysSpending: state.dashboardData.next7DaysSpending,
|
||||||
weeklyShifts: state.dashboardData.weeklyShifts,
|
weeklyShifts: state.dashboardData.weeklyShifts,
|
||||||
next7DaysScheduled: state.dashboardData.next7DaysScheduled,
|
next7DaysScheduled: state.dashboardData.next7DaysScheduled,
|
||||||
|
subtitle: subtitle,
|
||||||
);
|
);
|
||||||
case 'coverage':
|
case 'coverage':
|
||||||
return CoverageWidget(
|
return CoverageWidget(
|
||||||
@@ -106,10 +112,12 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
|||||||
100)
|
100)
|
||||||
.toInt()
|
.toInt()
|
||||||
: 0,
|
: 0,
|
||||||
|
subtitle: subtitle,
|
||||||
);
|
);
|
||||||
case 'liveActivity':
|
case 'liveActivity':
|
||||||
return LiveActivityWidget(
|
return LiveActivityWidget(
|
||||||
onViewAllPressed: () => Modular.to.navigate('/client-main/coverage/'),
|
onViewAllPressed: () => Modular.to.navigate('/client-main/coverage/'),
|
||||||
|
subtitle: subtitle,
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
@@ -133,4 +141,21 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _getWidgetSubtitle(String id) {
|
||||||
|
switch (id) {
|
||||||
|
case 'actions':
|
||||||
|
return 'Quick access to create and manage orders';
|
||||||
|
case 'reorder':
|
||||||
|
return 'Easily reorder from your past activity';
|
||||||
|
case 'spending':
|
||||||
|
return 'Track your spending and budget in real-time';
|
||||||
|
case 'coverage':
|
||||||
|
return 'Overview of your current shift coverage';
|
||||||
|
case 'liveActivity':
|
||||||
|
return 'Real-time updates on your active shifts';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ class LiveActivityWidget extends StatefulWidget {
|
|||||||
/// Callback when "View all" is pressed.
|
/// Callback when "View all" is pressed.
|
||||||
final VoidCallback onViewAllPressed;
|
final VoidCallback onViewAllPressed;
|
||||||
|
|
||||||
|
/// Optional subtitle for the section.
|
||||||
|
final String? subtitle;
|
||||||
|
|
||||||
/// Creates a [LiveActivityWidget].
|
/// Creates a [LiveActivityWidget].
|
||||||
const LiveActivityWidget({super.key, required this.onViewAllPressed});
|
const LiveActivityWidget({
|
||||||
|
super.key,
|
||||||
|
required this.onViewAllPressed,
|
||||||
|
this.subtitle
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<LiveActivityWidget> createState() => _LiveActivityWidgetState();
|
State<LiveActivityWidget> createState() => _LiveActivityWidgetState();
|
||||||
@@ -100,6 +107,7 @@ class _LiveActivityWidgetState extends State<LiveActivityWidget> {
|
|||||||
final TranslationsClientHomeEn i18n = t.client_home;
|
final TranslationsClientHomeEn i18n = t.client_home;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -121,7 +129,13 @@ class _LiveActivityWidgetState extends State<LiveActivityWidget> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space2),
|
if (widget.subtitle != null) ...<Widget>[
|
||||||
|
Text(
|
||||||
|
widget.subtitle!,
|
||||||
|
style: UiTypography.body2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
FutureBuilder<_LiveActivityData>(
|
FutureBuilder<_LiveActivityData>(
|
||||||
future: _liveActivityFuture,
|
future: _liveActivityFuture,
|
||||||
builder: (BuildContext context,
|
builder: (BuildContext context,
|
||||||
|
|||||||
@@ -11,15 +11,23 @@ class ReorderWidget extends StatelessWidget {
|
|||||||
/// Callback when a reorder button is pressed.
|
/// Callback when a reorder button is pressed.
|
||||||
final Function(Map<String, dynamic> shiftData) onReorderPressed;
|
final Function(Map<String, dynamic> shiftData) onReorderPressed;
|
||||||
|
|
||||||
|
/// Optional subtitle for the section.
|
||||||
|
final String? subtitle;
|
||||||
|
|
||||||
/// Creates a [ReorderWidget].
|
/// Creates a [ReorderWidget].
|
||||||
const ReorderWidget({
|
const ReorderWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.orders,
|
required this.orders,
|
||||||
required this.onReorderPressed,
|
required this.onReorderPressed,
|
||||||
|
this.subtitle,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (orders.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
final TranslationsClientHomeReorderEn i18n = t.client_home.reorder;
|
final TranslationsClientHomeReorderEn i18n = t.client_home.reorder;
|
||||||
|
|
||||||
final List<ReorderItem> recentOrders = orders;
|
final List<ReorderItem> recentOrders = orders;
|
||||||
@@ -33,6 +41,13 @@ class ReorderWidget extends StatelessWidget {
|
|||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (subtitle != null) ...<Widget>[
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: UiTypography.body2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: UiConstants.space2),
|
const SizedBox(height: UiConstants.space2),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 140,
|
height: 140,
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class SpendingWidget extends StatelessWidget {
|
|||||||
/// The number of scheduled shifts for next 7 days.
|
/// The number of scheduled shifts for next 7 days.
|
||||||
final int next7DaysScheduled;
|
final int next7DaysScheduled;
|
||||||
|
|
||||||
|
/// Optional subtitle for the section.
|
||||||
|
final String? subtitle;
|
||||||
|
|
||||||
/// Creates a [SpendingWidget].
|
/// Creates a [SpendingWidget].
|
||||||
const SpendingWidget({
|
const SpendingWidget({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -23,6 +26,7 @@ class SpendingWidget extends StatelessWidget {
|
|||||||
required this.next7DaysSpending,
|
required this.next7DaysSpending,
|
||||||
required this.weeklyShifts,
|
required this.weeklyShifts,
|
||||||
required this.next7DaysScheduled,
|
required this.next7DaysScheduled,
|
||||||
|
this.subtitle,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -38,7 +42,13 @@ class SpendingWidget extends StatelessWidget {
|
|||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space2),
|
if (subtitle != null) ...<Widget>[
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: UiTypography.body2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(UiConstants.space3),
|
padding: const EdgeInsets.all(UiConstants.space3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -114,58 +124,6 @@ class SpendingWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space3),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.only(top: UiConstants.space3),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
border: Border(top: BorderSide(color: Colors.white24)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Container(
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.2),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Icon(
|
|
||||||
UiIcons.sparkles,
|
|
||||||
color: UiColors.white,
|
|
||||||
size: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space2),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
'💡 ' +
|
|
||||||
i18n.dashboard.insight_lightbulb(amount: '180'),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 1),
|
|
||||||
Text(
|
|
||||||
i18n.dashboard.insight_tip,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.8),
|
|
||||||
fontSize: 9,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -143,7 +143,19 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DateTime _endOfDay(DateTime dateTime) {
|
DateTime _endOfDay(DateTime dateTime) {
|
||||||
return DateTime(dateTime.year, dateTime.month, dateTime.day, 23, 59, 59);
|
// We add the current microseconds to ensure the query variables are unique
|
||||||
|
// each time we fetch, bypassing any potential Data Connect caching.
|
||||||
|
final DateTime now = DateTime.now();
|
||||||
|
return DateTime(
|
||||||
|
dateTime.year,
|
||||||
|
dateTime.month,
|
||||||
|
dateTime.day,
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59,
|
||||||
|
now.millisecond,
|
||||||
|
now.microsecond,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatTime(fdc.Timestamp? timestamp) {
|
String _formatTime(fdc.Timestamp? timestamp) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'package:flutter_modular/flutter_modular.dart';
|
|||||||
extension ViewOrdersNavigator on IModularNavigator {
|
extension ViewOrdersNavigator on IModularNavigator {
|
||||||
/// Navigates to the Create Order feature.
|
/// Navigates to the Create Order feature.
|
||||||
void navigateToCreateOrder() {
|
void navigateToCreateOrder() {
|
||||||
pushNamed('/client/create-order/');
|
navigate('/client/create-order/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the Order Details (placeholder for now).
|
/// Navigates to the Order Details (placeholder for now).
|
||||||
|
|||||||
@@ -51,22 +51,21 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Force initialization of cubit immediately
|
||||||
|
_cubit = BlocProvider.of<ViewOrdersCubit>(context, listen: false);
|
||||||
|
|
||||||
if (widget.initialDate != null) {
|
if (widget.initialDate != null) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (_didInitialJump) return;
|
if (_didInitialJump) return;
|
||||||
_didInitialJump = true;
|
_didInitialJump = true;
|
||||||
_cubit ??= BlocProvider.of<ViewOrdersCubit>(context);
|
_cubit?.jumpToDate(widget.initialDate!);
|
||||||
_cubit!.jumpToDate(widget.initialDate!);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_cubit == null) {
|
|
||||||
_cubit = BlocProvider.of<ViewOrdersCubit>(context);
|
|
||||||
}
|
|
||||||
return BlocBuilder<ViewOrdersCubit, ViewOrdersState>(
|
return BlocBuilder<ViewOrdersCubit, ViewOrdersState>(
|
||||||
builder: (BuildContext context, ViewOrdersState state) {
|
builder: (BuildContext context, ViewOrdersState state) {
|
||||||
final List<DateTime> calendarDays = state.calendarDays;
|
final List<DateTime> calendarDays = state.calendarDays;
|
||||||
@@ -89,7 +88,6 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: UiColors.white,
|
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
// Background Gradient
|
// Background Gradient
|
||||||
@@ -222,16 +220,17 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
UiButton.primary(
|
if (state.filteredOrders.isNotEmpty)
|
||||||
text: t.client_view_orders.post_button,
|
UiButton.primary(
|
||||||
leadingIcon: UiIcons.add,
|
text: t.client_view_orders.post_button,
|
||||||
onPressed: () => Modular.to.navigateToCreateOrder(),
|
leadingIcon: UiIcons.add,
|
||||||
size: UiButtonSize.small,
|
onPressed: () => Modular.to.navigateToCreateOrder(),
|
||||||
style: ElevatedButton.styleFrom(
|
size: UiButtonSize.small,
|
||||||
minimumSize: const Size(0, 48),
|
style: ElevatedButton.styleFrom(
|
||||||
maximumSize: const Size(0, 48),
|
minimumSize: const Size(0, 48),
|
||||||
|
maximumSize: const Size(0, 48),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -308,14 +308,15 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
if (order.workersNeeded != 0)
|
||||||
const Icon(
|
const Icon(
|
||||||
UiIcons.success,
|
UiIcons.error,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: UiColors.textSuccess,
|
color: UiColors.textError,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'${order.workersNeeded} Workers Filled',
|
'${order.workersNeeded} Workers Needed',
|
||||||
style: UiTypography.body2m.textPrimary,
|
style: UiTypography.body2m.textPrimary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class ViewOrdersModule extends Module {
|
|||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addLazySingleton<IViewOrdersRepository>(
|
i.add<IViewOrdersRepository>(
|
||||||
() => ViewOrdersRepositoryImpl(
|
() => ViewOrdersRepositoryImpl(
|
||||||
firebaseAuth: firebase.FirebaseAuth.instance,
|
firebaseAuth: firebase.FirebaseAuth.instance,
|
||||||
dataConnect: ExampleConnector.instance,
|
dataConnect: ExampleConnector.instance,
|
||||||
@@ -29,11 +29,11 @@ class ViewOrdersModule extends Module {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
i.addLazySingleton(GetOrdersUseCase.new);
|
i.add(GetOrdersUseCase.new);
|
||||||
i.addLazySingleton(GetAcceptedApplicationsForDayUseCase.new);
|
i.add(GetAcceptedApplicationsForDayUseCase.new);
|
||||||
|
|
||||||
// BLoCs
|
// BLoCs
|
||||||
i.addSingleton(
|
i.add(
|
||||||
() => ViewOrdersCubit(
|
() => ViewOrdersCubit(
|
||||||
getOrdersUseCase: i.get<GetOrdersUseCase>(),
|
getOrdersUseCase: i.get<GetOrdersUseCase>(),
|
||||||
getAcceptedAppsUseCase: i.get<GetAcceptedApplicationsForDayUseCase>(),
|
getAcceptedAppsUseCase: i.get<GetAcceptedApplicationsForDayUseCase>(),
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,183 +0,0 @@
|
|||||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
|
||||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
|
||||||
import 'package:krow_data_connect/src/session/staff_session_store.dart';
|
|
||||||
import '../../domain/repositories/availability_repository.dart';
|
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
|
||||||
|
|
||||||
|
|
||||||
/// Implementation of [AvailabilityRepository].
|
|
||||||
///
|
|
||||||
/// Uses [StafRepositoryMock] (conceptually) from data_connect to fetch and store data.
|
|
||||||
class AvailabilityRepositoryImpl implements AvailabilityRepository {
|
|
||||||
AvailabilityRepositoryImpl();
|
|
||||||
|
|
||||||
String get _currentStaffId {
|
|
||||||
final session = StaffSessionStore.instance.session;
|
|
||||||
if (session?.staff?.id == null) throw Exception('User not logged in');
|
|
||||||
return session!.staff!.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
static const List<Map<String, String>> _slotDefinitions = [
|
|
||||||
{
|
|
||||||
'id': 'morning',
|
|
||||||
'label': 'Morning',
|
|
||||||
'timeRange': '4:00 AM - 12:00 PM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'id': 'afternoon',
|
|
||||||
'label': 'Afternoon',
|
|
||||||
'timeRange': '12:00 PM - 6:00 PM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'id': 'evening',
|
|
||||||
'label': 'Evening',
|
|
||||||
'timeRange': '6:00 PM - 12:00 AM',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<DayAvailability>> getAvailability(
|
|
||||||
DateTime start, DateTime end) async {
|
|
||||||
|
|
||||||
// 1. Fetch Weekly Template from Backend
|
|
||||||
Map<dc.DayOfWeek, Map<dc.AvailabilitySlot, bool>> weeklyTemplate = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
final response = await dc.ExampleConnector.instance
|
|
||||||
.getStaffAvailabilityStatsByStaffId(staffId: _currentStaffId)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Note: getStaffAvailabilityStatsByStaffId might not return detailed slots per day in this schema version?
|
|
||||||
// Wait, the previous code used `listStaffAvailabilitiesByStaffId` but that method didn't exist in generated code search?
|
|
||||||
// Genereted code showed `listStaffAvailabilityStats`.
|
|
||||||
// Let's assume there is a listStaffAvailabilities or similar, OR we use the stats.
|
|
||||||
// But for now, let's look at the generated.dart again.
|
|
||||||
// It has `CreateStaffAvailability`, `UpdateStaffAvailability`, `DeleteStaffAvailability`.
|
|
||||||
// But LIST seems to be `listStaffAvailabilityStats`? Maybe `listStaffAvailability` is missing?
|
|
||||||
|
|
||||||
// If we can't fetch it, we'll just return default empty.
|
|
||||||
// For the sake of fixing build, I will comment out the fetch logic if the method doesn't exist,
|
|
||||||
// AR replace it with a valid call if I can find one.
|
|
||||||
// The snippet showed `listStaffAvailabilityStats`.
|
|
||||||
|
|
||||||
// Let's try to infer from the code I saw earlier.
|
|
||||||
// `dc.ExampleConnector.instance.listStaffAvailabilitiesByStaffId` was used.
|
|
||||||
// If that produced an error "Method not defined", I should fix it.
|
|
||||||
// But the error log didn't show "Method not defined" for `listStaffAvailabilitiesByStaffId`.
|
|
||||||
// It showed mismatch in return types of `getAvailability`.
|
|
||||||
// So assuming `listStaffAvailabilitiesByStaffId` DOES exist or I should mock it.
|
|
||||||
|
|
||||||
// However, fixing the TYPE mismatch is the priority.
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
// If error (or empty), use default empty template
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Map Template to Requested Date Range
|
|
||||||
final List<DayAvailability> days = [];
|
|
||||||
final dayCount = end.difference(start).inDays;
|
|
||||||
|
|
||||||
for (int i = 0; i <= dayCount; i++) {
|
|
||||||
final date = start.add(Duration(days: i));
|
|
||||||
// final dayOfWeek = _mapDateTimeToDayOfWeek(date.weekday);
|
|
||||||
|
|
||||||
// final daySlotsMap = weeklyTemplate[dayOfWeek] ?? {};
|
|
||||||
|
|
||||||
// Determine overall day availability (true if ANY slot is available)
|
|
||||||
// final bool isDayAvailable = daySlotsMap.values.any((val) => val == true);
|
|
||||||
|
|
||||||
final slots = _slotDefinitions.map((def) {
|
|
||||||
// Map string ID 'morning' -> Enum AvailabilitySlot.MORNING
|
|
||||||
// final slotEnum = _mapStringToSlotEnum(def['id']!);
|
|
||||||
// final isSlotAvailable = daySlotsMap[slotEnum] ?? false; // Default false if not set
|
|
||||||
|
|
||||||
return AvailabilitySlot(
|
|
||||||
id: def['id']!,
|
|
||||||
label: def['label']!,
|
|
||||||
timeRange: def['timeRange']!,
|
|
||||||
isAvailable: false, // Defaulting to false since fetch is commented out/incomplete
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
days.add(DayAvailability(
|
|
||||||
date: date,
|
|
||||||
isAvailable: false,
|
|
||||||
slots: slots,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return days;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<DayAvailability> updateDayAvailability(
|
|
||||||
DayAvailability availability) async {
|
|
||||||
|
|
||||||
// Stub implementation to fix build
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
return availability;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<DayAvailability>> applyQuickSet(
|
|
||||||
DateTime start, DateTime end, String type) async {
|
|
||||||
|
|
||||||
final List<DayAvailability> updatedDays = [];
|
|
||||||
final dayCount = end.difference(start).inDays;
|
|
||||||
|
|
||||||
for (int i = 0; i <= dayCount; i++) {
|
|
||||||
final date = start.add(Duration(days: i));
|
|
||||||
bool dayEnabled = false;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'all': dayEnabled = true; break;
|
|
||||||
case 'weekdays':
|
|
||||||
dayEnabled = date.weekday != DateTime.saturday && date.weekday != DateTime.sunday;
|
|
||||||
break;
|
|
||||||
case 'weekends':
|
|
||||||
dayEnabled = date.weekday == DateTime.saturday || date.weekday == DateTime.sunday;
|
|
||||||
break;
|
|
||||||
case 'clear': dayEnabled = false; break;
|
|
||||||
}
|
|
||||||
|
|
||||||
final slots = _slotDefinitions.map((def) {
|
|
||||||
return AvailabilitySlot(
|
|
||||||
id: def['id']!,
|
|
||||||
label: def['label']!,
|
|
||||||
timeRange: def['timeRange']!,
|
|
||||||
isAvailable: dayEnabled,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
updatedDays.add(DayAvailability(
|
|
||||||
date: date,
|
|
||||||
isAvailable: dayEnabled,
|
|
||||||
slots: slots,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return updatedDays;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
dc.DayOfWeek _mapDateTimeToDayOfWeek(DateTime date) {
|
|
||||||
switch (date.weekday) {
|
|
||||||
case DateTime.monday: return dc.DayOfWeek.MONDAY;
|
|
||||||
case DateTime.tuesday: return dc.DayOfWeek.TUESDAY;
|
|
||||||
case DateTime.wednesday: return dc.DayOfWeek.WEDNESDAY;
|
|
||||||
case DateTime.thursday: return dc.DayOfWeek.THURSDAY;
|
|
||||||
case DateTime.friday: return dc.DayOfWeek.FRIDAY;
|
|
||||||
case DateTime.saturday: return dc.DayOfWeek.SATURDAY;
|
|
||||||
case DateTime.sunday: return dc.DayOfWeek.SUNDAY;
|
|
||||||
default: return dc.DayOfWeek.MONDAY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dc.AvailabilitySlot _mapStringToSlotEnum(String id) {
|
|
||||||
switch (id.toLowerCase()) {
|
|
||||||
case 'morning': return dc.AvailabilitySlot.MORNING;
|
|
||||||
case 'afternoon': return dc.AvailabilitySlot.AFTERNOON;
|
|
||||||
case 'evening': return dc.AvailabilitySlot.EVENING;
|
|
||||||
default: return dc.AvailabilitySlot.MORNING;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||||
|
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import '../../domain/repositories/availability_repository.dart';
|
||||||
|
|
||||||
|
/// Implementation of [AvailabilityRepository] using Firebase Data Connect.
|
||||||
|
///
|
||||||
|
/// Note: The backend schema supports recurring availablity (Weekly/DayOfWeek),
|
||||||
|
/// not specific date availability. Therefore, updating availability for a specific
|
||||||
|
/// date will update the availability for that Day of Week globally (Recurring).
|
||||||
|
class AvailabilityRepositoryImpl implements AvailabilityRepository {
|
||||||
|
final dc.ExampleConnector _dataConnect;
|
||||||
|
final firebase.FirebaseAuth _firebaseAuth;
|
||||||
|
|
||||||
|
AvailabilityRepositoryImpl({
|
||||||
|
required dc.ExampleConnector dataConnect,
|
||||||
|
required firebase.FirebaseAuth firebaseAuth,
|
||||||
|
}) : _dataConnect = dataConnect,
|
||||||
|
_firebaseAuth = firebaseAuth;
|
||||||
|
|
||||||
|
Future<String> _getStaffId() async {
|
||||||
|
final firebase.User? user = _firebaseAuth.currentUser;
|
||||||
|
if (user == null) throw Exception('User not authenticated');
|
||||||
|
|
||||||
|
final QueryResult<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables> result =
|
||||||
|
await _dataConnect.getStaffByUserId(userId: user.uid).execute();
|
||||||
|
if (result.data.staffs.isEmpty) {
|
||||||
|
throw Exception('Staff profile not found');
|
||||||
|
}
|
||||||
|
return result.data.staffs.first.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<DayAvailability>> getAvailability(DateTime start, DateTime end) async {
|
||||||
|
final String staffId = await _getStaffId();
|
||||||
|
|
||||||
|
// 1. Fetch Weekly recurring availability
|
||||||
|
final QueryResult<dc.ListStaffAvailabilitiesByStaffIdData, dc.ListStaffAvailabilitiesByStaffIdVariables> result =
|
||||||
|
await _dataConnect.listStaffAvailabilitiesByStaffId(staffId: staffId).limit(100).execute();
|
||||||
|
|
||||||
|
final List<dc.ListStaffAvailabilitiesByStaffIdStaffAvailabilities> items = result.data.staffAvailabilities;
|
||||||
|
|
||||||
|
// 2. Map to lookup: DayOfWeek -> Map<SlotName, IsAvailable>
|
||||||
|
final Map<dc.DayOfWeek, Map<dc.AvailabilitySlot, bool>> weeklyMap = {};
|
||||||
|
|
||||||
|
for (final item in items) {
|
||||||
|
dc.DayOfWeek day;
|
||||||
|
try {
|
||||||
|
day = dc.DayOfWeek.values.byName(item.day.stringValue);
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
dc.AvailabilitySlot slot;
|
||||||
|
try {
|
||||||
|
slot = dc.AvailabilitySlot.values.byName(item.slot.stringValue);
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isAvailable = false;
|
||||||
|
try {
|
||||||
|
final dc.AvailabilityStatus status = dc.AvailabilityStatus.values.byName(item.status.stringValue);
|
||||||
|
isAvailable = _statusToBool(status);
|
||||||
|
} catch (_) {
|
||||||
|
isAvailable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!weeklyMap.containsKey(day)) {
|
||||||
|
weeklyMap[day] = {};
|
||||||
|
}
|
||||||
|
weeklyMap[day]![slot] = isAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Generate DayAvailability for requested range
|
||||||
|
final List<DayAvailability> days = [];
|
||||||
|
final int dayCount = end.difference(start).inDays;
|
||||||
|
|
||||||
|
for (int i = 0; i <= dayCount; i++) {
|
||||||
|
final DateTime date = start.add(Duration(days: i));
|
||||||
|
final dc.DayOfWeek dow = _toBackendDay(date.weekday);
|
||||||
|
|
||||||
|
final Map<dc.AvailabilitySlot, bool> daySlots = weeklyMap[dow] ?? {};
|
||||||
|
|
||||||
|
// We define 3 standard slots for every day
|
||||||
|
final List<AvailabilitySlot> slots = [
|
||||||
|
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.MORNING),
|
||||||
|
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.AFTERNOON),
|
||||||
|
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.EVENING),
|
||||||
|
];
|
||||||
|
|
||||||
|
final bool isDayAvailable = slots.any((s) => s.isAvailable);
|
||||||
|
|
||||||
|
days.add(DayAvailability(
|
||||||
|
date: date,
|
||||||
|
isAvailable: isDayAvailable,
|
||||||
|
slots: slots,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
AvailabilitySlot _createSlot(
|
||||||
|
DateTime date,
|
||||||
|
dc.DayOfWeek dow,
|
||||||
|
Map<dc.AvailabilitySlot, bool> existingSlots,
|
||||||
|
dc.AvailabilitySlot slotEnum,
|
||||||
|
) {
|
||||||
|
final bool isAvailable = existingSlots[slotEnum] ?? false;
|
||||||
|
return AvailabilityAdapter.fromPrimitive(slotEnum.name, isAvailable: isAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DayAvailability> updateDayAvailability(DayAvailability availability) async {
|
||||||
|
final String staffId = await _getStaffId();
|
||||||
|
final dc.DayOfWeek dow = _toBackendDay(availability.date.weekday);
|
||||||
|
|
||||||
|
// Update each slot in the backend.
|
||||||
|
// This updates the recurring rule for this DayOfWeek.
|
||||||
|
for (final AvailabilitySlot slot in availability.slots) {
|
||||||
|
final dc.AvailabilitySlot slotEnum = _toBackendSlot(slot.id);
|
||||||
|
final dc.AvailabilityStatus status = _boolToStatus(slot.isAvailable);
|
||||||
|
|
||||||
|
await _upsertSlot(staffId, dow, slotEnum, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<DayAvailability>> applyQuickSet(DateTime start, DateTime end, String type) async {
|
||||||
|
final String staffId = await _getStaffId();
|
||||||
|
|
||||||
|
// QuickSet updates the Recurring schedule for all days involved.
|
||||||
|
// However, if the user selects a range that covers e.g. Mon-Fri, we update Mon-Fri.
|
||||||
|
|
||||||
|
final int dayCount = end.difference(start).inDays;
|
||||||
|
final Set<dc.DayOfWeek> processedDays = {};
|
||||||
|
final List<DayAvailability> resultDays = [];
|
||||||
|
|
||||||
|
for (int i = 0; i <= dayCount; i++) {
|
||||||
|
final DateTime date = start.add(Duration(days: i));
|
||||||
|
final dc.DayOfWeek dow = _toBackendDay(date.weekday);
|
||||||
|
|
||||||
|
// Logic to determine if enabled based on type
|
||||||
|
bool enableDay = false;
|
||||||
|
if (type == 'all') enableDay = true;
|
||||||
|
else if (type == 'clear') enableDay = false;
|
||||||
|
else if (type == 'weekdays') {
|
||||||
|
enableDay = (dow != dc.DayOfWeek.SATURDAY && dow != dc.DayOfWeek.SUNDAY);
|
||||||
|
} else if (type == 'weekends') {
|
||||||
|
enableDay = (dow == dc.DayOfWeek.SATURDAY || dow == dc.DayOfWeek.SUNDAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update backend once per DayOfWeek (since it's recurring)
|
||||||
|
// to avoid redundant calls if range > 1 week.
|
||||||
|
if (!processedDays.contains(dow)) {
|
||||||
|
processedDays.add(dow);
|
||||||
|
|
||||||
|
final dc.AvailabilityStatus status = _boolToStatus(enableDay);
|
||||||
|
|
||||||
|
await Future.wait([
|
||||||
|
_upsertSlot(staffId, dow, dc.AvailabilitySlot.MORNING, status),
|
||||||
|
_upsertSlot(staffId, dow, dc.AvailabilitySlot.AFTERNOON, status),
|
||||||
|
_upsertSlot(staffId, dow, dc.AvailabilitySlot.EVENING, status),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare return object
|
||||||
|
final slots = [
|
||||||
|
AvailabilityAdapter.fromPrimitive('MORNING', isAvailable: enableDay),
|
||||||
|
AvailabilityAdapter.fromPrimitive('AFTERNOON', isAvailable: enableDay),
|
||||||
|
AvailabilityAdapter.fromPrimitive('EVENING', isAvailable: enableDay),
|
||||||
|
];
|
||||||
|
|
||||||
|
resultDays.add(DayAvailability(
|
||||||
|
date: date,
|
||||||
|
isAvailable: enableDay,
|
||||||
|
slots: slots,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _upsertSlot(String staffId, dc.DayOfWeek day, dc.AvailabilitySlot slot, dc.AvailabilityStatus status) async {
|
||||||
|
// Check if exists
|
||||||
|
final result = await _dataConnect.getStaffAvailabilityByKey(
|
||||||
|
staffId: staffId,
|
||||||
|
day: day,
|
||||||
|
slot: slot,
|
||||||
|
).execute();
|
||||||
|
|
||||||
|
if (result.data.staffAvailability != null) {
|
||||||
|
// Update
|
||||||
|
await _dataConnect.updateStaffAvailability(
|
||||||
|
staffId: staffId,
|
||||||
|
day: day,
|
||||||
|
slot: slot,
|
||||||
|
).status(status).execute();
|
||||||
|
} else {
|
||||||
|
// Create
|
||||||
|
await _dataConnect.createStaffAvailability(
|
||||||
|
staffId: staffId,
|
||||||
|
day: day,
|
||||||
|
slot: slot,
|
||||||
|
).status(status).execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private Helpers ---
|
||||||
|
|
||||||
|
dc.DayOfWeek _toBackendDay(int weekday) {
|
||||||
|
switch (weekday) {
|
||||||
|
case DateTime.monday: return dc.DayOfWeek.MONDAY;
|
||||||
|
case DateTime.tuesday: return dc.DayOfWeek.TUESDAY;
|
||||||
|
case DateTime.wednesday: return dc.DayOfWeek.WEDNESDAY;
|
||||||
|
case DateTime.thursday: return dc.DayOfWeek.THURSDAY;
|
||||||
|
case DateTime.friday: return dc.DayOfWeek.FRIDAY;
|
||||||
|
case DateTime.saturday: return dc.DayOfWeek.SATURDAY;
|
||||||
|
case DateTime.sunday: return dc.DayOfWeek.SUNDAY;
|
||||||
|
default: return dc.DayOfWeek.MONDAY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dc.AvailabilitySlot _toBackendSlot(String id) {
|
||||||
|
switch (id.toLowerCase()) {
|
||||||
|
case 'morning': return dc.AvailabilitySlot.MORNING;
|
||||||
|
case 'afternoon': return dc.AvailabilitySlot.AFTERNOON;
|
||||||
|
case 'evening': return dc.AvailabilitySlot.EVENING;
|
||||||
|
default: return dc.AvailabilitySlot.MORNING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _statusToBool(dc.AvailabilityStatus status) {
|
||||||
|
return status == dc.AvailabilityStatus.CONFIRMED_AVAILABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
dc.AvailabilityStatus _boolToStatus(bool isAvailable) {
|
||||||
|
return isAvailable ? dc.AvailabilityStatus.CONFIRMED_AVAILABLE : dc.AvailabilityStatus.BLOCKED;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
|
||||||
import '../../domain/usecases/apply_quick_set_usecase.dart';
|
import '../../domain/usecases/apply_quick_set_usecase.dart';
|
||||||
import '../../domain/usecases/get_weekly_availability_usecase.dart';
|
import '../../domain/usecases/get_weekly_availability_usecase.dart';
|
||||||
import '../../domain/usecases/update_day_availability_usecase.dart';
|
import '../../domain/usecases/update_day_availability_usecase.dart';
|
||||||
@@ -45,7 +44,11 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState> {
|
|||||||
|
|
||||||
void _onSelectDate(SelectDate event, Emitter<AvailabilityState> emit) {
|
void _onSelectDate(SelectDate event, Emitter<AvailabilityState> emit) {
|
||||||
if (state is AvailabilityLoaded) {
|
if (state is AvailabilityLoaded) {
|
||||||
emit((state as AvailabilityLoaded).copyWith(selectedDate: event.date));
|
// Clear success message on navigation
|
||||||
|
emit((state as AvailabilityLoaded).copyWith(
|
||||||
|
selectedDate: event.date,
|
||||||
|
clearSuccessMessage: true,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +58,10 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState> {
|
|||||||
) async {
|
) async {
|
||||||
if (state is AvailabilityLoaded) {
|
if (state is AvailabilityLoaded) {
|
||||||
final currentState = state as AvailabilityLoaded;
|
final currentState = state as AvailabilityLoaded;
|
||||||
|
|
||||||
|
// Clear message
|
||||||
|
emit(currentState.copyWith(clearSuccessMessage: true));
|
||||||
|
|
||||||
final newWeekStart = currentState.currentWeekStart
|
final newWeekStart = currentState.currentWeekStart
|
||||||
.add(Duration(days: event.direction * 7));
|
.add(Duration(days: event.direction * 7));
|
||||||
|
|
||||||
@@ -77,12 +84,23 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState> {
|
|||||||
return d.date == event.day.date ? newDay : d;
|
return d.date == event.day.date ? newDay : d;
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
emit(currentState.copyWith(days: updatedDays));
|
// Optimistic update
|
||||||
|
emit(currentState.copyWith(
|
||||||
|
days: updatedDays,
|
||||||
|
clearSuccessMessage: true,
|
||||||
|
));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDayAvailability(UpdateDayAvailabilityParams(newDay));
|
await updateDayAvailability(UpdateDayAvailabilityParams(newDay));
|
||||||
|
// Success feedback
|
||||||
|
if (state is AvailabilityLoaded) {
|
||||||
|
emit((state as AvailabilityLoaded).copyWith(successMessage: 'Availability updated'));
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(currentState.copyWith(days: currentState.days));
|
// Revert
|
||||||
|
if (state is AvailabilityLoaded) {
|
||||||
|
emit((state as AvailabilityLoaded).copyWith(days: currentState.days));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,12 +125,23 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState> {
|
|||||||
return d.date == event.day.date ? newDay : d;
|
return d.date == event.day.date ? newDay : d;
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
emit(currentState.copyWith(days: updatedDays));
|
// Optimistic update
|
||||||
|
emit(currentState.copyWith(
|
||||||
|
days: updatedDays,
|
||||||
|
clearSuccessMessage: true,
|
||||||
|
));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDayAvailability(UpdateDayAvailabilityParams(newDay));
|
await updateDayAvailability(UpdateDayAvailabilityParams(newDay));
|
||||||
|
// Success feedback
|
||||||
|
if (state is AvailabilityLoaded) {
|
||||||
|
emit((state as AvailabilityLoaded).copyWith(successMessage: 'Availability updated'));
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(currentState.copyWith(days: currentState.days));
|
// Revert
|
||||||
|
if (state is AvailabilityLoaded) {
|
||||||
|
emit((state as AvailabilityLoaded).copyWith(days: currentState.days));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,12 +153,26 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState> {
|
|||||||
if (state is AvailabilityLoaded) {
|
if (state is AvailabilityLoaded) {
|
||||||
final currentState = state as AvailabilityLoaded;
|
final currentState = state as AvailabilityLoaded;
|
||||||
|
|
||||||
|
emit(currentState.copyWith(
|
||||||
|
isActionInProgress: true,
|
||||||
|
clearSuccessMessage: true,
|
||||||
|
));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final newDays = await applyQuickSet(
|
final newDays = await applyQuickSet(
|
||||||
ApplyQuickSetParams(currentState.currentWeekStart, event.type));
|
ApplyQuickSetParams(currentState.currentWeekStart, event.type));
|
||||||
emit(currentState.copyWith(days: newDays));
|
|
||||||
|
emit(currentState.copyWith(
|
||||||
|
days: newDays,
|
||||||
|
isActionInProgress: false,
|
||||||
|
successMessage: 'Availability updated',
|
||||||
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Handle error
|
emit(currentState.copyWith(
|
||||||
|
isActionInProgress: false,
|
||||||
|
// Could set error message here if we had a field for it, or emit AvailabilityError
|
||||||
|
// But emitting AvailabilityError would replace the whole screen.
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,15 @@ class AvailabilityLoaded extends AvailabilityState {
|
|||||||
final List<DayAvailability> days;
|
final List<DayAvailability> days;
|
||||||
final DateTime currentWeekStart;
|
final DateTime currentWeekStart;
|
||||||
final DateTime selectedDate;
|
final DateTime selectedDate;
|
||||||
|
final bool isActionInProgress;
|
||||||
|
final String? successMessage;
|
||||||
|
|
||||||
const AvailabilityLoaded({
|
const AvailabilityLoaded({
|
||||||
required this.days,
|
required this.days,
|
||||||
required this.currentWeekStart,
|
required this.currentWeekStart,
|
||||||
required this.selectedDate,
|
required this.selectedDate,
|
||||||
|
this.isActionInProgress = false,
|
||||||
|
this.successMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Helper to get the currently selected day's availability object
|
/// Helper to get the currently selected day's availability object
|
||||||
@@ -34,11 +38,16 @@ class AvailabilityLoaded extends AvailabilityState {
|
|||||||
List<DayAvailability>? days,
|
List<DayAvailability>? days,
|
||||||
DateTime? currentWeekStart,
|
DateTime? currentWeekStart,
|
||||||
DateTime? selectedDate,
|
DateTime? selectedDate,
|
||||||
|
bool? isActionInProgress,
|
||||||
|
String? successMessage, // Nullable override
|
||||||
|
bool clearSuccessMessage = false,
|
||||||
}) {
|
}) {
|
||||||
return AvailabilityLoaded(
|
return AvailabilityLoaded(
|
||||||
days: days ?? this.days,
|
days: days ?? this.days,
|
||||||
currentWeekStart: currentWeekStart ?? this.currentWeekStart,
|
currentWeekStart: currentWeekStart ?? this.currentWeekStart,
|
||||||
selectedDate: selectedDate ?? this.selectedDate,
|
selectedDate: selectedDate ?? this.selectedDate,
|
||||||
|
isActionInProgress: isActionInProgress ?? this.isActionInProgress,
|
||||||
|
successMessage: clearSuccessMessage ? null : (successMessage ?? this.successMessage),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +56,7 @@ class AvailabilityLoaded extends AvailabilityState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [days, currentWeekStart, selectedDate];
|
List<Object?> get props => [days, currentWeekStart, selectedDate, isActionInProgress, successMessage];
|
||||||
}
|
}
|
||||||
|
|
||||||
class AvailabilityError extends AvailabilityState {
|
class AvailabilityError extends AvailabilityState {
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart' hide ModularWatchExtension;
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
|
||||||
|
import '../blocs/availability_bloc.dart';
|
||||||
|
import '../blocs/availability_event.dart';
|
||||||
|
import '../blocs/availability_state.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
class AvailabilityPage extends StatefulWidget {
|
class AvailabilityPage extends StatefulWidget {
|
||||||
const AvailabilityPage({super.key});
|
const AvailabilityPage({super.key});
|
||||||
|
|
||||||
@@ -11,314 +18,104 @@ class AvailabilityPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AvailabilityPageState extends State<AvailabilityPage> {
|
class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||||
late DateTime _currentWeekStart;
|
final AvailabilityBloc _bloc = Modular.get<AvailabilityBloc>();
|
||||||
late DateTime _selectedDate;
|
|
||||||
|
|
||||||
// Mock Availability State
|
|
||||||
// Map of day name (lowercase) to availability status
|
|
||||||
Map<String, bool> _availability = {
|
|
||||||
'monday': true,
|
|
||||||
'tuesday': true,
|
|
||||||
'wednesday': true,
|
|
||||||
'thursday': true,
|
|
||||||
'friday': true,
|
|
||||||
'saturday': false,
|
|
||||||
'sunday': false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Map of day name to time slot map
|
|
||||||
Map<String, Map<String, bool>> _timeSlotAvailability = {
|
|
||||||
'monday': {'morning': true, 'afternoon': true, 'evening': true},
|
|
||||||
'tuesday': {'morning': true, 'afternoon': true, 'evening': true},
|
|
||||||
'wednesday': {'morning': true, 'afternoon': true, 'evening': true},
|
|
||||||
'thursday': {'morning': true, 'afternoon': true, 'evening': true},
|
|
||||||
'friday': {'morning': true, 'afternoon': true, 'evening': true},
|
|
||||||
'saturday': {'morning': false, 'afternoon': false, 'evening': false},
|
|
||||||
'sunday': {'morning': false, 'afternoon': false, 'evening': false},
|
|
||||||
};
|
|
||||||
|
|
||||||
final List<String> _dayNames = [
|
|
||||||
'sunday',
|
|
||||||
'monday',
|
|
||||||
'tuesday',
|
|
||||||
'wednesday',
|
|
||||||
'thursday',
|
|
||||||
'friday',
|
|
||||||
'saturday',
|
|
||||||
];
|
|
||||||
|
|
||||||
final List<Map<String, dynamic>> _timeSlots = [
|
|
||||||
{
|
|
||||||
'slotId': 'morning',
|
|
||||||
'label': 'Morning',
|
|
||||||
'timeRange': '4:00 AM - 12:00 PM',
|
|
||||||
'icon': LucideIcons.sunrise,
|
|
||||||
'bg': const Color(0xFFE6EBF9), // bg-[#0032A0]/10
|
|
||||||
'iconColor': const Color(0xFF0032A0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'slotId': 'afternoon',
|
|
||||||
'label': 'Afternoon',
|
|
||||||
'timeRange': '12:00 PM - 6:00 PM',
|
|
||||||
'icon': LucideIcons.sun,
|
|
||||||
'bg': const Color(0xFFCCD6EC), // bg-[#0032A0]/20
|
|
||||||
'iconColor': const Color(0xFF0032A0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'slotId': 'evening',
|
|
||||||
'label': 'Evening',
|
|
||||||
'timeRange': '6:00 PM - 12:00 AM',
|
|
||||||
'icon': LucideIcons.moon,
|
|
||||||
'bg': const Color(0xFFEBEDEE), // bg-[#333F48]/10
|
|
||||||
'iconColor': const Color(0xFF333F48),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_calculateInitialWeek();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _calculateInitialWeek() {
|
||||||
final today = DateTime.now();
|
final today = DateTime.now();
|
||||||
|
|
||||||
// Dart equivalent for Monday start:
|
|
||||||
final day = today.weekday; // Mon=1, Sun=7
|
final day = today.weekday; // Mon=1, Sun=7
|
||||||
final diff = day - 1;
|
final diff = day - 1; // Assuming Monday start
|
||||||
_currentWeekStart = today.subtract(Duration(days: diff));
|
DateTime currentWeekStart = today.subtract(Duration(days: diff));
|
||||||
// Reset time to midnight
|
currentWeekStart = DateTime(
|
||||||
_currentWeekStart = DateTime(
|
currentWeekStart.year,
|
||||||
_currentWeekStart.year,
|
currentWeekStart.month,
|
||||||
_currentWeekStart.month,
|
currentWeekStart.day,
|
||||||
_currentWeekStart.day,
|
|
||||||
);
|
);
|
||||||
|
_bloc.add(LoadAvailability(currentWeekStart));
|
||||||
_selectedDate = today;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<DateTime> _getWeekDates() {
|
|
||||||
return List.generate(
|
|
||||||
7,
|
|
||||||
(index) => _currentWeekStart.add(Duration(days: index)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDay(DateTime date) {
|
|
||||||
return DateFormat('EEE').format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isToday(DateTime date) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
return date.year == now.year &&
|
|
||||||
date.month == now.month &&
|
|
||||||
date.day == now.day;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isSelected(DateTime date) {
|
|
||||||
return date.year == _selectedDate.year &&
|
|
||||||
date.month == _selectedDate.month &&
|
|
||||||
date.day == _selectedDate.day;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _navigateWeek(int direction) {
|
|
||||||
setState(() {
|
|
||||||
_currentWeekStart = _currentWeekStart.add(Duration(days: direction * 7));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toggleDayAvailability(String dayName) {
|
|
||||||
setState(() {
|
|
||||||
_availability[dayName] = !(_availability[dayName] ?? false);
|
|
||||||
// React code also updates mutation. We mock this.
|
|
||||||
// NOTE: In prototype we mock it. Refactor will move this to BLoC.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getDayKey(DateTime date) {
|
|
||||||
// DateTime.weekday: Mon=1...Sun=7.
|
|
||||||
// _dayNames array: 0=Sun, 1=Mon...
|
|
||||||
// Dart weekday: 7 is Sunday. 7 % 7 = 0.
|
|
||||||
return _dayNames[date.weekday % 7];
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toggleTimeSlot(String slotId) {
|
|
||||||
final dayKey = _getDayKey(_selectedDate);
|
|
||||||
final currentDaySlots =
|
|
||||||
_timeSlotAvailability[dayKey] ??
|
|
||||||
{'morning': true, 'afternoon': true, 'evening': true};
|
|
||||||
final newValue = !(currentDaySlots[slotId] ?? true);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_timeSlotAvailability[dayKey] = {...currentDaySlots, slotId: newValue};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isTimeSlotActive(String slotId) {
|
|
||||||
final dayKey = _getDayKey(_selectedDate);
|
|
||||||
final daySlots = _timeSlotAvailability[dayKey];
|
|
||||||
if (daySlots == null) return true;
|
|
||||||
return daySlots[slotId] != false;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getMonthYear() {
|
|
||||||
final middleDate = _currentWeekStart.add(const Duration(days: 3));
|
|
||||||
return DateFormat('MMMM yyyy').format(middleDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _quickSet(String type) {
|
|
||||||
Map<String, bool> newAvailability = {};
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'all':
|
|
||||||
for (var day in _dayNames) newAvailability[day] = true;
|
|
||||||
break;
|
|
||||||
case 'weekdays':
|
|
||||||
for (var day in _dayNames)
|
|
||||||
newAvailability[day] = (day != 'saturday' && day != 'sunday');
|
|
||||||
break;
|
|
||||||
case 'weekends':
|
|
||||||
for (var day in _dayNames)
|
|
||||||
newAvailability[day] = (day == 'saturday' || day == 'sunday');
|
|
||||||
break;
|
|
||||||
case 'clear':
|
|
||||||
for (var day in _dayNames) newAvailability[day] = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_availability = newAvailability;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final selectedDayKey = _getDayKey(_selectedDate);
|
return BlocProvider.value(
|
||||||
final isSelectedDayAvailable = _availability[selectedDayKey] ?? false;
|
value: _bloc,
|
||||||
final weekDates = _getWeekDates();
|
child: Scaffold(
|
||||||
|
backgroundColor: AppColors.krowBackground,
|
||||||
return Scaffold(
|
appBar: UiAppBar(
|
||||||
backgroundColor: const Color(
|
title: 'My Availability',
|
||||||
0xFFFAFBFC,
|
centerTitle: false,
|
||||||
), // slate-50 to white gradient approximation
|
showBackButton: true,
|
||||||
body: SingleChildScrollView(
|
),
|
||||||
padding: const EdgeInsets.only(bottom: 100),
|
body: BlocListener<AvailabilityBloc, AvailabilityState>(
|
||||||
child: Column(
|
listener: (context, state) {
|
||||||
children: [
|
if (state is AvailabilityLoaded && state.successMessage != null) {
|
||||||
_buildHeader(),
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||||
Padding(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
SnackBar(
|
||||||
child: Column(
|
content: Text(state.successMessage!),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
backgroundColor: Colors.green,
|
||||||
children: [
|
behavior: SnackBarBehavior.floating,
|
||||||
_buildQuickSet(),
|
),
|
||||||
const SizedBox(height: 24),
|
);
|
||||||
_buildWeekNavigation(weekDates),
|
}
|
||||||
const SizedBox(height: 24),
|
},
|
||||||
_buildSelectedDayAvailability(
|
child: BlocBuilder<AvailabilityBloc, AvailabilityState>(
|
||||||
selectedDayKey,
|
builder: (context, state) {
|
||||||
isSelectedDayAvailable,
|
if (state is AvailabilityLoading) {
|
||||||
),
|
return const Center(child: CircularProgressIndicator());
|
||||||
const SizedBox(height: 24),
|
} else if (state is AvailabilityLoaded) {
|
||||||
_buildInfoCard(),
|
return Stack(
|
||||||
],
|
children: [
|
||||||
),
|
SingleChildScrollView(
|
||||||
),
|
padding: const EdgeInsets.only(bottom: 100),
|
||||||
],
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildQuickSet(context),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildWeekNavigation(context, state),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildSelectedDayAvailability(
|
||||||
|
context,
|
||||||
|
state.selectedDayAvailability,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildInfoCard(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state.isActionInProgress)
|
||||||
|
Container(
|
||||||
|
color: Colors.black.withOpacity(0.3),
|
||||||
|
child: const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else if (state is AvailabilityError) {
|
||||||
|
return Center(child: Text('Error: ${state.message}'));
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeader() {
|
Widget _buildQuickSet(BuildContext context) {
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 60, 20, 20),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(
|
|
||||||
LucideIcons.arrowLeft,
|
|
||||||
color: AppColors.krowCharcoal,
|
|
||||||
),
|
|
||||||
onPressed: () => Modular.to.pop(),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(
|
|
||||||
color: AppColors.krowBlue.withOpacity(0.2),
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: CircleAvatar(
|
|
||||||
backgroundColor: AppColors.krowBlue.withOpacity(
|
|
||||||
0.1,
|
|
||||||
),
|
|
||||||
radius: 18,
|
|
||||||
child: const Text(
|
|
||||||
'K', // Mock initial
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.krowBlue,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
const Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'My Availability',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppColors.krowCharcoal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Set when you can work',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: AppColors.krowMuted,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.krowBlue.withOpacity(0.1),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
LucideIcons.calendar,
|
|
||||||
color: AppColors.krowBlue,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildQuickSet() {
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -340,27 +137,34 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildQuickSetButton('All Week', () => _quickSet('all')),
|
child: _buildQuickSetButton(
|
||||||
|
context,
|
||||||
|
'All Week',
|
||||||
|
'all',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildQuickSetButton(
|
child: _buildQuickSetButton(
|
||||||
|
context,
|
||||||
'Weekdays',
|
'Weekdays',
|
||||||
() => _quickSet('weekdays'),
|
'weekdays',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildQuickSetButton(
|
child: _buildQuickSetButton(
|
||||||
|
context,
|
||||||
'Weekends',
|
'Weekends',
|
||||||
() => _quickSet('weekends'),
|
'weekends',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildQuickSetButton(
|
child: _buildQuickSetButton(
|
||||||
|
context,
|
||||||
'Clear All',
|
'Clear All',
|
||||||
() => _quickSet('clear'),
|
'clear',
|
||||||
isDestructive: true,
|
isDestructive: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -372,14 +176,15 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildQuickSetButton(
|
Widget _buildQuickSetButton(
|
||||||
|
BuildContext context,
|
||||||
String label,
|
String label,
|
||||||
VoidCallback onTap, {
|
String type, {
|
||||||
bool isDestructive = false,
|
bool isDestructive = false,
|
||||||
}) {
|
}) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 32,
|
height: 32,
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: onTap,
|
onPressed: () => context.read<AvailabilityBloc>().add(PerformQuickSet(type)),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
@@ -387,8 +192,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
|||||||
? Colors.red.withOpacity(0.2)
|
? Colors.red.withOpacity(0.2)
|
||||||
: AppColors.krowBlue.withOpacity(0.2),
|
: AppColors.krowBlue.withOpacity(0.2),
|
||||||
),
|
),
|
||||||
backgroundColor:
|
backgroundColor: Colors.transparent,
|
||||||
Colors.transparent,
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
@@ -404,7 +208,11 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildWeekNavigation(List<DateTime> weekDates) {
|
Widget _buildWeekNavigation(BuildContext context, AvailabilityLoaded state) {
|
||||||
|
// Middle date for month display
|
||||||
|
final middleDate = state.currentWeekStart.add(const Duration(days: 3));
|
||||||
|
final monthYear = DateFormat('MMMM yyyy').format(middleDate);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -429,10 +237,10 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
|||||||
children: [
|
children: [
|
||||||
_buildNavButton(
|
_buildNavButton(
|
||||||
LucideIcons.chevronLeft,
|
LucideIcons.chevronLeft,
|
||||||
() => _navigateWeek(-1),
|
() => context.read<AvailabilityBloc>().add(const NavigateWeek(-1)),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
_getMonthYear(),
|
monthYear,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -441,7 +249,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
|||||||
),
|
),
|
||||||
_buildNavButton(
|
_buildNavButton(
|
||||||
LucideIcons.chevronRight,
|
LucideIcons.chevronRight,
|
||||||
() => _navigateWeek(1),
|
() => context.read<AvailabilityBloc>().add(const NavigateWeek(1)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -449,7 +257,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
|||||||
// Days Row
|
// Days Row
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: weekDates.map((date) => _buildDayItem(date)).toList(),
|
children: state.days.map((day) => _buildDayItem(context, day, state.selectedDate)).toList(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -471,15 +279,14 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDayItem(DateTime date) {
|
Widget _buildDayItem(BuildContext context, DayAvailability day, DateTime selectedDate) {
|
||||||
final isSelected = _isSelected(date);
|
final isSelected = AvailabilityLoaded.isSameDay(day.date, selectedDate);
|
||||||
final dayKey = _getDayKey(date);
|
final isAvailable = day.isAvailable;
|
||||||
final isAvailable = _availability[dayKey] ?? false;
|
final isToday = AvailabilityLoaded.isSameDay(day.date, DateTime.now());
|
||||||
final isToday = _isToday(date);
|
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => setState(() => _selectedDate = date),
|
onTap: () => context.read<AvailabilityBloc>().add(SelectDate(day.date)),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
@@ -514,7 +321,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
|||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
date.day.toString().padLeft(2, '0'),
|
day.date.day.toString().padLeft(2, '0'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -527,7 +334,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
_formatDay(date),
|
DateFormat('EEE').format(day.date),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: isSelected
|
color: isSelected
|
||||||
@@ -559,10 +366,11 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSelectedDayAvailability(
|
Widget _buildSelectedDayAvailability(
|
||||||
String selectedDayKey,
|
BuildContext context,
|
||||||
bool isAvailable,
|
DayAvailability day,
|
||||||
) {
|
) {
|
||||||
final dateStr = DateFormat('EEEE, MMM d').format(_selectedDate);
|
final dateStr = DateFormat('EEEE, MMM d').format(day.date);
|
||||||
|
final isAvailable = day.isAvailable;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
@@ -606,7 +414,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
|||||||
),
|
),
|
||||||
Switch(
|
Switch(
|
||||||
value: isAvailable,
|
value: isAvailable,
|
||||||
onChanged: (val) => _toggleDayAvailability(selectedDayKey),
|
onChanged: (val) => context.read<AvailabilityBloc>().add(ToggleDayStatus(day)),
|
||||||
activeColor: AppColors.krowBlue,
|
activeColor: AppColors.krowBlue,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -614,124 +422,164 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
|||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Time Slots
|
// Time Slots (only from Domain)
|
||||||
..._timeSlots.map((slot) {
|
...day.slots.map((slot) {
|
||||||
final isActive = _isTimeSlotActive(slot['slotId']);
|
// Get UI config for this slot ID
|
||||||
// Determine styles based on state
|
final uiConfig = _getSlotUiConfig(slot.id);
|
||||||
final isEnabled =
|
|
||||||
isAvailable; // If day is off, slots are disabled visually
|
|
||||||
|
|
||||||
// Container style
|
return _buildTimeSlotItem(context, day, slot, uiConfig);
|
||||||
Color bgColor;
|
|
||||||
Color borderColor;
|
|
||||||
|
|
||||||
if (!isEnabled) {
|
|
||||||
bgColor = const Color(0xFFF8FAFC); // slate-50
|
|
||||||
borderColor = const Color(0xFFF1F5F9); // slate-100
|
|
||||||
} else if (isActive) {
|
|
||||||
bgColor = AppColors.krowBlue.withOpacity(0.05);
|
|
||||||
borderColor = AppColors.krowBlue.withOpacity(0.2);
|
|
||||||
} else {
|
|
||||||
bgColor = const Color(0xFFF8FAFC); // slate-50
|
|
||||||
borderColor = const Color(0xFFE2E8F0); // slate-200
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text colors
|
|
||||||
final titleColor = (isEnabled && isActive)
|
|
||||||
? AppColors.krowCharcoal
|
|
||||||
: AppColors.krowMuted;
|
|
||||||
final subtitleColor = (isEnabled && isActive)
|
|
||||||
? AppColors.krowMuted
|
|
||||||
: Colors.grey.shade400;
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: isEnabled ? () => _toggleTimeSlot(slot['slotId']) : null,
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: bgColor,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: borderColor, width: 2),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// Icon
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: slot['bg'],
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
slot['icon'],
|
|
||||||
color: slot['iconColor'],
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
// Text
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
slot['label'],
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: titleColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
slot['timeRange'],
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: subtitleColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Checkbox indicator
|
|
||||||
if (isEnabled && isActive)
|
|
||||||
Container(
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: AppColors.krowBlue,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
LucideIcons.check,
|
|
||||||
size: 16,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else if (isEnabled && !isActive)
|
|
||||||
Container(
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: const Color(0xFFCBD5E1),
|
|
||||||
width: 2,
|
|
||||||
), // slate-300
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
}).toList(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _getSlotUiConfig(String slotId) {
|
||||||
|
switch (slotId) {
|
||||||
|
case 'morning':
|
||||||
|
return {
|
||||||
|
'icon': LucideIcons.sunrise,
|
||||||
|
'bg': const Color(0xFFE6EBF9), // bg-[#0032A0]/10
|
||||||
|
'iconColor': const Color(0xFF0032A0),
|
||||||
|
};
|
||||||
|
case 'afternoon':
|
||||||
|
return {
|
||||||
|
'icon': LucideIcons.sun,
|
||||||
|
'bg': const Color(0xFFCCD6EC), // bg-[#0032A0]/20
|
||||||
|
'iconColor': const Color(0xFF0032A0),
|
||||||
|
};
|
||||||
|
case 'evening':
|
||||||
|
return {
|
||||||
|
'icon': LucideIcons.moon,
|
||||||
|
'bg': const Color(0xFFEBEDEE), // bg-[#333F48]/10
|
||||||
|
'iconColor': const Color(0xFF333F48),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
'icon': LucideIcons.clock,
|
||||||
|
'bg': Colors.grey.shade100,
|
||||||
|
'iconColor': Colors.grey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTimeSlotItem(
|
||||||
|
BuildContext context,
|
||||||
|
DayAvailability day,
|
||||||
|
AvailabilitySlot slot,
|
||||||
|
Map<String, dynamic> uiConfig
|
||||||
|
) {
|
||||||
|
// Determine styles based on state
|
||||||
|
final isEnabled = day.isAvailable;
|
||||||
|
final isActive = slot.isAvailable;
|
||||||
|
|
||||||
|
// Container style
|
||||||
|
Color bgColor;
|
||||||
|
Color borderColor;
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
bgColor = const Color(0xFFF8FAFC); // slate-50
|
||||||
|
borderColor = const Color(0xFFF1F5F9); // slate-100
|
||||||
|
} else if (isActive) {
|
||||||
|
bgColor = AppColors.krowBlue.withOpacity(0.05);
|
||||||
|
borderColor = AppColors.krowBlue.withOpacity(0.2);
|
||||||
|
} else {
|
||||||
|
bgColor = const Color(0xFFF8FAFC); // slate-50
|
||||||
|
borderColor = const Color(0xFFE2E8F0); // slate-200
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text colors
|
||||||
|
final titleColor = (isEnabled && isActive)
|
||||||
|
? AppColors.krowCharcoal
|
||||||
|
: AppColors.krowMuted;
|
||||||
|
final subtitleColor = (isEnabled && isActive)
|
||||||
|
? AppColors.krowMuted
|
||||||
|
: Colors.grey.shade400;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: isEnabled ? () => context.read<AvailabilityBloc>().add(ToggleSlotStatus(day, slot.id)) : null,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: borderColor, width: 2),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Icon
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: uiConfig['bg'],
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
uiConfig['icon'],
|
||||||
|
color: uiConfig['iconColor'],
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Text
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
slot.label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: titleColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
slot.timeRange,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: subtitleColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Checkbox indicator
|
||||||
|
if (isEnabled && isActive)
|
||||||
|
Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.krowBlue,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
LucideIcons.check,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (isEnabled && !isActive)
|
||||||
|
Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(0xFFCBD5E1),
|
||||||
|
width: 2,
|
||||||
|
), // slate-300
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildInfoCard() {
|
Widget _buildInfoCard() {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|||||||
@@ -1,693 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flutter_modular/flutter_modular.dart' hide ModularWatchExtension;
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
|
||||||
|
|
||||||
import '../blocs/availability_bloc.dart';
|
|
||||||
import '../blocs/availability_event.dart';
|
|
||||||
import '../blocs/availability_state.dart';
|
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
|
||||||
|
|
||||||
class AvailabilityPage extends StatefulWidget {
|
|
||||||
const AvailabilityPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<AvailabilityPage> createState() => _AvailabilityPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AvailabilityPageState extends State<AvailabilityPage> {
|
|
||||||
final AvailabilityBloc _bloc = Modular.get<AvailabilityBloc>();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_calculateInitialWeek();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _calculateInitialWeek() {
|
|
||||||
final today = DateTime.now();
|
|
||||||
final day = today.weekday; // Mon=1, Sun=7
|
|
||||||
final diff = day - 1; // Assuming Monday start
|
|
||||||
DateTime currentWeekStart = today.subtract(Duration(days: diff));
|
|
||||||
currentWeekStart = DateTime(
|
|
||||||
currentWeekStart.year,
|
|
||||||
currentWeekStart.month,
|
|
||||||
currentWeekStart.day,
|
|
||||||
);
|
|
||||||
_bloc.add(LoadAvailability(currentWeekStart));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return BlocProvider.value(
|
|
||||||
value: _bloc,
|
|
||||||
child: Scaffold(
|
|
||||||
backgroundColor: AppColors.krowBackground,
|
|
||||||
body: BlocBuilder<AvailabilityBloc, AvailabilityState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
if (state is AvailabilityLoading) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
} else if (state is AvailabilityLoaded) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.only(bottom: 100),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
_buildHeader(),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
_buildQuickSet(context),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
_buildWeekNavigation(context, state),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
_buildSelectedDayAvailability(
|
|
||||||
context,
|
|
||||||
state.selectedDayAvailability,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
_buildInfoCard(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (state is AvailabilityError) {
|
|
||||||
return Center(child: Text('Error: ${state.message}'));
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHeader() {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 60, 20, 20),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(
|
|
||||||
LucideIcons.arrowLeft,
|
|
||||||
color: AppColors.krowCharcoal,
|
|
||||||
),
|
|
||||||
onPressed: () => Modular.to.pop(),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(
|
|
||||||
color: AppColors.krowBlue.withOpacity(0.2),
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: CircleAvatar(
|
|
||||||
backgroundColor: AppColors.krowBlue.withOpacity(
|
|
||||||
0.1,
|
|
||||||
),
|
|
||||||
radius: 18,
|
|
||||||
child: const Text(
|
|
||||||
'K', // Mock initial
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.krowBlue,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
const Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'My Availability',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppColors.krowCharcoal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Set when you can work',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: AppColors.krowMuted,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.krowBlue.withOpacity(0.1),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
LucideIcons.calendar,
|
|
||||||
color: AppColors.krowBlue,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildQuickSet(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.krowBlue.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Quick Set Availability',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: Color(0xFF333F48),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _buildQuickSetButton(
|
|
||||||
context,
|
|
||||||
'All Week',
|
|
||||||
'all',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: _buildQuickSetButton(
|
|
||||||
context,
|
|
||||||
'Weekdays',
|
|
||||||
'weekdays',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: _buildQuickSetButton(
|
|
||||||
context,
|
|
||||||
'Weekends',
|
|
||||||
'weekends',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: _buildQuickSetButton(
|
|
||||||
context,
|
|
||||||
'Clear All',
|
|
||||||
'clear',
|
|
||||||
isDestructive: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildQuickSetButton(
|
|
||||||
BuildContext context,
|
|
||||||
String label,
|
|
||||||
String type, {
|
|
||||||
bool isDestructive = false,
|
|
||||||
}) {
|
|
||||||
return SizedBox(
|
|
||||||
height: 32,
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: () => context.read<AvailabilityBloc>().add(PerformQuickSet(type)),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
side: BorderSide(
|
|
||||||
color: isDestructive
|
|
||||||
? Colors.red.withOpacity(0.2)
|
|
||||||
: AppColors.krowBlue.withOpacity(0.2),
|
|
||||||
),
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
foregroundColor: isDestructive ? Colors.red : AppColors.krowBlue,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildWeekNavigation(BuildContext context, AvailabilityLoaded state) {
|
|
||||||
// Middle date for month display
|
|
||||||
final middleDate = state.currentWeekStart.add(const Duration(days: 3));
|
|
||||||
final monthYear = DateFormat('MMMM yyyy').format(middleDate);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: Colors.grey.shade100),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.05),
|
|
||||||
blurRadius: 2,
|
|
||||||
offset: const Offset(0, 1),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Nav Header
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
_buildNavButton(
|
|
||||||
LucideIcons.chevronLeft,
|
|
||||||
() => context.read<AvailabilityBloc>().add(const NavigateWeek(-1)),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
monthYear,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.krowCharcoal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_buildNavButton(
|
|
||||||
LucideIcons.chevronRight,
|
|
||||||
() => context.read<AvailabilityBloc>().add(const NavigateWeek(1)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Days Row
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: state.days.map((day) => _buildDayItem(context, day, state.selectedDate)).toList(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildNavButton(IconData icon, VoidCallback onTap) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Color(0xFFF1F5F9), // slate-100
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(icon, size: 20, color: AppColors.krowMuted),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDayItem(BuildContext context, DayAvailability day, DateTime selectedDate) {
|
|
||||||
final isSelected = AvailabilityLoaded.isSameDay(day.date, selectedDate);
|
|
||||||
final isAvailable = day.isAvailable;
|
|
||||||
final isToday = AvailabilityLoaded.isSameDay(day.date, DateTime.now());
|
|
||||||
|
|
||||||
return Expanded(
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () => context.read<AvailabilityBloc>().add(SelectDate(day.date)),
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected
|
|
||||||
? AppColors.krowBlue
|
|
||||||
: (isAvailable
|
|
||||||
? const Color(0xFFECFDF5)
|
|
||||||
: const Color(0xFFF8FAFC)), // emerald-50 or slate-50
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? AppColors.krowBlue
|
|
||||||
: (isAvailable
|
|
||||||
? const Color(0xFFA7F3D0)
|
|
||||||
: Colors.transparent), // emerald-200
|
|
||||||
),
|
|
||||||
boxShadow: isSelected
|
|
||||||
? [
|
|
||||||
BoxShadow(
|
|
||||||
color: AppColors.krowBlue.withOpacity(0.3),
|
|
||||||
blurRadius: 8,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
day.date.day.toString().padLeft(2, '0'),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: isSelected
|
|
||||||
? Colors.white
|
|
||||||
: (isAvailable
|
|
||||||
? const Color(0xFF047857)
|
|
||||||
: AppColors.krowMuted), // emerald-700
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
DateFormat('EEE').format(day.date),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
color: isSelected
|
|
||||||
? Colors.white.withOpacity(0.8)
|
|
||||||
: (isAvailable
|
|
||||||
? const Color(0xFF047857)
|
|
||||||
: AppColors.krowMuted),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (isToday && !isSelected)
|
|
||||||
Positioned(
|
|
||||||
bottom: -8,
|
|
||||||
child: Container(
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: AppColors.krowBlue,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSelectedDayAvailability(
|
|
||||||
BuildContext context,
|
|
||||||
DayAvailability day,
|
|
||||||
) {
|
|
||||||
final dateStr = DateFormat('EEEE, MMM d').format(day.date);
|
|
||||||
final isAvailable = day.isAvailable;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: Colors.grey.shade100),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.05),
|
|
||||||
blurRadius: 2,
|
|
||||||
offset: const Offset(0, 1),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Header Row
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
dateStr,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.krowCharcoal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
isAvailable ? 'You are available' : 'Not available',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: AppColors.krowMuted,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Switch(
|
|
||||||
value: isAvailable,
|
|
||||||
onChanged: (val) => context.read<AvailabilityBloc>().add(ToggleDayStatus(day)),
|
|
||||||
activeColor: AppColors.krowBlue,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Time Slots (only from Domain)
|
|
||||||
...day.slots.map((slot) {
|
|
||||||
// Get UI config for this slot ID
|
|
||||||
final uiConfig = _getSlotUiConfig(slot.id);
|
|
||||||
|
|
||||||
return _buildTimeSlotItem(context, day, slot, uiConfig);
|
|
||||||
}).toList(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> _getSlotUiConfig(String slotId) {
|
|
||||||
switch (slotId) {
|
|
||||||
case 'morning':
|
|
||||||
return {
|
|
||||||
'icon': LucideIcons.sunrise,
|
|
||||||
'bg': const Color(0xFFE6EBF9), // bg-[#0032A0]/10
|
|
||||||
'iconColor': const Color(0xFF0032A0),
|
|
||||||
};
|
|
||||||
case 'afternoon':
|
|
||||||
return {
|
|
||||||
'icon': LucideIcons.sun,
|
|
||||||
'bg': const Color(0xFFCCD6EC), // bg-[#0032A0]/20
|
|
||||||
'iconColor': const Color(0xFF0032A0),
|
|
||||||
};
|
|
||||||
case 'evening':
|
|
||||||
return {
|
|
||||||
'icon': LucideIcons.moon,
|
|
||||||
'bg': const Color(0xFFEBEDEE), // bg-[#333F48]/10
|
|
||||||
'iconColor': const Color(0xFF333F48),
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
'icon': LucideIcons.clock,
|
|
||||||
'bg': Colors.grey.shade100,
|
|
||||||
'iconColor': Colors.grey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTimeSlotItem(
|
|
||||||
BuildContext context,
|
|
||||||
DayAvailability day,
|
|
||||||
AvailabilitySlot slot,
|
|
||||||
Map<String, dynamic> uiConfig
|
|
||||||
) {
|
|
||||||
// Determine styles based on state
|
|
||||||
final isEnabled = day.isAvailable;
|
|
||||||
final isActive = slot.isAvailable;
|
|
||||||
|
|
||||||
// Container style
|
|
||||||
Color bgColor;
|
|
||||||
Color borderColor;
|
|
||||||
|
|
||||||
if (!isEnabled) {
|
|
||||||
bgColor = const Color(0xFFF8FAFC); // slate-50
|
|
||||||
borderColor = const Color(0xFFF1F5F9); // slate-100
|
|
||||||
} else if (isActive) {
|
|
||||||
bgColor = AppColors.krowBlue.withOpacity(0.05);
|
|
||||||
borderColor = AppColors.krowBlue.withOpacity(0.2);
|
|
||||||
} else {
|
|
||||||
bgColor = const Color(0xFFF8FAFC); // slate-50
|
|
||||||
borderColor = const Color(0xFFE2E8F0); // slate-200
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text colors
|
|
||||||
final titleColor = (isEnabled && isActive)
|
|
||||||
? AppColors.krowCharcoal
|
|
||||||
: AppColors.krowMuted;
|
|
||||||
final subtitleColor = (isEnabled && isActive)
|
|
||||||
? AppColors.krowMuted
|
|
||||||
: Colors.grey.shade400;
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: isEnabled ? () => context.read<AvailabilityBloc>().add(ToggleSlotStatus(day, slot.id)) : null,
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: bgColor,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: borderColor, width: 2),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// Icon
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: uiConfig['bg'],
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
uiConfig['icon'],
|
|
||||||
color: uiConfig['iconColor'],
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
// Text
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
slot.label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: titleColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
slot.timeRange,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: subtitleColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Checkbox indicator
|
|
||||||
if (isEnabled && isActive)
|
|
||||||
Container(
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: AppColors.krowBlue,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
LucideIcons.check,
|
|
||||||
size: 16,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else if (isEnabled && !isActive)
|
|
||||||
Container(
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: const Color(0xFFCBD5E1),
|
|
||||||
width: 2,
|
|
||||||
), // slate-300
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildInfoCard() {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.krowBlue.withOpacity(0.05),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: const Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(LucideIcons.clock, size: 20, color: AppColors.krowBlue),
|
|
||||||
SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Auto-Match uses your availability',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: AppColors.krowCharcoal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
"When enabled, you'll only be matched with shifts during your available times.",
|
|
||||||
style: TextStyle(fontSize: 12, color: AppColors.krowMuted),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AppColors {
|
|
||||||
static const Color krowBlue = Color(0xFF0A39DF);
|
|
||||||
static const Color krowYellow = Color(0xFFFFED4A);
|
|
||||||
static const Color krowCharcoal = Color(0xFF121826);
|
|
||||||
static const Color krowMuted = Color(0xFF6A7382);
|
|
||||||
static const Color krowBorder = Color(0xFFE3E6E9);
|
|
||||||
static const Color krowBackground = Color(0xFFFAFBFC);
|
|
||||||
|
|
||||||
static const Color white = Colors.white;
|
|
||||||
static const Color black = Colors.black;
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,28 @@
|
|||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'data/repositories/availability_repository_impl.dart';
|
import 'package:staff_availability/src/presentation/pages/availability_page.dart';
|
||||||
|
|
||||||
|
import 'data/repositories_impl/availability_repository_impl.dart';
|
||||||
import 'domain/repositories/availability_repository.dart';
|
import 'domain/repositories/availability_repository.dart';
|
||||||
import 'domain/usecases/apply_quick_set_usecase.dart';
|
import 'domain/usecases/apply_quick_set_usecase.dart';
|
||||||
import 'domain/usecases/get_weekly_availability_usecase.dart';
|
import 'domain/usecases/get_weekly_availability_usecase.dart';
|
||||||
import 'domain/usecases/update_day_availability_usecase.dart';
|
import 'domain/usecases/update_day_availability_usecase.dart';
|
||||||
import 'presentation/blocs/availability_bloc.dart';
|
import 'presentation/blocs/availability_bloc.dart';
|
||||||
import 'presentation/pages/availability_page.dart';
|
|
||||||
|
|
||||||
class StaffAvailabilityModule extends Module {
|
class StaffAvailabilityModule extends Module {
|
||||||
@override
|
@override
|
||||||
void binds(i) {
|
List<Module> get imports => [DataConnectModule()];
|
||||||
// Data Sources
|
|
||||||
i.add(StaffRepositoryMock.new);
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
void binds(Injector i) {
|
||||||
// Repository
|
// Repository
|
||||||
i.add<AvailabilityRepository>(AvailabilityRepositoryImpl.new);
|
i.add<AvailabilityRepository>(
|
||||||
|
() => AvailabilityRepositoryImpl(
|
||||||
|
dataConnect: ExampleConnector.instance,
|
||||||
|
firebaseAuth: FirebaseAuth.instance,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
i.add(GetWeeklyAvailabilityUseCase.new);
|
i.add(GetWeeklyAvailabilityUseCase.new);
|
||||||
@@ -27,7 +34,7 @@ class StaffAvailabilityModule extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void routes(r) {
|
void routes(RouteManager r) {
|
||||||
r.child('/', child: (_) => const AvailabilityPage());
|
r.child('/', child: (_) => const AvailabilityPage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ dependencies:
|
|||||||
krow_core:
|
krow_core:
|
||||||
path: ../../../core
|
path: ../../../core
|
||||||
firebase_data_connect: ^0.2.2+2
|
firebase_data_connect: ^0.2.2+2
|
||||||
|
firebase_auth: ^6.1.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import '../../domain/repositories/clock_in_repository_interface.dart';
|
|
||||||
|
|
||||||
/// Implementation of [ClockInRepositoryInterface] using Mock Data.
|
|
||||||
///
|
|
||||||
/// This implementation uses hardcoded data to match the prototype UI.
|
|
||||||
class ClockInRepositoryImpl implements ClockInRepositoryInterface {
|
|
||||||
|
|
||||||
ClockInRepositoryImpl();
|
|
||||||
|
|
||||||
// Local state for the mock implementation
|
|
||||||
bool _isCheckedIn = false;
|
|
||||||
DateTime? _checkInTime;
|
|
||||||
DateTime? _checkOutTime;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Shift?> getTodaysShift() async {
|
|
||||||
// Simulate network delay
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
// Mock Shift matching the prototype
|
|
||||||
return Shift(
|
|
||||||
id: '1',
|
|
||||||
title: 'Warehouse Assistant',
|
|
||||||
clientName: 'Amazon Warehouse',
|
|
||||||
logoUrl:
|
|
||||||
'https://upload.wikimedia.org/wikipedia/commons/thumb/0/06/Amazon_2024.svg/500px-Amazon_2024.svg.png',
|
|
||||||
hourlyRate: 22.50,
|
|
||||||
location: 'San Francisco, CA',
|
|
||||||
locationAddress: '123 Market St, San Francisco, CA 94105',
|
|
||||||
date: DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
|
||||||
startTime: '09:00',
|
|
||||||
endTime: '17:00',
|
|
||||||
createdDate: DateTime.now().subtract(const Duration(days: 2)).toIso8601String(),
|
|
||||||
status: 'assigned',
|
|
||||||
description: 'General warehouse duties including packing and sorting.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Map<String, dynamic>> getAttendanceStatus() async {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 300));
|
|
||||||
return {
|
|
||||||
'isCheckedIn': _isCheckedIn,
|
|
||||||
'checkInTime': _checkInTime,
|
|
||||||
'checkOutTime': _checkOutTime,
|
|
||||||
'activeShiftId': '1',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Map<String, dynamic>> clockIn({required String shiftId, String? notes}) async {
|
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
_isCheckedIn = true;
|
|
||||||
_checkInTime = DateTime.now();
|
|
||||||
|
|
||||||
return getAttendanceStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Map<String, dynamic>> clockOut({String? notes, int? breakTimeMinutes}) async {
|
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
_isCheckedIn = false;
|
|
||||||
_checkOutTime = DateTime.now();
|
|
||||||
|
|
||||||
return getAttendanceStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<Map<String, dynamic>>> getActivityLog() async {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 300));
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
'date': DateTime.now().subtract(const Duration(days: 1)),
|
|
||||||
'start': '09:00 AM',
|
|
||||||
'end': '05:00 PM',
|
|
||||||
'hours': '8h',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'date': DateTime.now().subtract(const Duration(days: 2)),
|
|
||||||
'start': '09:00 AM',
|
|
||||||
'end': '05:00 PM',
|
|
||||||
'hours': '8h',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'date': DateTime.now().subtract(const Duration(days: 3)),
|
|
||||||
'start': '09:00 AM',
|
|
||||||
'end': '05:00 PM',
|
|
||||||
'hours': '8h',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||||
|
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import '../../domain/repositories/clock_in_repository_interface.dart';
|
||||||
|
|
||||||
|
/// Implementation of [ClockInRepositoryInterface] using Firebase Data Connect.
|
||||||
|
class ClockInRepositoryImpl implements ClockInRepositoryInterface {
|
||||||
|
final dc.ExampleConnector _dataConnect;
|
||||||
|
final firebase.FirebaseAuth _firebaseAuth;
|
||||||
|
|
||||||
|
ClockInRepositoryImpl({
|
||||||
|
required dc.ExampleConnector dataConnect,
|
||||||
|
required firebase.FirebaseAuth firebaseAuth,
|
||||||
|
}) : _dataConnect = dataConnect,
|
||||||
|
_firebaseAuth = firebaseAuth;
|
||||||
|
|
||||||
|
Future<String> _getStaffId() async {
|
||||||
|
final firebase.User? user = _firebaseAuth.currentUser;
|
||||||
|
if (user == null) throw Exception('User not authenticated');
|
||||||
|
|
||||||
|
final QueryResult<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables> result =
|
||||||
|
await _dataConnect.getStaffByUserId(userId: user.uid).execute();
|
||||||
|
if (result.data.staffs.isEmpty) {
|
||||||
|
throw Exception('Staff profile not found');
|
||||||
|
}
|
||||||
|
return result.data.staffs.first.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to convert Data Connect Timestamp to DateTime
|
||||||
|
DateTime? _toDateTime(dynamic t) {
|
||||||
|
if (t == null) return null;
|
||||||
|
// Attempt to use toJson assuming it matches the generated code's expectation of String
|
||||||
|
try {
|
||||||
|
// If t has toDate (e.g. cloud_firestore), usage would be t.toDate()
|
||||||
|
// But here we rely on toJson or toString
|
||||||
|
return DateTime.tryParse(t.toJson() as String);
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
return DateTime.tryParse(t.toString());
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to create Timestamp from DateTime
|
||||||
|
Timestamp _fromDateTime(DateTime d) {
|
||||||
|
// Assuming Timestamp.fromJson takes an ISO string
|
||||||
|
return Timestamp.fromJson(d.toIso8601String());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to find today's active application
|
||||||
|
Future<dc.GetApplicationsByStaffIdApplications?> _getTodaysApplication(String staffId) async {
|
||||||
|
final DateTime now = DateTime.now();
|
||||||
|
|
||||||
|
// Fetch recent applications (assuming meaningful limit)
|
||||||
|
final QueryResult<dc.GetApplicationsByStaffIdData, dc.GetApplicationsByStaffIdVariables> result =
|
||||||
|
await _dataConnect.getApplicationsByStaffId(
|
||||||
|
staffId: staffId,
|
||||||
|
).limit(20).execute();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return result.data.applications.firstWhere((dc.GetApplicationsByStaffIdApplications app) {
|
||||||
|
final DateTime? shiftTime = _toDateTime(app.shift.startTime);
|
||||||
|
|
||||||
|
if (shiftTime == null) return false;
|
||||||
|
|
||||||
|
final bool isSameDay = shiftTime.year == now.year &&
|
||||||
|
shiftTime.month == now.month &&
|
||||||
|
shiftTime.day == now.day;
|
||||||
|
|
||||||
|
if (!isSameDay) return false;
|
||||||
|
|
||||||
|
// Check Status
|
||||||
|
final dynamic status = app.status.stringValue;
|
||||||
|
return status != 'PENDING' && status != 'REJECTED' && status != 'NO_SHOW' && status != 'CANCELED';
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Shift?> getTodaysShift() async {
|
||||||
|
final String staffId = await _getStaffId();
|
||||||
|
final dc.GetApplicationsByStaffIdApplications? app = await _getTodaysApplication(staffId);
|
||||||
|
|
||||||
|
if (app == null) return null;
|
||||||
|
|
||||||
|
final dc.GetApplicationsByStaffIdApplicationsShift shift = app.shift;
|
||||||
|
|
||||||
|
final QueryResult<dc.GetShiftByIdData, dc.GetShiftByIdVariables> shiftResult =
|
||||||
|
await _dataConnect.getShiftById(id: shift.id).execute();
|
||||||
|
|
||||||
|
if (shiftResult.data.shift == null) return null;
|
||||||
|
|
||||||
|
final dc.GetShiftByIdShift fullShift = shiftResult.data.shift!;
|
||||||
|
|
||||||
|
return Shift(
|
||||||
|
id: fullShift.id,
|
||||||
|
title: fullShift.title,
|
||||||
|
clientName: fullShift.order.business.businessName,
|
||||||
|
logoUrl: '', // Not available in GetShiftById
|
||||||
|
hourlyRate: 0.0,
|
||||||
|
location: fullShift.location ?? '',
|
||||||
|
locationAddress: fullShift.locationAddress ?? '',
|
||||||
|
date: _toDateTime(fullShift.startTime)?.toIso8601String() ?? '',
|
||||||
|
startTime: _toDateTime(fullShift.startTime)?.toIso8601String() ?? '',
|
||||||
|
endTime: _toDateTime(fullShift.endTime)?.toIso8601String() ?? '',
|
||||||
|
createdDate: _toDateTime(fullShift.createdAt)?.toIso8601String() ?? '',
|
||||||
|
status: fullShift.status?.stringValue,
|
||||||
|
description: fullShift.description,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AttendanceStatus> getAttendanceStatus() async {
|
||||||
|
final String staffId = await _getStaffId();
|
||||||
|
final dc.GetApplicationsByStaffIdApplications? app = await _getTodaysApplication(staffId);
|
||||||
|
|
||||||
|
if (app == null) {
|
||||||
|
return const AttendanceStatus(isCheckedIn: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClockInAdapter.toAttendanceStatus(
|
||||||
|
status: app.status.stringValue,
|
||||||
|
checkInTime: _toDateTime(app.checkInTime),
|
||||||
|
checkOutTime: _toDateTime(app.checkOutTime),
|
||||||
|
activeShiftId: app.shiftId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AttendanceStatus> clockIn({required String shiftId, String? notes}) async {
|
||||||
|
final String staffId = await _getStaffId();
|
||||||
|
|
||||||
|
final QueryResult<dc.GetApplicationsByStaffIdData, dc.GetApplicationsByStaffIdVariables> appsResult =
|
||||||
|
await _dataConnect.getApplicationsByStaffId(staffId: staffId).execute();
|
||||||
|
|
||||||
|
final dc.GetApplicationsByStaffIdApplications app = appsResult.data.applications.firstWhere((dc.GetApplicationsByStaffIdApplications a) => a.shiftId == shiftId);
|
||||||
|
|
||||||
|
await _dataConnect.updateApplicationStatus(
|
||||||
|
id: app.id,
|
||||||
|
roleId: app.shiftRole.id,
|
||||||
|
)
|
||||||
|
.status(dc.ApplicationStatus.CHECKED_IN)
|
||||||
|
.checkInTime(_fromDateTime(DateTime.now()))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return getAttendanceStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AttendanceStatus> clockOut({String? notes, int? breakTimeMinutes}) async {
|
||||||
|
final String staffId = await _getStaffId();
|
||||||
|
|
||||||
|
final dc.GetApplicationsByStaffIdApplications? app = await _getTodaysApplication(staffId);
|
||||||
|
if (app == null) throw Exception('No active shift found to clock out');
|
||||||
|
|
||||||
|
await _dataConnect.updateApplicationStatus(
|
||||||
|
id: app.id,
|
||||||
|
roleId: app.shiftRole.id,
|
||||||
|
)
|
||||||
|
.status(dc.ApplicationStatus.CHECKED_OUT)
|
||||||
|
.checkOutTime(_fromDateTime(DateTime.now()))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return getAttendanceStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Map<String, dynamic>>> getActivityLog() async {
|
||||||
|
// Placeholder as this wasn't main focus and returns raw maps
|
||||||
|
return <Map<String, dynamic>>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
|
||||||
|
|
||||||
/// Repository interface for Clock In/Out functionality
|
|
||||||
abstract class ClockInRepository {
|
|
||||||
|
|
||||||
/// Retrieves the shift assigned to the user for the current day.
|
|
||||||
/// Returns null if no shift is assigned for today.
|
|
||||||
Future<Shift?> getTodaysShift();
|
|
||||||
|
|
||||||
/// Gets the current attendance status (e.g., checked in or not, times).
|
|
||||||
/// This helps in restoring the UI state if the app was killed.
|
|
||||||
Future<AttendanceStatus> getAttendanceStatus();
|
|
||||||
|
|
||||||
/// Checks the user in for the specified [shiftId].
|
|
||||||
/// Returns the updated [AttendanceStatus].
|
|
||||||
Future<AttendanceStatus> clockIn({required String shiftId, String? notes});
|
|
||||||
|
|
||||||
/// Checks the user out for the currently active shift.
|
|
||||||
/// Optionally accepts [breakTimeMinutes] if tracked.
|
|
||||||
Future<AttendanceStatus> clockOut({String? notes, int? breakTimeMinutes});
|
|
||||||
|
|
||||||
/// Retrieves a list of recent clock-in/out activities.
|
|
||||||
Future<List<Map<String, dynamic>>> getActivityLog();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simple entity to hold attendance state
|
|
||||||
class AttendanceStatus {
|
|
||||||
final bool isCheckedIn;
|
|
||||||
final DateTime? checkInTime;
|
|
||||||
final DateTime? checkOutTime;
|
|
||||||
final String? activeShiftId;
|
|
||||||
|
|
||||||
const AttendanceStatus({
|
|
||||||
this.isCheckedIn = false,
|
|
||||||
this.checkInTime,
|
|
||||||
this.checkOutTime,
|
|
||||||
this.activeShiftId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,35 +1,24 @@
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
/// Interface for the Clock In feature repository.
|
/// Repository interface for Clock In/Out functionality
|
||||||
///
|
abstract class ClockInRepositoryInterface {
|
||||||
/// Defines the methods for managing clock-in/out operations and retrieving
|
|
||||||
/// related shift and attendance data.
|
/// Retrieves the shift assigned to the user for the current day.
|
||||||
abstract interface class ClockInRepositoryInterface {
|
/// Returns null if no shift is assigned for today.
|
||||||
/// Retrieves the shift scheduled for today.
|
|
||||||
Future<Shift?> getTodaysShift();
|
Future<Shift?> getTodaysShift();
|
||||||
|
|
||||||
/// Retrieves the current attendance status (check-in time, check-out time, etc.).
|
/// Gets the current attendance status (e.g., checked in or not, times).
|
||||||
///
|
/// This helps in restoring the UI state if the app was killed.
|
||||||
/// Returns a Map containing:
|
Future<AttendanceStatus> getAttendanceStatus();
|
||||||
/// - 'isCheckedIn': bool
|
|
||||||
/// - 'checkInTime': DateTime?
|
|
||||||
/// - 'checkOutTime': DateTime?
|
|
||||||
Future<Map<String, dynamic>> getAttendanceStatus();
|
|
||||||
|
|
||||||
/// Clocks the user in for a specific shift.
|
/// Checks the user in for the specified [shiftId].
|
||||||
Future<Map<String, dynamic>> clockIn({
|
/// Returns the updated [AttendanceStatus].
|
||||||
required String shiftId,
|
Future<AttendanceStatus> clockIn({required String shiftId, String? notes});
|
||||||
String? notes,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Clocks the user out of the current shift.
|
/// Checks the user out for the currently active shift.
|
||||||
Future<Map<String, dynamic>> clockOut({
|
/// Optionally accepts [breakTimeMinutes] if tracked.
|
||||||
String? notes,
|
Future<AttendanceStatus> clockOut({String? notes, int? breakTimeMinutes});
|
||||||
int? breakTimeMinutes,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Retrieves the history of clock-in/out activity.
|
/// Retrieves a list of recent clock-in/out activities.
|
||||||
///
|
|
||||||
/// Returns a list of maps, where each map represents an activity entry.
|
|
||||||
Future<List<Map<String, dynamic>>> getActivityLog();
|
Future<List<Map<String, dynamic>>> getActivityLog();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../repositories/clock_in_repository_interface.dart';
|
import '../repositories/clock_in_repository_interface.dart';
|
||||||
import '../arguments/clock_in_arguments.dart';
|
import '../arguments/clock_in_arguments.dart';
|
||||||
|
|
||||||
/// Use case for clocking in a user.
|
/// Use case for clocking in a user.
|
||||||
class ClockInUseCase implements UseCase<ClockInArguments, Map<String, dynamic>> {
|
class ClockInUseCase implements UseCase<ClockInArguments, AttendanceStatus> {
|
||||||
final ClockInRepositoryInterface _repository;
|
final ClockInRepositoryInterface _repository;
|
||||||
|
|
||||||
ClockInUseCase(this._repository);
|
ClockInUseCase(this._repository);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Map<String, dynamic>> call(ClockInArguments arguments) {
|
Future<AttendanceStatus> call(ClockInArguments arguments) {
|
||||||
return _repository.clockIn(
|
return _repository.clockIn(
|
||||||
shiftId: arguments.shiftId,
|
shiftId: arguments.shiftId,
|
||||||
notes: arguments.notes,
|
notes: arguments.notes,
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../repositories/clock_in_repository_interface.dart';
|
import '../repositories/clock_in_repository_interface.dart';
|
||||||
import '../arguments/clock_out_arguments.dart';
|
import '../arguments/clock_out_arguments.dart';
|
||||||
|
|
||||||
/// Use case for clocking out a user.
|
/// Use case for clocking out a user.
|
||||||
class ClockOutUseCase implements UseCase<ClockOutArguments, Map<String, dynamic>> {
|
class ClockOutUseCase implements UseCase<ClockOutArguments, AttendanceStatus> {
|
||||||
final ClockInRepositoryInterface _repository;
|
final ClockInRepositoryInterface _repository;
|
||||||
|
|
||||||
ClockOutUseCase(this._repository);
|
ClockOutUseCase(this._repository);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Map<String, dynamic>> call(ClockOutArguments arguments) {
|
Future<AttendanceStatus> call(ClockOutArguments arguments) {
|
||||||
return _repository.clockOut(
|
return _repository.clockOut(
|
||||||
notes: arguments.notes,
|
notes: arguments.notes,
|
||||||
breakTimeMinutes: arguments.breakTimeMinutes,
|
breakTimeMinutes: arguments.breakTimeMinutes,
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../repositories/clock_in_repository_interface.dart';
|
import '../repositories/clock_in_repository_interface.dart';
|
||||||
|
|
||||||
/// Use case for getting the current attendance status (check-in/out times).
|
/// Use case for getting the current attendance status (check-in/out times).
|
||||||
class GetAttendanceStatusUseCase implements NoInputUseCase<Map<String, dynamic>> {
|
class GetAttendanceStatusUseCase implements NoInputUseCase<AttendanceStatus> {
|
||||||
final ClockInRepositoryInterface _repository;
|
final ClockInRepositoryInterface _repository;
|
||||||
|
|
||||||
GetAttendanceStatusUseCase(this._repository);
|
GetAttendanceStatusUseCase(this._repository);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Map<String, dynamic>> call() {
|
Future<AttendanceStatus> call() {
|
||||||
return _repository.getAttendanceStatus();
|
return _repository.getAttendanceStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,15 +33,8 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
|
|||||||
on<CheckInRequested>(_onCheckIn);
|
on<CheckInRequested>(_onCheckIn);
|
||||||
on<CheckOutRequested>(_onCheckOut);
|
on<CheckOutRequested>(_onCheckOut);
|
||||||
on<CheckInModeChanged>(_onModeChanged);
|
on<CheckInModeChanged>(_onModeChanged);
|
||||||
}
|
|
||||||
|
|
||||||
AttendanceStatus _mapToStatus(Map<String, dynamic> map) {
|
add(ClockInPageLoaded());
|
||||||
return AttendanceStatus(
|
|
||||||
isCheckedIn: map['isCheckedIn'] as bool? ?? false,
|
|
||||||
checkInTime: map['checkInTime'] as DateTime?,
|
|
||||||
checkOutTime: map['checkOutTime'] as DateTime?,
|
|
||||||
activeShiftId: map['activeShiftId'] as String?,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onLoaded(
|
Future<void> _onLoaded(
|
||||||
@@ -51,13 +44,13 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
|
|||||||
emit(state.copyWith(status: ClockInStatus.loading));
|
emit(state.copyWith(status: ClockInStatus.loading));
|
||||||
try {
|
try {
|
||||||
final shift = await _getTodaysShift();
|
final shift = await _getTodaysShift();
|
||||||
final statusMap = await _getAttendanceStatus();
|
final status = await _getAttendanceStatus();
|
||||||
final activity = await _getActivityLog();
|
final activity = await _getActivityLog();
|
||||||
|
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
status: ClockInStatus.success,
|
status: ClockInStatus.success,
|
||||||
todayShift: shift,
|
todayShift: shift,
|
||||||
attendance: _mapToStatus(statusMap),
|
attendance: status,
|
||||||
activityLog: activity,
|
activityLog: activity,
|
||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -88,12 +81,12 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
|
|||||||
) async {
|
) async {
|
||||||
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
||||||
try {
|
try {
|
||||||
final newStatusMap = await _clockIn(
|
final newStatus = await _clockIn(
|
||||||
ClockInArguments(shiftId: event.shiftId, notes: event.notes),
|
ClockInArguments(shiftId: event.shiftId, notes: event.notes),
|
||||||
);
|
);
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
status: ClockInStatus.success,
|
status: ClockInStatus.success,
|
||||||
attendance: _mapToStatus(newStatusMap),
|
attendance: newStatus,
|
||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
@@ -109,7 +102,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
|
|||||||
) async {
|
) async {
|
||||||
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
||||||
try {
|
try {
|
||||||
final newStatusMap = await _clockOut(
|
final newStatus = await _clockOut(
|
||||||
ClockOutArguments(
|
ClockOutArguments(
|
||||||
notes: event.notes,
|
notes: event.notes,
|
||||||
breakTimeMinutes: 0, // Should be passed from event if supported
|
breakTimeMinutes: 0, // Should be passed from event if supported
|
||||||
@@ -117,7 +110,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
|
|||||||
);
|
);
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
status: ClockInStatus.success,
|
status: ClockInStatus.success,
|
||||||
attendance: _mapToStatus(newStatusMap),
|
attendance: newStatus,
|
||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
|
|||||||
@@ -3,24 +3,6 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
|
|
||||||
enum ClockInStatus { initial, loading, success, failure, actionInProgress }
|
enum ClockInStatus { initial, loading, success, failure, actionInProgress }
|
||||||
|
|
||||||
/// View model representing the user's current attendance state.
|
|
||||||
class AttendanceStatus extends Equatable {
|
|
||||||
final bool isCheckedIn;
|
|
||||||
final DateTime? checkInTime;
|
|
||||||
final DateTime? checkOutTime;
|
|
||||||
final String? activeShiftId;
|
|
||||||
|
|
||||||
const AttendanceStatus({
|
|
||||||
this.isCheckedIn = false,
|
|
||||||
this.checkInTime,
|
|
||||||
this.checkOutTime,
|
|
||||||
this.activeShiftId,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [isCheckedIn, checkInTime, checkOutTime, activeShiftId];
|
|
||||||
}
|
|
||||||
|
|
||||||
class ClockInState extends Equatable {
|
class ClockInState extends Equatable {
|
||||||
final ClockInStatus status;
|
final ClockInStatus status;
|
||||||
final Shift? todayShift;
|
final Shift? todayShift;
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
import '../bloc/clock_in_bloc.dart';
|
import '../bloc/clock_in_bloc.dart';
|
||||||
import '../bloc/clock_in_event.dart';
|
import '../bloc/clock_in_event.dart';
|
||||||
import '../bloc/clock_in_state.dart';
|
import '../bloc/clock_in_state.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
import '../widgets/attendance_card.dart';
|
import '../widgets/attendance_card.dart';
|
||||||
import '../widgets/date_selector.dart';
|
|
||||||
import '../widgets/swipe_to_check_in.dart';
|
|
||||||
import '../widgets/lunch_break_modal.dart';
|
|
||||||
import '../widgets/commute_tracker.dart';
|
import '../widgets/commute_tracker.dart';
|
||||||
|
import '../widgets/date_selector.dart';
|
||||||
|
import '../widgets/lunch_break_modal.dart';
|
||||||
|
import '../widgets/swipe_to_check_in.dart';
|
||||||
|
|
||||||
class ClockInPage extends StatefulWidget {
|
class ClockInPage extends StatefulWidget {
|
||||||
const ClockInPage({super.key});
|
const ClockInPage({super.key});
|
||||||
@@ -28,23 +29,24 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_bloc = Modular.get<ClockInBloc>();
|
_bloc = Modular.get<ClockInBloc>();
|
||||||
_bloc.add(ClockInPageLoaded());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider.value(
|
return BlocProvider<ClockInBloc>.value(
|
||||||
value: _bloc,
|
value: _bloc,
|
||||||
child: BlocConsumer<ClockInBloc, ClockInState>(
|
child: BlocConsumer<ClockInBloc, ClockInState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state.status == ClockInStatus.failure && state.errorMessage != null) {
|
if (state.status == ClockInStatus.failure &&
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
state.errorMessage != null) {
|
||||||
SnackBar(content: Text(state.errorMessage!)),
|
ScaffoldMessenger.of(
|
||||||
);
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(state.errorMessage!)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.status == ClockInStatus.loading && state.todayShift == null) {
|
if (state.status == ClockInStatus.loading &&
|
||||||
|
state.todayShift == null) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
body: Center(child: CircularProgressIndicator()),
|
body: Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
@@ -64,416 +66,320 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
: '--:-- --';
|
: '--:-- --';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
appBar: UiAppBar(
|
||||||
body: Container(
|
titleWidget: Text(
|
||||||
decoration: const BoxDecoration(
|
'Clock In to your Shift',
|
||||||
gradient: LinearGradient(
|
style: UiTypography.title1m.textPrimary,
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [
|
|
||||||
Color(0xFFF8FAFC), // slate-50
|
|
||||||
Colors.white,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
showBackButton: false,
|
||||||
child: SingleChildScrollView(
|
centerTitle: false,
|
||||||
padding: const EdgeInsets.only(bottom: 100),
|
),
|
||||||
child: Column(
|
body: SafeArea(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: SingleChildScrollView(
|
||||||
children: [
|
padding: const EdgeInsets.only(
|
||||||
_buildHeader(),
|
bottom: UiConstants.space24,
|
||||||
|
top: UiConstants.space6,
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: <Widget>[
|
||||||
// Commute Tracker (shows before date selector when applicable)
|
Padding(
|
||||||
if (todayShift != null)
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
CommuteTracker(
|
child: Column(
|
||||||
shift: todayShift,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
hasLocationConsent: false, // Mock value
|
children: [
|
||||||
isCommuteModeOn: false, // Mock value
|
// Commute Tracker (shows before date selector when applicable)
|
||||||
distanceMeters: 500, // Mock value for demo
|
if (todayShift != null)
|
||||||
etaMinutes: 8, // Mock value for demo
|
CommuteTracker(
|
||||||
),
|
shift: todayShift,
|
||||||
// Date Selector
|
hasLocationConsent: false, // Mock value
|
||||||
DateSelector(
|
isCommuteModeOn: false, // Mock value
|
||||||
selectedDate: state.selectedDate,
|
distanceMeters: 500, // Mock value for demo
|
||||||
onSelect: (date) => _bloc.add(DateSelected(date)),
|
etaMinutes: 8, // Mock value for demo
|
||||||
shiftDates: [
|
|
||||||
DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
// Date Selector
|
||||||
|
DateSelector(
|
||||||
|
selectedDate: state.selectedDate,
|
||||||
|
onSelect: (date) => _bloc.add(DateSelected(date)),
|
||||||
|
shiftDates: [
|
||||||
|
DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Today Attendance Section
|
|
||||||
const Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
"Today Attendance",
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppColors.krowCharcoal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
GridView.count(
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
crossAxisCount: 2,
|
|
||||||
mainAxisSpacing: 12,
|
|
||||||
crossAxisSpacing: 12,
|
|
||||||
childAspectRatio: 1.0,
|
|
||||||
children: [
|
|
||||||
AttendanceCard(
|
|
||||||
type: AttendanceType.checkin,
|
|
||||||
title: "Check In",
|
|
||||||
value: checkInStr,
|
|
||||||
subtitle: checkInTime != null
|
|
||||||
? "On Time"
|
|
||||||
: "Pending",
|
|
||||||
scheduledTime: "09:00 AM",
|
|
||||||
),
|
|
||||||
AttendanceCard(
|
|
||||||
type: AttendanceType.checkout,
|
|
||||||
title: "Check Out",
|
|
||||||
value: checkOutStr,
|
|
||||||
subtitle: checkOutTime != null
|
|
||||||
? "Go Home"
|
|
||||||
: "Pending",
|
|
||||||
scheduledTime: "05:00 PM",
|
|
||||||
),
|
|
||||||
AttendanceCard(
|
|
||||||
type: AttendanceType.breaks,
|
|
||||||
title: "Break Time",
|
|
||||||
// TODO: Connect to Data Connect when 'breakDuration' field is added to Shift schema.
|
|
||||||
value: "00:30 min",
|
|
||||||
subtitle: "Scheduled 00:30 min",
|
|
||||||
),
|
|
||||||
const AttendanceCard(
|
|
||||||
type: AttendanceType.days,
|
|
||||||
title: "Total Days",
|
|
||||||
// TODO: Connect to Data Connect when 'staffStats' or similar aggregation API is available.
|
|
||||||
// Currently avoided to prevent fetching full shift history for a simple count.
|
|
||||||
value: "28",
|
|
||||||
subtitle: "Working Days",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Your Activity Header
|
// Your Activity Header
|
||||||
// Your Activity Header
|
const Text(
|
||||||
Row(
|
"Your Activity",
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
textAlign: TextAlign.start,
|
||||||
children: [
|
style: TextStyle(
|
||||||
const Text(
|
fontSize: 18,
|
||||||
"Your Activity",
|
fontWeight: FontWeight.bold,
|
||||||
style: TextStyle(
|
color: AppColors.krowCharcoal,
|
||||||
fontSize: 18,
|
),
|
||||||
fontWeight: FontWeight.bold,
|
),
|
||||||
color: AppColors.krowCharcoal,
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Selected Shift Info Card
|
||||||
|
if (todayShift != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(0xFFE2E8F0),
|
||||||
|
), // slate-200
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 2,
|
||||||
|
offset: const Offset(0, 1),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
GestureDetector(
|
),
|
||||||
onTap: () {
|
child: Row(
|
||||||
debugPrint('Navigating to shifts...');
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
},
|
children: [
|
||||||
child: Row(
|
Expanded(
|
||||||
children: const [
|
child: Column(
|
||||||
Text(
|
crossAxisAlignment:
|
||||||
"View all",
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
"TODAY'S SHIFT",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.krowBlue,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
todayShift.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(
|
||||||
|
0xFF1E293B,
|
||||||
|
), // slate-800
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"${todayShift.clientName} • ${todayShift.location}",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(
|
||||||
|
0xFF64748B,
|
||||||
|
), // slate-500
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
"9:00 AM - 5:00 PM",
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.krowBlue,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
fontSize: 14,
|
color: Color(0xFF475569), // slate-600
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: 4),
|
Text(
|
||||||
Icon(
|
"\$${todayShift.hourlyRate}/hr",
|
||||||
LucideIcons.chevronRight,
|
style: const TextStyle(
|
||||||
size: 16,
|
fontSize: 12,
|
||||||
color: AppColors.krowBlue,
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.krowBlue,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// Check-in Mode Toggle
|
|
||||||
const Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
"Check-in Method",
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: Color(0xFF334155), // slate-700
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFF1F5F9), // slate-100
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
_buildModeTab("Swipe", LucideIcons.mapPin, 'swipe', state.checkInMode),
|
|
||||||
// _buildModeTab("NFC Tap", LucideIcons.wifi, 'nfc', state.checkInMode),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Selected Shift Info Card
|
// Swipe To Check In / Checked Out State / No Shift State
|
||||||
if (todayShift != null)
|
if (todayShift != null && checkOutTime == null) ...[
|
||||||
Container(
|
SwipeToCheckIn(
|
||||||
padding: const EdgeInsets.all(12),
|
isCheckedIn: isCheckedIn,
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
mode: state.checkInMode,
|
||||||
decoration: BoxDecoration(
|
isLoading:
|
||||||
color: Colors.white,
|
state.status ==
|
||||||
borderRadius: BorderRadius.circular(12),
|
ClockInStatus.actionInProgress,
|
||||||
border: Border.all(
|
onCheckIn: () async {
|
||||||
color: const Color(0xFFE2E8F0),
|
// Show NFC dialog if mode is 'nfc'
|
||||||
), // slate-200
|
if (state.checkInMode == 'nfc') {
|
||||||
boxShadow: [
|
await _showNFCDialog(context);
|
||||||
BoxShadow(
|
} else {
|
||||||
color: Colors.black.withOpacity(0.05),
|
_bloc.add(
|
||||||
blurRadius: 2,
|
CheckInRequested(shiftId: todayShift.id),
|
||||||
offset: const Offset(0, 1),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
"TODAY'S SHIFT",
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.krowBlue,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
todayShift.title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Color(0xFF1E293B), // slate-800
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
"${todayShift.clientName} • ${todayShift.location}",
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Color(0xFF64748B), // slate-500
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
"9:00 AM - 5:00 PM",
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: Color(0xFF475569), // slate-600
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
"\$${todayShift.hourlyRate}/hr",
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.krowBlue,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Swipe To Check In / Checked Out State / No Shift State
|
|
||||||
if (todayShift != null && checkOutTime == null) ...[
|
|
||||||
SwipeToCheckIn(
|
|
||||||
isCheckedIn: isCheckedIn,
|
|
||||||
mode: state.checkInMode,
|
|
||||||
isLoading: state.status == ClockInStatus.actionInProgress,
|
|
||||||
onCheckIn: () async {
|
|
||||||
// Show NFC dialog if mode is 'nfc'
|
|
||||||
if (state.checkInMode == 'nfc') {
|
|
||||||
await _showNFCDialog(context);
|
|
||||||
} else {
|
|
||||||
_bloc.add(CheckInRequested(shiftId: todayShift.id));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCheckOut: () {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => LunchBreakDialog(
|
|
||||||
onComplete: () {
|
|
||||||
Navigator.of(context).pop(); // Close dialog first
|
|
||||||
_bloc.add(const CheckOutRequested());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
|
},
|
||||||
|
onCheckOut: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => LunchBreakDialog(
|
||||||
|
onComplete: () {
|
||||||
|
Navigator.of(
|
||||||
|
context,
|
||||||
|
).pop(); // Close dialog first
|
||||||
|
_bloc.add(const CheckOutRequested());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
] else if (todayShift != null &&
|
||||||
|
checkOutTime != null) ...[
|
||||||
|
// Shift Completed State
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFECFDF5), // emerald-50
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(0xFFA7F3D0),
|
||||||
|
), // emerald-200
|
||||||
),
|
),
|
||||||
] else if (todayShift != null && checkOutTime != null) ...[
|
child: Column(
|
||||||
// Shift Completed State
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(24),
|
width: 48,
|
||||||
decoration: BoxDecoration(
|
height: 48,
|
||||||
color: const Color(0xFFECFDF5), // emerald-50
|
decoration: const BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
color: Color(0xFFD1FAE5), // emerald-100
|
||||||
border: Border.all(
|
shape: BoxShape.circle,
|
||||||
color: const Color(0xFFA7F3D0),
|
|
||||||
), // emerald-200
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Color(0xFFD1FAE5), // emerald-100
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
LucideIcons.check,
|
|
||||||
color: Color(0xFF059669), // emerald-600
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
child: const Icon(
|
||||||
const Text(
|
LucideIcons.check,
|
||||||
"Shift Completed!",
|
color: Color(0xFF059669), // emerald-600
|
||||||
style: TextStyle(
|
size: 24,
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Color(0xFF065F46), // emerald-800
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
),
|
||||||
const Text(
|
const SizedBox(height: 12),
|
||||||
"Great work today",
|
const Text(
|
||||||
style: TextStyle(
|
"Shift Completed!",
|
||||||
fontSize: 14,
|
style: TextStyle(
|
||||||
color: Color(0xFF059669), // emerald-600
|
fontSize: 16,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF065F46), // emerald-800
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
"Great work today",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFF059669), // emerald-600
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
] else ...[
|
),
|
||||||
// No Shift State
|
] else ...[
|
||||||
Container(
|
// No Shift State
|
||||||
padding: const EdgeInsets.all(24),
|
Container(
|
||||||
decoration: BoxDecoration(
|
width: double.infinity,
|
||||||
color: const Color(0xFFF1F5F9), // slate-100
|
padding: const EdgeInsets.all(24),
|
||||||
borderRadius: BorderRadius.circular(16),
|
decoration: BoxDecoration(
|
||||||
),
|
color: const Color(0xFFF1F5F9), // slate-100
|
||||||
child: Column(
|
borderRadius: BorderRadius.circular(16),
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
"No confirmed shifts for today",
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: Color(0xFF475569), // slate-600
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
const Text(
|
|
||||||
"Accept a shift to clock in",
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: Color(0xFF64748B), // slate-500
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
child: const Column(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
"No confirmed shifts for today",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF475569), // slate-600
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
"Accept a shift to clock in",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFF64748B), // slate-500
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
// Checked In Banner
|
// Checked In Banner
|
||||||
if (isCheckedIn && checkInTime != null) ...[
|
if (isCheckedIn && checkInTime != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFECFDF5), // emerald-50
|
color: const Color(0xFFECFDF5), // emerald-50
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: const Color(0xFFA7F3D0),
|
color: const Color(0xFFA7F3D0),
|
||||||
), // emerald-200
|
), // emerald-200
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment:
|
||||||
children: [
|
MainAxisAlignment.spaceBetween,
|
||||||
Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Column(
|
||||||
children: [
|
crossAxisAlignment:
|
||||||
const Text(
|
CrossAxisAlignment.start,
|
||||||
"Checked in at",
|
children: [
|
||||||
style: TextStyle(
|
const Text(
|
||||||
fontSize: 12,
|
"Checked in at",
|
||||||
fontWeight: FontWeight.w500,
|
style: TextStyle(
|
||||||
color: Color(0xFF059669),
|
fontSize: 12,
|
||||||
),
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF059669),
|
||||||
),
|
),
|
||||||
Text(
|
),
|
||||||
DateFormat('h:mm a').format(checkInTime),
|
Text(
|
||||||
style: const TextStyle(
|
DateFormat(
|
||||||
fontSize: 16,
|
'h:mm a',
|
||||||
fontWeight: FontWeight.bold,
|
).format(checkInTime),
|
||||||
color: Color(0xFF065F46),
|
style: const TextStyle(
|
||||||
),
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF065F46),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Color(0xFFD1FAE5),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
LucideIcons.check,
|
|
||||||
color: Color(0xFF059669),
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFD1FAE5),
|
||||||
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
],
|
child: const Icon(
|
||||||
),
|
LucideIcons.check,
|
||||||
|
color: Color(0xFF059669),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
],
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Recent Activity List
|
// Recent Activity List
|
||||||
if (state.activityLog.isNotEmpty) ...state.activityLog.map(
|
if (state.activityLog.isNotEmpty)
|
||||||
|
...state.activityLog.map(
|
||||||
(activity) => Container(
|
(activity) => Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
@@ -490,7 +396,9 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.krowBlue.withOpacity(0.1),
|
color: AppColors.krowBlue.withOpacity(
|
||||||
|
0.1,
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
@@ -502,23 +410,28 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
DateFormat(
|
DateFormat('MMM d').format(
|
||||||
'MMM d',
|
activity['date'] as DateTime,
|
||||||
).format(activity['date'] as DateTime),
|
),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF0F172A), // slate-900
|
color: Color(
|
||||||
|
0xFF0F172A,
|
||||||
|
), // slate-900
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
"${activity['start']} - ${activity['end']}",
|
"${activity['start']} - ${activity['end']}",
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Color(0xFF64748B), // slate-500
|
color: Color(
|
||||||
|
0xFF64748B,
|
||||||
|
), // slate-500
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -542,7 +455,6 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -551,7 +463,12 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildModeTab(String label, IconData icon, String value, String currentMode) {
|
Widget _buildModeTab(
|
||||||
|
String label,
|
||||||
|
IconData icon,
|
||||||
|
String value,
|
||||||
|
String currentMode,
|
||||||
|
) {
|
||||||
final isSelected = currentMode == value;
|
final isSelected = currentMode == value;
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
@@ -775,7 +692,7 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
// After dialog closes, trigger the event if scan was successful (simulated)
|
// After dialog closes, trigger the event if scan was successful (simulated)
|
||||||
// In real app, we would check the dialog result
|
// In real app, we would check the dialog result
|
||||||
if (scanned && _bloc.state.todayShift != null) {
|
if (scanned && _bloc.state.todayShift != null) {
|
||||||
_bloc.add(CheckInRequested(shiftId: _bloc.state.todayShift!.id));
|
_bloc.add(CheckInRequested(shiftId: _bloc.state.todayShift!.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'data/repositories/clock_in_repository_impl.dart';
|
|
||||||
|
import 'data/repositories_impl/clock_in_repository_impl.dart';
|
||||||
import 'domain/repositories/clock_in_repository_interface.dart';
|
import 'domain/repositories/clock_in_repository_interface.dart';
|
||||||
import 'domain/usecases/clock_in_usecase.dart';
|
import 'domain/usecases/clock_in_usecase.dart';
|
||||||
import 'domain/usecases/clock_out_usecase.dart';
|
import 'domain/usecases/clock_out_usecase.dart';
|
||||||
@@ -13,11 +15,13 @@ import 'presentation/pages/clock_in_page.dart';
|
|||||||
class StaffClockInModule extends Module {
|
class StaffClockInModule extends Module {
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Data Sources (Mocks from data_connect)
|
|
||||||
i.add<ShiftsRepositoryMock>(ShiftsRepositoryMock.new);
|
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
i.add<ClockInRepositoryInterface>(ClockInRepositoryImpl.new);
|
i.add<ClockInRepositoryInterface>(
|
||||||
|
() => ClockInRepositoryImpl(
|
||||||
|
dataConnect: ExampleConnector.instance,
|
||||||
|
firebaseAuth: FirebaseAuth.instance,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Use Cases
|
// Use Cases
|
||||||
i.add<GetTodaysShiftUseCase>(GetTodaysShiftUseCase.new);
|
i.add<GetTodaysShiftUseCase>(GetTodaysShiftUseCase.new);
|
||||||
|
|||||||
@@ -31,3 +31,4 @@ dependencies:
|
|||||||
firebase_data_connect: ^0.2.2+2
|
firebase_data_connect: ^0.2.2+2
|
||||||
geolocator: ^10.1.0
|
geolocator: ^10.1.0
|
||||||
permission_handler: ^11.0.1
|
permission_handler: ^11.0.1
|
||||||
|
firebase_auth: ^6.1.4
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
|
||||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||||
import 'package:krow_data_connect/src/session/staff_session_store.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'package:staff_home/src/domain/entities/shift.dart';
|
import 'package:staff_home/src/domain/entities/shift.dart';
|
||||||
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
extension TimestampExt on Timestamp {
|
extension TimestampExt on Timestamp {
|
||||||
DateTime toDate() {
|
DateTime toDate() {
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import 'package:staff_home/src/domain/entities/shift.dart';
|
|
||||||
|
|
||||||
class MockService {
|
|
||||||
static final Shift _sampleShift1 = Shift(
|
|
||||||
id: '1',
|
|
||||||
title: 'Line Cook',
|
|
||||||
clientName: 'The Burger Joint',
|
|
||||||
hourlyRate: 22.50,
|
|
||||||
location: 'Downtown, NY',
|
|
||||||
locationAddress: '123 Main St, New York, NY 10001',
|
|
||||||
date: DateTime.now().toIso8601String(),
|
|
||||||
startTime: '16:00',
|
|
||||||
endTime: '22:00',
|
|
||||||
createdDate: DateTime.now()
|
|
||||||
.subtract(const Duration(hours: 2))
|
|
||||||
.toIso8601String(),
|
|
||||||
tipsAvailable: true,
|
|
||||||
mealProvided: true,
|
|
||||||
managers: [ShiftManager(name: 'John Doe', phone: '+1 555 0101')],
|
|
||||||
description: 'Help with dinner service. Must be experienced with grill.',
|
|
||||||
);
|
|
||||||
|
|
||||||
static final Shift _sampleShift2 = Shift(
|
|
||||||
id: '2',
|
|
||||||
title: 'Dishwasher',
|
|
||||||
clientName: 'Pasta Place',
|
|
||||||
hourlyRate: 18.00,
|
|
||||||
location: 'Brooklyn, NY',
|
|
||||||
locationAddress: '456 Bedford Ave, Brooklyn, NY 11211',
|
|
||||||
date: DateTime.now().add(const Duration(days: 1)).toIso8601String(),
|
|
||||||
startTime: '18:00',
|
|
||||||
endTime: '23:00',
|
|
||||||
createdDate: DateTime.now()
|
|
||||||
.subtract(const Duration(hours: 5))
|
|
||||||
.toIso8601String(),
|
|
||||||
tipsAvailable: false,
|
|
||||||
mealProvided: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
static final Shift _sampleShift3 = Shift(
|
|
||||||
id: '3',
|
|
||||||
title: 'Bartender',
|
|
||||||
clientName: 'Rooftop Bar',
|
|
||||||
hourlyRate: 25.00,
|
|
||||||
location: 'Manhattan, NY',
|
|
||||||
locationAddress: '789 5th Ave, New York, NY 10022',
|
|
||||||
date: DateTime.now().add(const Duration(days: 2)).toIso8601String(),
|
|
||||||
startTime: '19:00',
|
|
||||||
endTime: '02:00',
|
|
||||||
createdDate: DateTime.now()
|
|
||||||
.subtract(const Duration(hours: 1))
|
|
||||||
.toIso8601String(),
|
|
||||||
tipsAvailable: true,
|
|
||||||
parkingAvailable: true,
|
|
||||||
description: 'High volume bar. Mixology experience required.',
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<List<Shift>> getTodayShifts() async {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
return [_sampleShift1];
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<Shift>> getTomorrowShifts() async {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
return [_sampleShift2];
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<Shift>> getRecommendedShifts() async {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
return [_sampleShift3, _sampleShift1, _sampleShift2];
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> createWorkerProfile(Map<String, dynamic> data) async {
|
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,19 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:core_localization/core_localization.dart';
|
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
|
|
||||||
import 'package:staff_home/src/presentation/blocs/home_cubit.dart';
|
import 'package:staff_home/src/presentation/blocs/home_cubit.dart';
|
||||||
import 'package:staff_home/src/presentation/navigation/home_navigator.dart';
|
import 'package:staff_home/src/presentation/navigation/home_navigator.dart';
|
||||||
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart';
|
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart';
|
||||||
import 'package:staff_home/src/presentation/widgets/home_page/home_header.dart';
|
import 'package:staff_home/src/presentation/widgets/home_page/home_header.dart';
|
||||||
import 'package:staff_home/src/presentation/widgets/home_page/pending_payment_card.dart';
|
|
||||||
import 'package:staff_home/src/presentation/widgets/home_page/placeholder_banner.dart';
|
import 'package:staff_home/src/presentation/widgets/home_page/placeholder_banner.dart';
|
||||||
import 'package:staff_home/src/presentation/widgets/home_page/quick_action_item.dart';
|
import 'package:staff_home/src/presentation/widgets/home_page/quick_action_item.dart';
|
||||||
import 'package:staff_home/src/presentation/widgets/home_page/recommended_shift_card.dart';
|
import 'package:staff_home/src/presentation/widgets/home_page/recommended_shift_card.dart';
|
||||||
import 'package:staff_home/src/presentation/widgets/home_page/section_header.dart';
|
import 'package:staff_home/src/presentation/widgets/home_page/section_header.dart';
|
||||||
import 'package:staff_home/src/presentation/widgets/shift_card.dart';
|
import 'package:staff_home/src/presentation/widgets/shift_card.dart';
|
||||||
import 'package:staff_home/src/presentation/widgets/worker/auto_match_toggle.dart';
|
import 'package:staff_home/src/presentation/widgets/worker/auto_match_toggle.dart';
|
||||||
import 'package:staff_home/src/presentation/widgets/worker/benefits_widget.dart';
|
|
||||||
import 'package:staff_home/src/presentation/widgets/worker/improve_yourself_widget.dart';
|
|
||||||
import 'package:staff_home/src/presentation/widgets/worker/more_ways_widget.dart';
|
|
||||||
|
|
||||||
/// The home page for the staff worker application.
|
/// The home page for the staff worker application.
|
||||||
///
|
///
|
||||||
@@ -75,31 +70,7 @@ class WorkerHomePage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space6),
|
|
||||||
PlaceholderBanner(
|
|
||||||
title: bannersI18n.availability_title,
|
|
||||||
subtitle: bannersI18n.availability_subtitle,
|
|
||||||
bg: UiColors.accent.withOpacity(0.1),
|
|
||||||
accent: UiColors.accent,
|
|
||||||
onTap: () => Modular.to.pushAvailability(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
|
||||||
|
|
||||||
// Auto Match Toggle
|
|
||||||
BlocBuilder<HomeCubit, HomeState>(
|
|
||||||
buildWhen: (previous, current) =>
|
|
||||||
previous.autoMatchEnabled !=
|
|
||||||
current.autoMatchEnabled,
|
|
||||||
builder: (context, state) {
|
|
||||||
return AutoMatchToggle(
|
|
||||||
enabled: state.autoMatchEnabled,
|
|
||||||
onToggle: (val) => BlocProvider.of<HomeCubit>(
|
|
||||||
context,
|
|
||||||
listen: false,
|
|
||||||
).toggleAutoMatch(val),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
|
||||||
// Quick Actions
|
// Quick Actions
|
||||||
@@ -120,13 +91,6 @@ class WorkerHomePage extends StatelessWidget {
|
|||||||
onTap: () => Modular.to.pushAvailability(),
|
onTap: () => Modular.to.pushAvailability(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
|
||||||
child: QuickActionItem(
|
|
||||||
icon: LucideIcons.messageSquare,
|
|
||||||
label: quickI18n.messages,
|
|
||||||
onTap: () => Modular.to.pushMessages(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: QuickActionItem(
|
child: QuickActionItem(
|
||||||
icon: LucideIcons.dollarSign,
|
icon: LucideIcons.dollarSign,
|
||||||
@@ -212,15 +176,9 @@ class WorkerHomePage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Pending Payment Card
|
|
||||||
const PendingPaymentCard(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Recommended Shifts
|
// Recommended Shifts
|
||||||
SectionHeader(
|
SectionHeader(
|
||||||
title: sectionsI18n.recommended_for_you,
|
title: sectionsI18n.recommended_for_you,
|
||||||
action: sectionsI18n.view_all,
|
|
||||||
onAction: () => Modular.to.pushShifts(tab: 'find'),
|
|
||||||
),
|
),
|
||||||
BlocBuilder<HomeCubit, HomeState>(
|
BlocBuilder<HomeCubit, HomeState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
@@ -246,14 +204,6 @@ class WorkerHomePage extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
const BenefitsWidget(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
const ImproveYourselfWidget(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
const MoreWaysToUseKrowWidget(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:staff_home/src/data/repositories/home_repository_impl.dart';
|
import 'package:staff_home/src/data/repositories/home_repository_impl.dart';
|
||||||
import 'package:staff_home/src/data/services/mock_service.dart';
|
|
||||||
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
||||||
import 'package:staff_home/src/presentation/blocs/home_cubit.dart';
|
import 'package:staff_home/src/presentation/blocs/home_cubit.dart';
|
||||||
import 'package:staff_home/src/presentation/pages/worker_home_page.dart';
|
import 'package:staff_home/src/presentation/pages/worker_home_page.dart';
|
||||||
@@ -14,9 +13,6 @@ import 'package:staff_home/src/presentation/pages/worker_home_page.dart';
|
|||||||
class StaffHomeModule extends Module {
|
class StaffHomeModule extends Module {
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Data layer - Mock service (will be replaced with real implementation)
|
|
||||||
i.addLazySingleton<MockService>(MockService.new);
|
|
||||||
|
|
||||||
// Repository
|
// Repository
|
||||||
i.addLazySingleton<HomeRepository>(
|
i.addLazySingleton<HomeRepository>(
|
||||||
() => HomeRepositoryImpl(),
|
() => HomeRepositoryImpl(),
|
||||||
|
|||||||
@@ -1,75 +1,111 @@
|
|||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||||
import 'package:krow_data_connect/src/session/staff_session_store.dart';
|
import 'package:krow_data_connect/src/session/staff_session_store.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../../domain/entities/payment_summary.dart';
|
|
||||||
import '../../domain/repositories/payments_repository.dart';
|
import '../../domain/repositories/payments_repository.dart';
|
||||||
|
|
||||||
class PaymentsRepositoryImpl implements PaymentsRepository {
|
class PaymentsRepositoryImpl implements PaymentsRepository {
|
||||||
PaymentsRepositoryImpl();
|
final dc.ExampleConnector _dataConnect;
|
||||||
|
|
||||||
|
PaymentsRepositoryImpl() : _dataConnect = dc.ExampleConnector.instance;
|
||||||
|
|
||||||
|
/// Helper to convert Data Connect Timestamp to DateTime
|
||||||
|
DateTime? _toDateTime(dynamic t) {
|
||||||
|
if (t == null) return null;
|
||||||
|
try {
|
||||||
|
// Attempt to deserialize via standard methods
|
||||||
|
return DateTime.tryParse(t.toJson() as String);
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
return DateTime.tryParse(t.toString());
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<PaymentSummary> getPaymentSummary() async {
|
Future<PaymentSummary> getPaymentSummary() async {
|
||||||
// Current requirement: Mock data only for summary
|
final StaffSession? session = StaffSessionStore.instance.session;
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
if (session?.staff?.id == null) {
|
||||||
return const PaymentSummary(
|
return const PaymentSummary(
|
||||||
weeklyEarnings: 847.50,
|
weeklyEarnings: 0,
|
||||||
monthlyEarnings: 3240.0,
|
monthlyEarnings: 0,
|
||||||
pendingEarnings: 285.0,
|
pendingEarnings: 0,
|
||||||
totalEarnings: 12450.0,
|
totalEarnings: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String currentStaffId = session!.staff!.id;
|
||||||
|
|
||||||
|
// Fetch recent payments with a limit
|
||||||
|
// Note: limit is chained on the query builder
|
||||||
|
final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> result =
|
||||||
|
await _dataConnect.listRecentPaymentsByStaffId(
|
||||||
|
staffId: currentStaffId,
|
||||||
|
).limit(100).execute();
|
||||||
|
|
||||||
|
final List<dc.ListRecentPaymentsByStaffIdRecentPayments> payments = result.data.recentPayments;
|
||||||
|
|
||||||
|
double weekly = 0;
|
||||||
|
double monthly = 0;
|
||||||
|
double pending = 0;
|
||||||
|
double total = 0;
|
||||||
|
|
||||||
|
final DateTime now = DateTime.now();
|
||||||
|
final DateTime startOfWeek = now.subtract(const Duration(days: 7));
|
||||||
|
final DateTime startOfMonth = DateTime(now.year, now.month, 1);
|
||||||
|
|
||||||
|
for (final dc.ListRecentPaymentsByStaffIdRecentPayments p in payments) {
|
||||||
|
final DateTime? date = _toDateTime(p.invoice.issueDate) ?? _toDateTime(p.createdAt);
|
||||||
|
final double amount = p.invoice.amount;
|
||||||
|
final String? status = p.status?.stringValue;
|
||||||
|
|
||||||
|
if (status == 'PENDING') {
|
||||||
|
pending += amount;
|
||||||
|
} else if (status == 'PAID') {
|
||||||
|
total += amount;
|
||||||
|
if (date != null) {
|
||||||
|
if (date.isAfter(startOfWeek)) weekly += amount;
|
||||||
|
if (date.isAfter(startOfMonth)) monthly += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PaymentSummary(
|
||||||
|
weeklyEarnings: weekly,
|
||||||
|
monthlyEarnings: monthly,
|
||||||
|
pendingEarnings: pending,
|
||||||
|
totalEarnings: total,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<StaffPayment>> getPaymentHistory(String period) async {
|
Future<List<StaffPayment>> getPaymentHistory(String period) async {
|
||||||
final session = StaffSessionStore.instance.session;
|
final StaffSession? session = StaffSessionStore.instance.session;
|
||||||
if (session?.staff?.id == null) return [];
|
if (session?.staff?.id == null) return <StaffPayment>[];
|
||||||
|
|
||||||
final String currentStaffId = session!.staff!.id;
|
final String currentStaffId = session!.staff!.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await ExampleConnector.instance
|
final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> response =
|
||||||
|
await _dataConnect
|
||||||
.listRecentPaymentsByStaffId(staffId: currentStaffId)
|
.listRecentPaymentsByStaffId(staffId: currentStaffId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return response.data.recentPayments.map((payment) {
|
return response.data.recentPayments.map((dc.ListRecentPaymentsByStaffIdRecentPayments payment) {
|
||||||
return StaffPayment(
|
return StaffPayment(
|
||||||
id: payment.id,
|
id: payment.id,
|
||||||
staffId: payment.staffId,
|
staffId: payment.staffId,
|
||||||
assignmentId: payment.applicationId,
|
assignmentId: payment.applicationId,
|
||||||
amount: payment.invoice.amount,
|
amount: payment.invoice.amount,
|
||||||
status: _mapStatus(payment.status),
|
status: PaymentAdapter.toPaymentStatus(payment.status?.stringValue ?? 'UNKNOWN'),
|
||||||
paidAt: payment.invoice.issueDate?.toDate(),
|
paidAt: _toDateTime(payment.invoice.issueDate),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return [];
|
return <StaffPayment>[];
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PaymentStatus _mapStatus(EnumValue<RecentPaymentStatus>? status) {
|
|
||||||
if (status == null || status is! Known) return PaymentStatus.pending;
|
|
||||||
|
|
||||||
switch ((status as Known).value) {
|
|
||||||
case RecentPaymentStatus.PAID:
|
|
||||||
return PaymentStatus.paid;
|
|
||||||
case RecentPaymentStatus.PENDING:
|
|
||||||
return PaymentStatus.pending;
|
|
||||||
case RecentPaymentStatus.FAILED:
|
|
||||||
return PaymentStatus.failed;
|
|
||||||
default:
|
|
||||||
return PaymentStatus.pending;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on DateTime {
|
|
||||||
// Simple toDate if needed, but Data Connect Timestamp has toDate() usually
|
|
||||||
// or we need the extension from earlier
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TimestampExt on Timestamp {
|
|
||||||
DateTime toDate() {
|
|
||||||
return DateTime.fromMillisecondsSinceEpoch(seconds.toInt() * 1000 + nanoseconds ~/ 1000000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../entities/payment_summary.dart';
|
|
||||||
|
|
||||||
/// Repository interface for Payments feature.
|
/// Repository interface for Payments feature.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import '../entities/payment_summary.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../repositories/payments_repository.dart';
|
import '../repositories/payments_repository.dart';
|
||||||
|
|
||||||
/// Use case to retrieve payment summary information.
|
/// Use case to retrieve payment summary information.
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ import 'presentation/pages/payments_page.dart';
|
|||||||
class StaffPaymentsModule extends Module {
|
class StaffPaymentsModule extends Module {
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Data Connect Mocks
|
// Repositories
|
||||||
i.add<FinancialRepositoryMock>(FinancialRepositoryMock.new);
|
|
||||||
|
|
||||||
// Repositories
|
|
||||||
i.add<PaymentsRepository>(PaymentsRepositoryImpl.new);
|
i.add<PaymentsRepository>(PaymentsRepositoryImpl.new);
|
||||||
|
|
||||||
// Use Cases
|
// Use Cases
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../../../domain/arguments/get_payment_history_arguments.dart';
|
import '../../../domain/arguments/get_payment_history_arguments.dart';
|
||||||
import '../../../domain/entities/payment_summary.dart';
|
|
||||||
import '../../../domain/usecases/get_payment_history_usecase.dart';
|
import '../../../domain/usecases/get_payment_history_usecase.dart';
|
||||||
import '../../../domain/usecases/get_payment_summary_usecase.dart';
|
import '../../../domain/usecases/get_payment_summary_usecase.dart';
|
||||||
import 'payments_event.dart';
|
import 'payments_event.dart';
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../../../domain/entities/payment_summary.dart';
|
|
||||||
|
|
||||||
abstract class PaymentsState extends Equatable {
|
abstract class PaymentsState extends Equatable {
|
||||||
const PaymentsState();
|
const PaymentsState();
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Pending Pay
|
// Pending Pay
|
||||||
PendingPayCard(
|
if(state.summary.pendingEarnings > 0) PendingPayCard(
|
||||||
amount: state.summary.pendingEarnings,
|
amount: state.summary.pendingEarnings,
|
||||||
onCashOut: () {
|
onCashOut: () {
|
||||||
Modular.to.pushNamed('/early-pay');
|
Modular.to.pushNamed('/early-pay');
|
||||||
@@ -173,62 +173,43 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Recent Payments
|
|
||||||
const Text(
|
|
||||||
"Recent Payments",
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Color(0xFF0F172A),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Column(
|
|
||||||
children: state.history.map((StaffPayment payment) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: PaymentHistoryItem(
|
|
||||||
amount: payment.amount,
|
|
||||||
title: "Shift Payment",
|
|
||||||
location: "Varies",
|
|
||||||
address: "Payment ID: ${payment.id}",
|
|
||||||
date: payment.paidAt != null
|
|
||||||
? DateFormat('E, MMM d').format(payment.paidAt!)
|
|
||||||
: 'Pending',
|
|
||||||
workedTime: "Completed",
|
|
||||||
hours: 0,
|
|
||||||
rate: 0.0,
|
|
||||||
status: payment.status.name.toUpperCase(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Export History Button
|
|
||||||
SizedBox(
|
// Recent Payments
|
||||||
width: double.infinity,
|
if (state.history.isNotEmpty) Column(
|
||||||
height: 48,
|
children: [
|
||||||
child: OutlinedButton.icon(
|
const Text(
|
||||||
onPressed: () {
|
"Recent Payments",
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
style: TextStyle(
|
||||||
const SnackBar(
|
fontSize: 14,
|
||||||
content: Text('PDF Exported'),
|
fontWeight: FontWeight.w600,
|
||||||
duration: Duration(seconds: 2),
|
color: Color(0xFF0F172A),
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(LucideIcons.download, size: 16),
|
|
||||||
label: const Text("Export History"),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: const Color(0xFF0F172A),
|
|
||||||
side: const BorderSide(color: Color(0xFFE2E8F0)),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
|
Column(
|
||||||
|
children: state.history.map((StaffPayment payment) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: PaymentHistoryItem(
|
||||||
|
amount: payment.amount,
|
||||||
|
title: "Shift Payment",
|
||||||
|
location: "Varies",
|
||||||
|
address: "Payment ID: ${payment.id}",
|
||||||
|
date: payment.paidAt != null
|
||||||
|
? DateFormat('E, MMM d').format(payment.paidAt!)
|
||||||
|
: 'Pending',
|
||||||
|
workedTime: "Completed",
|
||||||
|
hours: 0,
|
||||||
|
rate: 0.0,
|
||||||
|
status: payment.status.name.toUpperCase(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -124,73 +124,34 @@ class StaffProfilePage extends StatelessWidget {
|
|||||||
ProfileMenuItem(
|
ProfileMenuItem(
|
||||||
icon: UiIcons.user,
|
icon: UiIcons.user,
|
||||||
label: i18n.menu_items.personal_info,
|
label: i18n.menu_items.personal_info,
|
||||||
completed: profile.phone != null,
|
|
||||||
onTap: () => Modular.to.pushPersonalInfo(),
|
onTap: () => Modular.to.pushPersonalInfo(),
|
||||||
),
|
),
|
||||||
ProfileMenuItem(
|
ProfileMenuItem(
|
||||||
icon: UiIcons.phone,
|
icon: UiIcons.phone,
|
||||||
label: i18n.menu_items.emergency_contact,
|
label: i18n.menu_items.emergency_contact,
|
||||||
completed: false,
|
|
||||||
onTap: () => Modular.to.pushEmergencyContact(),
|
onTap: () => Modular.to.pushEmergencyContact(),
|
||||||
),
|
),
|
||||||
ProfileMenuItem(
|
ProfileMenuItem(
|
||||||
icon: UiIcons.briefcase,
|
icon: UiIcons.briefcase,
|
||||||
label: i18n.menu_items.experience,
|
label: i18n.menu_items.experience,
|
||||||
completed: false,
|
|
||||||
onTap: () => Modular.to.pushExperience(),
|
onTap: () => Modular.to.pushExperience(),
|
||||||
),
|
),
|
||||||
ProfileMenuItem(
|
|
||||||
icon: UiIcons.user,
|
|
||||||
label: i18n.menu_items.attire,
|
|
||||||
completed: false,
|
|
||||||
onTap: () => Modular.to.pushAttire(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
SectionTitle(i18n.sections.compliance),
|
Column(
|
||||||
ProfileMenuGrid(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisCount: 3,
|
|
||||||
children: [
|
children: [
|
||||||
ProfileMenuItem(
|
SectionTitle(i18n.sections.compliance),
|
||||||
icon: UiIcons.file,
|
ProfileMenuGrid(
|
||||||
label: i18n.menu_items.documents,
|
crossAxisCount: 3,
|
||||||
completed: false,
|
children: [
|
||||||
onTap: () => Modular.to.pushDocuments(),
|
ProfileMenuItem(
|
||||||
),
|
icon: UiIcons.file,
|
||||||
ProfileMenuItem(
|
label: i18n.menu_items.tax_forms,
|
||||||
icon: UiIcons.shield,
|
onTap: () => Modular.to.pushTaxForms(),
|
||||||
label: i18n.menu_items.certificates,
|
),
|
||||||
completed: false,
|
],
|
||||||
onTap: () => Modular.to.pushCertificates(),
|
|
||||||
),
|
|
||||||
ProfileMenuItem(
|
|
||||||
icon: UiIcons.file,
|
|
||||||
label: i18n.menu_items.tax_forms,
|
|
||||||
completed: false,
|
|
||||||
onTap: () => Modular.to.pushTaxForms(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
|
||||||
SectionTitle(i18n.sections.level_up),
|
|
||||||
ProfileMenuGrid(
|
|
||||||
crossAxisCount: 3,
|
|
||||||
children: [
|
|
||||||
ProfileMenuItem(
|
|
||||||
icon: UiIcons.sparkles,
|
|
||||||
label: i18n.menu_items.krow_university,
|
|
||||||
onTap: () => Modular.to.pushKrowUniversity(),
|
|
||||||
),
|
|
||||||
ProfileMenuItem(
|
|
||||||
icon: UiIcons.briefcase,
|
|
||||||
label: i18n.menu_items.trainings,
|
|
||||||
onTap: () => Modular.to.pushTrainings(),
|
|
||||||
),
|
|
||||||
ProfileMenuItem(
|
|
||||||
icon: UiIcons.target,
|
|
||||||
label: i18n.menu_items.leaderboard,
|
|
||||||
onTap: () => Modular.to.pushLeaderboard(),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -217,31 +178,10 @@ class StaffProfilePage extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
SectionTitle(i18n.sections.support),
|
|
||||||
ProfileMenuGrid(
|
|
||||||
crossAxisCount: 3,
|
|
||||||
children: [
|
|
||||||
ProfileMenuItem(
|
|
||||||
icon: UiIcons.help,
|
|
||||||
label: i18n.menu_items.faqs,
|
|
||||||
onTap: () => Modular.to.pushFaqs(),
|
|
||||||
),
|
|
||||||
ProfileMenuItem(
|
|
||||||
icon: UiIcons.shield,
|
|
||||||
label: i18n.menu_items.privacy_security,
|
|
||||||
onTap: () => Modular.to.pushPrivacy(),
|
|
||||||
),
|
|
||||||
ProfileMenuItem(
|
|
||||||
icon: UiIcons.messageCircle,
|
|
||||||
label: i18n.menu_items.messages,
|
|
||||||
onTap: () => Modular.to.pushMessages(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
|
||||||
LogoutButton(
|
LogoutButton(
|
||||||
onTap: () => _onSignOut(cubit, state),
|
onTap: () => _onSignOut(cubit, state),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: UiConstants.space12),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class ProfileMenuGrid extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Spacing between items
|
// Spacing between items
|
||||||
final double spacing = UiConstants.space3;
|
const double spacing = UiConstants.space3;
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
@@ -27,6 +27,8 @@ class ProfileMenuGrid extends StatelessWidget {
|
|||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: spacing,
|
spacing: spacing,
|
||||||
runSpacing: spacing,
|
runSpacing: spacing,
|
||||||
|
alignment: WrapAlignment.start,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.start,
|
||||||
children: children.map((child) {
|
children: children.map((child) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: itemWidth,
|
width: itemWidth,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class BankAccountCubit extends Cubit<BankAccountState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> addAccount({
|
Future<void> addAccount({
|
||||||
|
required String bankName,
|
||||||
required String routingNumber,
|
required String routingNumber,
|
||||||
required String accountNumber,
|
required String accountNumber,
|
||||||
required String type,
|
required String type,
|
||||||
@@ -47,7 +48,7 @@ class BankAccountCubit extends Cubit<BankAccountState> {
|
|||||||
final BankAccount newAccount = BankAccount(
|
final BankAccount newAccount = BankAccount(
|
||||||
id: '', // Generated by server usually
|
id: '', // Generated by server usually
|
||||||
userId: '', // Handled by Repo/Auth
|
userId: '', // Handled by Repo/Auth
|
||||||
bankName: 'New Bank', // Mock
|
bankName: bankName,
|
||||||
accountNumber: accountNumber,
|
accountNumber: accountNumber,
|
||||||
accountName: '',
|
accountName: '',
|
||||||
sortCode: routingNumber,
|
sortCode: routingNumber,
|
||||||
@@ -63,6 +64,7 @@ class BankAccountCubit extends Cubit<BankAccountState> {
|
|||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
|
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
|
status: BankAccountStatus.accountAdded,
|
||||||
showForm: false, // Close form on success
|
showForm: false, // Close form on success
|
||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
enum BankAccountStatus { initial, loading, loaded, error }
|
enum BankAccountStatus { initial, loading, loaded, error, accountAdded }
|
||||||
|
|
||||||
class BankAccountState extends Equatable {
|
class BankAccountState extends Equatable {
|
||||||
final BankAccountStatus status;
|
final BankAccountStatus status;
|
||||||
|
|||||||
@@ -44,8 +44,23 @@ class BankAccountPage extends StatelessWidget {
|
|||||||
child: Container(color: UiColors.border, height: 1.0),
|
child: Container(color: UiColors.border, height: 1.0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: BlocBuilder<BankAccountCubit, BankAccountState>(
|
body: BlocConsumer<BankAccountCubit, BankAccountState>(
|
||||||
bloc: cubit,
|
bloc: cubit,
|
||||||
|
listener: (BuildContext context, BankAccountState state) {
|
||||||
|
if (state.status == BankAccountStatus.accountAdded) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
strings.account_added_success,
|
||||||
|
style: UiTypography.body2r.textPrimary,
|
||||||
|
),
|
||||||
|
backgroundColor: UiColors.tagSuccess,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
builder: (BuildContext context, BankAccountState state) {
|
builder: (BuildContext context, BankAccountState state) {
|
||||||
if (state.status == BankAccountStatus.loading && state.accounts.isEmpty) {
|
if (state.status == BankAccountStatus.loading && state.accounts.isEmpty) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
@@ -96,8 +111,9 @@ class BankAccountPage extends StatelessWidget {
|
|||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
child: AddAccountForm(
|
child: AddAccountForm(
|
||||||
strings: strings,
|
strings: strings,
|
||||||
onSubmit: (String routing, String account, String type) {
|
onSubmit: (String bankName, String routing, String account, String type) {
|
||||||
cubit.addAccount(
|
cubit.addAccount(
|
||||||
|
bankName: bankName,
|
||||||
routingNumber: routing,
|
routingNumber: routing,
|
||||||
accountNumber: account,
|
accountNumber: account,
|
||||||
type: type,
|
type: type,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import '../blocs/bank_account_cubit.dart';
|
|||||||
|
|
||||||
class AddAccountForm extends StatefulWidget {
|
class AddAccountForm extends StatefulWidget {
|
||||||
final dynamic strings;
|
final dynamic strings;
|
||||||
final Function(String routing, String account, String type) onSubmit;
|
final Function(String bankName, String routing, String account, String type) onSubmit;
|
||||||
final VoidCallback onCancel;
|
final VoidCallback onCancel;
|
||||||
|
|
||||||
const AddAccountForm({super.key, required this.strings, required this.onSubmit, required this.onCancel});
|
const AddAccountForm({super.key, required this.strings, required this.onSubmit, required this.onCancel});
|
||||||
@@ -15,12 +15,14 @@ class AddAccountForm extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AddAccountFormState extends State<AddAccountForm> {
|
class _AddAccountFormState extends State<AddAccountForm> {
|
||||||
|
final TextEditingController _bankNameController = TextEditingController();
|
||||||
final TextEditingController _routingController = TextEditingController();
|
final TextEditingController _routingController = TextEditingController();
|
||||||
final TextEditingController _accountController = TextEditingController();
|
final TextEditingController _accountController = TextEditingController();
|
||||||
String _selectedType = 'CHECKING';
|
String _selectedType = 'CHECKING';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_bankNameController.dispose();
|
||||||
_routingController.dispose();
|
_routingController.dispose();
|
||||||
_accountController.dispose();
|
_accountController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -44,6 +46,13 @@ class _AddAccountFormState extends State<AddAccountForm> {
|
|||||||
style: UiTypography.headline4m.copyWith(color: UiColors.textPrimary), // Was header4
|
style: UiTypography.headline4m.copyWith(color: UiColors.textPrimary), // Was header4
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
UiTextField(
|
||||||
|
label: widget.strings.bank_name,
|
||||||
|
hintText: widget.strings.bank_hint,
|
||||||
|
controller: _bankNameController,
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
UiTextField(
|
UiTextField(
|
||||||
label: widget.strings.routing_number,
|
label: widget.strings.routing_number,
|
||||||
hintText: widget.strings.routing_hint,
|
hintText: widget.strings.routing_hint,
|
||||||
@@ -90,6 +99,7 @@ class _AddAccountFormState extends State<AddAccountForm> {
|
|||||||
text: widget.strings.save,
|
text: widget.strings.save,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
widget.onSubmit(
|
widget.onSubmit(
|
||||||
|
_bankNameController.text,
|
||||||
_routingController.text,
|
_routingController.text,
|
||||||
_accountController.text,
|
_accountController.text,
|
||||||
_selectedType,
|
_selectedType,
|
||||||
|
|||||||
@@ -1,64 +1,78 @@
|
|||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||||
|
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../../domain/entities/time_card.dart';
|
// ignore: implementation_imports
|
||||||
|
import 'package:krow_domain/src/adapters/financial/time_card_adapter.dart';
|
||||||
import '../../domain/repositories/time_card_repository.dart';
|
import '../../domain/repositories/time_card_repository.dart';
|
||||||
|
|
||||||
|
/// Implementation of [TimeCardRepository] using Firebase Data Connect.
|
||||||
class TimeCardRepositoryImpl implements TimeCardRepository {
|
class TimeCardRepositoryImpl implements TimeCardRepository {
|
||||||
final ShiftsRepositoryMock shiftsRepository;
|
final dc.ExampleConnector _dataConnect;
|
||||||
|
final firebase.FirebaseAuth _firebaseAuth;
|
||||||
|
|
||||||
TimeCardRepositoryImpl({required this.shiftsRepository});
|
/// Creates a [TimeCardRepositoryImpl].
|
||||||
|
TimeCardRepositoryImpl({
|
||||||
|
required dc.ExampleConnector dataConnect,
|
||||||
|
required firebase.FirebaseAuth firebaseAuth,
|
||||||
|
}) : _dataConnect = dataConnect,
|
||||||
|
_firebaseAuth = firebaseAuth;
|
||||||
|
|
||||||
|
Future<String> _getStaffId() async {
|
||||||
|
final firebase.User? user = _firebaseAuth.currentUser;
|
||||||
|
if (user == null) throw Exception('User not authenticated');
|
||||||
|
|
||||||
|
final fdc.QueryResult<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables> result =
|
||||||
|
await _dataConnect.getStaffByUserId(userId: user.uid).execute();
|
||||||
|
if (result.data.staffs.isEmpty) {
|
||||||
|
throw Exception('Staff profile not found');
|
||||||
|
}
|
||||||
|
return result.data.staffs.first.id;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<TimeCard>> getTimeCards(DateTime month) async {
|
Future<List<TimeCard>> getTimeCards(DateTime month) async {
|
||||||
// We use ShiftsRepositoryMock as it contains shift details (title, client, etc).
|
final String staffId = await _getStaffId();
|
||||||
// In a real app, we might query 'TimeCards' directly or join Shift+Payment.
|
// Fetch applications. Limit can be adjusted, assuming 100 is safe for now.
|
||||||
// For now, we simulate TimeCards from Shifts.
|
final fdc.QueryResult<dc.GetApplicationsByStaffIdData, dc.GetApplicationsByStaffIdVariables> result =
|
||||||
final List<Shift> shifts = await shiftsRepository.getMyShifts();
|
await _dataConnect.getApplicationsByStaffId(staffId: staffId).limit(100).execute();
|
||||||
|
|
||||||
// Map to TimeCard and filter by the requested month.
|
return result.data.applications
|
||||||
return shifts
|
.where((dc.GetApplicationsByStaffIdApplications app) {
|
||||||
.map((Shift shift) {
|
final DateTime? shiftDate = app.shift.date?.toDateTime();
|
||||||
double hours = 8.0;
|
if (shiftDate == null) return false;
|
||||||
// Simple parse for mock
|
return shiftDate.year == month.year && shiftDate.month == month.month;
|
||||||
try {
|
})
|
||||||
// Assuming HH:mm
|
.map((dc.GetApplicationsByStaffIdApplications app) {
|
||||||
final int start = int.parse(shift.startTime.split(':')[0]);
|
final DateTime shiftDate = app.shift.date!.toDateTime();
|
||||||
final int end = int.parse(shift.endTime.split(':')[0]);
|
final String startTime = _formatTime(app.checkInTime) ?? _formatTime(app.shift.startTime) ?? '';
|
||||||
hours = (end - start).abs().toDouble();
|
final String endTime = _formatTime(app.checkOutTime) ?? _formatTime(app.shift.endTime) ?? '';
|
||||||
if (hours == 0) hours = 8.0;
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
return TimeCard(
|
// Prefer shiftRole values for pay/hours
|
||||||
id: shift.id,
|
final double hours = app.shiftRole.hours ?? 0.0;
|
||||||
shiftTitle: shift.title,
|
final double rate = app.shiftRole.role.costPerHour;
|
||||||
clientName: shift.clientName,
|
final double pay = app.shiftRole.totalValue ?? 0.0;
|
||||||
date: DateTime.tryParse(shift.date) ?? DateTime.now(),
|
|
||||||
startTime: shift.startTime,
|
return TimeCardAdapter.fromPrimitives(
|
||||||
endTime: shift.endTime,
|
id: app.id,
|
||||||
|
shiftTitle: app.shift.title,
|
||||||
|
clientName: app.shift.order.business.businessName,
|
||||||
|
date: shiftDate,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
totalHours: hours,
|
totalHours: hours,
|
||||||
hourlyRate: shift.hourlyRate,
|
hourlyRate: rate,
|
||||||
totalPay: hours * shift.hourlyRate,
|
totalPay: pay,
|
||||||
status: _mapStatus(shift.status),
|
status: app.status.stringValue,
|
||||||
location: shift.location,
|
location: app.shift.location,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.where((TimeCard tc) =>
|
|
||||||
tc.date.year == month.year && tc.date.month == month.month)
|
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
TimeCardStatus _mapStatus(String? shiftStatus) {
|
String? _formatTime(fdc.Timestamp? timestamp) {
|
||||||
if (shiftStatus == null) return TimeCardStatus.pending;
|
if (timestamp == null) return null;
|
||||||
// Map shift status to TimeCardStatus
|
return DateFormat('HH:mm').format(timestamp.toDateTime());
|
||||||
switch (shiftStatus.toLowerCase()) {
|
|
||||||
case 'confirmed':
|
|
||||||
return TimeCardStatus.pending;
|
|
||||||
case 'completed':
|
|
||||||
return TimeCardStatus.approved;
|
|
||||||
case 'paid':
|
|
||||||
return TimeCardStatus.paid;
|
|
||||||
default:
|
|
||||||
return TimeCardStatus.pending;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../entities/time_card.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
/// Repository interface for accessing time card data.
|
/// Repository interface for accessing time card data.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import '../../domain/entities/time_card.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../arguments/get_time_cards_arguments.dart';
|
import '../arguments/get_time_cards_arguments.dart';
|
||||||
import '../repositories/time_card_repository.dart';
|
import '../repositories/time_card_repository.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import '../../domain/entities/time_card.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../../domain/arguments/get_time_cards_arguments.dart';
|
import '../../domain/arguments/get_time_cards_arguments.dart';
|
||||||
import '../../domain/usecases/get_time_cards_usecase.dart';
|
import '../../domain/usecases/get_time_cards_usecase.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import '../../domain/entities/time_card.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import 'timesheet_card.dart';
|
import 'timesheet_card.dart';
|
||||||
|
|
||||||
/// Displays the list of shift history or an empty state.
|
/// Displays the list of shift history or an empty state.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import '../../domain/entities/time_card.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
/// A card widget displaying details of a single shift/timecard.
|
/// A card widget displaying details of a single shift/timecard.
|
||||||
class TimesheetCard extends StatelessWidget {
|
class TimesheetCard extends StatelessWidget {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
library staff_time_card;
|
library staff_time_card;
|
||||||
|
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
|
|
||||||
@@ -11,15 +12,23 @@ import 'presentation/pages/time_card_page.dart';
|
|||||||
|
|
||||||
export 'presentation/pages/time_card_page.dart';
|
export 'presentation/pages/time_card_page.dart';
|
||||||
|
|
||||||
|
/// Module for the Staff Time Card feature.
|
||||||
|
///
|
||||||
|
/// This module configures dependency injection for accessing time card data,
|
||||||
|
/// including the repositories, use cases, and BLoCs.
|
||||||
class StaffTimeCardModule extends Module {
|
class StaffTimeCardModule extends Module {
|
||||||
|
@override
|
||||||
|
List<Module> get imports => <Module>[DataConnectModule()];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
// In a real app, ShiftsRepository might be provided by a Core Data Module.
|
i.add<TimeCardRepository>(
|
||||||
// For this self-contained feature/mock, we instantiate it here if not available globally.
|
() => TimeCardRepositoryImpl(
|
||||||
// Assuming we need a local instance for the mock to work or it's stateless.
|
dataConnect: ExampleConnector.instance,
|
||||||
i.add<ShiftsRepositoryMock>(ShiftsRepositoryMock.new);
|
firebaseAuth: FirebaseAuth.instance,
|
||||||
i.add<TimeCardRepository>(TimeCardRepositoryImpl.new);
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
i.add<GetTimeCardsUseCase>(GetTimeCardsUseCase.new);
|
i.add<GetTimeCardsUseCase>(GetTimeCardsUseCase.new);
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
|
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:core_localization/core_localization.dart';
|
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
|
|
||||||
import '../blocs/personal_info_bloc.dart';
|
import '../blocs/personal_info_bloc.dart';
|
||||||
import '../blocs/personal_info_event.dart';
|
|
||||||
import '../blocs/personal_info_state.dart';
|
import '../blocs/personal_info_state.dart';
|
||||||
import '../widgets/personal_info_content.dart';
|
import '../widgets/personal_info_content.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -32,28 +32,41 @@ class PersonalInfoContent extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||||
|
late final TextEditingController _emailController;
|
||||||
late final TextEditingController _phoneController;
|
late final TextEditingController _phoneController;
|
||||||
late final TextEditingController _locationsController;
|
late final TextEditingController _locationsController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_emailController = TextEditingController(text: widget.staff.email);
|
||||||
_phoneController = TextEditingController(text: widget.staff.phone ?? '');
|
_phoneController = TextEditingController(text: widget.staff.phone ?? '');
|
||||||
_locationsController = TextEditingController(text: widget.staff.preferredLocations?.join(', ')?? '');
|
_locationsController = TextEditingController(text: widget.staff.preferredLocations?.join(', ')?? '');
|
||||||
|
|
||||||
// Listen to changes and update BLoC
|
// Listen to changes and update BLoC
|
||||||
|
_emailController.addListener(_onEmailChanged);
|
||||||
_phoneController.addListener(_onPhoneChanged);
|
_phoneController.addListener(_onPhoneChanged);
|
||||||
_locationsController.addListener(_onAddressChanged);
|
_locationsController.addListener(_onAddressChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_emailController.dispose();
|
||||||
_phoneController.dispose();
|
_phoneController.dispose();
|
||||||
_locationsController.dispose();
|
_locationsController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void _onEmailChanged() {
|
||||||
|
context.read<PersonalInfoBloc>().add(
|
||||||
|
PersonalInfoFieldChanged(
|
||||||
|
field: 'email',
|
||||||
|
value: _emailController.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _onPhoneChanged() {
|
void _onPhoneChanged() {
|
||||||
context.read<PersonalInfoBloc>().add(
|
context.read<PersonalInfoBloc>().add(
|
||||||
PersonalInfoFieldChanged(
|
PersonalInfoFieldChanged(
|
||||||
@@ -114,6 +127,7 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
|||||||
PersonalInfoForm(
|
PersonalInfoForm(
|
||||||
fullName: widget.staff.name,
|
fullName: widget.staff.name,
|
||||||
email: widget.staff.email,
|
email: widget.staff.email,
|
||||||
|
emailController: _emailController,
|
||||||
phoneController: _phoneController,
|
phoneController: _phoneController,
|
||||||
locationsController: _locationsController,
|
locationsController: _locationsController,
|
||||||
enabled: !isSaving,
|
enabled: !isSaving,
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ class PersonalInfoForm extends StatelessWidget {
|
|||||||
/// The staff member's email (read-only).
|
/// The staff member's email (read-only).
|
||||||
final String email;
|
final String email;
|
||||||
|
|
||||||
|
/// Controller for the email field.
|
||||||
|
final TextEditingController emailController;
|
||||||
|
|
||||||
/// Controller for the phone number field.
|
/// Controller for the phone number field.
|
||||||
final TextEditingController phoneController;
|
final TextEditingController phoneController;
|
||||||
|
|
||||||
@@ -29,6 +32,7 @@ class PersonalInfoForm extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.fullName,
|
required this.fullName,
|
||||||
required this.email,
|
required this.email,
|
||||||
|
required this.emailController,
|
||||||
required this.phoneController,
|
required this.phoneController,
|
||||||
required this.locationsController,
|
required this.locationsController,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
@@ -48,7 +52,13 @@ class PersonalInfoForm extends StatelessWidget {
|
|||||||
|
|
||||||
_FieldLabel(text: i18n.email_label),
|
_FieldLabel(text: i18n.email_label),
|
||||||
const SizedBox(height: UiConstants.space2),
|
const SizedBox(height: UiConstants.space2),
|
||||||
_ReadOnlyField(value: email),
|
_EditableField(
|
||||||
|
controller: emailController,
|
||||||
|
hint: i18n.email_label,
|
||||||
|
enabled: enabled,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autofillHints: const [AutofillHints.email],
|
||||||
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
|
||||||
_FieldLabel(text: i18n.phone_label),
|
_FieldLabel(text: i18n.phone_label),
|
||||||
@@ -122,11 +132,15 @@ class _EditableField extends StatelessWidget {
|
|||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final String hint;
|
final String hint;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
|
final TextInputType? keyboardType;
|
||||||
|
final Iterable<String>? autofillHints;
|
||||||
|
|
||||||
const _EditableField({
|
const _EditableField({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.hint,
|
required this.hint,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
|
this.keyboardType,
|
||||||
|
this.autofillHints,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -134,6 +148,8 @@ class _EditableField extends StatelessWidget {
|
|||||||
return TextField(
|
return TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
autofillHints: autofillHints,
|
||||||
style: UiTypography.body2r.copyWith(color: UiColors.textPrimary),
|
style: UiTypography.body2r.copyWith(color: UiColors.textPrimary),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
|
||||||
import 'package:krow_data_connect/src/session/staff_session_store.dart';
|
import 'package:krow_data_connect/src/session/staff_session_store.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import '../../domain/repositories/shifts_repository_interface.dart';
|
import '../../domain/repositories/shifts_repository_interface.dart';
|
||||||
|
|
||||||
extension TimestampExt on Timestamp {
|
|
||||||
DateTime toDate() {
|
|
||||||
return DateTime.fromMillisecondsSinceEpoch(seconds.toInt() * 1000 + nanoseconds ~/ 1000000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation of [ShiftsRepositoryInterface] that delegates to [ShiftsRepositoryMock].
|
|
||||||
///
|
|
||||||
/// This class resides in the data layer and handles the communication with
|
|
||||||
/// the external data sources (currently mocks).
|
|
||||||
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||||
ShiftsRepositoryImpl();
|
final dc.ExampleConnector _dataConnect;
|
||||||
|
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||||
|
|
||||||
|
ShiftsRepositoryImpl() : _dataConnect = dc.ExampleConnector.instance;
|
||||||
|
|
||||||
// Cache: ShiftID -> ApplicationID (For Accept/Decline)
|
// Cache: ShiftID -> ApplicationID (For Accept/Decline)
|
||||||
final Map<String, String> _shiftToAppIdMap = {};
|
final Map<String, String> _shiftToAppIdMap = {};
|
||||||
@@ -24,193 +17,205 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
|||||||
final Map<String, String> _appToRoleIdMap = {};
|
final Map<String, String> _appToRoleIdMap = {};
|
||||||
|
|
||||||
String get _currentStaffId {
|
String get _currentStaffId {
|
||||||
final session = StaffSessionStore.instance.session;
|
final StaffSession? session = StaffSessionStore.instance.session;
|
||||||
if (session?.staff?.id == null) throw Exception('User not logged in');
|
if (session?.staff?.id != null) {
|
||||||
return session!.staff!.id;
|
return session!.staff!.id;
|
||||||
|
}
|
||||||
|
// Fallback? Or throw.
|
||||||
|
// If not logged in, we shouldn't be here.
|
||||||
|
return _auth.currentUser?.uid ?? 'STAFF_123';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to convert Data Connect Timestamp to DateTime
|
||||||
|
DateTime? _toDateTime(dynamic t) {
|
||||||
|
if (t == null) return null;
|
||||||
|
try {
|
||||||
|
if (t is String) return DateTime.tryParse(t);
|
||||||
|
// If it accepts toJson
|
||||||
|
try {
|
||||||
|
return DateTime.tryParse(t.toJson() as String);
|
||||||
|
} catch (_) {}
|
||||||
|
// If it's a Timestamp object (depends on SDK), usually .toDate() exists but 'dynamic' hides it.
|
||||||
|
// Assuming toString or toJson covers it, or using helper.
|
||||||
|
return DateTime.now(); // Placeholder if type unknown, but ideally fetch correct value
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Shift>> getMyShifts() async {
|
Future<List<Shift>> getMyShifts() async {
|
||||||
return _fetchApplications(ApplicationStatus.ACCEPTED);
|
return _fetchApplications(dc.ApplicationStatus.ACCEPTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Shift>> getPendingAssignments() async {
|
Future<List<Shift>> getPendingAssignments() async {
|
||||||
// Fetch both PENDING (User applied) and OFFERED (Business offered) if schema supports
|
return _fetchApplications(dc.ApplicationStatus.PENDING);
|
||||||
// For now assuming PENDING covers invitations/offers.
|
|
||||||
return _fetchApplications(ApplicationStatus.PENDING);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Shift>> _fetchApplications(ApplicationStatus status) async {
|
Future<List<Shift>> _fetchApplications(dc.ApplicationStatus status) async {
|
||||||
try {
|
try {
|
||||||
final response = await ExampleConnector.instance
|
final response = await _dataConnect
|
||||||
.getApplicationsByStaffId(staffId: _currentStaffId)
|
.getApplicationsByStaffId(staffId: _currentStaffId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return response.data.applications
|
final apps = response.data.applications.where((app) => app.status == status);
|
||||||
.where((app) => app.status is Known && (app.status as Known).value == status)
|
final List<Shift> shifts = [];
|
||||||
.map((app) {
|
|
||||||
// Cache IDs for actions
|
|
||||||
_shiftToAppIdMap[app.shift.id] = app.id;
|
|
||||||
_appToRoleIdMap[app.id] = app.shiftRole.roleId;
|
|
||||||
|
|
||||||
return _mapApplicationToShift(app);
|
for (final app in apps) {
|
||||||
})
|
_shiftToAppIdMap[app.shift.id] = app.id;
|
||||||
.toList();
|
_appToRoleIdMap[app.id] = app.shiftRole.id;
|
||||||
|
|
||||||
|
final shiftTuple = await _getShiftDetails(app.shift.id);
|
||||||
|
if (shiftTuple != null) {
|
||||||
|
shifts.add(shiftTuple);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shifts;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return [];
|
return <Shift>[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Shift>> getAvailableShifts(String query, String type) async {
|
Future<List<Shift>> getAvailableShifts(String query, String type) async {
|
||||||
try {
|
try {
|
||||||
final response = await ExampleConnector.instance.listShifts().execute();
|
final result = await _dataConnect.listShifts().execute();
|
||||||
|
final allShifts = result.data.shifts;
|
||||||
|
|
||||||
var shifts = response.data.shifts
|
final List<Shift> mappedShifts = [];
|
||||||
.where((s) => s.status is Known && (s.status as Known).value == ShiftStatus.OPEN)
|
|
||||||
.map((s) => _mapConnectorShiftToDomain(s))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// Client-side filtering
|
for (final s in allShifts) {
|
||||||
if (query.isNotEmpty) {
|
// For each shift, map to Domain Shift
|
||||||
shifts = shifts.where((s) =>
|
// Note: date fields in generated code might be specific types
|
||||||
s.title.toLowerCase().contains(query.toLowerCase()) ||
|
final startDt = _toDateTime(s.startTime);
|
||||||
s.clientName.toLowerCase().contains(query.toLowerCase())
|
final endDt = _toDateTime(s.endTime);
|
||||||
).toList();
|
final createdDt = _toDateTime(s.createdAt);
|
||||||
}
|
|
||||||
|
|
||||||
if (type != 'all') {
|
mappedShifts.add(Shift(
|
||||||
if (type == 'one-day') {
|
id: s.id,
|
||||||
shifts = shifts.where((s) => !s.title.contains('Multi-Day')).toList();
|
title: s.title,
|
||||||
} else if (type == 'multi-day') {
|
clientName: s.order.business.businessName,
|
||||||
shifts = shifts.where((s) => s.title.contains('Multi-Day')).toList();
|
logoUrl: null,
|
||||||
|
hourlyRate: s.cost ?? 0.0,
|
||||||
|
location: s.location ?? '',
|
||||||
|
locationAddress: s.locationAddress ?? '',
|
||||||
|
date: startDt?.toIso8601String() ?? '',
|
||||||
|
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||||
|
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||||
|
createdDate: createdDt?.toIso8601String() ?? '',
|
||||||
|
status: s.status?.stringValue ?? 'OPEN',
|
||||||
|
description: s.description,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return shifts;
|
|
||||||
|
|
||||||
} catch (e) {
|
if (query.isNotEmpty) {
|
||||||
return [];
|
return mappedShifts.where((s) =>
|
||||||
}
|
s.title.toLowerCase().contains(query.toLowerCase()) ||
|
||||||
|
s.clientName.toLowerCase().contains(query.toLowerCase())
|
||||||
|
).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return mappedShifts;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
return <Shift>[];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Shift?> getShiftDetails(String shiftId) async {
|
Future<Shift?> getShiftDetails(String shiftId) async {
|
||||||
try {
|
return _getShiftDetails(shiftId);
|
||||||
final response = await ExampleConnector.instance.getShiftById(id: shiftId).execute();
|
}
|
||||||
final s = response.data.shift;
|
|
||||||
if (s == null) return null;
|
|
||||||
|
|
||||||
// Map to domain Shift
|
Future<Shift?> _getShiftDetails(String shiftId) async {
|
||||||
return Shift(
|
try {
|
||||||
id: s.id,
|
final result = await _dataConnect.getShiftById(id: shiftId).execute();
|
||||||
title: s.title,
|
final s = result.data.shift;
|
||||||
clientName: s.order.business.businessName,
|
if (s == null) return null;
|
||||||
hourlyRate: s.cost ?? 0.0,
|
|
||||||
location: s.location ?? 'Unknown',
|
final startDt = _toDateTime(s.startTime);
|
||||||
locationAddress: s.locationAddress ?? '',
|
final endDt = _toDateTime(s.endTime);
|
||||||
date: s.date?.toDate().toIso8601String() ?? '',
|
final createdDt = _toDateTime(s.createdAt);
|
||||||
startTime: DateFormat('HH:mm').format(s.startTime?.toDate() ?? DateTime.now()),
|
|
||||||
endTime: DateFormat('HH:mm').format(s.endTime?.toDate() ?? DateTime.now()),
|
return Shift(
|
||||||
createdDate: s.createdAt?.toDate().toIso8601String() ?? '',
|
id: s.id,
|
||||||
tipsAvailable: false,
|
title: s.title,
|
||||||
mealProvided: false,
|
clientName: s.order.business.businessName,
|
||||||
managers: [],
|
logoUrl: null,
|
||||||
description: s.description,
|
hourlyRate: s.cost ?? 0.0,
|
||||||
);
|
location: s.location ?? '',
|
||||||
} catch (e) {
|
locationAddress: s.locationAddress ?? '',
|
||||||
return null;
|
date: startDt?.toIso8601String() ?? '',
|
||||||
}
|
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||||
|
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||||
|
createdDate: createdDt?.toIso8601String() ?? '',
|
||||||
|
status: s.status?.stringValue ?? 'OPEN',
|
||||||
|
description: s.description,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> applyForShift(String shiftId) async {
|
Future<void> applyForShift(String shiftId) async {
|
||||||
// API LIMITATION: 'createApplication' requires roleId.
|
final rolesResult = await _dataConnect.listShiftRolesByShiftId(shiftId: shiftId).execute();
|
||||||
// 'listShifts' / 'getShiftById' does not currently return the Shift's available Roles.
|
if (rolesResult.data.shiftRoles.isEmpty) throw Exception('No open roles for this shift');
|
||||||
// We cannot reliably apply for a shift without knowing the Role ID.
|
|
||||||
// Falling back to Mock delay for now.
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
// In future:
|
final role = rolesResult.data.shiftRoles.first;
|
||||||
// 1. Fetch Shift Roles
|
|
||||||
// 2. Select Role
|
await _dataConnect.createApplication(
|
||||||
// 3. createApplication(shiftId, roleId, staffId, status: PENDING, origin: MOBILE)
|
shiftId: shiftId,
|
||||||
|
staffId: _currentStaffId,
|
||||||
|
roleId: role.id,
|
||||||
|
status: dc.ApplicationStatus.PENDING,
|
||||||
|
origin: dc.ApplicationOrigin.STAFF,
|
||||||
|
).execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> acceptShift(String shiftId) async {
|
Future<void> acceptShift(String shiftId) async {
|
||||||
await _updateApplicationStatus(shiftId, ApplicationStatus.ACCEPTED);
|
await _updateApplicationStatus(shiftId, dc.ApplicationStatus.ACCEPTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> declineShift(String shiftId) async {
|
Future<void> declineShift(String shiftId) async {
|
||||||
await _updateApplicationStatus(shiftId, ApplicationStatus.REJECTED);
|
await _updateApplicationStatus(shiftId, dc.ApplicationStatus.REJECTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateApplicationStatus(String shiftId, ApplicationStatus newStatus) async {
|
Future<void> _updateApplicationStatus(String shiftId, dc.ApplicationStatus newStatus) async {
|
||||||
String? appId = _shiftToAppIdMap[shiftId];
|
String? appId = _shiftToAppIdMap[shiftId];
|
||||||
String? roleId;
|
String? roleId;
|
||||||
|
|
||||||
// Refresh if missing from cache
|
|
||||||
if (appId == null) {
|
if (appId == null) {
|
||||||
|
// Try to find it in pending
|
||||||
await getPendingAssignments();
|
await getPendingAssignments();
|
||||||
appId = _shiftToAppIdMap[shiftId];
|
|
||||||
}
|
}
|
||||||
roleId = _appToRoleIdMap[appId];
|
// Re-check map
|
||||||
|
appId = _shiftToAppIdMap[shiftId];
|
||||||
|
if (appId != null) {
|
||||||
|
roleId = _appToRoleIdMap[appId];
|
||||||
|
} else {
|
||||||
|
// Fallback fetch
|
||||||
|
final apps = await _dataConnect.getApplicationsByStaffId(staffId: _currentStaffId).execute();
|
||||||
|
final app = apps.data.applications.where((a) => a.shiftId == shiftId).firstOrNull;
|
||||||
|
if (app != null) {
|
||||||
|
appId = app.id;
|
||||||
|
roleId = app.shiftRole.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (appId == null || roleId == null) {
|
if (appId == null || roleId == null) {
|
||||||
throw Exception("Application not found for shift $shiftId");
|
throw Exception("Application not found for shift $shiftId");
|
||||||
}
|
}
|
||||||
|
|
||||||
await ExampleConnector.instance.updateApplicationStatus(
|
await _dataConnect.updateApplicationStatus(
|
||||||
id: appId,
|
id: appId,
|
||||||
roleId: roleId,
|
roleId: roleId,
|
||||||
)
|
)
|
||||||
.status(newStatus)
|
.status(newStatus)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mappers
|
|
||||||
|
|
||||||
Shift _mapApplicationToShift(GetApplicationsByStaffIdApplications app) {
|
|
||||||
final s = app.shift;
|
|
||||||
final r = app.shiftRole;
|
|
||||||
final statusVal = app.status is Known
|
|
||||||
? (app.status as Known).value.name.toLowerCase() : 'pending';
|
|
||||||
|
|
||||||
return Shift(
|
|
||||||
id: s.id,
|
|
||||||
title: r.role.name,
|
|
||||||
clientName: s.order.business.businessName,
|
|
||||||
hourlyRate: r.role.costPerHour,
|
|
||||||
location: s.location ?? 'Unknown',
|
|
||||||
locationAddress: s.location ?? '',
|
|
||||||
date: s.date?.toDate().toIso8601String() ?? '',
|
|
||||||
startTime: DateFormat('HH:mm').format(r.startTime?.toDate() ?? DateTime.now()),
|
|
||||||
endTime: DateFormat('HH:mm').format(r.endTime?.toDate() ?? DateTime.now()),
|
|
||||||
createdDate: app.createdAt?.toDate().toIso8601String() ?? '',
|
|
||||||
status: statusVal,
|
|
||||||
description: null,
|
|
||||||
managers: [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Shift _mapConnectorShiftToDomain(ListShiftsShifts s) {
|
|
||||||
return Shift(
|
|
||||||
id: s.id,
|
|
||||||
title: s.title,
|
|
||||||
clientName: s.order.business.businessName,
|
|
||||||
hourlyRate: s.cost ?? 0.0,
|
|
||||||
location: s.location ?? 'Unknown',
|
|
||||||
locationAddress: s.locationAddress ?? '',
|
|
||||||
date: s.date?.toDate().toIso8601String() ?? '',
|
|
||||||
startTime: DateFormat('HH:mm').format(s.startTime?.toDate() ?? DateTime.now()),
|
|
||||||
endTime: DateFormat('HH:mm').format(s.endTime?.toDate() ?? DateTime.now()),
|
|
||||||
createdDate: s.createdAt?.toDate().toIso8601String() ?? '',
|
|
||||||
description: s.description,
|
|
||||||
managers: [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -157,19 +157,6 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
_buildDemoButton("Demo: Cancel <4hr", const Color(0xFFEF4444), () {
|
|
||||||
setState(() => _cancelledShiftDemo = 'lastMinute');
|
|
||||||
_showCancelledModal('lastMinute');
|
|
||||||
}),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_buildDemoButton("Demo: Cancel >4hr", const Color(0xFFF59E0B), () {
|
|
||||||
setState(() => _cancelledShiftDemo = 'advance');
|
|
||||||
_showCancelledModal('advance');
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
Reference in New Issue
Block a user