diff --git a/BLOCKERS.md b/BLOCKERS.md index 8cf36426..34982d58 100644 --- a/BLOCKERS.md +++ b/BLOCKERS.md @@ -22,6 +22,11 @@ ### Why this task is blocked: - Although this page existed in the prototype, it was not connected to any other pages. In other words, there was no way to navigate to it from anywhere in the application. Therefore, this issue can be closed, as the page is not required in the main application. +### Github issue +- https://github.com/Oloodi/krow-workforce/issues/262 +### Why this task is blocked: +- Although this page existed in the prototype, it was not connected to any other pages. In other words, there was no way to navigate to it from anywhere in the application. Therefore, this issue can be closed, as the page is not required in the main application. + # Deviations ## App diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/strings.g.dart b/apps/mobile/packages/core_localization/lib/src/l10n/strings.g.dart index ed16a6a0..aa09b232 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/strings.g.dart +++ b/apps/mobile/packages/core_localization/lib/src/l10n/strings.g.dart @@ -6,7 +6,7 @@ /// Locales: 2 /// Strings: 1026 (513 per locale) /// -/// Built on 2026-01-26 at 02:14 UTC +/// Built on 2026-01-26 at 03:26 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/mobile/packages/data_connect/lib/src/mocks/staff_repository_mock.dart b/apps/mobile/packages/data_connect/lib/src/mocks/staff_repository_mock.dart index c032464f..f561ff11 100644 --- a/apps/mobile/packages/data_connect/lib/src/mocks/staff_repository_mock.dart +++ b/apps/mobile/packages/data_connect/lib/src/mocks/staff_repository_mock.dart @@ -36,4 +36,23 @@ class StaffRepositoryMock { await Future.delayed(const Duration(milliseconds: 500)); return staff; } + + // Mock Availability Data Store + final Map _mockAvailability = {}; + + Future> getAvailability(String userId, DateTime start, DateTime end) async { + await Future.delayed(const Duration(milliseconds: 300)); + // Return mock structure: Date ISO String -> { isAvailable: bool, slots: { id: bool } } + + // Auto-generate some data if empty + if (_mockAvailability.isEmpty) { + // Just return empty, let the caller handle defaults + } + return _mockAvailability; + } + + Future updateAvailability(String userId, String dateIso, Map data) async { + await Future.delayed(const Duration(milliseconds: 200)); + _mockAvailability[dateIso] = data; + } } \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 9f00c59a..17d83564 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -73,3 +73,7 @@ export 'src/entities/support/working_area.dart'; // Home export 'src/entities/home/home_dashboard_data.dart'; export 'src/entities/home/reorder_item.dart'; + +// Availability +export 'src/entities/availability/availability_slot.dart'; +export 'src/entities/availability/day_availability.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/availability/availability_slot.dart b/apps/mobile/packages/domain/lib/src/entities/availability/availability_slot.dart new file mode 100644 index 00000000..45d7ef01 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/availability/availability_slot.dart @@ -0,0 +1,33 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a specific time slot within a day (e.g., Morning, Afternoon, Evening). +class AvailabilitySlot extends Equatable { + final String id; + final String label; + final String timeRange; + final bool isAvailable; + + const AvailabilitySlot({ + required this.id, + required this.label, + required this.timeRange, + this.isAvailable = true, + }); + + AvailabilitySlot copyWith({ + String? id, + String? label, + String? timeRange, + bool? isAvailable, + }) { + return AvailabilitySlot( + id: id ?? this.id, + label: label ?? this.label, + timeRange: timeRange ?? this.timeRange, + isAvailable: isAvailable ?? this.isAvailable, + ); + } + + @override + List get props => [id, label, timeRange, isAvailable]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/availability/day_availability.dart b/apps/mobile/packages/domain/lib/src/entities/availability/day_availability.dart new file mode 100644 index 00000000..6dd7732e --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/availability/day_availability.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; + +import 'availability_slot.dart'; + +/// Represents availability configuration for a specific date. +class DayAvailability extends Equatable { + final DateTime date; + final bool isAvailable; + final List slots; + + const DayAvailability({ + required this.date, + this.isAvailable = false, + this.slots = const [], + }); + + DayAvailability copyWith({ + DateTime? date, + bool? isAvailable, + List? slots, + }) { + return DayAvailability( + date: date ?? this.date, + isAvailable: isAvailable ?? this.isAvailable, + slots: slots ?? this.slots, + ); + } + + @override + List get props => [date, isAvailable, slots]; +} diff --git a/apps/mobile/packages/features/staff/availability/analysis_options.yaml b/apps/mobile/packages/features/staff/availability/analysis_options.yaml new file mode 100644 index 00000000..a932a962 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + public_member_api_docs: false diff --git a/apps/mobile/packages/features/staff/availability/lib/src/data/repositories/availability_repository_impl.dart b/apps/mobile/packages/features/staff/availability/lib/src/data/repositories/availability_repository_impl.dart new file mode 100644 index 00000000..69d7594c --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/data/repositories/availability_repository_impl.dart @@ -0,0 +1,164 @@ +import 'package:krow_data_connect/krow_data_connect.dart' hide AvailabilitySlot; +import 'package:krow_domain/krow_domain.dart'; +import '../../domain/repositories/availability_repository.dart'; +import 'package:intl/intl.dart'; + +/// Implementation of [AvailabilityRepository]. +/// +/// Uses [StaffRepositoryMock] from data_connect to fetch and store data. +class AvailabilityRepositoryImpl implements AvailabilityRepository { + final StaffRepositoryMock _dataSource; + + // Mock User ID - in real app invoke AuthUseCase to get current user + final String _userId = 'mock_user_123'; + + static const List> _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', + }, + ]; + + AvailabilityRepositoryImpl({StaffRepositoryMock? dataSource}) + : _dataSource = dataSource ?? StaffRepositoryMock(); + + @override + Future> getAvailability( + DateTime start, DateTime end) async { + final rawData = await _dataSource.getAvailability(_userId, start, end); + final List days = []; + + // Loop through each day in range + for (int i = 0; i <= end.difference(start).inDays; i++) { + final date = start.add(Duration(days: i)); + final dateKey = DateFormat('yyyy-MM-dd').format(date); + + final dayData = rawData[dateKey]; + + if (dayData != null) { + days.add(_mapFromData(date, dayData)); + } else { + // Default: Available M-F, Not Sat-Sun (matching prototype logic) + final isWeekend = date.weekday == DateTime.saturday || date.weekday == DateTime.sunday; + // Prototype: Sat/Sun false + + days.add(DayAvailability( + date: date, + isAvailable: !isWeekend, + slots: _generateDefaultSlots(isEnabled: !isWeekend), + )); + } + } + return days; + } + + @override + Future updateDayAvailability( + DayAvailability availability) async { + final dateKey = DateFormat('yyyy-MM-dd').format(availability.date); + final data = _mapToData(availability); + + await _dataSource.updateAvailability(_userId, dateKey, data); + return availability; + } + + @override + Future> applyQuickSet( + DateTime start, DateTime end, String type) async { + final List updatedDays = []; + + for (int i = 0; i <= end.difference(start).inDays; i++) { + final date = start.add(Duration(days: i)); + bool isAvailable = false; + + switch (type) { + case 'all': + isAvailable = true; + break; + case 'weekdays': + isAvailable = date.weekday != DateTime.saturday && date.weekday != DateTime.sunday; + break; + case 'weekends': + isAvailable = date.weekday == DateTime.saturday || date.weekday == DateTime.sunday; + break; + case 'clear': + isAvailable = false; + break; + } + + // Keep existing slot preferences, just toggle main switch? + // Or reset slots too? Prototype behavior: just sets map[day] = bool. + // But it implies slots are active if day is active? + // For now, allow slots to be default true if day is enabled. + + final day = DayAvailability( + date: date, + isAvailable: isAvailable, + slots: _generateDefaultSlots(isEnabled: isAvailable), + ); + + await updateDayAvailability(day); + updatedDays.add(day); + } + return updatedDays; + } + + // --- Helpers --- + + List _generateDefaultSlots({bool isEnabled = true}) { + return _slotDefinitions.map((def) { + return AvailabilitySlot( + id: def['id']!, + label: def['label']!, + timeRange: def['timeRange']!, + isAvailable: true, // Default slots to true + ); + }).toList(); + } + + DayAvailability _mapFromData(DateTime date, Map data) { + final isAvailable = data['isAvailable'] as bool? ?? false; + final Map slotsMap = data['slots'] ?? {}; + + final slots = _slotDefinitions.map((def) { + final slotId = def['id']!; + final slotEnabled = slotsMap[slotId] as bool? ?? true; // Default true if not stored + + return AvailabilitySlot( + id: slotId, + label: def['label']!, + timeRange: def['timeRange']!, + isAvailable: slotEnabled, + ); + }).toList(); + + return DayAvailability( + date: date, + isAvailable: isAvailable, + slots: slots, + ); + } + + Map _mapToData(DayAvailability day) { + Map slotsMap = {}; + for (var slot in day.slots) { + slotsMap[slot.id] = slot.isAvailable; + } + + return { + 'isAvailable': day.isAvailable, + 'slots': slotsMap, + }; + } +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/domain/repositories/availability_repository.dart b/apps/mobile/packages/features/staff/availability/lib/src/domain/repositories/availability_repository.dart new file mode 100644 index 00000000..3678be8d --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/domain/repositories/availability_repository.dart @@ -0,0 +1,12 @@ +import 'package:krow_domain/krow_domain.dart'; + +abstract class AvailabilityRepository { + /// Fetches availability for a given date range (usually a week). + Future> getAvailability(DateTime start, DateTime end); + + /// Updates the availability for a specific day. + Future updateDayAvailability(DayAvailability availability); + + /// Applies a preset configuration (e.g. All Week, Weekdays only) to a range. + Future> applyQuickSet(DateTime start, DateTime end, String type); +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/apply_quick_set_usecase.dart b/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/apply_quick_set_usecase.dart new file mode 100644 index 00000000..6ff4735e --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/apply_quick_set_usecase.dart @@ -0,0 +1,28 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/availability_repository.dart'; + +/// Use case to apply a quick-set availability pattern (e.g., "Weekdays", "All Week") to a week. +class ApplyQuickSetUseCase extends UseCase> { + final AvailabilityRepository repository; + + ApplyQuickSetUseCase(this.repository); + + /// [type] can be 'all', 'weekdays', 'weekends', 'clear' + @override + Future> call(ApplyQuickSetParams params) { + final end = params.start.add(const Duration(days: 6)); + return repository.applyQuickSet(params.start, end, params.type); + } +} + +/// Parameters for [ApplyQuickSetUseCase]. +class ApplyQuickSetParams extends UseCaseArgument { + final DateTime start; + final String type; + + const ApplyQuickSetParams(this.start, this.type); + + @override + List get props => [start, type]; +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/get_weekly_availability_usecase.dart b/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/get_weekly_availability_usecase.dart new file mode 100644 index 00000000..b9b03a28 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/get_weekly_availability_usecase.dart @@ -0,0 +1,30 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/availability_repository.dart'; + +/// Use case to fetch availability for a specific week. +/// +/// This encapsulates the logic of calculating the week range and fetching data +/// from the repository. +class GetWeeklyAvailabilityUseCase extends UseCase> { + final AvailabilityRepository repository; + + GetWeeklyAvailabilityUseCase(this.repository); + + @override + Future> call(GetWeeklyAvailabilityParams params) async { + // Calculate end of week (assuming start is start of week) + final end = params.start.add(const Duration(days: 6)); + return repository.getAvailability(params.start, end); + } +} + +/// Parameters for [GetWeeklyAvailabilityUseCase]. +class GetWeeklyAvailabilityParams extends UseCaseArgument { + final DateTime start; + + const GetWeeklyAvailabilityParams(this.start); + + @override + List get props => [start]; +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/update_day_availability_usecase.dart b/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/update_day_availability_usecase.dart new file mode 100644 index 00000000..a3e32543 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/update_day_availability_usecase.dart @@ -0,0 +1,25 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/availability_repository.dart'; + +/// Use case to update the availability configuration for a specific day. +class UpdateDayAvailabilityUseCase extends UseCase { + final AvailabilityRepository repository; + + UpdateDayAvailabilityUseCase(this.repository); + + @override + Future call(UpdateDayAvailabilityParams params) { + return repository.updateDayAvailability(params.availability); + } +} + +/// Parameters for [UpdateDayAvailabilityUseCase]. +class UpdateDayAvailabilityParams extends UseCaseArgument { + final DayAvailability availability; + + const UpdateDayAvailabilityParams(this.availability); + + @override + List get props => [availability]; +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_bloc.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_bloc.dart new file mode 100644 index 00000000..4073db48 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_bloc.dart @@ -0,0 +1,136 @@ +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'; +import 'availability_event.dart'; +import 'availability_state.dart'; + +class AvailabilityBloc extends Bloc { + final GetWeeklyAvailabilityUseCase getWeeklyAvailability; + final UpdateDayAvailabilityUseCase updateDayAvailability; + final ApplyQuickSetUseCase applyQuickSet; + + AvailabilityBloc({ + required this.getWeeklyAvailability, + required this.updateDayAvailability, + required this.applyQuickSet, + }) : super(AvailabilityInitial()) { + on(_onLoadAvailability); + on(_onSelectDate); + on(_onNavigateWeek); + on(_onToggleDayStatus); + on(_onToggleSlotStatus); + on(_onPerformQuickSet); + } + + Future _onLoadAvailability( + LoadAvailability event, + Emitter emit, + ) async { + emit(AvailabilityLoading()); + try { + final days = await getWeeklyAvailability( + GetWeeklyAvailabilityParams(event.weekStart)); + emit(AvailabilityLoaded( + days: days, + currentWeekStart: event.weekStart, + selectedDate: event.preselectedDate ?? + (days.isNotEmpty ? days.first.date : DateTime.now()), + )); + } catch (e) { + emit(AvailabilityError(e.toString())); + } + } + + void _onSelectDate(SelectDate event, Emitter emit) { + if (state is AvailabilityLoaded) { + emit((state as AvailabilityLoaded).copyWith(selectedDate: event.date)); + } + } + + Future _onNavigateWeek( + NavigateWeek event, + Emitter emit, + ) async { + if (state is AvailabilityLoaded) { + final currentState = state as AvailabilityLoaded; + final newWeekStart = currentState.currentWeekStart + .add(Duration(days: event.direction * 7)); + + final diff = currentState.selectedDate.difference(currentState.currentWeekStart).inDays; + final newSelectedDate = newWeekStart.add(Duration(days: diff)); + + add(LoadAvailability(newWeekStart, preselectedDate: newSelectedDate)); + } + } + + Future _onToggleDayStatus( + ToggleDayStatus event, + Emitter emit, + ) async { + if (state is AvailabilityLoaded) { + final currentState = state as AvailabilityLoaded; + + final newDay = event.day.copyWith(isAvailable: !event.day.isAvailable); + final updatedDays = currentState.days.map((d) { + return d.date == event.day.date ? newDay : d; + }).toList(); + + emit(currentState.copyWith(days: updatedDays)); + + try { + await updateDayAvailability(UpdateDayAvailabilityParams(newDay)); + } catch (e) { + emit(currentState.copyWith(days: currentState.days)); + } + } + } + + Future _onToggleSlotStatus( + ToggleSlotStatus event, + Emitter emit, + ) async { + if (state is AvailabilityLoaded) { + final currentState = state as AvailabilityLoaded; + + final updatedSlots = event.day.slots.map((s) { + if (s.id == event.slotId) { + return s.copyWith(isAvailable: !s.isAvailable); + } + return s; + }).toList(); + + final newDay = event.day.copyWith(slots: updatedSlots); + + final updatedDays = currentState.days.map((d) { + return d.date == event.day.date ? newDay : d; + }).toList(); + + emit(currentState.copyWith(days: updatedDays)); + + try { + await updateDayAvailability(UpdateDayAvailabilityParams(newDay)); + } catch (e) { + emit(currentState.copyWith(days: currentState.days)); + } + } + } + + Future _onPerformQuickSet( + PerformQuickSet event, + Emitter emit, + ) async { + if (state is AvailabilityLoaded) { + final currentState = state as AvailabilityLoaded; + + try { + final newDays = await applyQuickSet( + ApplyQuickSetParams(currentState.currentWeekStart, event.type)); + emit(currentState.copyWith(days: newDays)); + } catch (e) { + // Handle error + } + } + } +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_event.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_event.dart new file mode 100644 index 00000000..e6074504 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_event.dart @@ -0,0 +1,54 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +abstract class AvailabilityEvent extends Equatable { + const AvailabilityEvent(); + @override + List get props => []; +} + +class LoadAvailability extends AvailabilityEvent { + final DateTime weekStart; + final DateTime? preselectedDate; // Maintain selection after reload + + const LoadAvailability(this.weekStart, {this.preselectedDate}); + + @override + List get props => [weekStart, preselectedDate]; +} + +class SelectDate extends AvailabilityEvent { + final DateTime date; + const SelectDate(this.date); + @override + List get props => [date]; +} + +class ToggleDayStatus extends AvailabilityEvent { + final DayAvailability day; + const ToggleDayStatus(this.day); + @override + List get props => [day]; +} + +class ToggleSlotStatus extends AvailabilityEvent { + final DayAvailability day; + final String slotId; + const ToggleSlotStatus(this.day, this.slotId); + @override + List get props => [day, slotId]; +} + +class NavigateWeek extends AvailabilityEvent { + final int direction; // -1 or 1 + const NavigateWeek(this.direction); + @override + List get props => [direction]; +} + +class PerformQuickSet extends AvailabilityEvent { + final String type; // all, weekdays, weekends, clear + const PerformQuickSet(this.type); + @override + List get props => [type]; +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_state.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_state.dart new file mode 100644 index 00000000..5c8b52ba --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_state.dart @@ -0,0 +1,58 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +abstract class AvailabilityState extends Equatable { + const AvailabilityState(); + @override + List get props => []; +} + +class AvailabilityInitial extends AvailabilityState {} + +class AvailabilityLoading extends AvailabilityState {} + +class AvailabilityLoaded extends AvailabilityState { + final List days; + final DateTime currentWeekStart; + final DateTime selectedDate; + + const AvailabilityLoaded({ + required this.days, + required this.currentWeekStart, + required this.selectedDate, + }); + + /// Helper to get the currently selected day's availability object + DayAvailability get selectedDayAvailability { + return days.firstWhere( + (d) => isSameDay(d.date, selectedDate), + orElse: () => DayAvailability(date: selectedDate), // Fallback + ); + } + + AvailabilityLoaded copyWith({ + List? days, + DateTime? currentWeekStart, + DateTime? selectedDate, + }) { + return AvailabilityLoaded( + days: days ?? this.days, + currentWeekStart: currentWeekStart ?? this.currentWeekStart, + selectedDate: selectedDate ?? this.selectedDate, + ); + } + + static bool isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + @override + List get props => [days, currentWeekStart, selectedDate]; +} + +class AvailabilityError extends AvailabilityState { + final String message; + const AvailabilityError(this.message); + @override + List get props => [message]; +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart new file mode 100644 index 00000000..97c43cd4 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart @@ -0,0 +1,783 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +class AvailabilityPage extends StatefulWidget { + const AvailabilityPage({super.key}); + + @override + State createState() => _AvailabilityPageState(); +} + +class _AvailabilityPageState extends State { + late DateTime _currentWeekStart; + late DateTime _selectedDate; + + // Mock Availability State + // Map of day name (lowercase) to availability status + Map _availability = { + 'monday': true, + 'tuesday': true, + 'wednesday': true, + 'thursday': true, + 'friday': true, + 'saturday': false, + 'sunday': false, + }; + + // Map of day name to time slot map + Map> _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 _dayNames = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + ]; + + final List> _timeSlots = [ + { + 'slotId': 'morning', + 'label': 'Morning', + 'timeRange': '4:00 AM - 12:00 PM', + 'icon': LucideIcons.sunrise, + 'bg': const Color(0xFFE6EBF9), // bg-[#0032A0]/10 + 'iconColor': const Color(0xFF0032A0), + }, + { + 'slotId': 'afternoon', + 'label': 'Afternoon', + 'timeRange': '12:00 PM - 6:00 PM', + 'icon': LucideIcons.sun, + 'bg': const Color(0xFFCCD6EC), // bg-[#0032A0]/20 + 'iconColor': const Color(0xFF0032A0), + }, + { + 'slotId': 'evening', + 'label': 'Evening', + 'timeRange': '6:00 PM - 12:00 AM', + 'icon': LucideIcons.moon, + 'bg': const Color(0xFFEBEDEE), // bg-[#333F48]/10 + 'iconColor': const Color(0xFF333F48), + }, + ]; + + @override + void initState() { + super.initState(); + 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, + ); + + _selectedDate = today; + } + + List _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 newAvailability = {}; + + switch (type) { + case 'all': + for (var day in _dayNames) newAvailability[day] = true; + break; + case 'weekdays': + for (var day in _dayNames) + newAvailability[day] = (day != 'saturday' && day != 'sunday'); + break; + case 'weekends': + for (var day in _dayNames) + newAvailability[day] = (day == 'saturday' || day == 'sunday'); + break; + case 'clear': + for (var day in _dayNames) newAvailability[day] = false; + break; + } + + setState(() { + _availability = newAvailability; + }); + } + + @override + 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(), + ], + ), + ), + ], + ), + ), + ); + } + + 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() { + 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('All Week', () => _quickSet('all')), + ), + const SizedBox(width: 8), + Expanded( + child: _buildQuickSetButton( + 'Weekdays', + () => _quickSet('weekdays'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildQuickSetButton( + 'Weekends', + () => _quickSet('weekends'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildQuickSetButton( + 'Clear All', + () => _quickSet('clear'), + isDestructive: true, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildQuickSetButton( + String label, + VoidCallback onTap, { + bool isDestructive = false, + }) { + return SizedBox( + height: 32, + child: OutlinedButton( + onPressed: onTap, + 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(List weekDates) { + 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, + () => _navigateWeek(-1), + ), + Text( + _getMonthYear(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.krowCharcoal, + ), + ), + _buildNavButton( + LucideIcons.chevronRight, + () => _navigateWeek(1), + ), + ], + ), + ), + // Days Row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: weekDates.map((date) => _buildDayItem(date)).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(DateTime date) { + final isSelected = _isSelected(date); + final dayKey = _getDayKey(date); + final isAvailable = _availability[dayKey] ?? false; + final isToday = _isToday(date); + + return Expanded( + child: GestureDetector( + onTap: () => setState(() => _selectedDate = 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( + 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( + _formatDay(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( + String selectedDayKey, + bool isAvailable, + ) { + final dateStr = DateFormat('EEEE, MMM d').format(_selectedDate); + + 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) => _toggleDayAvailability(selectedDayKey), + activeColor: AppColors.krowBlue, + ), + ], + ), + + 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 + ), + ), + ], + ), + ), + ); + }).toList(), + ], + ), + ); + } + + 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; +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page_new.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page_new.dart new file mode 100644 index 00000000..ef684b8b --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page_new.dart @@ -0,0 +1,693 @@ +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 createState() => _AvailabilityPageState(); +} + +class _AvailabilityPageState extends State { + final AvailabilityBloc _bloc = Modular.get(); + + @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( + 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().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().add(const NavigateWeek(-1)), + ), + Text( + monthYear, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.krowCharcoal, + ), + ), + _buildNavButton( + LucideIcons.chevronRight, + () => context.read().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().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().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 _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 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().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; +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart b/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart new file mode 100644 index 00000000..199f0d10 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart @@ -0,0 +1,33 @@ +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; +import 'data/repositories/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); + + // Repository + i.add(AvailabilityRepositoryImpl.new); + + // UseCases + i.add(GetWeeklyAvailabilityUseCase.new); + i.add(UpdateDayAvailabilityUseCase.new); + i.add(ApplyQuickSetUseCase.new); + + // BLoC + i.add(AvailabilityBloc.new); + } + + @override + void routes(r) { + r.child('/', child: (_) => const AvailabilityPage()); + } +} diff --git a/apps/mobile/packages/features/staff/availability/lib/staff_availability.dart b/apps/mobile/packages/features/staff/availability/lib/staff_availability.dart new file mode 100644 index 00000000..07f01569 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/staff_availability.dart @@ -0,0 +1,3 @@ +library staff_availability; + +export 'src/staff_availability_module.dart'; diff --git a/apps/mobile/packages/features/staff/availability/pubspec.yaml b/apps/mobile/packages/features/staff/availability/pubspec.yaml new file mode 100644 index 00000000..1b20e6bd --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/pubspec.yaml @@ -0,0 +1,35 @@ +name: staff_availability +description: Staff Availability Feature +version: 0.0.1 +publish_to: 'none' +resolution: workspace + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + flutter_bloc: ^8.1.3 + equatable: ^2.0.5 + intl: ^0.20.0 + lucide_icons: ^0.257.0 + flutter_modular: ^6.3.2 + + # Internal packages + core_localization: + path: ../../../core_localization + design_system: + path: ../../../design_system + krow_domain: + path: ../../../domain + krow_data_connect: + path: ../../../data_connect + krow_core: + path: ../../../core + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/navigation/home_navigator.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/navigation/home_navigator.dart index e3671168..056a5636 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/navigation/home_navigator.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/navigation/home_navigator.dart @@ -13,7 +13,7 @@ extension HomeNavigator on IModularNavigator { /// Navigates to the availability page. void pushAvailability() { - pushNamed('/availability'); + pushNamed('/worker-main/availability'); } /// Navigates to the messages page. diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart index 43e41c1a..5472cb35 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart @@ -13,6 +13,7 @@ import 'package:staff_attire/staff_attire.dart'; import 'package:staff_shifts/staff_shifts.dart'; import 'package:staff_payments/staff_payements.dart'; import 'package:staff_time_card/staff_time_card.dart'; +import 'package:staff_availability/staff_availability.dart'; import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart'; import 'package:staff_main/src/presentation/constants/staff_main_routes.dart'; @@ -72,5 +73,6 @@ class StaffMainModule extends Module { '/time-card', module: StaffTimeCardModule(), ); + r.module('/availability', module: StaffAvailabilityModule()); } } diff --git a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml index 47e0e85e..362acace 100644 --- a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml +++ b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml @@ -49,6 +49,8 @@ dependencies: path: ../payments staff_time_card: path: ../profile_sections/finances/time_card + staff_availability: + path: ../availability dev_dependencies: flutter_test: diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index c8e7684c..913fe41f 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -1079,6 +1079,13 @@ packages: relative: true source: path version: "0.0.1" + staff_availability: + dependency: transitive + description: + path: "packages/features/staff/availability" + relative: true + source: path + version: "0.0.1" staff_bank_account: dependency: transitive description: