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:
Achintha Isuru
2026-01-30 18:11:34 -05:00
committed by GitHub
85 changed files with 2440 additions and 2731 deletions

View File

@@ -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:

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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',

View File

@@ -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',

View File

@@ -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';

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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.
}

View File

@@ -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];
}

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart';
/// Summary of staff earnings.
class PaymentSummary extends Equatable {
final double weeklyEarnings;
final double monthlyEarnings;

View File

@@ -13,6 +13,9 @@ enum PaymentStatus {
/// Transfer failed.
failed,
/// Status unknown.
unknown,
}
/// Represents a payout to a [Staff] member for a completed [Assignment].

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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,
),
],
),
);

View File

@@ -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,
),
),
],
),
),
],
),
);
},
);
}

View File

@@ -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,
),
],
),
);

View File

@@ -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(),
},

View File

@@ -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(),
},
);
}
}

View File

@@ -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 {
),
),
],
),
],
);
}
}

View File

@@ -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,

View File

@@ -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 '';
}
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
),
),
],
),
),
],
),
),
],
),
),

View File

@@ -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) {

View File

@@ -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).

View File

@@ -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),
),
),
),
],
),
),

View File

@@ -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,
),
],

View File

@@ -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>(),

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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.
));
}
}
}

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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());
}
}

View File

@@ -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:

View File

@@ -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',
},
];
}
}

View File

@@ -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>>[];
}
}

View File

@@ -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,
});
}

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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();
}
}

View File

@@ -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(

View File

@@ -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;

View File

@@ -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));
}
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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() {

View File

@@ -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));
}
}

View File

@@ -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(),
],
),
),

View File

@@ -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(),

View File

@@ -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);
}
}

View File

@@ -1,5 +1,4 @@
import 'package:krow_domain/krow_domain.dart';
import '../entities/payment_summary.dart';
/// Repository interface for Payments feature.
///

View File

@@ -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.

View File

@@ -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

View File

@@ -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';

View File

@@ -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();

View File

@@ -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),
],
),

View File

@@ -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),
],
),
),

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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());
}
}

View File

@@ -1,4 +1,4 @@
import '../entities/time_card.dart';
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for accessing time card data.
///

View File

@@ -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';

View File

@@ -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';

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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';

View File

@@ -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,

View File

@@ -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,

View File

@@ -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: [],
);
}
}

View File

@@ -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),