feat: Implement staff availability feature with BLoC architecture

- Created AvailabilityPage with UI for managing staff availability.
- Integrated BLoC for state management, including LoadAvailability and ToggleDayStatus events.
- Added quick set options for availability (All Week, Weekdays, Weekends, Clear All).
- Implemented week navigation and day selection with visual feedback.
- Developed time slot management for each day, allowing toggling of availability.
- Established StaffAvailabilityModule for dependency injection and routing.
- Updated pubspec.yaml with necessary dependencies for the feature.
This commit is contained in:
Achintha Isuru
2026-01-25 22:35:09 -05:00
parent 323f0a9370
commit 2a820b3e4f
24 changed files with 2164 additions and 2 deletions

View File

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

View File

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

View File

@@ -36,4 +36,23 @@ class StaffRepositoryMock {
await Future.delayed(const Duration(milliseconds: 500));
return staff;
}
// Mock Availability Data Store
final Map<String, dynamic> _mockAvailability = {};
Future<Map<String, dynamic>> 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<void> updateAvailability(String userId, String dateIso, Map<String, dynamic> data) async {
await Future.delayed(const Duration(milliseconds: 200));
_mockAvailability[dateIso] = data;
}
}

View File

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

View File

@@ -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<Object?> get props => [id, label, timeRange, isAvailable];
}

View File

@@ -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<AvailabilitySlot> slots;
const DayAvailability({
required this.date,
this.isAvailable = false,
this.slots = const [],
});
DayAvailability copyWith({
DateTime? date,
bool? isAvailable,
List<AvailabilitySlot>? slots,
}) {
return DayAvailability(
date: date ?? this.date,
isAvailable: isAvailable ?? this.isAvailable,
slots: slots ?? this.slots,
);
}
@override
List<Object?> get props => [date, isAvailable, slots];
}

View File

@@ -0,0 +1,5 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
public_member_api_docs: false

View File

@@ -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<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',
},
];
AvailabilityRepositoryImpl({StaffRepositoryMock? dataSource})
: _dataSource = dataSource ?? StaffRepositoryMock();
@override
Future<List<DayAvailability>> getAvailability(
DateTime start, DateTime end) async {
final rawData = await _dataSource.getAvailability(_userId, start, end);
final List<DayAvailability> 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<DayAvailability> 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<List<DayAvailability>> applyQuickSet(
DateTime start, DateTime end, String type) async {
final List<DayAvailability> 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<AvailabilitySlot> _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<String, dynamic> data) {
final isAvailable = data['isAvailable'] as bool? ?? false;
final Map<String, dynamic> 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<String, dynamic> _mapToData(DayAvailability day) {
Map<String, bool> slotsMap = {};
for (var slot in day.slots) {
slotsMap[slot.id] = slot.isAvailable;
}
return {
'isAvailable': day.isAvailable,
'slots': slotsMap,
};
}
}

View File

@@ -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<List<DayAvailability>> getAvailability(DateTime start, DateTime end);
/// Updates the availability for a specific day.
Future<DayAvailability> updateDayAvailability(DayAvailability availability);
/// Applies a preset configuration (e.g. All Week, Weekdays only) to a range.
Future<List<DayAvailability>> applyQuickSet(DateTime start, DateTime end, String type);
}

View File

@@ -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<ApplyQuickSetParams, List<DayAvailability>> {
final AvailabilityRepository repository;
ApplyQuickSetUseCase(this.repository);
/// [type] can be 'all', 'weekdays', 'weekends', 'clear'
@override
Future<List<DayAvailability>> 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<Object?> get props => [start, type];
}

View File

@@ -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<GetWeeklyAvailabilityParams, List<DayAvailability>> {
final AvailabilityRepository repository;
GetWeeklyAvailabilityUseCase(this.repository);
@override
Future<List<DayAvailability>> 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<Object?> get props => [start];
}

View File

@@ -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<UpdateDayAvailabilityParams, DayAvailability> {
final AvailabilityRepository repository;
UpdateDayAvailabilityUseCase(this.repository);
@override
Future<DayAvailability> call(UpdateDayAvailabilityParams params) {
return repository.updateDayAvailability(params.availability);
}
}
/// Parameters for [UpdateDayAvailabilityUseCase].
class UpdateDayAvailabilityParams extends UseCaseArgument {
final DayAvailability availability;
const UpdateDayAvailabilityParams(this.availability);
@override
List<Object?> get props => [availability];
}

View File

@@ -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<AvailabilityEvent, AvailabilityState> {
final GetWeeklyAvailabilityUseCase getWeeklyAvailability;
final UpdateDayAvailabilityUseCase updateDayAvailability;
final ApplyQuickSetUseCase applyQuickSet;
AvailabilityBloc({
required this.getWeeklyAvailability,
required this.updateDayAvailability,
required this.applyQuickSet,
}) : super(AvailabilityInitial()) {
on<LoadAvailability>(_onLoadAvailability);
on<SelectDate>(_onSelectDate);
on<NavigateWeek>(_onNavigateWeek);
on<ToggleDayStatus>(_onToggleDayStatus);
on<ToggleSlotStatus>(_onToggleSlotStatus);
on<PerformQuickSet>(_onPerformQuickSet);
}
Future<void> _onLoadAvailability(
LoadAvailability event,
Emitter<AvailabilityState> 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<AvailabilityState> emit) {
if (state is AvailabilityLoaded) {
emit((state as AvailabilityLoaded).copyWith(selectedDate: event.date));
}
}
Future<void> _onNavigateWeek(
NavigateWeek event,
Emitter<AvailabilityState> 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<void> _onToggleDayStatus(
ToggleDayStatus event,
Emitter<AvailabilityState> 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<void> _onToggleSlotStatus(
ToggleSlotStatus event,
Emitter<AvailabilityState> 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<void> _onPerformQuickSet(
PerformQuickSet event,
Emitter<AvailabilityState> 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
}
}
}
}

View File

@@ -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<Object?> get props => [];
}
class LoadAvailability extends AvailabilityEvent {
final DateTime weekStart;
final DateTime? preselectedDate; // Maintain selection after reload
const LoadAvailability(this.weekStart, {this.preselectedDate});
@override
List<Object?> get props => [weekStart, preselectedDate];
}
class SelectDate extends AvailabilityEvent {
final DateTime date;
const SelectDate(this.date);
@override
List<Object?> get props => [date];
}
class ToggleDayStatus extends AvailabilityEvent {
final DayAvailability day;
const ToggleDayStatus(this.day);
@override
List<Object?> get props => [day];
}
class ToggleSlotStatus extends AvailabilityEvent {
final DayAvailability day;
final String slotId;
const ToggleSlotStatus(this.day, this.slotId);
@override
List<Object?> get props => [day, slotId];
}
class NavigateWeek extends AvailabilityEvent {
final int direction; // -1 or 1
const NavigateWeek(this.direction);
@override
List<Object?> get props => [direction];
}
class PerformQuickSet extends AvailabilityEvent {
final String type; // all, weekdays, weekends, clear
const PerformQuickSet(this.type);
@override
List<Object?> get props => [type];
}

View File

@@ -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<Object?> get props => [];
}
class AvailabilityInitial extends AvailabilityState {}
class AvailabilityLoading extends AvailabilityState {}
class AvailabilityLoaded extends AvailabilityState {
final List<DayAvailability> 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<DayAvailability>? 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<Object?> get props => [days, currentWeekStart, selectedDate];
}
class AvailabilityError extends AvailabilityState {
final String message;
const AvailabilityError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -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<AvailabilityPage> createState() => _AvailabilityPageState();
}
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),
},
];
@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<DateTime> _getWeekDates() {
return List.generate(
7,
(index) => _currentWeekStart.add(Duration(days: index)),
);
}
String _formatDay(DateTime date) {
return DateFormat('EEE').format(date);
}
bool _isToday(DateTime date) {
final now = DateTime.now();
return date.year == now.year &&
date.month == now.month &&
date.day == now.day;
}
bool _isSelected(DateTime date) {
return date.year == _selectedDate.year &&
date.month == _selectedDate.month &&
date.day == _selectedDate.day;
}
void _navigateWeek(int direction) {
setState(() {
_currentWeekStart = _currentWeekStart.add(Duration(days: direction * 7));
});
}
void _toggleDayAvailability(String dayName) {
setState(() {
_availability[dayName] = !(_availability[dayName] ?? false);
// React code also updates mutation. We mock this.
// NOTE: In prototype we mock it. Refactor will move this to BLoC.
});
}
String _getDayKey(DateTime date) {
// DateTime.weekday: Mon=1...Sun=7.
// _dayNames array: 0=Sun, 1=Mon...
// Dart weekday: 7 is Sunday. 7 % 7 = 0.
return _dayNames[date.weekday % 7];
}
void _toggleTimeSlot(String slotId) {
final dayKey = _getDayKey(_selectedDate);
final currentDaySlots =
_timeSlotAvailability[dayKey] ??
{'morning': true, 'afternoon': true, 'evening': true};
final newValue = !(currentDaySlots[slotId] ?? true);
setState(() {
_timeSlotAvailability[dayKey] = {...currentDaySlots, slotId: newValue};
});
}
bool _isTimeSlotActive(String slotId) {
final dayKey = _getDayKey(_selectedDate);
final daySlots = _timeSlotAvailability[dayKey];
if (daySlots == null) return true;
return daySlots[slotId] != false;
}
String _getMonthYear() {
final middleDate = _currentWeekStart.add(const Duration(days: 3));
return DateFormat('MMMM yyyy').format(middleDate);
}
void _quickSet(String type) {
Map<String, bool> newAvailability = {};
switch (type) {
case 'all':
for (var day in _dayNames) newAvailability[day] = true;
break;
case 'weekdays':
for (var day in _dayNames)
newAvailability[day] = (day != 'saturday' && day != 'sunday');
break;
case 'weekends':
for (var day in _dayNames)
newAvailability[day] = (day == 'saturday' || day == 'sunday');
break;
case 'clear':
for (var day in _dayNames) newAvailability[day] = false;
break;
}
setState(() {
_availability = newAvailability;
});
}
@override
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<DateTime> 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;
}

View File

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

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

View File

@@ -0,0 +1,3 @@
library staff_availability;
export 'src/staff_availability_module.dart';

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,8 @@ dependencies:
path: ../payments
staff_time_card:
path: ../profile_sections/finances/time_card
staff_availability:
path: ../availability
dev_dependencies:
flutter_test:

View File

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