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
|
||||
description: "Krow Client Application"
|
||||
publish_to: "none"
|
||||
version: 0.0.1-M3+2
|
||||
version: 0.0.1-M3+3
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
|
||||
@@ -517,6 +517,8 @@
|
||||
"secure_subtitle": "Your account details are encrypted and safe.",
|
||||
"primary": "Primary",
|
||||
"add_new_account": "Add New Account",
|
||||
"bank_name": "Bank Name",
|
||||
"bank_hint": "Enter bank name",
|
||||
"routing_number": "Routing Number",
|
||||
"routing_hint": "Enter routing number",
|
||||
"account_number": "Account Number",
|
||||
@@ -526,7 +528,8 @@
|
||||
"savings": "Savings",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"account_ending": "Ending in $last4"
|
||||
"account_ending": "Ending in $last4",
|
||||
"account_added_success": "Bank account added successfully!"
|
||||
},
|
||||
"logout": {
|
||||
"button": "Sign Out"
|
||||
|
||||
@@ -515,6 +515,8 @@
|
||||
"secure_title": "Seguro y Cifrado",
|
||||
"secure_subtitle": "Su información bancaria está cifrada y almacenada de forma segura. Nunca compartimos sus detalles.",
|
||||
"add_new_account": "Agregar Nueva Cuenta",
|
||||
"bank_name": "Nombre del Banco",
|
||||
"bank_hint": "Ingrese nombre del banco",
|
||||
"routing_number": "Número de Ruta",
|
||||
"routing_hint": "9 dígitos",
|
||||
"account_number": "Número de Cuenta",
|
||||
@@ -525,7 +527,8 @@
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"primary": "Principal",
|
||||
"account_ending": "Termina en $last4"
|
||||
"account_ending": "Termina en $last4",
|
||||
"account_added_success": "¡Cuenta bancaria agregada exitosamente!"
|
||||
},
|
||||
"logout": {
|
||||
"button": "Cerrar Sesión"
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// 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
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -2253,6 +2253,12 @@ class TranslationsStaffProfileBankAccountPageEn {
|
||||
/// en: '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'
|
||||
String get routing_number => 'Routing Number';
|
||||
|
||||
@@ -2282,6 +2288,9 @@ class TranslationsStaffProfileBankAccountPageEn {
|
||||
|
||||
/// en: '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
|
||||
@@ -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.primary' => 'Primary',
|
||||
'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_hint' => 'Enter routing 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.save' => 'Save',
|
||||
'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.onboarding.personal_info.title' => 'Personal Info',
|
||||
'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.no_experience' => 'No experience',
|
||||
'staff_time_card.title' => 'Timecard',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'staff_time_card.hours_worked' => 'Hours Worked',
|
||||
'staff_time_card.total_earnings' => 'Total Earnings',
|
||||
'staff_time_card.shift_history' => 'Shift History',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'staff_time_card.no_shifts' => 'No shifts for this month',
|
||||
'staff_time_card.hours' => 'hours',
|
||||
'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_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 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_hint => '9 dígitos';
|
||||
@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 primary => 'Principal';
|
||||
@override String account_ending({required Object last4}) => 'Termina en ${last4}';
|
||||
@override String get account_added_success => '¡Cuenta bancaria agregada exitosamente!';
|
||||
}
|
||||
|
||||
// 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_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.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_hint' => '9 dígitos',
|
||||
'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.primary' => 'Principal',
|
||||
'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.onboarding.personal_info.title' => 'Información Personal',
|
||||
'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.no_experience' => 'No experience',
|
||||
'staff_time_card.title' => 'Tarjeta de tiempo',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'staff_time_card.hours_worked' => 'Horas trabajadas',
|
||||
'staff_time_card.total_earnings' => 'Ganancias totales',
|
||||
'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.hours' => 'horas',
|
||||
'staff_time_card.per_hr' => '/hr',
|
||||
|
||||
@@ -29,6 +29,7 @@ export 'src/entities/events/work_session.dart';
|
||||
|
||||
// Shifts
|
||||
export 'src/entities/shifts/shift.dart';
|
||||
export 'src/adapters/shifts/shift_adapter.dart';
|
||||
|
||||
// Orders & Requests
|
||||
export 'src/entities/orders/order_type.dart';
|
||||
@@ -45,9 +46,11 @@ export 'src/entities/skills/skill_kit.dart';
|
||||
|
||||
// Financial & Payroll
|
||||
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_decline.dart';
|
||||
export 'src/entities/financial/staff_payment.dart';
|
||||
export 'src/entities/financial/payment_summary.dart';
|
||||
|
||||
// Profile
|
||||
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';
|
||||
|
||||
// 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/day_availability.dart';
|
||||
|
||||
@@ -87,3 +93,4 @@ export 'src/adapters/profile/experience_adapter.dart';
|
||||
export 'src/entities/profile/experience_skill.dart';
|
||||
export 'src/adapters/profile/bank_account_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';
|
||||
|
||||
/// Summary of staff earnings.
|
||||
class PaymentSummary extends Equatable {
|
||||
final double weeklyEarnings;
|
||||
final double monthlyEarnings;
|
||||
@@ -13,6 +13,9 @@ enum PaymentStatus {
|
||||
|
||||
/// Transfer failed.
|
||||
failed,
|
||||
|
||||
/// Status unknown.
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Represents a payout to a [Staff] member for a completed [Assignment].
|
||||
|
||||
@@ -1,30 +1,52 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Status of a time card.
|
||||
enum TimeCardStatus {
|
||||
/// Waiting for approval or payment.
|
||||
pending,
|
||||
/// Approved by manager.
|
||||
approved,
|
||||
/// Payment has been issued.
|
||||
paid,
|
||||
/// Disputed by staff or client.
|
||||
disputed;
|
||||
|
||||
/// Whether the card is approved.
|
||||
bool get isApproved => this == TimeCardStatus.approved;
|
||||
/// Whether the card is paid.
|
||||
bool get isPaid => this == TimeCardStatus.paid;
|
||||
/// Whether the card is disputed.
|
||||
bool get isDisputed => this == TimeCardStatus.disputed;
|
||||
/// Whether the card is pending.
|
||||
bool get isPending => this == TimeCardStatus.pending;
|
||||
}
|
||||
|
||||
/// Represents a time card for a staff member.
|
||||
class TimeCard extends Equatable {
|
||||
/// Unique identifier of the time card (often matches Application ID).
|
||||
final String id;
|
||||
/// Title of the shift.
|
||||
final String shiftTitle;
|
||||
/// Name of the client business.
|
||||
final String clientName;
|
||||
/// Date of the shift.
|
||||
final DateTime date;
|
||||
/// Actual or scheduled start time.
|
||||
final String startTime;
|
||||
/// Actual or scheduled end time.
|
||||
final String endTime;
|
||||
/// Total hours worked.
|
||||
final double totalHours;
|
||||
/// Hourly pay rate.
|
||||
final double hourlyRate;
|
||||
/// Total pay amount.
|
||||
final double totalPay;
|
||||
/// Current status of the time card.
|
||||
final TimeCardStatus status;
|
||||
/// Location name.
|
||||
final String? location;
|
||||
|
||||
/// Creates a [TimeCard].
|
||||
const TimeCard({
|
||||
required this.id,
|
||||
required this.shiftTitle,
|
||||
@@ -39,10 +39,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
return _getUserProfile(
|
||||
firebaseUserId: firebaseUser.uid,
|
||||
fallbackEmail: firebaseUser.email ?? email,
|
||||
requireBusinessRole: true,
|
||||
);
|
||||
|
||||
//TO-DO: validate that user is business role and has business account
|
||||
|
||||
} on firebase.FirebaseAuthException catch (e) {
|
||||
if (e.code == 'invalid-credential' || e.code == 'wrong-password') {
|
||||
throw Exception('Incorrect email or password.');
|
||||
@@ -138,12 +137,18 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
Future<domain.User> _getUserProfile({
|
||||
required String firebaseUserId,
|
||||
required String? fallbackEmail,
|
||||
bool requireBusinessRole = false,
|
||||
}) async {
|
||||
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response = await _dataConnect.getUserById(id: firebaseUserId).execute();
|
||||
final dc.GetUserByIdUser? user = response.data?.user;
|
||||
if (user == null) {
|
||||
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;
|
||||
if (email == null || email.isEmpty) {
|
||||
|
||||
@@ -15,10 +15,6 @@ import 'presentation/pages/billing_page.dart';
|
||||
class BillingModule extends Module {
|
||||
@override
|
||||
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
|
||||
i.addSingleton<BillingRepository>(
|
||||
() => BillingRepositoryImpl(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.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_event.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../widgets/billing_header.dart';
|
||||
import '../widgets/invoice_history_section.dart';
|
||||
import '../widgets/payment_method_card.dart';
|
||||
import '../widgets/pending_invoices_section.dart';
|
||||
import '../widgets/savings_card.dart';
|
||||
import '../widgets/spending_breakdown_card.dart';
|
||||
|
||||
/// 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
|
||||
/// state of the [BillingBloc].
|
||||
class BillingView extends StatelessWidget {
|
||||
class BillingView extends StatefulWidget {
|
||||
/// Creates a [BillingView].
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<BillingBloc, BillingState>(
|
||||
builder: (BuildContext context, BillingState state) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
BillingHeader(
|
||||
currentBill: state.currentBill,
|
||||
savings: state.savings,
|
||||
onBack: () => Modular.to.pop(),
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: <Widget>[
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
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) {
|
||||
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) {
|
||||
@@ -72,23 +187,49 @@ class BillingView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
|
||||
PendingInvoicesSection(invoices: state.pendingInvoices),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
],
|
||||
const PaymentMethodCard(),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
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),
|
||||
SavingsCard(savings: state.savings),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
InvoiceHistorySection(invoices: state.invoiceHistory),
|
||||
const SizedBox(height: UiConstants.space24),
|
||||
Text(
|
||||
'No Invoices for the selected period',
|
||||
style: UiTypography.body1m.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -24,11 +24,13 @@ class _PaymentMethodCardState extends State<PaymentMethodCard> {
|
||||
return null;
|
||||
}
|
||||
|
||||
final fdc.QueryResult<dc.GetAccountsByOwnerIdData,
|
||||
dc.GetAccountsByOwnerIdVariables> result =
|
||||
await dc.ExampleConnector.instance
|
||||
.getAccountsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
final fdc.QueryResult<
|
||||
dc.GetAccountsByOwnerIdData,
|
||||
dc.GetAccountsByOwnerIdVariables
|
||||
>
|
||||
result = await dc.ExampleConnector.instance
|
||||
.getAccountsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -36,115 +38,123 @@ class _PaymentMethodCardState extends State<PaymentMethodCard> {
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<dc.GetAccountsByOwnerIdData?>(
|
||||
future: _accountsFuture,
|
||||
builder: (BuildContext context,
|
||||
AsyncSnapshot<dc.GetAccountsByOwnerIdData?> snapshot) {
|
||||
final List<dc.GetAccountsByOwnerIdAccounts> accounts =
|
||||
snapshot.data?.accounts ??
|
||||
<dc.GetAccountsByOwnerIdAccounts>[];
|
||||
final dc.GetAccountsByOwnerIdAccounts? account =
|
||||
accounts.isNotEmpty ? accounts.first : null;
|
||||
final String bankLabel =
|
||||
account?.bank.isNotEmpty == true ? account!.bank : '----';
|
||||
final String last4 =
|
||||
account?.last4.isNotEmpty == true ? account!.last4 : '----';
|
||||
final bool isPrimary = account?.isPrimary ?? false;
|
||||
final String expiryLabel = _formatExpiry(account?.expiryTime);
|
||||
builder:
|
||||
(
|
||||
BuildContext context,
|
||||
AsyncSnapshot<dc.GetAccountsByOwnerIdData?> snapshot,
|
||||
) {
|
||||
final List<dc.GetAccountsByOwnerIdAccounts> accounts =
|
||||
snapshot.data?.accounts ?? <dc.GetAccountsByOwnerIdAccounts>[];
|
||||
final dc.GetAccountsByOwnerIdAccounts? account = accounts.isNotEmpty
|
||||
? accounts.first
|
||||
: null;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t.client_billing.payment_method,
|
||||
style: UiTypography.title2b.textPrimary,
|
||||
),
|
||||
const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
if (account != null) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgSecondary,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
if (account == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final String bankLabel = account.bank.isNotEmpty == true
|
||||
? account.bank
|
||||
: '----';
|
||||
final String last4 = account.last4.isNotEmpty == true
|
||||
? account.last4
|
||||
: '----';
|
||||
final bool isPrimary = account.isPrimary ?? false;
|
||||
final String expiryLabel = _formatExpiry(account.expiryTime);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
child: Row(
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
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,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
t.client_billing.payment_method,
|
||||
style: UiTypography.title2b.textPrimary,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
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_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../blocs/coverage_bloc.dart';
|
||||
import '../blocs/coverage_event.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_shift_list.dart';
|
||||
import '../widgets/late_workers_alert.dart';
|
||||
@@ -14,10 +15,41 @@ import '../widgets/late_workers_alert.dart';
|
||||
/// Page for displaying daily coverage information.
|
||||
///
|
||||
/// Shows shifts, worker statuses, and coverage statistics for a selected date.
|
||||
class CoveragePage extends StatelessWidget {
|
||||
class CoveragePage extends StatefulWidget {
|
||||
/// Creates a [CoveragePage].
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<CoverageBloc>(
|
||||
@@ -26,26 +58,159 @@ class CoveragePage extends StatelessWidget {
|
||||
child: Scaffold(
|
||||
body: BlocBuilder<CoverageBloc, CoverageState>(
|
||||
builder: (BuildContext context, CoverageState state) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
CoverageHeader(
|
||||
selectedDate: state.selectedDate ?? DateTime.now(),
|
||||
coveragePercent: state.stats?.coveragePercent ?? 0,
|
||||
totalConfirmed: state.stats?.totalConfirmed ?? 0,
|
||||
totalNeeded: state.stats?.totalNeeded ?? 0,
|
||||
onDateSelected: (DateTime date) {
|
||||
BlocProvider.of<CoverageBloc>(context).add(
|
||||
CoverageLoadRequested(date: date),
|
||||
);
|
||||
},
|
||||
onRefresh: () {
|
||||
BlocProvider.of<CoverageBloc>(context).add(
|
||||
const CoverageRefreshRequested(),
|
||||
);
|
||||
},
|
||||
final DateTime selectedDate = state.selectedDate ?? DateTime.now();
|
||||
|
||||
return CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: <Widget>[
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
expandedHeight: 300.0,
|
||||
backgroundColor: UiColors.primary,
|
||||
leading: IconButton(
|
||||
onPressed: () => Modular.to.pop(),
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
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(
|
||||
child: _buildBody(context: context, state: state),
|
||||
SliverList(
|
||||
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),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -120,7 +285,9 @@ class CoveragePage extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
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,
|
||||
message: labels.success_message,
|
||||
buttonLabel: labels.back_to_orders,
|
||||
onDone: () => Modular.to.navigate(
|
||||
onDone: () => Modular.to.pushNamedAndRemoveUntil(
|
||||
'/client-main/orders/',
|
||||
(_) => false,
|
||||
arguments: <String, dynamic>{
|
||||
'initialDate': state.date.toIso8601String(),
|
||||
},
|
||||
|
||||
@@ -74,23 +74,39 @@ class ClientHomePage extends StatelessWidget {
|
||||
|
||||
/// Builds the widget list in normal mode with visibility filters.
|
||||
Widget _buildNormalModeList(ClientHomeState state) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
UiConstants.space4,
|
||||
0,
|
||||
UiConstants.space4,
|
||||
100,
|
||||
),
|
||||
children: state.widgetOrder.map((String id) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: DashboardWidgetBuilder(
|
||||
id: id,
|
||||
state: state,
|
||||
isEditMode: false,
|
||||
),
|
||||
final List<String> visibleWidgets = state.widgetOrder.where((String id) {
|
||||
if (id == 'reorder' && state.reorderItems.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return state.widgetVisibility[id] ?? true;
|
||||
}).toList();
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
separatorBuilder: (BuildContext context, int index) {
|
||||
return const Divider(color: UiColors.border, height: 0.2);
|
||||
},
|
||||
itemCount: visibleWidgets.length,
|
||||
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.
|
||||
final VoidCallback onCreateOrderPressed;
|
||||
|
||||
/// Optional subtitle for the section.
|
||||
final String? subtitle;
|
||||
|
||||
/// Creates an [ActionsWidget].
|
||||
const ActionsWidget({
|
||||
super.key,
|
||||
required this.onRapidPressed,
|
||||
required this.onCreateOrderPressed,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -22,8 +26,11 @@ class ActionsWidget extends StatelessWidget {
|
||||
// Check if client_home exists in t
|
||||
final TranslationsClientHomeActionsEn i18n = t.client_home.actions;
|
||||
|
||||
return Row(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
/// TODO: FEATURE_NOT_YET_IMPLEMENTED
|
||||
// Expanded(
|
||||
// child: _ActionCard(
|
||||
@@ -55,6 +62,8 @@ class ActionsWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,17 +12,36 @@ class CoverageWidget extends StatelessWidget {
|
||||
/// The percentage of coverage (0-100).
|
||||
final int coveragePercent;
|
||||
|
||||
/// Optional subtitle for the section.
|
||||
final String? subtitle;
|
||||
|
||||
/// Creates a [CoverageWidget].
|
||||
const CoverageWidget({
|
||||
super.key,
|
||||
this.totalNeeded = 0,
|
||||
this.totalConfirmed = 0,
|
||||
this.coveragePercent = 0,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
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
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagActive,
|
||||
color: backgroundColor,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Text(
|
||||
'$coveragePercent% Covered',
|
||||
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(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
@@ -65,7 +90,7 @@ class CoverageWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
if (totalConfirmed != 0) Expanded(
|
||||
child: _MetricCard(
|
||||
icon: UiIcons.success,
|
||||
iconColor: UiColors.iconSuccess,
|
||||
|
||||
@@ -56,11 +56,15 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
||||
|
||||
/// Builds the actual widget content based on the widget ID.
|
||||
Widget _buildWidgetContent(BuildContext context) {
|
||||
// Only show subtitle in normal mode
|
||||
final String? subtitle = !isEditMode ? _getWidgetSubtitle(id) : null;
|
||||
|
||||
switch (id) {
|
||||
case 'actions':
|
||||
return ActionsWidget(
|
||||
onRapidPressed: () => Modular.to.pushRapidOrder(),
|
||||
onCreateOrderPressed: () => Modular.to.pushCreateOrder(),
|
||||
subtitle: subtitle,
|
||||
);
|
||||
case 'reorder':
|
||||
return ReorderWidget(
|
||||
@@ -88,6 +92,7 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
},
|
||||
subtitle: subtitle,
|
||||
);
|
||||
case 'spending':
|
||||
return SpendingWidget(
|
||||
@@ -95,6 +100,7 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
||||
next7DaysSpending: state.dashboardData.next7DaysSpending,
|
||||
weeklyShifts: state.dashboardData.weeklyShifts,
|
||||
next7DaysScheduled: state.dashboardData.next7DaysScheduled,
|
||||
subtitle: subtitle,
|
||||
);
|
||||
case 'coverage':
|
||||
return CoverageWidget(
|
||||
@@ -106,10 +112,12 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
||||
100)
|
||||
.toInt()
|
||||
: 0,
|
||||
subtitle: subtitle,
|
||||
);
|
||||
case 'liveActivity':
|
||||
return LiveActivityWidget(
|
||||
onViewAllPressed: () => Modular.to.navigate('/client-main/coverage/'),
|
||||
subtitle: subtitle,
|
||||
);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
@@ -133,4 +141,21 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
||||
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.
|
||||
final VoidCallback onViewAllPressed;
|
||||
|
||||
/// Optional subtitle for the section.
|
||||
final String? subtitle;
|
||||
|
||||
/// Creates a [LiveActivityWidget].
|
||||
const LiveActivityWidget({super.key, required this.onViewAllPressed});
|
||||
const LiveActivityWidget({
|
||||
super.key,
|
||||
required this.onViewAllPressed,
|
||||
this.subtitle
|
||||
});
|
||||
|
||||
@override
|
||||
State<LiveActivityWidget> createState() => _LiveActivityWidgetState();
|
||||
@@ -100,6 +107,7 @@ class _LiveActivityWidgetState extends State<LiveActivityWidget> {
|
||||
final TranslationsClientHomeEn i18n = t.client_home;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
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>(
|
||||
future: _liveActivityFuture,
|
||||
builder: (BuildContext context,
|
||||
|
||||
@@ -11,15 +11,23 @@ class ReorderWidget extends StatelessWidget {
|
||||
/// Callback when a reorder button is pressed.
|
||||
final Function(Map<String, dynamic> shiftData) onReorderPressed;
|
||||
|
||||
/// Optional subtitle for the section.
|
||||
final String? subtitle;
|
||||
|
||||
/// Creates a [ReorderWidget].
|
||||
const ReorderWidget({
|
||||
super.key,
|
||||
required this.orders,
|
||||
required this.onReorderPressed,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (orders.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final TranslationsClientHomeReorderEn i18n = t.client_home.reorder;
|
||||
|
||||
final List<ReorderItem> recentOrders = orders;
|
||||
@@ -33,6 +41,13 @@ class ReorderWidget extends StatelessWidget {
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
SizedBox(
|
||||
height: 140,
|
||||
|
||||
@@ -16,6 +16,9 @@ class SpendingWidget extends StatelessWidget {
|
||||
/// The number of scheduled shifts for next 7 days.
|
||||
final int next7DaysScheduled;
|
||||
|
||||
/// Optional subtitle for the section.
|
||||
final String? subtitle;
|
||||
|
||||
/// Creates a [SpendingWidget].
|
||||
const SpendingWidget({
|
||||
super.key,
|
||||
@@ -23,6 +26,7 @@ class SpendingWidget extends StatelessWidget {
|
||||
required this.next7DaysSpending,
|
||||
required this.weeklyShifts,
|
||||
required this.next7DaysScheduled,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -38,7 +42,13 @@ class SpendingWidget extends StatelessWidget {
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
if (subtitle != null) ...<Widget>[
|
||||
Text(
|
||||
subtitle!,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
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) {
|
||||
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) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:flutter_modular/flutter_modular.dart';
|
||||
extension ViewOrdersNavigator on IModularNavigator {
|
||||
/// Navigates to the Create Order feature.
|
||||
void navigateToCreateOrder() {
|
||||
pushNamed('/client/create-order/');
|
||||
navigate('/client/create-order/');
|
||||
}
|
||||
|
||||
/// Navigates to the Order Details (placeholder for now).
|
||||
|
||||
@@ -51,22 +51,21 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Force initialization of cubit immediately
|
||||
_cubit = BlocProvider.of<ViewOrdersCubit>(context, listen: false);
|
||||
|
||||
if (widget.initialDate != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (_didInitialJump) return;
|
||||
_didInitialJump = true;
|
||||
_cubit ??= BlocProvider.of<ViewOrdersCubit>(context);
|
||||
_cubit!.jumpToDate(widget.initialDate!);
|
||||
_cubit?.jumpToDate(widget.initialDate!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_cubit == null) {
|
||||
_cubit = BlocProvider.of<ViewOrdersCubit>(context);
|
||||
}
|
||||
return BlocBuilder<ViewOrdersCubit, ViewOrdersState>(
|
||||
builder: (BuildContext context, ViewOrdersState state) {
|
||||
final List<DateTime> calendarDays = state.calendarDays;
|
||||
@@ -89,7 +88,6 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: UiColors.white,
|
||||
body: Stack(
|
||||
children: <Widget>[
|
||||
// Background Gradient
|
||||
@@ -222,16 +220,17 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
UiButton.primary(
|
||||
text: t.client_view_orders.post_button,
|
||||
leadingIcon: UiIcons.add,
|
||||
onPressed: () => Modular.to.navigateToCreateOrder(),
|
||||
size: UiButtonSize.small,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
maximumSize: const Size(0, 48),
|
||||
if (state.filteredOrders.isNotEmpty)
|
||||
UiButton.primary(
|
||||
text: t.client_view_orders.post_button,
|
||||
leadingIcon: UiIcons.add,
|
||||
onPressed: () => Modular.to.navigateToCreateOrder(),
|
||||
size: UiButtonSize.small,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
maximumSize: const Size(0, 48),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -308,14 +308,15 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
if (order.workersNeeded != 0)
|
||||
const Icon(
|
||||
UiIcons.success,
|
||||
UiIcons.error,
|
||||
size: 16,
|
||||
color: UiColors.textSuccess,
|
||||
color: UiColors.textError,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${order.workersNeeded} Workers Filled',
|
||||
'${order.workersNeeded} Workers Needed',
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -21,7 +21,7 @@ class ViewOrdersModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<IViewOrdersRepository>(
|
||||
i.add<IViewOrdersRepository>(
|
||||
() => ViewOrdersRepositoryImpl(
|
||||
firebaseAuth: firebase.FirebaseAuth.instance,
|
||||
dataConnect: ExampleConnector.instance,
|
||||
@@ -29,11 +29,11 @@ class ViewOrdersModule extends Module {
|
||||
);
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton(GetOrdersUseCase.new);
|
||||
i.addLazySingleton(GetAcceptedApplicationsForDayUseCase.new);
|
||||
i.add(GetOrdersUseCase.new);
|
||||
i.add(GetAcceptedApplicationsForDayUseCase.new);
|
||||
|
||||
// BLoCs
|
||||
i.addSingleton(
|
||||
i.add(
|
||||
() => ViewOrdersCubit(
|
||||
getOrdersUseCase: i.get<GetOrdersUseCase>(),
|
||||
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:krow_domain/krow_domain.dart';
|
||||
import '../../domain/usecases/apply_quick_set_usecase.dart';
|
||||
import '../../domain/usecases/get_weekly_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) {
|
||||
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 {
|
||||
if (state is AvailabilityLoaded) {
|
||||
final currentState = state as AvailabilityLoaded;
|
||||
|
||||
// Clear message
|
||||
emit(currentState.copyWith(clearSuccessMessage: true));
|
||||
|
||||
final newWeekStart = currentState.currentWeekStart
|
||||
.add(Duration(days: event.direction * 7));
|
||||
|
||||
@@ -77,12 +84,23 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState> {
|
||||
return d.date == event.day.date ? newDay : d;
|
||||
}).toList();
|
||||
|
||||
emit(currentState.copyWith(days: updatedDays));
|
||||
// Optimistic update
|
||||
emit(currentState.copyWith(
|
||||
days: updatedDays,
|
||||
clearSuccessMessage: true,
|
||||
));
|
||||
|
||||
try {
|
||||
await updateDayAvailability(UpdateDayAvailabilityParams(newDay));
|
||||
// Success feedback
|
||||
if (state is AvailabilityLoaded) {
|
||||
emit((state as AvailabilityLoaded).copyWith(successMessage: 'Availability updated'));
|
||||
}
|
||||
} 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;
|
||||
}).toList();
|
||||
|
||||
emit(currentState.copyWith(days: updatedDays));
|
||||
// Optimistic update
|
||||
emit(currentState.copyWith(
|
||||
days: updatedDays,
|
||||
clearSuccessMessage: true,
|
||||
));
|
||||
|
||||
try {
|
||||
await updateDayAvailability(UpdateDayAvailabilityParams(newDay));
|
||||
// Success feedback
|
||||
if (state is AvailabilityLoaded) {
|
||||
emit((state as AvailabilityLoaded).copyWith(successMessage: 'Availability updated'));
|
||||
}
|
||||
} 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) {
|
||||
final currentState = state as AvailabilityLoaded;
|
||||
|
||||
emit(currentState.copyWith(
|
||||
isActionInProgress: true,
|
||||
clearSuccessMessage: true,
|
||||
));
|
||||
|
||||
try {
|
||||
final newDays = await applyQuickSet(
|
||||
ApplyQuickSetParams(currentState.currentWeekStart, event.type));
|
||||
emit(currentState.copyWith(days: newDays));
|
||||
|
||||
emit(currentState.copyWith(
|
||||
days: newDays,
|
||||
isActionInProgress: false,
|
||||
successMessage: 'Availability updated',
|
||||
));
|
||||
} 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 DateTime currentWeekStart;
|
||||
final DateTime selectedDate;
|
||||
final bool isActionInProgress;
|
||||
final String? successMessage;
|
||||
|
||||
const AvailabilityLoaded({
|
||||
required this.days,
|
||||
required this.currentWeekStart,
|
||||
required this.selectedDate,
|
||||
this.isActionInProgress = false,
|
||||
this.successMessage,
|
||||
});
|
||||
|
||||
/// Helper to get the currently selected day's availability object
|
||||
@@ -34,11 +38,16 @@ class AvailabilityLoaded extends AvailabilityState {
|
||||
List<DayAvailability>? days,
|
||||
DateTime? currentWeekStart,
|
||||
DateTime? selectedDate,
|
||||
bool? isActionInProgress,
|
||||
String? successMessage, // Nullable override
|
||||
bool clearSuccessMessage = false,
|
||||
}) {
|
||||
return AvailabilityLoaded(
|
||||
days: days ?? this.days,
|
||||
currentWeekStart: currentWeekStart ?? this.currentWeekStart,
|
||||
selectedDate: selectedDate ?? this.selectedDate,
|
||||
isActionInProgress: isActionInProgress ?? this.isActionInProgress,
|
||||
successMessage: clearSuccessMessage ? null : (successMessage ?? this.successMessage),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,7 +56,7 @@ class AvailabilityLoaded extends AvailabilityState {
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [days, currentWeekStart, selectedDate];
|
||||
List<Object?> get props => [days, currentWeekStart, selectedDate, isActionInProgress, successMessage];
|
||||
}
|
||||
|
||||
class AvailabilityError extends AvailabilityState {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import 'package:design_system/design_system.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: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});
|
||||
|
||||
@@ -11,314 +18,104 @@ class AvailabilityPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
late DateTime _currentWeekStart;
|
||||
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),
|
||||
},
|
||||
];
|
||||
final AvailabilityBloc _bloc = Modular.get<AvailabilityBloc>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_calculateInitialWeek();
|
||||
}
|
||||
|
||||
void _calculateInitialWeek() {
|
||||
final today = DateTime.now();
|
||||
|
||||
// Dart equivalent for Monday start:
|
||||
final day = today.weekday; // Mon=1, Sun=7
|
||||
final diff = day - 1;
|
||||
_currentWeekStart = today.subtract(Duration(days: diff));
|
||||
// Reset time to midnight
|
||||
_currentWeekStart = DateTime(
|
||||
_currentWeekStart.year,
|
||||
_currentWeekStart.month,
|
||||
_currentWeekStart.day,
|
||||
final diff = day - 1; // Assuming Monday start
|
||||
DateTime currentWeekStart = today.subtract(Duration(days: diff));
|
||||
currentWeekStart = DateTime(
|
||||
currentWeekStart.year,
|
||||
currentWeekStart.month,
|
||||
currentWeekStart.day,
|
||||
);
|
||||
|
||||
_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;
|
||||
});
|
||||
_bloc.add(LoadAvailability(currentWeekStart));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedDayKey = _getDayKey(_selectedDate);
|
||||
final isSelectedDayAvailable = _availability[selectedDayKey] ?? false;
|
||||
final weekDates = _getWeekDates();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(
|
||||
0xFFFAFBFC,
|
||||
), // slate-50 to white gradient approximation
|
||||
body: 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(),
|
||||
const SizedBox(height: 24),
|
||||
_buildWeekNavigation(weekDates),
|
||||
const SizedBox(height: 24),
|
||||
_buildSelectedDayAvailability(
|
||||
selectedDayKey,
|
||||
isSelectedDayAvailable,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildInfoCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
return BlocProvider.value(
|
||||
value: _bloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: AppColors.krowBackground,
|
||||
appBar: UiAppBar(
|
||||
title: 'My Availability',
|
||||
centerTitle: false,
|
||||
showBackButton: true,
|
||||
),
|
||||
body: BlocListener<AvailabilityBloc, AvailabilityState>(
|
||||
listener: (context, state) {
|
||||
if (state is AvailabilityLoaded && state.successMessage != null) {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.successMessage!),
|
||||
backgroundColor: Colors.green,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<AvailabilityBloc, AvailabilityState>(
|
||||
builder: (context, state) {
|
||||
if (state is AvailabilityLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (state is AvailabilityLoaded) {
|
||||
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() {
|
||||
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() {
|
||||
Widget _buildQuickSet(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -340,27 +137,34 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildQuickSetButton('All Week', () => _quickSet('all')),
|
||||
child: _buildQuickSetButton(
|
||||
context,
|
||||
'All Week',
|
||||
'all',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildQuickSetButton(
|
||||
context,
|
||||
'Weekdays',
|
||||
() => _quickSet('weekdays'),
|
||||
'weekdays',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildQuickSetButton(
|
||||
context,
|
||||
'Weekends',
|
||||
() => _quickSet('weekends'),
|
||||
'weekends',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildQuickSetButton(
|
||||
context,
|
||||
'Clear All',
|
||||
() => _quickSet('clear'),
|
||||
'clear',
|
||||
isDestructive: true,
|
||||
),
|
||||
),
|
||||
@@ -372,14 +176,15 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
}
|
||||
|
||||
Widget _buildQuickSetButton(
|
||||
BuildContext context,
|
||||
String label,
|
||||
VoidCallback onTap, {
|
||||
String type, {
|
||||
bool isDestructive = false,
|
||||
}) {
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: OutlinedButton(
|
||||
onPressed: onTap,
|
||||
onPressed: () => context.read<AvailabilityBloc>().add(PerformQuickSet(type)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
side: BorderSide(
|
||||
@@ -387,8 +192,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
? Colors.red.withOpacity(0.2)
|
||||
: AppColors.krowBlue.withOpacity(0.2),
|
||||
),
|
||||
backgroundColor:
|
||||
Colors.transparent,
|
||||
backgroundColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
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(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -429,10 +237,10 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
children: [
|
||||
_buildNavButton(
|
||||
LucideIcons.chevronLeft,
|
||||
() => _navigateWeek(-1),
|
||||
() => context.read<AvailabilityBloc>().add(const NavigateWeek(-1)),
|
||||
),
|
||||
Text(
|
||||
_getMonthYear(),
|
||||
monthYear,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -441,7 +249,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
),
|
||||
_buildNavButton(
|
||||
LucideIcons.chevronRight,
|
||||
() => _navigateWeek(1),
|
||||
() => context.read<AvailabilityBloc>().add(const NavigateWeek(1)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -449,7 +257,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
// Days Row
|
||||
Row(
|
||||
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) {
|
||||
final isSelected = _isSelected(date);
|
||||
final dayKey = _getDayKey(date);
|
||||
final isAvailable = _availability[dayKey] ?? false;
|
||||
final isToday = _isToday(date);
|
||||
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: () => setState(() => _selectedDate = date),
|
||||
onTap: () => context.read<AvailabilityBloc>().add(SelectDate(day.date)),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
@@ -514,7 +321,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
date.day.toString().padLeft(2, '0'),
|
||||
day.date.day.toString().padLeft(2, '0'),
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -527,7 +334,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatDay(date),
|
||||
DateFormat('EEE').format(day.date),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: isSelected
|
||||
@@ -559,10 +366,11 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
}
|
||||
|
||||
Widget _buildSelectedDayAvailability(
|
||||
String selectedDayKey,
|
||||
bool isAvailable,
|
||||
BuildContext context,
|
||||
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(
|
||||
padding: const EdgeInsets.all(20),
|
||||
@@ -606,7 +414,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
),
|
||||
Switch(
|
||||
value: isAvailable,
|
||||
onChanged: (val) => _toggleDayAvailability(selectedDayKey),
|
||||
onChanged: (val) => context.read<AvailabilityBloc>().add(ToggleDayStatus(day)),
|
||||
activeColor: AppColors.krowBlue,
|
||||
),
|
||||
],
|
||||
@@ -614,123 +422,163 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Time Slots
|
||||
..._timeSlots.map((slot) {
|
||||
final isActive = _isTimeSlotActive(slot['slotId']);
|
||||
// Determine styles based on state
|
||||
final isEnabled =
|
||||
isAvailable; // If day is off, slots are disabled visually
|
||||
|
||||
// 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 ? () => _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
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
// 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(
|
||||
|
||||
@@ -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: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/usecases/apply_quick_set_usecase.dart';
|
||||
import 'domain/usecases/get_weekly_availability_usecase.dart';
|
||||
import 'domain/usecases/update_day_availability_usecase.dart';
|
||||
import 'presentation/blocs/availability_bloc.dart';
|
||||
import 'presentation/pages/availability_page.dart';
|
||||
|
||||
class StaffAvailabilityModule extends Module {
|
||||
@override
|
||||
void binds(i) {
|
||||
// Data Sources
|
||||
i.add(StaffRepositoryMock.new);
|
||||
List<Module> get imports => [DataConnectModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repository
|
||||
i.add<AvailabilityRepository>(AvailabilityRepositoryImpl.new);
|
||||
i.add<AvailabilityRepository>(
|
||||
() => AvailabilityRepositoryImpl(
|
||||
dataConnect: ExampleConnector.instance,
|
||||
firebaseAuth: FirebaseAuth.instance,
|
||||
),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
i.add(GetWeeklyAvailabilityUseCase.new);
|
||||
@@ -27,7 +34,7 @@ class StaffAvailabilityModule extends Module {
|
||||
}
|
||||
|
||||
@override
|
||||
void routes(r) {
|
||||
void routes(RouteManager r) {
|
||||
r.child('/', child: (_) => const AvailabilityPage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ dependencies:
|
||||
krow_core:
|
||||
path: ../../../core
|
||||
firebase_data_connect: ^0.2.2+2
|
||||
firebase_auth: ^6.1.4
|
||||
|
||||
dev_dependencies:
|
||||
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';
|
||||
|
||||
/// Interface for the Clock In feature repository.
|
||||
///
|
||||
/// Defines the methods for managing clock-in/out operations and retrieving
|
||||
/// related shift and attendance data.
|
||||
abstract interface class ClockInRepositoryInterface {
|
||||
/// Retrieves the shift scheduled for today.
|
||||
/// Repository interface for Clock In/Out functionality
|
||||
abstract class ClockInRepositoryInterface {
|
||||
|
||||
/// Retrieves the shift assigned to the user for the current day.
|
||||
/// Returns null if no shift is assigned for today.
|
||||
Future<Shift?> getTodaysShift();
|
||||
|
||||
/// Retrieves the current attendance status (check-in time, check-out time, etc.).
|
||||
///
|
||||
/// Returns a Map containing:
|
||||
/// - 'isCheckedIn': bool
|
||||
/// - 'checkInTime': DateTime?
|
||||
/// - 'checkOutTime': DateTime?
|
||||
Future<Map<String, dynamic>> getAttendanceStatus();
|
||||
/// 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();
|
||||
|
||||
/// Clocks the user in for a specific shift.
|
||||
Future<Map<String, dynamic>> clockIn({
|
||||
required String shiftId,
|
||||
String? notes,
|
||||
});
|
||||
/// Checks the user in for the specified [shiftId].
|
||||
/// Returns the updated [AttendanceStatus].
|
||||
Future<AttendanceStatus> clockIn({required String shiftId, String? notes});
|
||||
|
||||
/// Clocks the user out of the current shift.
|
||||
Future<Map<String, dynamic>> clockOut({
|
||||
String? notes,
|
||||
int? breakTimeMinutes,
|
||||
});
|
||||
/// Checks the user out for the currently active shift.
|
||||
/// Optionally accepts [breakTimeMinutes] if tracked.
|
||||
Future<AttendanceStatus> clockOut({String? notes, int? breakTimeMinutes});
|
||||
|
||||
/// Retrieves the history of clock-in/out activity.
|
||||
///
|
||||
/// Returns a list of maps, where each map represents an activity entry.
|
||||
/// Retrieves a list of recent clock-in/out activities.
|
||||
Future<List<Map<String, dynamic>>> getActivityLog();
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/clock_in_repository_interface.dart';
|
||||
import '../arguments/clock_in_arguments.dart';
|
||||
|
||||
/// Use case for clocking in a user.
|
||||
class ClockInUseCase implements UseCase<ClockInArguments, Map<String, dynamic>> {
|
||||
class ClockInUseCase implements UseCase<ClockInArguments, AttendanceStatus> {
|
||||
final ClockInRepositoryInterface _repository;
|
||||
|
||||
ClockInUseCase(this._repository);
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> call(ClockInArguments arguments) {
|
||||
Future<AttendanceStatus> call(ClockInArguments arguments) {
|
||||
return _repository.clockIn(
|
||||
shiftId: arguments.shiftId,
|
||||
notes: arguments.notes,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/clock_in_repository_interface.dart';
|
||||
import '../arguments/clock_out_arguments.dart';
|
||||
|
||||
/// Use case for clocking out a user.
|
||||
class ClockOutUseCase implements UseCase<ClockOutArguments, Map<String, dynamic>> {
|
||||
class ClockOutUseCase implements UseCase<ClockOutArguments, AttendanceStatus> {
|
||||
final ClockInRepositoryInterface _repository;
|
||||
|
||||
ClockOutUseCase(this._repository);
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> call(ClockOutArguments arguments) {
|
||||
Future<AttendanceStatus> call(ClockOutArguments arguments) {
|
||||
return _repository.clockOut(
|
||||
notes: arguments.notes,
|
||||
breakTimeMinutes: arguments.breakTimeMinutes,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/clock_in_repository_interface.dart';
|
||||
|
||||
/// 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;
|
||||
|
||||
GetAttendanceStatusUseCase(this._repository);
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> call() {
|
||||
Future<AttendanceStatus> call() {
|
||||
return _repository.getAttendanceStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,15 +33,8 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
|
||||
on<CheckInRequested>(_onCheckIn);
|
||||
on<CheckOutRequested>(_onCheckOut);
|
||||
on<CheckInModeChanged>(_onModeChanged);
|
||||
}
|
||||
|
||||
AttendanceStatus _mapToStatus(Map<String, dynamic> map) {
|
||||
return AttendanceStatus(
|
||||
isCheckedIn: map['isCheckedIn'] as bool? ?? false,
|
||||
checkInTime: map['checkInTime'] as DateTime?,
|
||||
checkOutTime: map['checkOutTime'] as DateTime?,
|
||||
activeShiftId: map['activeShiftId'] as String?,
|
||||
);
|
||||
add(ClockInPageLoaded());
|
||||
}
|
||||
|
||||
Future<void> _onLoaded(
|
||||
@@ -51,13 +44,13 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
|
||||
emit(state.copyWith(status: ClockInStatus.loading));
|
||||
try {
|
||||
final shift = await _getTodaysShift();
|
||||
final statusMap = await _getAttendanceStatus();
|
||||
final status = await _getAttendanceStatus();
|
||||
final activity = await _getActivityLog();
|
||||
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.success,
|
||||
todayShift: shift,
|
||||
attendance: _mapToStatus(statusMap),
|
||||
attendance: status,
|
||||
activityLog: activity,
|
||||
));
|
||||
} catch (e) {
|
||||
@@ -88,12 +81,12 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
|
||||
) async {
|
||||
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
||||
try {
|
||||
final newStatusMap = await _clockIn(
|
||||
final newStatus = await _clockIn(
|
||||
ClockInArguments(shiftId: event.shiftId, notes: event.notes),
|
||||
);
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.success,
|
||||
attendance: _mapToStatus(newStatusMap),
|
||||
attendance: newStatus,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
@@ -109,7 +102,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
|
||||
) async {
|
||||
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
||||
try {
|
||||
final newStatusMap = await _clockOut(
|
||||
final newStatus = await _clockOut(
|
||||
ClockOutArguments(
|
||||
notes: event.notes,
|
||||
breakTimeMinutes: 0, // Should be passed from event if supported
|
||||
@@ -117,7 +110,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
|
||||
);
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.success,
|
||||
attendance: _mapToStatus(newStatusMap),
|
||||
attendance: newStatus,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
|
||||
@@ -3,24 +3,6 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
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 {
|
||||
final ClockInStatus status;
|
||||
final Shift? todayShift;
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import '../bloc/clock_in_bloc.dart';
|
||||
import '../bloc/clock_in_event.dart';
|
||||
import '../bloc/clock_in_state.dart';
|
||||
import '../theme/app_colors.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/date_selector.dart';
|
||||
import '../widgets/lunch_break_modal.dart';
|
||||
import '../widgets/swipe_to_check_in.dart';
|
||||
|
||||
class ClockInPage extends StatefulWidget {
|
||||
const ClockInPage({super.key});
|
||||
@@ -28,23 +29,24 @@ class _ClockInPageState extends State<ClockInPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = Modular.get<ClockInBloc>();
|
||||
_bloc.add(ClockInPageLoaded());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
return BlocProvider<ClockInBloc>.value(
|
||||
value: _bloc,
|
||||
child: BlocConsumer<ClockInBloc, ClockInState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == ClockInStatus.failure && state.errorMessage != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.errorMessage!)),
|
||||
);
|
||||
if (state.status == ClockInStatus.failure &&
|
||||
state.errorMessage != null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(state.errorMessage!)));
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.status == ClockInStatus.loading && state.todayShift == null) {
|
||||
if (state.status == ClockInStatus.loading &&
|
||||
state.todayShift == null) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
@@ -64,416 +66,320 @@ class _ClockInPageState extends State<ClockInPage> {
|
||||
: '--:-- --';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFF8FAFC), // slate-50
|
||||
Colors.white,
|
||||
],
|
||||
),
|
||||
appBar: UiAppBar(
|
||||
titleWidget: Text(
|
||||
'Clock In to your Shift',
|
||||
style: UiTypography.title1m.textPrimary,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
// Commute Tracker (shows before date selector when applicable)
|
||||
if (todayShift != null)
|
||||
CommuteTracker(
|
||||
shift: todayShift,
|
||||
hasLocationConsent: false, // Mock value
|
||||
isCommuteModeOn: false, // Mock value
|
||||
distanceMeters: 500, // Mock value for demo
|
||||
etaMinutes: 8, // Mock value for demo
|
||||
),
|
||||
// Date Selector
|
||||
DateSelector(
|
||||
selectedDate: state.selectedDate,
|
||||
onSelect: (date) => _bloc.add(DateSelected(date)),
|
||||
shiftDates: [
|
||||
DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
||||
],
|
||||
showBackButton: false,
|
||||
centerTitle: false,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space24,
|
||||
top: UiConstants.space6,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Commute Tracker (shows before date selector when applicable)
|
||||
if (todayShift != null)
|
||||
CommuteTracker(
|
||||
shift: todayShift,
|
||||
hasLocationConsent: false, // Mock value
|
||||
isCommuteModeOn: false, // Mock value
|
||||
distanceMeters: 500, // Mock value for demo
|
||||
etaMinutes: 8, // Mock value for demo
|
||||
),
|
||||
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
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"Your Activity",
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.krowCharcoal,
|
||||
// Your Activity Header
|
||||
const Text(
|
||||
"Your Activity",
|
||||
textAlign: TextAlign.start,
|
||||
style: TextStyle(
|
||||
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: () {
|
||||
debugPrint('Navigating to shifts...');
|
||||
},
|
||||
child: Row(
|
||||
children: const [
|
||||
Text(
|
||||
"View all",
|
||||
],
|
||||
),
|
||||
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(
|
||||
color: AppColors.krowBlue,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
color: Color(0xFF475569), // slate-600
|
||||
),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 16,
|
||||
color: AppColors.krowBlue,
|
||||
Text(
|
||||
"\$${todayShift.hourlyRate}/hr",
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
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
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
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());
|
||||
},
|
||||
),
|
||||
// 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());
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
] 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) ...[
|
||||
// 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
|
||||
),
|
||||
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,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFD1FAE5), // emerald-100
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
"Shift Completed!",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF065F46), // emerald-800
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.check,
|
||||
color: Color(0xFF059669), // emerald-600
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
"Great work today",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF059669), // emerald-600
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
"Shift Completed!",
|
||||
style: TextStyle(
|
||||
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
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F5F9), // slate-100
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
// No Shift State
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F5F9), // slate-100
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
],
|
||||
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
|
||||
if (isCheckedIn && checkInTime != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFECFDF5), // emerald-50
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFA7F3D0),
|
||||
), // emerald-200
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Checked in at",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF059669),
|
||||
),
|
||||
// Checked In Banner
|
||||
if (isCheckedIn && checkInTime != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFECFDF5), // emerald-50
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFA7F3D0),
|
||||
), // emerald-200
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Checked in at",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF059669),
|
||||
),
|
||||
Text(
|
||||
DateFormat('h:mm a').format(checkInTime),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF065F46),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
DateFormat(
|
||||
'h:mm a',
|
||||
).format(checkInTime),
|
||||
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
|
||||
if (state.activityLog.isNotEmpty) ...state.activityLog.map(
|
||||
// Recent Activity List
|
||||
if (state.activityLog.isNotEmpty)
|
||||
...state.activityLog.map(
|
||||
(activity) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -490,7 +396,9 @@ class _ClockInPageState extends State<ClockInPage> {
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.krowBlue.withOpacity(0.1),
|
||||
color: AppColors.krowBlue.withOpacity(
|
||||
0.1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
@@ -502,23 +410,28 @@ class _ClockInPageState extends State<ClockInPage> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
DateFormat(
|
||||
'MMM d',
|
||||
).format(activity['date'] as DateTime),
|
||||
DateFormat('MMM d').format(
|
||||
activity['date'] as DateTime,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF0F172A), // slate-900
|
||||
color: Color(
|
||||
0xFF0F172A,
|
||||
), // slate-900
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${activity['start']} - ${activity['end']}",
|
||||
style: const TextStyle(
|
||||
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;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
@@ -678,7 +595,7 @@ class _ClockInPageState extends State<ClockInPage> {
|
||||
|
||||
Future<void> _showNFCDialog(BuildContext context) async {
|
||||
bool scanned = false;
|
||||
|
||||
|
||||
// Using a local navigator context since we are in a dialog
|
||||
await showDialog(
|
||||
context: context,
|
||||
@@ -771,11 +688,11 @@ class _ClockInPageState extends State<ClockInPage> {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
// After dialog closes, trigger the event if scan was successful (simulated)
|
||||
// In real app, we would check the dialog result
|
||||
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: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/usecases/clock_in_usecase.dart';
|
||||
import 'domain/usecases/clock_out_usecase.dart';
|
||||
@@ -13,11 +15,13 @@ import 'presentation/pages/clock_in_page.dart';
|
||||
class StaffClockInModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Data Sources (Mocks from data_connect)
|
||||
i.add<ShiftsRepositoryMock>(ShiftsRepositoryMock.new);
|
||||
|
||||
// Repositories
|
||||
i.add<ClockInRepositoryInterface>(ClockInRepositoryImpl.new);
|
||||
i.add<ClockInRepositoryInterface>(
|
||||
() => ClockInRepositoryImpl(
|
||||
dataConnect: ExampleConnector.instance,
|
||||
firebaseAuth: FirebaseAuth.instance,
|
||||
),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
i.add<GetTodaysShiftUseCase>(GetTodaysShiftUseCase.new);
|
||||
|
||||
@@ -31,3 +31,4 @@ dependencies:
|
||||
firebase_data_connect: ^0.2.2+2
|
||||
geolocator: ^10.1.0
|
||||
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: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/repositories/home_repository.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
extension TimestampExt on Timestamp {
|
||||
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_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.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/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/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/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/section_header.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/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.
|
||||
///
|
||||
@@ -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),
|
||||
|
||||
// Quick Actions
|
||||
@@ -120,13 +91,6 @@ class WorkerHomePage extends StatelessWidget {
|
||||
onTap: () => Modular.to.pushAvailability(),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: QuickActionItem(
|
||||
icon: LucideIcons.messageSquare,
|
||||
label: quickI18n.messages,
|
||||
onTap: () => Modular.to.pushMessages(),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: QuickActionItem(
|
||||
icon: LucideIcons.dollarSign,
|
||||
@@ -212,15 +176,9 @@ class WorkerHomePage extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Pending Payment Card
|
||||
const PendingPaymentCard(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Recommended Shifts
|
||||
SectionHeader(
|
||||
title: sectionsI18n.recommended_for_you,
|
||||
action: sectionsI18n.view_all,
|
||||
onAction: () => Modular.to.pushShifts(tab: 'find'),
|
||||
),
|
||||
BlocBuilder<HomeCubit, HomeState>(
|
||||
builder: (context, state) {
|
||||
@@ -246,14 +204,6 @@ class WorkerHomePage extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
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_modular/flutter_modular.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/presentation/blocs/home_cubit.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 {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Data layer - Mock service (will be replaced with real implementation)
|
||||
i.addLazySingleton<MockService>(MockService.new);
|
||||
|
||||
// Repository
|
||||
i.addLazySingleton<HomeRepository>(
|
||||
() => 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_domain/krow_domain.dart';
|
||||
import '../../domain/entities/payment_summary.dart';
|
||||
import '../../domain/repositories/payments_repository.dart';
|
||||
|
||||
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
|
||||
Future<PaymentSummary> getPaymentSummary() async {
|
||||
// Current requirement: Mock data only for summary
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
return const PaymentSummary(
|
||||
weeklyEarnings: 847.50,
|
||||
monthlyEarnings: 3240.0,
|
||||
pendingEarnings: 285.0,
|
||||
totalEarnings: 12450.0,
|
||||
final StaffSession? session = StaffSessionStore.instance.session;
|
||||
if (session?.staff?.id == null) {
|
||||
return const PaymentSummary(
|
||||
weeklyEarnings: 0,
|
||||
monthlyEarnings: 0,
|
||||
pendingEarnings: 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
|
||||
Future<List<StaffPayment>> getPaymentHistory(String period) async {
|
||||
final session = StaffSessionStore.instance.session;
|
||||
if (session?.staff?.id == null) return [];
|
||||
final StaffSession? session = StaffSessionStore.instance.session;
|
||||
if (session?.staff?.id == null) return <StaffPayment>[];
|
||||
|
||||
final String currentStaffId = session!.staff!.id;
|
||||
|
||||
try {
|
||||
final response = await ExampleConnector.instance
|
||||
final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> response =
|
||||
await _dataConnect
|
||||
.listRecentPaymentsByStaffId(staffId: currentStaffId)
|
||||
.execute();
|
||||
|
||||
return response.data.recentPayments.map((payment) {
|
||||
return response.data.recentPayments.map((dc.ListRecentPaymentsByStaffIdRecentPayments payment) {
|
||||
return StaffPayment(
|
||||
id: payment.id,
|
||||
staffId: payment.staffId,
|
||||
assignmentId: payment.applicationId,
|
||||
amount: payment.invoice.amount,
|
||||
status: _mapStatus(payment.status),
|
||||
paidAt: payment.invoice.issueDate?.toDate(),
|
||||
status: PaymentAdapter.toPaymentStatus(payment.status?.stringValue ?? 'UNKNOWN'),
|
||||
paidAt: _toDateTime(payment.invoice.issueDate),
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
return <StaffPayment>[];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 '../entities/payment_summary.dart';
|
||||
|
||||
/// Repository interface for Payments feature.
|
||||
///
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../entities/payment_summary.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/payments_repository.dart';
|
||||
|
||||
/// Use case to retrieve payment summary information.
|
||||
|
||||
@@ -10,10 +10,7 @@ import 'presentation/pages/payments_page.dart';
|
||||
class StaffPaymentsModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Data Connect Mocks
|
||||
i.add<FinancialRepositoryMock>(FinancialRepositoryMock.new);
|
||||
|
||||
// Repositories
|
||||
// Repositories
|
||||
i.add<PaymentsRepository>(PaymentsRepositoryImpl.new);
|
||||
|
||||
// Use Cases
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../../domain/arguments/get_payment_history_arguments.dart';
|
||||
import '../../../domain/entities/payment_summary.dart';
|
||||
import '../../../domain/usecases/get_payment_history_usecase.dart';
|
||||
import '../../../domain/usecases/get_payment_summary_usecase.dart';
|
||||
import 'payments_event.dart';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../../domain/entities/payment_summary.dart';
|
||||
|
||||
abstract class PaymentsState extends Equatable {
|
||||
const PaymentsState();
|
||||
|
||||
@@ -165,7 +165,7 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Pending Pay
|
||||
PendingPayCard(
|
||||
if(state.summary.pendingEarnings > 0) PendingPayCard(
|
||||
amount: state.summary.pendingEarnings,
|
||||
onCashOut: () {
|
||||
Modular.to.pushNamed('/early-pay');
|
||||
@@ -173,62 +173,43 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
||||
),
|
||||
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(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('PDF Exported'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
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),
|
||||
|
||||
// Recent Payments
|
||||
if (state.history.isNotEmpty) Column(
|
||||
children: [
|
||||
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: 32),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -124,73 +124,34 @@ class StaffProfilePage extends StatelessWidget {
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.user,
|
||||
label: i18n.menu_items.personal_info,
|
||||
completed: profile.phone != null,
|
||||
onTap: () => Modular.to.pushPersonalInfo(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.phone,
|
||||
label: i18n.menu_items.emergency_contact,
|
||||
completed: false,
|
||||
onTap: () => Modular.to.pushEmergencyContact(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.briefcase,
|
||||
label: i18n.menu_items.experience,
|
||||
completed: false,
|
||||
onTap: () => Modular.to.pushExperience(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.user,
|
||||
label: i18n.menu_items.attire,
|
||||
completed: false,
|
||||
onTap: () => Modular.to.pushAttire(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
SectionTitle(i18n.sections.compliance),
|
||||
ProfileMenuGrid(
|
||||
crossAxisCount: 3,
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.file,
|
||||
label: i18n.menu_items.documents,
|
||||
completed: false,
|
||||
onTap: () => Modular.to.pushDocuments(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.shield,
|
||||
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(),
|
||||
SectionTitle(i18n.sections.compliance),
|
||||
ProfileMenuGrid(
|
||||
crossAxisCount: 3,
|
||||
children: [
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.file,
|
||||
label: i18n.menu_items.tax_forms,
|
||||
onTap: () => Modular.to.pushTaxForms(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -217,31 +178,10 @@ class StaffProfilePage extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
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(
|
||||
onTap: () => _onSignOut(cubit, state),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space12),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -16,7 +16,7 @@ class ProfileMenuGrid extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Spacing between items
|
||||
final double spacing = UiConstants.space3;
|
||||
const double spacing = UiConstants.space3;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -27,6 +27,8 @@ class ProfileMenuGrid extends StatelessWidget {
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
alignment: WrapAlignment.start,
|
||||
crossAxisAlignment: WrapCrossAlignment.start,
|
||||
children: children.map((child) {
|
||||
return SizedBox(
|
||||
width: itemWidth,
|
||||
|
||||
@@ -37,6 +37,7 @@ class BankAccountCubit extends Cubit<BankAccountState> {
|
||||
}
|
||||
|
||||
Future<void> addAccount({
|
||||
required String bankName,
|
||||
required String routingNumber,
|
||||
required String accountNumber,
|
||||
required String type,
|
||||
@@ -47,7 +48,7 @@ class BankAccountCubit extends Cubit<BankAccountState> {
|
||||
final BankAccount newAccount = BankAccount(
|
||||
id: '', // Generated by server usually
|
||||
userId: '', // Handled by Repo/Auth
|
||||
bankName: 'New Bank', // Mock
|
||||
bankName: bankName,
|
||||
accountNumber: accountNumber,
|
||||
accountName: '',
|
||||
sortCode: routingNumber,
|
||||
@@ -63,6 +64,7 @@ class BankAccountCubit extends Cubit<BankAccountState> {
|
||||
await loadAccounts();
|
||||
|
||||
emit(state.copyWith(
|
||||
status: BankAccountStatus.accountAdded,
|
||||
showForm: false, // Close form on success
|
||||
));
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:equatable/equatable.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 {
|
||||
final BankAccountStatus status;
|
||||
|
||||
@@ -44,8 +44,23 @@ class BankAccountPage extends StatelessWidget {
|
||||
child: Container(color: UiColors.border, height: 1.0),
|
||||
),
|
||||
),
|
||||
body: BlocBuilder<BankAccountCubit, BankAccountState>(
|
||||
body: BlocConsumer<BankAccountCubit, BankAccountState>(
|
||||
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) {
|
||||
if (state.status == BankAccountStatus.loading && state.accounts.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@@ -96,8 +111,9 @@ class BankAccountPage extends StatelessWidget {
|
||||
backgroundColor: Colors.transparent,
|
||||
child: AddAccountForm(
|
||||
strings: strings,
|
||||
onSubmit: (String routing, String account, String type) {
|
||||
onSubmit: (String bankName, String routing, String account, String type) {
|
||||
cubit.addAccount(
|
||||
bankName: bankName,
|
||||
routingNumber: routing,
|
||||
accountNumber: account,
|
||||
type: type,
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../blocs/bank_account_cubit.dart';
|
||||
|
||||
class AddAccountForm extends StatefulWidget {
|
||||
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;
|
||||
|
||||
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> {
|
||||
final TextEditingController _bankNameController = TextEditingController();
|
||||
final TextEditingController _routingController = TextEditingController();
|
||||
final TextEditingController _accountController = TextEditingController();
|
||||
String _selectedType = 'CHECKING';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bankNameController.dispose();
|
||||
_routingController.dispose();
|
||||
_accountController.dispose();
|
||||
super.dispose();
|
||||
@@ -44,6 +46,13 @@ class _AddAccountFormState extends State<AddAccountForm> {
|
||||
style: UiTypography.headline4m.copyWith(color: UiColors.textPrimary), // Was header4
|
||||
),
|
||||
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(
|
||||
label: widget.strings.routing_number,
|
||||
hintText: widget.strings.routing_hint,
|
||||
@@ -90,6 +99,7 @@ class _AddAccountFormState extends State<AddAccountForm> {
|
||||
text: widget.strings.save,
|
||||
onPressed: () {
|
||||
widget.onSubmit(
|
||||
_bankNameController.text,
|
||||
_routingController.text,
|
||||
_accountController.text,
|
||||
_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 '../../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';
|
||||
|
||||
/// Implementation of [TimeCardRepository] using Firebase Data Connect.
|
||||
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
|
||||
Future<List<TimeCard>> getTimeCards(DateTime month) async {
|
||||
// We use ShiftsRepositoryMock as it contains shift details (title, client, etc).
|
||||
// In a real app, we might query 'TimeCards' directly or join Shift+Payment.
|
||||
// For now, we simulate TimeCards from Shifts.
|
||||
final List<Shift> shifts = await shiftsRepository.getMyShifts();
|
||||
final String staffId = await _getStaffId();
|
||||
// Fetch applications. Limit can be adjusted, assuming 100 is safe for now.
|
||||
final fdc.QueryResult<dc.GetApplicationsByStaffIdData, dc.GetApplicationsByStaffIdVariables> result =
|
||||
await _dataConnect.getApplicationsByStaffId(staffId: staffId).limit(100).execute();
|
||||
|
||||
// Map to TimeCard and filter by the requested month.
|
||||
return shifts
|
||||
.map((Shift shift) {
|
||||
double hours = 8.0;
|
||||
// Simple parse for mock
|
||||
try {
|
||||
// Assuming HH:mm
|
||||
final int start = int.parse(shift.startTime.split(':')[0]);
|
||||
final int end = int.parse(shift.endTime.split(':')[0]);
|
||||
hours = (end - start).abs().toDouble();
|
||||
if (hours == 0) hours = 8.0;
|
||||
} catch (_) {}
|
||||
return result.data.applications
|
||||
.where((dc.GetApplicationsByStaffIdApplications app) {
|
||||
final DateTime? shiftDate = app.shift.date?.toDateTime();
|
||||
if (shiftDate == null) return false;
|
||||
return shiftDate.year == month.year && shiftDate.month == month.month;
|
||||
})
|
||||
.map((dc.GetApplicationsByStaffIdApplications app) {
|
||||
final DateTime shiftDate = app.shift.date!.toDateTime();
|
||||
final String startTime = _formatTime(app.checkInTime) ?? _formatTime(app.shift.startTime) ?? '';
|
||||
final String endTime = _formatTime(app.checkOutTime) ?? _formatTime(app.shift.endTime) ?? '';
|
||||
|
||||
return TimeCard(
|
||||
id: shift.id,
|
||||
shiftTitle: shift.title,
|
||||
clientName: shift.clientName,
|
||||
date: DateTime.tryParse(shift.date) ?? DateTime.now(),
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
// Prefer shiftRole values for pay/hours
|
||||
final double hours = app.shiftRole.hours ?? 0.0;
|
||||
final double rate = app.shiftRole.role.costPerHour;
|
||||
final double pay = app.shiftRole.totalValue ?? 0.0;
|
||||
|
||||
return TimeCardAdapter.fromPrimitives(
|
||||
id: app.id,
|
||||
shiftTitle: app.shift.title,
|
||||
clientName: app.shift.order.business.businessName,
|
||||
date: shiftDate,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
totalHours: hours,
|
||||
hourlyRate: shift.hourlyRate,
|
||||
totalPay: hours * shift.hourlyRate,
|
||||
status: _mapStatus(shift.status),
|
||||
location: shift.location,
|
||||
hourlyRate: rate,
|
||||
totalPay: pay,
|
||||
status: app.status.stringValue,
|
||||
location: app.shift.location,
|
||||
);
|
||||
})
|
||||
.where((TimeCard tc) =>
|
||||
tc.date.year == month.year && tc.date.month == month.month)
|
||||
.toList();
|
||||
}
|
||||
|
||||
TimeCardStatus _mapStatus(String? shiftStatus) {
|
||||
if (shiftStatus == null) return TimeCardStatus.pending;
|
||||
// Map shift status to TimeCardStatus
|
||||
switch (shiftStatus.toLowerCase()) {
|
||||
case 'confirmed':
|
||||
return TimeCardStatus.pending;
|
||||
case 'completed':
|
||||
return TimeCardStatus.approved;
|
||||
case 'paid':
|
||||
return TimeCardStatus.paid;
|
||||
default:
|
||||
return TimeCardStatus.pending;
|
||||
}
|
||||
String? _formatTime(fdc.Timestamp? timestamp) {
|
||||
if (timestamp == null) return null;
|
||||
return DateFormat('HH:mm').format(timestamp.toDateTime());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import '../entities/time_card.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for accessing time card data.
|
||||
///
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 '../repositories/time_card_repository.dart';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.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/usecases/get_time_cards_usecase.dart';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import '../../domain/entities/time_card.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'timesheet_card.dart';
|
||||
|
||||
/// 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:design_system/design_system.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.
|
||||
class TimesheetCard extends StatelessWidget {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
library staff_time_card;
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter_modular/flutter_modular.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';
|
||||
|
||||
/// 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 {
|
||||
@override
|
||||
List<Module> get imports => <Module>[DataConnectModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
// In a real app, ShiftsRepository might be provided by a Core Data Module.
|
||||
// For this self-contained feature/mock, we instantiate it here if not available globally.
|
||||
// Assuming we need a local instance for the mock to work or it's stateless.
|
||||
i.add<ShiftsRepositoryMock>(ShiftsRepositoryMock.new);
|
||||
i.add<TimeCardRepository>(TimeCardRepositoryImpl.new);
|
||||
i.add<TimeCardRepository>(
|
||||
() => TimeCardRepositoryImpl(
|
||||
dataConnect: ExampleConnector.instance,
|
||||
firebaseAuth: FirebaseAuth.instance,
|
||||
),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
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_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
|
||||
import '../blocs/personal_info_bloc.dart';
|
||||
import '../blocs/personal_info_event.dart';
|
||||
import '../blocs/personal_info_state.dart';
|
||||
import '../widgets/personal_info_content.dart';
|
||||
|
||||
|
||||
@@ -32,28 +32,41 @@ class PersonalInfoContent extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||
late final TextEditingController _emailController;
|
||||
late final TextEditingController _phoneController;
|
||||
late final TextEditingController _locationsController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_emailController = TextEditingController(text: widget.staff.email);
|
||||
_phoneController = TextEditingController(text: widget.staff.phone ?? '');
|
||||
_locationsController = TextEditingController(text: widget.staff.preferredLocations?.join(', ')?? '');
|
||||
|
||||
// Listen to changes and update BLoC
|
||||
_emailController.addListener(_onEmailChanged);
|
||||
_phoneController.addListener(_onPhoneChanged);
|
||||
_locationsController.addListener(_onAddressChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_phoneController.dispose();
|
||||
_locationsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
void _onEmailChanged() {
|
||||
context.read<PersonalInfoBloc>().add(
|
||||
PersonalInfoFieldChanged(
|
||||
field: 'email',
|
||||
value: _emailController.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPhoneChanged() {
|
||||
context.read<PersonalInfoBloc>().add(
|
||||
PersonalInfoFieldChanged(
|
||||
@@ -114,6 +127,7 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||
PersonalInfoForm(
|
||||
fullName: widget.staff.name,
|
||||
email: widget.staff.email,
|
||||
emailController: _emailController,
|
||||
phoneController: _phoneController,
|
||||
locationsController: _locationsController,
|
||||
enabled: !isSaving,
|
||||
|
||||
@@ -15,6 +15,9 @@ class PersonalInfoForm extends StatelessWidget {
|
||||
/// The staff member's email (read-only).
|
||||
final String email;
|
||||
|
||||
/// Controller for the email field.
|
||||
final TextEditingController emailController;
|
||||
|
||||
/// Controller for the phone number field.
|
||||
final TextEditingController phoneController;
|
||||
|
||||
@@ -29,6 +32,7 @@ class PersonalInfoForm extends StatelessWidget {
|
||||
super.key,
|
||||
required this.fullName,
|
||||
required this.email,
|
||||
required this.emailController,
|
||||
required this.phoneController,
|
||||
required this.locationsController,
|
||||
this.enabled = true,
|
||||
@@ -48,7 +52,13 @@ class PersonalInfoForm extends StatelessWidget {
|
||||
|
||||
_FieldLabel(text: i18n.email_label),
|
||||
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),
|
||||
|
||||
_FieldLabel(text: i18n.phone_label),
|
||||
@@ -122,11 +132,15 @@ class _EditableField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String hint;
|
||||
final bool enabled;
|
||||
final TextInputType? keyboardType;
|
||||
final Iterable<String>? autofillHints;
|
||||
|
||||
const _EditableField({
|
||||
required this.controller,
|
||||
required this.hint,
|
||||
this.enabled = true,
|
||||
this.keyboardType,
|
||||
this.autofillHints,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -134,6 +148,8 @@ class _EditableField extends StatelessWidget {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: keyboardType,
|
||||
autofillHints: autofillHints,
|
||||
style: UiTypography.body2r.copyWith(color: UiColors.textPrimary),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
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_domain/krow_domain.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:firebase_auth/firebase_auth.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 {
|
||||
ShiftsRepositoryImpl();
|
||||
final dc.ExampleConnector _dataConnect;
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
|
||||
ShiftsRepositoryImpl() : _dataConnect = dc.ExampleConnector.instance;
|
||||
|
||||
// Cache: ShiftID -> ApplicationID (For Accept/Decline)
|
||||
final Map<String, String> _shiftToAppIdMap = {};
|
||||
@@ -24,193 +17,205 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
final Map<String, String> _appToRoleIdMap = {};
|
||||
|
||||
String get _currentStaffId {
|
||||
final session = StaffSessionStore.instance.session;
|
||||
if (session?.staff?.id == null) throw Exception('User not logged in');
|
||||
return session!.staff!.id;
|
||||
final StaffSession? session = StaffSessionStore.instance.session;
|
||||
if (session?.staff?.id != null) {
|
||||
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
|
||||
Future<List<Shift>> getMyShifts() async {
|
||||
return _fetchApplications(ApplicationStatus.ACCEPTED);
|
||||
return _fetchApplications(dc.ApplicationStatus.ACCEPTED);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getPendingAssignments() async {
|
||||
// Fetch both PENDING (User applied) and OFFERED (Business offered) if schema supports
|
||||
// For now assuming PENDING covers invitations/offers.
|
||||
return _fetchApplications(ApplicationStatus.PENDING);
|
||||
return _fetchApplications(dc.ApplicationStatus.PENDING);
|
||||
}
|
||||
|
||||
Future<List<Shift>> _fetchApplications(ApplicationStatus status) async {
|
||||
Future<List<Shift>> _fetchApplications(dc.ApplicationStatus status) async {
|
||||
try {
|
||||
final response = await ExampleConnector.instance
|
||||
final response = await _dataConnect
|
||||
.getApplicationsByStaffId(staffId: _currentStaffId)
|
||||
.execute();
|
||||
|
||||
return response.data.applications
|
||||
.where((app) => app.status is Known && (app.status as Known).value == status)
|
||||
.map((app) {
|
||||
// Cache IDs for actions
|
||||
_shiftToAppIdMap[app.shift.id] = app.id;
|
||||
_appToRoleIdMap[app.id] = app.shiftRole.roleId;
|
||||
final apps = response.data.applications.where((app) => app.status == status);
|
||||
final List<Shift> shifts = [];
|
||||
|
||||
return _mapApplicationToShift(app);
|
||||
})
|
||||
.toList();
|
||||
for (final app in apps) {
|
||||
_shiftToAppIdMap[app.shift.id] = app.id;
|
||||
_appToRoleIdMap[app.id] = app.shiftRole.id;
|
||||
|
||||
final shiftTuple = await _getShiftDetails(app.shift.id);
|
||||
if (shiftTuple != null) {
|
||||
shifts.add(shiftTuple);
|
||||
}
|
||||
}
|
||||
return shifts;
|
||||
} catch (e) {
|
||||
return [];
|
||||
return <Shift>[];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getAvailableShifts(String query, String type) async {
|
||||
try {
|
||||
final response = await ExampleConnector.instance.listShifts().execute();
|
||||
|
||||
var shifts = response.data.shifts
|
||||
.where((s) => s.status is Known && (s.status as Known).value == ShiftStatus.OPEN)
|
||||
.map((s) => _mapConnectorShiftToDomain(s))
|
||||
.toList();
|
||||
|
||||
// Client-side filtering
|
||||
if (query.isNotEmpty) {
|
||||
shifts = shifts.where((s) =>
|
||||
s.title.toLowerCase().contains(query.toLowerCase()) ||
|
||||
s.clientName.toLowerCase().contains(query.toLowerCase())
|
||||
).toList();
|
||||
}
|
||||
|
||||
if (type != 'all') {
|
||||
if (type == 'one-day') {
|
||||
shifts = shifts.where((s) => !s.title.contains('Multi-Day')).toList();
|
||||
} else if (type == 'multi-day') {
|
||||
shifts = shifts.where((s) => s.title.contains('Multi-Day')).toList();
|
||||
try {
|
||||
final result = await _dataConnect.listShifts().execute();
|
||||
final allShifts = result.data.shifts;
|
||||
|
||||
final List<Shift> mappedShifts = [];
|
||||
|
||||
for (final s in allShifts) {
|
||||
// For each shift, map to Domain Shift
|
||||
// Note: date fields in generated code might be specific types
|
||||
final startDt = _toDateTime(s.startTime);
|
||||
final endDt = _toDateTime(s.endTime);
|
||||
final createdDt = _toDateTime(s.createdAt);
|
||||
|
||||
mappedShifts.add(Shift(
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
clientName: s.order.business.businessName,
|
||||
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) {
|
||||
return [];
|
||||
}
|
||||
if (query.isNotEmpty) {
|
||||
return mappedShifts.where((s) =>
|
||||
s.title.toLowerCase().contains(query.toLowerCase()) ||
|
||||
s.clientName.toLowerCase().contains(query.toLowerCase())
|
||||
).toList();
|
||||
}
|
||||
|
||||
return mappedShifts;
|
||||
|
||||
} catch (e) {
|
||||
return <Shift>[];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Shift?> getShiftDetails(String shiftId) async {
|
||||
try {
|
||||
final response = await ExampleConnector.instance.getShiftById(id: shiftId).execute();
|
||||
final s = response.data.shift;
|
||||
if (s == null) return null;
|
||||
|
||||
// Map to domain Shift
|
||||
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() ?? '',
|
||||
tipsAvailable: false,
|
||||
mealProvided: false,
|
||||
managers: [],
|
||||
description: s.description,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return _getShiftDetails(shiftId);
|
||||
}
|
||||
|
||||
Future<Shift?> _getShiftDetails(String shiftId) async {
|
||||
try {
|
||||
final result = await _dataConnect.getShiftById(id: shiftId).execute();
|
||||
final s = result.data.shift;
|
||||
if (s == null) return null;
|
||||
|
||||
final startDt = _toDateTime(s.startTime);
|
||||
final endDt = _toDateTime(s.endTime);
|
||||
final createdDt = _toDateTime(s.createdAt);
|
||||
|
||||
return Shift(
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
clientName: s.order.business.businessName,
|
||||
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,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> applyForShift(String shiftId) async {
|
||||
// API LIMITATION: 'createApplication' requires roleId.
|
||||
// 'listShifts' / 'getShiftById' does not currently return the Shift's available Roles.
|
||||
// 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:
|
||||
// 1. Fetch Shift Roles
|
||||
// 2. Select Role
|
||||
// 3. createApplication(shiftId, roleId, staffId, status: PENDING, origin: MOBILE)
|
||||
final rolesResult = await _dataConnect.listShiftRolesByShiftId(shiftId: shiftId).execute();
|
||||
if (rolesResult.data.shiftRoles.isEmpty) throw Exception('No open roles for this shift');
|
||||
|
||||
final role = rolesResult.data.shiftRoles.first;
|
||||
|
||||
await _dataConnect.createApplication(
|
||||
shiftId: shiftId,
|
||||
staffId: _currentStaffId,
|
||||
roleId: role.id,
|
||||
status: dc.ApplicationStatus.PENDING,
|
||||
origin: dc.ApplicationOrigin.STAFF,
|
||||
).execute();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Future<void> acceptShift(String shiftId) async {
|
||||
await _updateApplicationStatus(shiftId, ApplicationStatus.ACCEPTED);
|
||||
await _updateApplicationStatus(shiftId, dc.ApplicationStatus.ACCEPTED);
|
||||
}
|
||||
|
||||
@override
|
||||
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? roleId;
|
||||
|
||||
// Refresh if missing from cache
|
||||
if (appId == null) {
|
||||
await getPendingAssignments();
|
||||
appId = _shiftToAppIdMap[shiftId];
|
||||
// Try to find it in pending
|
||||
await getPendingAssignments();
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
roleId = _appToRoleIdMap[appId];
|
||||
|
||||
if (appId == null || roleId == null) {
|
||||
throw Exception("Application not found for shift $shiftId");
|
||||
}
|
||||
|
||||
await ExampleConnector.instance.updateApplicationStatus(
|
||||
await _dataConnect.updateApplicationStatus(
|
||||
id: appId,
|
||||
roleId: roleId,
|
||||
)
|
||||
.status(newStatus)
|
||||
.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,
|
||||
),
|
||||
),
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user