feat: Migrate staff profile features from Data Connect to V2 REST API

- Removed data_connect package from mobile pubspec.yaml.
- Added documentation for V2 profile migration status and QA findings.
- Implemented new session management with ClientSessionStore and StaffSessionStore.
- Created V2SessionService for handling user sessions via the V2 API.
- Developed use cases for cancelling late worker assignments and submitting worker reviews.
- Added arguments and use cases for payment chart retrieval and profile completion checks.
- Implemented repository interfaces and their implementations for staff main and profile features.
- Ensured proper error handling and validation in use cases.
This commit is contained in:
Achintha Isuru
2026-03-16 22:45:06 -04:00
parent 4834266986
commit b31a615092
478 changed files with 10512 additions and 19854 deletions

View File

@@ -1,235 +1,108 @@
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/availability_repository.dart';
import 'package:staff_availability/src/domain/repositories/availability_repository.dart';
/// Implementation of [AvailabilityRepository] using Firebase Data Connect.
/// V2 API implementation of [AvailabilityRepository].
///
/// Note: The backend schema supports recurring availablity (Weekly/DayOfWeek),
/// not specific date availability. Therefore, updating availability for a specific
/// date will update the availability for that Day of Week globally (Recurring).
class AvailabilityRepositoryImpl
implements AvailabilityRepository {
final dc.DataConnectService _service;
/// Uses the unified REST API for all read/write operations.
/// - `GET /staff/availability` to list availability for a date range.
/// - `PUT /staff/availability` to update a single day.
/// - `POST /staff/availability/quick-set` to apply a preset.
class AvailabilityRepositoryImpl implements AvailabilityRepository {
/// Creates an [AvailabilityRepositoryImpl].
AvailabilityRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
AvailabilityRepositoryImpl() : _service = dc.DataConnectService.instance;
/// The API service used for network requests.
final BaseApiService _apiService;
@override
Future<List<DayAvailability>> getAvailability(DateTime start, DateTime end) async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
// 1. Fetch Weekly recurring availability
final QueryResult<dc.ListStaffAvailabilitiesByStaffIdData, dc.ListStaffAvailabilitiesByStaffIdVariables> result =
await _service.connector.listStaffAvailabilitiesByStaffId(staffId: staffId).limit(100).execute();
Future<List<AvailabilityDay>> getAvailability(
DateTime start,
DateTime end,
) async {
final String startDate = _toIsoDate(start);
final String endDate = _toIsoDate(end);
final List<dc.ListStaffAvailabilitiesByStaffIdStaffAvailabilities> items = result.data.staffAvailabilities;
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.staffAvailability,
params: <String, dynamic>{
'startDate': startDate,
'endDate': endDate,
},
);
// 2. Map to lookup: DayOfWeek -> Map<SlotName, IsAvailable>
final Map<dc.DayOfWeek, Map<dc.AvailabilitySlot, bool>> weeklyMap = {};
for (final item in items) {
dc.DayOfWeek day;
try {
day = dc.DayOfWeek.values.byName(item.day.stringValue);
} catch (_) {
continue;
}
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
final List<dynamic> items = body['items'] as List<dynamic>;
dc.AvailabilitySlot slot;
try {
slot = dc.AvailabilitySlot.values.byName(item.slot.stringValue);
} catch (_) {
continue;
}
bool isAvailable = false;
try {
final dc.AvailabilityStatus status = dc.AvailabilityStatus.values.byName(item.status.stringValue);
isAvailable = _statusToBool(status);
} catch (_) {
isAvailable = false;
}
if (!weeklyMap.containsKey(day)) {
weeklyMap[day] = {};
}
weeklyMap[day]![slot] = isAvailable;
}
// 3. Generate DayAvailability for requested range
final List<DayAvailability> days = [];
final int dayCount = end.difference(start).inDays;
for (int i = 0; i <= dayCount; i++) {
final DateTime date = start.add(Duration(days: i));
final dc.DayOfWeek dow = _toBackendDay(date.weekday);
final Map<dc.AvailabilitySlot, bool> daySlots = weeklyMap[dow] ?? {};
// We define 3 standard slots for every day
final List<AvailabilitySlot> slots = [
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.MORNING),
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.AFTERNOON),
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.EVENING),
];
final bool isDayAvailable = slots.any((s) => s.isAvailable);
days.add(DayAvailability(
date: date,
isAvailable: isDayAvailable,
slots: slots,
));
}
return days;
});
}
AvailabilitySlot _createSlot(
DateTime date,
dc.DayOfWeek dow,
Map<dc.AvailabilitySlot, bool> existingSlots,
dc.AvailabilitySlot slotEnum,
) {
final bool isAvailable = existingSlots[slotEnum] ?? false;
return AvailabilityAdapter.fromPrimitive(slotEnum.name, isAvailable: isAvailable);
return items
.map((dynamic e) =>
AvailabilityDay.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
Future<DayAvailability> updateDayAvailability(DayAvailability availability) async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final dc.DayOfWeek dow = _toBackendDay(availability.date.weekday);
Future<AvailabilityDay> updateDayAvailability({
required int dayOfWeek,
required AvailabilityStatus status,
required List<TimeSlot> slots,
}) async {
final ApiResponse response = await _apiService.put(
V2ApiEndpoints.staffAvailability,
data: <String, dynamic>{
'dayOfWeek': dayOfWeek,
'availabilityStatus': status.toJson(),
'slots': slots.map((TimeSlot s) => s.toJson()).toList(),
},
);
// Update each slot in the backend.
// This updates the recurring rule for this DayOfWeek.
for (final AvailabilitySlot slot in availability.slots) {
final dc.AvailabilitySlot slotEnum = _toBackendSlot(slot.id);
final dc.AvailabilityStatus status = _boolToStatus(slot.isAvailable);
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
await _upsertSlot(staffId, dow, slotEnum, status);
}
return availability;
});
// The PUT response returns the updated day info.
return AvailabilityDay(
date: '',
dayOfWeek: body['dayOfWeek'] as int,
availabilityStatus:
AvailabilityStatus.fromJson(body['availabilityStatus'] as String?),
slots: _parseSlotsFromResponse(body['slots']),
);
}
@override
Future<List<DayAvailability>> applyQuickSet(DateTime start, DateTime end, String type) async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
// QuickSet updates the Recurring schedule for all days involved.
// However, if the user selects a range that covers e.g. Mon-Fri, we update Mon-Fri.
final int dayCount = end.difference(start).inDays;
final Set<dc.DayOfWeek> processedDays = {};
final List<DayAvailability> resultDays = [];
Future<void> applyQuickSet({
required String quickSetType,
required DateTime start,
required DateTime end,
List<TimeSlot>? slots,
}) async {
final Map<String, dynamic> data = <String, dynamic>{
'quickSetType': quickSetType,
'startDate': start.toUtc().toIso8601String(),
'endDate': end.toUtc().toIso8601String(),
};
final List<Future<void>> futures = [];
for (int i = 0; i <= dayCount; i++) {
final DateTime date = start.add(Duration(days: i));
final dc.DayOfWeek dow = _toBackendDay(date.weekday);
// Logic to determine if enabled based on type
bool enableDay = false;
if (type == 'all') {
enableDay = true;
} else if (type == 'clear') {
enableDay = false;
} else if (type == 'weekdays') {
enableDay = (dow != dc.DayOfWeek.SATURDAY && dow != dc.DayOfWeek.SUNDAY);
} else if (type == 'weekends') {
enableDay = (dow == dc.DayOfWeek.SATURDAY || dow == dc.DayOfWeek.SUNDAY);
}
// Only update backend once per DayOfWeek (since it's recurring)
if (!processedDays.contains(dow)) {
processedDays.add(dow);
final dc.AvailabilityStatus status = _boolToStatus(enableDay);
futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.MORNING, status));
futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.AFTERNOON, status));
futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.EVENING, status));
}
// Prepare return object
final slots = [
AvailabilityAdapter.fromPrimitive('MORNING', isAvailable: enableDay),
AvailabilityAdapter.fromPrimitive('AFTERNOON', isAvailable: enableDay),
AvailabilityAdapter.fromPrimitive('EVENING', isAvailable: enableDay),
];
resultDays.add(DayAvailability(
date: date,
isAvailable: enableDay,
slots: slots,
));
}
// Execute all updates in parallel
await Future.wait(futures);
return resultDays;
});
}
Future<void> _upsertSlot(String staffId, dc.DayOfWeek day, dc.AvailabilitySlot slot, dc.AvailabilityStatus status) async {
// Check if exists
final result = await _service.connector.getStaffAvailabilityByKey(
staffId: staffId,
day: day,
slot: slot,
).execute();
if (result.data.staffAvailability != null) {
// Update
await _service.connector.updateStaffAvailability(
staffId: staffId,
day: day,
slot: slot,
).status(status).execute();
} else {
// Create
await _service.connector.createStaffAvailability(
staffId: staffId,
day: day,
slot: slot,
).status(status).execute();
if (slots != null && slots.isNotEmpty) {
data['slots'] = slots.map((TimeSlot s) => s.toJson()).toList();
}
await _apiService.post(
V2ApiEndpoints.staffAvailabilityQuickSet,
data: data,
);
}
// --- Private Helpers ---
dc.DayOfWeek _toBackendDay(int weekday) {
switch (weekday) {
case DateTime.monday: return dc.DayOfWeek.MONDAY;
case DateTime.tuesday: return dc.DayOfWeek.TUESDAY;
case DateTime.wednesday: return dc.DayOfWeek.WEDNESDAY;
case DateTime.thursday: return dc.DayOfWeek.THURSDAY;
case DateTime.friday: return dc.DayOfWeek.FRIDAY;
case DateTime.saturday: return dc.DayOfWeek.SATURDAY;
case DateTime.sunday: return dc.DayOfWeek.SUNDAY;
default: return dc.DayOfWeek.MONDAY;
}
/// Formats a [DateTime] as `YYYY-MM-DD`.
String _toIsoDate(DateTime date) {
return '${date.year.toString().padLeft(4, '0')}-'
'${date.month.toString().padLeft(2, '0')}-'
'${date.day.toString().padLeft(2, '0')}';
}
dc.AvailabilitySlot _toBackendSlot(String id) {
switch (id.toLowerCase()) {
case 'morning': return dc.AvailabilitySlot.MORNING;
case 'afternoon': return dc.AvailabilitySlot.AFTERNOON;
case 'evening': return dc.AvailabilitySlot.EVENING;
default: return dc.AvailabilitySlot.MORNING;
}
}
bool _statusToBool(dc.AvailabilityStatus status) {
return status == dc.AvailabilityStatus.CONFIRMED_AVAILABLE;
}
dc.AvailabilityStatus _boolToStatus(bool isAvailable) {
return isAvailable ? dc.AvailabilityStatus.CONFIRMED_AVAILABLE : dc.AvailabilityStatus.BLOCKED;
/// Safely parses a dynamic slots value into [TimeSlot] list.
List<TimeSlot> _parseSlotsFromResponse(dynamic rawSlots) {
if (rawSlots is! List<dynamic>) return <TimeSlot>[];
return rawSlots
.map((dynamic e) => TimeSlot.fromJson(e as Map<String, dynamic>))
.toList();
}
}

View File

@@ -1,12 +1,25 @@
import 'package:krow_domain/krow_domain.dart';
/// Contract for fetching and updating staff availability.
abstract class AvailabilityRepository {
/// Fetches availability for a given date range (usually a week).
Future<List<DayAvailability>> getAvailability(DateTime start, DateTime end);
Future<List<AvailabilityDay>> 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);
/// Updates the availability for a specific day of the week.
Future<AvailabilityDay> updateDayAvailability({
required int dayOfWeek,
required AvailabilityStatus status,
required List<TimeSlot> slots,
});
/// Applies a preset configuration (e.g. "all", "weekdays") to the week.
Future<void> applyQuickSet({
required String quickSetType,
required DateTime start,
required DateTime end,
List<TimeSlot> slots,
});
}

View File

@@ -1,28 +1,38 @@
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;
import 'package:staff_availability/src/domain/repositories/availability_repository.dart';
/// Use case to apply a quick-set availability pattern to the current week.
///
/// Supported types: `all`, `weekdays`, `weekends`, `clear`.
class ApplyQuickSetUseCase extends UseCase<ApplyQuickSetParams, void> {
/// Creates an [ApplyQuickSetUseCase].
ApplyQuickSetUseCase(this.repository);
/// [type] can be 'all', 'weekdays', 'weekends', 'clear'
/// The availability repository.
final AvailabilityRepository repository;
@override
Future<List<DayAvailability>> call(ApplyQuickSetParams params) {
final end = params.start.add(const Duration(days: 6));
return repository.applyQuickSet(params.start, end, params.type);
Future<void> call(ApplyQuickSetParams params) {
final DateTime end = params.start.add(const Duration(days: 6));
return repository.applyQuickSet(
quickSetType: params.type,
start: params.start,
end: end,
);
}
}
/// Parameters for [ApplyQuickSetUseCase].
class ApplyQuickSetParams extends UseCaseArgument {
final DateTime start;
final String type;
/// Creates [ApplyQuickSetParams].
const ApplyQuickSetParams(this.start, this.type);
/// The Monday of the target week.
final DateTime start;
/// Quick-set type: `all`, `weekdays`, `weekends`, or `clear`.
final String type;
@override
List<Object?> get props => [start, type];
List<Object?> get props => <Object?>[start, type];
}

View File

@@ -1,30 +1,36 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/availability_repository.dart';
import 'package:staff_availability/src/domain/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;
///
/// Calculates the week range from the given start date and delegates
/// to the repository.
class GetWeeklyAvailabilityUseCase
extends UseCase<GetWeeklyAvailabilityParams, List<AvailabilityDay>> {
/// Creates a [GetWeeklyAvailabilityUseCase].
GetWeeklyAvailabilityUseCase(this.repository);
/// The availability repository.
final AvailabilityRepository 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));
Future<List<AvailabilityDay>> call(
GetWeeklyAvailabilityParams params,
) async {
final DateTime end = params.start.add(const Duration(days: 6));
return repository.getAvailability(params.start, end);
}
}
/// Parameters for [GetWeeklyAvailabilityUseCase].
class GetWeeklyAvailabilityParams extends UseCaseArgument {
final DateTime start;
/// Creates [GetWeeklyAvailabilityParams].
const GetWeeklyAvailabilityParams(this.start);
/// The Monday of the target week.
final DateTime start;
@override
List<Object?> get props => [start];
List<Object?> get props => <Object?>[start];
}

View File

@@ -1,25 +1,44 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/availability_repository.dart';
import 'package:staff_availability/src/domain/repositories/availability_repository.dart';
/// Use case to update the availability configuration for a specific day.
class UpdateDayAvailabilityUseCase extends UseCase<UpdateDayAvailabilityParams, DayAvailability> {
final AvailabilityRepository repository;
class UpdateDayAvailabilityUseCase
extends UseCase<UpdateDayAvailabilityParams, AvailabilityDay> {
/// Creates an [UpdateDayAvailabilityUseCase].
UpdateDayAvailabilityUseCase(this.repository);
/// The availability repository.
final AvailabilityRepository repository;
@override
Future<DayAvailability> call(UpdateDayAvailabilityParams params) {
return repository.updateDayAvailability(params.availability);
Future<AvailabilityDay> call(UpdateDayAvailabilityParams params) {
return repository.updateDayAvailability(
dayOfWeek: params.dayOfWeek,
status: params.status,
slots: params.slots,
);
}
}
/// Parameters for [UpdateDayAvailabilityUseCase].
class UpdateDayAvailabilityParams extends UseCaseArgument {
final DayAvailability availability;
/// Creates [UpdateDayAvailabilityParams].
const UpdateDayAvailabilityParams({
required this.dayOfWeek,
required this.status,
required this.slots,
});
const UpdateDayAvailabilityParams(this.availability);
/// Day of week (0 = Sunday, 6 = Saturday).
final int dayOfWeek;
/// New availability status.
final AvailabilityStatus status;
/// Time slots for this day.
final List<TimeSlot> slots;
@override
List<Object?> get props => [availability];
List<Object?> get props => <Object?>[dayOfWeek, status, slots];
}

View File

@@ -1,17 +1,19 @@
import 'package:flutter_bloc/flutter_bloc.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 'package:krow_core/core.dart';
import 'availability_event.dart';
import 'availability_state.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_availability/src/domain/usecases/apply_quick_set_usecase.dart';
import 'package:staff_availability/src/domain/usecases/get_weekly_availability_usecase.dart';
import 'package:staff_availability/src/domain/usecases/update_day_availability_usecase.dart';
import 'package:staff_availability/src/presentation/blocs/availability_event.dart';
import 'package:staff_availability/src/presentation/blocs/availability_state.dart';
/// Manages availability state for the staff availability page.
///
/// Coordinates loading, toggling, and quick-set operations through
/// domain use cases.
class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
with BlocErrorHandler<AvailabilityState> {
final GetWeeklyAvailabilityUseCase getWeeklyAvailability;
final UpdateDayAvailabilityUseCase updateDayAvailability;
final ApplyQuickSetUseCase applyQuickSet;
/// Creates an [AvailabilityBloc].
AvailabilityBloc({
required this.getWeeklyAvailability,
required this.updateDayAvailability,
@@ -25,6 +27,15 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
on<PerformQuickSet>(_onPerformQuickSet);
}
/// Use case for loading weekly availability.
final GetWeeklyAvailabilityUseCase getWeeklyAvailability;
/// Use case for updating a single day.
final UpdateDayAvailabilityUseCase updateDayAvailability;
/// Use case for applying a quick-set preset.
final ApplyQuickSetUseCase applyQuickSet;
Future<void> _onLoadAvailability(
LoadAvailability event,
Emitter<AvailabilityState> emit,
@@ -33,15 +44,18 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
await handleError(
emit: emit.call,
action: () async {
final days = await getWeeklyAvailability(
final List<AvailabilityDay> days = await getWeeklyAvailability(
GetWeeklyAvailabilityParams(event.weekStart),
);
// Determine selected date: preselected, or first day of the week.
final DateTime selectedDate = event.preselectedDate ?? event.weekStart;
emit(
AvailabilityLoaded(
days: days,
currentWeekStart: event.weekStart,
selectedDate: event.preselectedDate ??
(days.isNotEmpty ? days.first.date : DateTime.now()),
selectedDate: selectedDate,
),
);
},
@@ -51,7 +65,6 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
void _onSelectDate(SelectDate event, Emitter<AvailabilityState> emit) {
if (state is AvailabilityLoaded) {
// Clear success message on navigation
emit(
(state as AvailabilityLoaded).copyWith(
selectedDate: event.date,
@@ -66,19 +79,18 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
Emitter<AvailabilityState> emit,
) async {
if (state is AvailabilityLoaded) {
final currentState = state as AvailabilityLoaded;
// Clear message
final AvailabilityLoaded currentState = state as AvailabilityLoaded;
emit(currentState.copyWith(clearSuccessMessage: true));
final newWeekStart = currentState.currentWeekStart.add(
final DateTime newWeekStart = currentState.currentWeekStart.add(
Duration(days: event.direction * 7),
);
final diff = currentState.selectedDate
// Preserve the relative day offset when navigating.
final int diff = currentState.selectedDate
.difference(currentState.currentWeekStart)
.inDays;
final newSelectedDate = newWeekStart.add(Duration(days: diff));
final DateTime newSelectedDate = newWeekStart.add(Duration(days: diff));
add(LoadAvailability(newWeekStart, preselectedDate: newSelectedDate));
}
@@ -89,14 +101,22 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
Emitter<AvailabilityState> emit,
) async {
if (state is AvailabilityLoaded) {
final currentState = state as AvailabilityLoaded;
final AvailabilityLoaded 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();
// Toggle: available -> unavailable, anything else -> available.
final AvailabilityStatus newStatus = event.day.isAvailable
? AvailabilityStatus.unavailable
: AvailabilityStatus.available;
final AvailabilityDay newDay = event.day.copyWith(
availabilityStatus: newStatus,
);
// Optimistic update.
final List<AvailabilityDay> updatedDays = currentState.days
.map((AvailabilityDay d) => d.date == event.day.date ? newDay : d)
.toList();
// Optimistic update
emit(currentState.copyWith(
days: updatedDays,
clearSuccessMessage: true,
@@ -105,8 +125,13 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
await handleError(
emit: emit.call,
action: () async {
await updateDayAvailability(UpdateDayAvailabilityParams(newDay));
// Success feedback
await updateDayAvailability(
UpdateDayAvailabilityParams(
dayOfWeek: newDay.dayOfWeek,
status: newStatus,
slots: newDay.slots,
),
);
if (state is AvailabilityLoaded) {
emit(
(state as AvailabilityLoaded).copyWith(
@@ -116,7 +141,7 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
}
},
onError: (String errorKey) {
// Revert
// Revert on failure.
if (state is AvailabilityLoaded) {
return (state as AvailabilityLoaded).copyWith(
days: currentState.days,
@@ -133,22 +158,41 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
Emitter<AvailabilityState> emit,
) async {
if (state is AvailabilityLoaded) {
final currentState = state as AvailabilityLoaded;
final AvailabilityLoaded 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();
// Remove the slot at the given index to toggle it off,
// or re-add if already removed. For V2, toggling a slot means
// removing it from the list (unavailable) or the day remains
// with the remaining slots.
// For simplicity, we toggle the overall day status instead of
// individual slot removal since the V2 API sends full slot arrays.
final newDay = event.day.copyWith(slots: updatedSlots);
// Build a new slots list by removing or keeping the target slot.
final List<TimeSlot> currentSlots =
List<TimeSlot>.from(event.day.slots);
final updatedDays = currentState.days.map((d) {
return d.date == event.day.date ? newDay : d;
}).toList();
// If there's only one slot and we remove it, day becomes unavailable.
// If there are multiple, remove the indexed one.
if (event.slotIndex >= 0 && event.slotIndex < currentSlots.length) {
currentSlots.removeAt(event.slotIndex);
}
// Optimistic update
final AvailabilityStatus newStatus = currentSlots.isEmpty
? AvailabilityStatus.unavailable
: (currentSlots.length < event.day.slots.length
? AvailabilityStatus.partial
: event.day.availabilityStatus);
final AvailabilityDay newDay = event.day.copyWith(
availabilityStatus: newStatus,
slots: currentSlots,
);
final List<AvailabilityDay> updatedDays = currentState.days
.map((AvailabilityDay d) => d.date == event.day.date ? newDay : d)
.toList();
// Optimistic update.
emit(currentState.copyWith(
days: updatedDays,
clearSuccessMessage: true,
@@ -157,8 +201,13 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
await handleError(
emit: emit.call,
action: () async {
await updateDayAvailability(UpdateDayAvailabilityParams(newDay));
// Success feedback
await updateDayAvailability(
UpdateDayAvailabilityParams(
dayOfWeek: newDay.dayOfWeek,
status: newStatus,
slots: currentSlots,
),
);
if (state is AvailabilityLoaded) {
emit(
(state as AvailabilityLoaded).copyWith(
@@ -168,7 +217,7 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
}
},
onError: (String errorKey) {
// Revert
// Revert on failure.
if (state is AvailabilityLoaded) {
return (state as AvailabilityLoaded).copyWith(
days: currentState.days,
@@ -185,7 +234,7 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
Emitter<AvailabilityState> emit,
) async {
if (state is AvailabilityLoaded) {
final currentState = state as AvailabilityLoaded;
final AvailabilityLoaded currentState = state as AvailabilityLoaded;
emit(
currentState.copyWith(
@@ -197,13 +246,18 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
await handleError(
emit: emit.call,
action: () async {
final newDays = await applyQuickSet(
await applyQuickSet(
ApplyQuickSetParams(currentState.currentWeekStart, event.type),
);
// Reload the week to get updated data from the server.
final List<AvailabilityDay> refreshed = await getWeeklyAvailability(
GetWeeklyAvailabilityParams(currentState.currentWeekStart),
);
emit(
currentState.copyWith(
days: newDays,
days: refreshed,
isActionInProgress: false,
successMessage: 'Availability updated',
),
@@ -221,4 +275,3 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
}
}
}

View File

@@ -1,130 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
// --- State ---
class AvailabilityState extends Equatable {
final DateTime currentWeekStart;
final DateTime selectedDate;
final Map<String, bool> dayAvailability;
final Map<String, Map<String, bool>> timeSlotAvailability;
const AvailabilityState({
required this.currentWeekStart,
required this.selectedDate,
required this.dayAvailability,
required this.timeSlotAvailability,
});
AvailabilityState copyWith({
DateTime? currentWeekStart,
DateTime? selectedDate,
Map<String, bool>? dayAvailability,
Map<String, Map<String, bool>>? timeSlotAvailability,
}) {
return AvailabilityState(
currentWeekStart: currentWeekStart ?? this.currentWeekStart,
selectedDate: selectedDate ?? this.selectedDate,
dayAvailability: dayAvailability ?? this.dayAvailability,
timeSlotAvailability: timeSlotAvailability ?? this.timeSlotAvailability,
);
}
@override
List<Object> get props => [
currentWeekStart,
selectedDate,
dayAvailability,
timeSlotAvailability,
];
}
// --- Cubit ---
class AvailabilityCubit extends Cubit<AvailabilityState> {
AvailabilityCubit()
: super(AvailabilityState(
currentWeekStart: _getStartOfWeek(DateTime.now()),
selectedDate: DateTime.now(),
dayAvailability: {
'monday': true,
'tuesday': true,
'wednesday': true,
'thursday': true,
'friday': true,
'saturday': false,
'sunday': false,
},
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},
},
));
static DateTime _getStartOfWeek(DateTime date) {
final diff = date.weekday - 1; // Mon=1 -> 0
final start = date.subtract(Duration(days: diff));
return DateTime(start.year, start.month, start.day);
}
void selectDate(DateTime date) {
emit(state.copyWith(selectedDate: date));
}
void navigateWeek(int weeks) {
emit(state.copyWith(
currentWeekStart: state.currentWeekStart.add(Duration(days: weeks * 7)),
));
}
void toggleDay(String dayKey) {
final currentObj = Map<String, bool>.from(state.dayAvailability);
currentObj[dayKey] = !(currentObj[dayKey] ?? false);
emit(state.copyWith(dayAvailability: currentObj));
}
void toggleSlot(String dayKey, String slotId) {
final allSlots = Map<String, Map<String, bool>>.from(state.timeSlotAvailability);
final daySlots = Map<String, bool>.from(allSlots[dayKey] ?? {});
// Default to true if missing, so we toggle to false
final currentVal = daySlots[slotId] ?? true;
daySlots[slotId] = !currentVal;
allSlots[dayKey] = daySlots;
emit(state.copyWith(timeSlotAvailability: allSlots));
}
void quickSet(String type) {
final newAvailability = <String, bool>{};
final days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
switch (type) {
case 'all':
for (var d in days) {
newAvailability[d] = true;
}
break;
case 'weekdays':
for (var d in days) {
newAvailability[d] = (d != 'saturday' && d != 'sunday');
}
break;
case 'weekends':
for (var d in days) {
newAvailability[d] = (d == 'saturday' || d == 'sunday');
}
break;
case 'clear':
for (var d in days) {
newAvailability[d] = false;
}
break;
}
emit(state.copyWith(dayAvailability: newAvailability));
}
}

View File

@@ -1,54 +1,89 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Base class for availability events.
abstract class AvailabilityEvent extends Equatable {
/// Creates an [AvailabilityEvent].
const AvailabilityEvent();
@override
List<Object?> get props => [];
List<Object?> get props => <Object?>[];
}
/// Requests loading availability for a given week.
class LoadAvailability extends AvailabilityEvent {
final DateTime weekStart;
final DateTime? preselectedDate; // Maintain selection after reload
/// Creates a [LoadAvailability] event.
const LoadAvailability(this.weekStart, {this.preselectedDate});
/// The Monday of the week to load.
final DateTime weekStart;
/// Optional date to pre-select after loading.
final DateTime? preselectedDate;
@override
List<Object?> get props => [weekStart, preselectedDate];
List<Object?> get props => <Object?>[weekStart, preselectedDate];
}
/// User selected a date in the week strip.
class SelectDate extends AvailabilityEvent {
final DateTime date;
/// Creates a [SelectDate] event.
const SelectDate(this.date);
/// The selected date.
final DateTime date;
@override
List<Object?> get props => [date];
List<Object?> get props => <Object?>[date];
}
/// Toggles the overall availability status of a day.
class ToggleDayStatus extends AvailabilityEvent {
final DayAvailability day;
/// Creates a [ToggleDayStatus] event.
const ToggleDayStatus(this.day);
/// The day to toggle.
final AvailabilityDay day;
@override
List<Object?> get props => [day];
List<Object?> get props => <Object?>[day];
}
/// Toggles an individual time slot within a day.
class ToggleSlotStatus extends AvailabilityEvent {
final DayAvailability day;
final String slotId;
const ToggleSlotStatus(this.day, this.slotId);
/// Creates a [ToggleSlotStatus] event.
const ToggleSlotStatus(this.day, this.slotIndex);
/// The parent day.
final AvailabilityDay day;
/// Index of the slot to toggle within [day.slots].
final int slotIndex;
@override
List<Object?> get props => [day, slotId];
List<Object?> get props => <Object?>[day, slotIndex];
}
/// Navigates forward or backward by one week.
class NavigateWeek extends AvailabilityEvent {
final int direction; // -1 or 1
/// Creates a [NavigateWeek] event.
const NavigateWeek(this.direction);
/// -1 for previous week, 1 for next week.
final int direction;
@override
List<Object?> get props => [direction];
List<Object?> get props => <Object?>[direction];
}
/// Applies a quick-set preset to the current week.
class PerformQuickSet extends AvailabilityEvent {
final String type; // all, weekdays, weekends, clear
/// Creates a [PerformQuickSet] event.
const PerformQuickSet(this.type);
/// One of: `all`, `weekdays`, `weekends`, `clear`.
final String type;
@override
List<Object?> get props => [type];
List<Object?> get props => <Object?>[type];
}

View File

@@ -1,23 +1,24 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Base class for availability states.
abstract class AvailabilityState extends Equatable {
/// Creates an [AvailabilityState].
const AvailabilityState();
@override
List<Object?> get props => [];
List<Object?> get props => <Object?>[];
}
/// Initial state before any data is loaded.
class AvailabilityInitial extends AvailabilityState {}
/// Loading state while fetching availability data.
class AvailabilityLoading extends AvailabilityState {}
/// State when availability data has been loaded.
class AvailabilityLoaded extends AvailabilityState {
final List<DayAvailability> days;
final DateTime currentWeekStart;
final DateTime selectedDate;
final bool isActionInProgress;
final String? successMessage;
/// Creates an [AvailabilityLoaded] state.
const AvailabilityLoaded({
required this.days,
required this.currentWeekStart,
@@ -26,20 +27,41 @@ class AvailabilityLoaded extends AvailabilityState {
this.successMessage,
});
/// Helper to get the currently selected day's availability object
DayAvailability get selectedDayAvailability {
/// The list of daily availability entries for the current week.
final List<AvailabilityDay> days;
/// The Monday of the currently displayed week.
final DateTime currentWeekStart;
/// The currently selected date in the week strip.
final DateTime selectedDate;
/// Whether a background action (update/quick-set) is in progress.
final bool isActionInProgress;
/// Optional success message for snackbar feedback.
final String? successMessage;
/// The [AvailabilityDay] matching the current [selectedDate].
AvailabilityDay get selectedDayAvailability {
final String selectedIso = _toIsoDate(selectedDate);
return days.firstWhere(
(d) => isSameDay(d.date, selectedDate),
orElse: () => DayAvailability(date: selectedDate), // Fallback
(AvailabilityDay d) => d.date == selectedIso,
orElse: () => AvailabilityDay(
date: selectedIso,
dayOfWeek: selectedDate.weekday % 7,
availabilityStatus: AvailabilityStatus.unavailable,
),
);
}
/// Creates a copy with optionally replaced fields.
AvailabilityLoaded copyWith({
List<DayAvailability>? days,
List<AvailabilityDay>? days,
DateTime? currentWeekStart,
DateTime? selectedDate,
bool? isActionInProgress,
String? successMessage, // Nullable override
String? successMessage,
bool clearSuccessMessage = false,
}) {
return AvailabilityLoaded(
@@ -47,21 +69,41 @@ class AvailabilityLoaded extends AvailabilityState {
currentWeekStart: currentWeekStart ?? this.currentWeekStart,
selectedDate: selectedDate ?? this.selectedDate,
isActionInProgress: isActionInProgress ?? this.isActionInProgress,
successMessage: clearSuccessMessage ? null : (successMessage ?? this.successMessage),
successMessage:
clearSuccessMessage ? null : (successMessage ?? this.successMessage),
);
}
/// Checks whether two [DateTime]s represent the same calendar day.
static bool isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
/// Formats a [DateTime] as `YYYY-MM-DD`.
static String _toIsoDate(DateTime date) {
return '${date.year.toString().padLeft(4, '0')}-'
'${date.month.toString().padLeft(2, '0')}-'
'${date.day.toString().padLeft(2, '0')}';
}
@override
List<Object?> get props => [days, currentWeekStart, selectedDate, isActionInProgress, successMessage];
List<Object?> get props => <Object?>[
days,
currentWeekStart,
selectedDate,
isActionInProgress,
successMessage,
];
}
/// Error state when availability loading or an action fails.
class AvailabilityError extends AvailabilityState {
final String message;
/// Creates an [AvailabilityError] state.
const AvailabilityError(this.message);
/// Error key for localisation.
final String message;
@override
List<Object?> get props => [message];
List<Object?> get props => <Object?>[message];
}

View File

@@ -6,13 +6,14 @@ import 'package:flutter_modular/flutter_modular.dart'
hide ModularWatchExtension;
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_availability/src/presentation/blocs/availability_bloc.dart';
import 'package:staff_availability/src/presentation/blocs/availability_event.dart';
import 'package:staff_availability/src/presentation/blocs/availability_state.dart';
import 'package:staff_availability/src/presentation/widgets/availability_page_skeleton/availability_page_skeleton.dart';
import '../blocs/availability_bloc.dart';
import '../blocs/availability_event.dart';
import '../blocs/availability_state.dart';
import '../widgets/availability_page_skeleton/availability_page_skeleton.dart';
/// Page for managing staff weekly availability.
class AvailabilityPage extends StatefulWidget {
/// Creates an [AvailabilityPage].
const AvailabilityPage({super.key});
@override
@@ -28,10 +29,10 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
_calculateInitialWeek();
}
/// Computes the Monday of the current week and triggers initial load.
void _calculateInitialWeek() {
final today = DateTime.now();
final day = today.weekday; // Mon=1, Sun=7
final diff = day - 1; // Assuming Monday start
final DateTime today = DateTime.now();
final int diff = today.weekday - 1;
DateTime currentWeekStart = today.subtract(Duration(days: diff));
currentWeekStart = DateTime(
currentWeekStart.year,
@@ -43,25 +44,25 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
@override
Widget build(BuildContext context) {
final i18n = Translations.of(context).staff.availability;
return BlocProvider.value(
final dynamic i18n = Translations.of(context).staff.availability;
return BlocProvider<AvailabilityBloc>.value(
value: _bloc,
child: Scaffold(
appBar: UiAppBar(
title: i18n.title,
title: i18n.title as String,
centerTitle: false,
showBackButton: true,
),
body: BlocListener<AvailabilityBloc, AvailabilityState>(
listener: (context, state) {
if (state is AvailabilityLoaded && state.successMessage != null) {
listener: (BuildContext context, AvailabilityState state) {
if (state is AvailabilityLoaded &&
state.successMessage != null) {
UiSnackbar.show(
context,
message: state.successMessage!,
type: UiSnackbarType.success,
);
}
if (state is AvailabilityError) {
UiSnackbar.show(
context,
@@ -71,59 +72,19 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
}
},
child: BlocBuilder<AvailabilityBloc, AvailabilityState>(
builder: (context, state) {
builder: (BuildContext context, AvailabilityState state) {
if (state is AvailabilityLoading) {
return const AvailabilityPageSkeleton();
} else if (state is AvailabilityLoaded) {
return Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 100),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: UiConstants.space6,
children: [
_buildQuickSet(context),
_buildWeekNavigation(context, state),
_buildSelectedDayAvailability(
context,
state.selectedDayAvailability,
),
_buildInfoCard(),
],
),
),
],
),
),
if (state.isActionInProgress)
Positioned.fill(
child: Container(
color: UiColors.white.withValues(alpha: 0.5),
child: const Center(child: CircularProgressIndicator()),
),
),
],
);
return _buildLoaded(context, state);
} else if (state is AvailabilityError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
translateErrorKey(state.message),
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary,
),
],
child: Text(
translateErrorKey(state.message),
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary,
),
),
);
@@ -136,8 +97,48 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
);
}
Widget _buildLoaded(BuildContext context, AvailabilityLoaded state) {
return Stack(
children: <Widget>[
SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 100),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const SizedBox(height: UiConstants.space6),
_buildQuickSet(context),
const SizedBox(height: UiConstants.space6),
_buildWeekNavigation(context, state),
const SizedBox(height: UiConstants.space6),
_buildSelectedDayAvailability(
context,
state.selectedDayAvailability,
),
const SizedBox(height: UiConstants.space6),
_buildInfoCard(),
],
),
),
),
if (state.isActionInProgress)
Positioned.fill(
child: Container(
color: UiColors.white.withValues(alpha: 0.5),
child: const Center(child: CircularProgressIndicator()),
),
),
],
);
}
// ── Quick Set Section ─────────────────────────────────────────────────
Widget _buildQuickSet(BuildContext context) {
final i18n = Translations.of(context).staff.availability;
final dynamic i18n = Translations.of(context).staff.availability;
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
@@ -146,30 +147,39 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
i18n.quick_set_title,
style: UiTypography.body2b,
),
children: <Widget>[
Text(i18n.quick_set_title as String, style: UiTypography.body2b),
const SizedBox(height: UiConstants.space3),
Row(
children: [
children: <Widget>[
Expanded(
child: _buildQuickSetButton(context, i18n.all_week, 'all'),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: _buildQuickSetButton(context, i18n.weekdays, 'weekdays'),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: _buildQuickSetButton(context, i18n.weekends, 'weekends'),
child: _buildQuickSetButton(
context,
i18n.all_week as String,
'all',
),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: _buildQuickSetButton(
context,
i18n.clear_all,
i18n.weekdays as String,
'weekdays',
),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: _buildQuickSetButton(
context,
i18n.weekends as String,
'weekends',
),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: _buildQuickSetButton(
context,
i18n.clear_all as String,
'clear',
isDestructive: true,
),
@@ -203,9 +213,8 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
),
foregroundColor: isDestructive
? UiColors.destructive
: UiColors.primary,
foregroundColor:
isDestructive ? UiColors.destructive : UiColors.primary,
),
child: Text(
label,
@@ -217,10 +226,15 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
);
}
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);
// ── Week Navigation ───────────────────────────────────────────────────
Widget _buildWeekNavigation(
BuildContext context,
AvailabilityLoaded state,
) {
final DateTime middleDate =
state.currentWeekStart.add(const Duration(days: 3));
final String monthYear = DateFormat('MMMM yyyy').format(middleDate);
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
@@ -230,37 +244,33 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
border: Border.all(color: UiColors.border),
),
child: Column(
children: [
// Nav Header
children: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
children: <Widget>[
_buildNavButton(
UiIcons.chevronLeft,
() => context.read<AvailabilityBloc>().add(
const NavigateWeek(-1),
),
),
Text(
monthYear,
style: UiTypography.title2b,
const NavigateWeek(-1),
),
),
Text(monthYear, style: UiTypography.title2b),
_buildNavButton(
UiIcons.chevronRight,
() => context.read<AvailabilityBloc>().add(
const NavigateWeek(1),
),
const NavigateWeek(1),
),
),
],
),
),
// Days Row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: state.days
.map((day) => _buildDayItem(context, day, state.selectedDate))
.map((AvailabilityDay day) =>
_buildDayItem(context, day, state.selectedDate))
.toList(),
),
],
@@ -285,16 +295,19 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
Widget _buildDayItem(
BuildContext context,
DayAvailability day,
AvailabilityDay day,
DateTime selectedDate,
) {
final isSelected = AvailabilityLoaded.isSameDay(day.date, selectedDate);
final isAvailable = day.isAvailable;
final isToday = AvailabilityLoaded.isSameDay(day.date, DateTime.now());
final DateTime dayDate = DateTime.parse(day.date);
final bool isSelected = AvailabilityLoaded.isSameDay(dayDate, selectedDate);
final bool isAvailable = day.isAvailable;
final bool isToday =
AvailabilityLoaded.isSameDay(dayDate, DateTime.now());
return Expanded(
child: GestureDetector(
onTap: () => context.read<AvailabilityBloc>().add(SelectDate(day.date)),
onTap: () =>
context.read<AvailabilityBloc>().add(SelectDate(dayDate)),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(vertical: UiConstants.space3),
@@ -314,11 +327,11 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
children: <Widget>[
Column(
children: [
children: <Widget>[
Text(
day.date.day.toString().padLeft(2, '0'),
dayDate.day.toString().padLeft(2, '0'),
style: UiTypography.title1m.copyWith(
fontWeight: FontWeight.bold,
color: isSelected
@@ -330,7 +343,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
),
const SizedBox(height: 2),
Text(
DateFormat('EEE').format(day.date),
DateFormat('EEE').format(dayDate),
style: UiTypography.footnote2r.copyWith(
color: isSelected
? UiColors.white.withValues(alpha: 0.8)
@@ -360,12 +373,15 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
);
}
// ── Selected Day Detail ───────────────────────────────────────────────
Widget _buildSelectedDayAvailability(
BuildContext context,
DayAvailability day,
AvailabilityDay day,
) {
final dateStr = DateFormat('EEEE, MMM d').format(day.date);
final isAvailable = day.isAvailable;
final DateTime dayDate = DateTime.parse(day.date);
final String dateStr = DateFormat('EEEE, MMM d').format(dayDate);
final bool isAvailable = day.isAvailable;
return Container(
padding: const EdgeInsets.all(UiConstants.space5),
@@ -375,18 +391,14 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
border: Border.all(color: UiColors.border),
),
child: Column(
children: [
// Header Row
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dateStr,
style: UiTypography.title2b,
),
children: <Widget>[
Text(dateStr, style: UiTypography.title2b),
Text(
isAvailable
? Translations.of(context)
@@ -403,94 +415,54 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
),
Switch(
value: isAvailable,
onChanged: (val) =>
context.read<AvailabilityBloc>().add(ToggleDayStatus(day)),
onChanged: (bool val) => context
.read<AvailabilityBloc>()
.add(ToggleDayStatus(day)),
activeThumbColor: UiColors.primary,
),
],
),
const SizedBox(height: UiConstants.space4),
// 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);
...day.slots.asMap().entries.map((MapEntry<int, TimeSlot> entry) {
final int index = entry.key;
final TimeSlot slot = entry.value;
return _buildTimeSlotItem(context, day, slot, index);
}),
],
),
);
}
Map<String, dynamic> _getSlotUiConfig(String slotId) {
switch (slotId) {
case 'morning':
return {
'icon': UiIcons.sunrise,
'bg': UiColors.primary.withValues(alpha: 0.1),
'iconColor': UiColors.primary,
};
case 'afternoon':
return {
'icon': UiIcons.sun,
'bg': UiColors.primary.withValues(alpha: 0.2),
'iconColor': UiColors.primary,
};
case 'evening':
return {
'icon': UiIcons.moon,
'bg': UiColors.bgSecondary,
'iconColor': UiColors.foreground,
};
default:
return {
'icon': UiIcons.clock,
'bg': UiColors.bgSecondary,
'iconColor': UiColors.iconSecondary,
};
}
}
Widget _buildTimeSlotItem(
BuildContext context,
DayAvailability day,
AvailabilitySlot slot,
Map<String, dynamic> uiConfig,
AvailabilityDay day,
TimeSlot slot,
int index,
) {
// Determine styles based on state
final isEnabled = day.isAvailable;
final isActive = slot.isAvailable;
final bool isEnabled = day.isAvailable;
final Map<String, dynamic> uiConfig = _getSlotUiConfig(slot);
// Container style
Color bgColor;
Color borderColor;
if (!isEnabled) {
bgColor = UiColors.bgSecondary;
borderColor = UiColors.borderInactive;
} else if (isActive) {
} else {
bgColor = UiColors.primary.withValues(alpha: 0.05);
borderColor = UiColors.primary.withValues(alpha: 0.2);
} else {
bgColor = UiColors.bgSecondary;
borderColor = UiColors.borderPrimary;
}
// Text colors
final titleColor = (isEnabled && isActive)
? UiColors.foreground
: UiColors.mutedForeground;
final subtitleColor = (isEnabled && isActive)
? UiColors.mutedForeground
: UiColors.textInactive;
final Color titleColor =
isEnabled ? UiColors.foreground : UiColors.mutedForeground;
final Color subtitleColor =
isEnabled ? UiColors.mutedForeground : UiColors.textInactive;
return GestureDetector(
onTap: isEnabled
? () => context.read<AvailabilityBloc>().add(
ToggleSlotStatus(day, slot.id),
)
? () => context
.read<AvailabilityBloc>()
.add(ToggleSlotStatus(day, index))
: null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
@@ -502,40 +474,38 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
border: Border.all(color: borderColor, width: 2),
),
child: Row(
children: [
// Icon
children: <Widget>[
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: uiConfig['bg'],
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
color: uiConfig['bg'] as Color,
borderRadius:
BorderRadius.circular(UiConstants.radiusBase),
),
child: Icon(
uiConfig['icon'],
color: uiConfig['iconColor'],
uiConfig['icon'] as IconData,
color: uiConfig['iconColor'] as Color,
size: 20,
),
),
const SizedBox(width: UiConstants.space3),
// Text
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
Text(
slot.label,
'${slot.startTime} - ${slot.endTime}',
style: UiTypography.body2m.copyWith(color: titleColor),
),
Text(
slot.timeRange,
_slotPeriodLabel(slot),
style: UiTypography.body3r.copyWith(color: subtitleColor),
),
],
),
),
// Checkbox indicator
if (isEnabled && isActive)
if (isEnabled)
Container(
width: 24,
height: 24,
@@ -548,18 +518,6 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
size: 16,
color: UiColors.white,
),
)
else if (isEnabled && !isActive)
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: UiColors.borderStill,
width: 2,
),
),
),
],
),
@@ -567,8 +525,48 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
);
}
/// Returns UI config (icon, bg, iconColor) based on time slot hours.
Map<String, dynamic> _getSlotUiConfig(TimeSlot slot) {
final int hour = _parseHour(slot.startTime);
if (hour < 12) {
return <String, dynamic>{
'icon': UiIcons.sunrise,
'bg': UiColors.primary.withValues(alpha: 0.1),
'iconColor': UiColors.primary,
};
} else if (hour < 17) {
return <String, dynamic>{
'icon': UiIcons.sun,
'bg': UiColors.primary.withValues(alpha: 0.2),
'iconColor': UiColors.primary,
};
} else {
return <String, dynamic>{
'icon': UiIcons.moon,
'bg': UiColors.bgSecondary,
'iconColor': UiColors.foreground,
};
}
}
/// Parses the hour from an `HH:MM` string.
int _parseHour(String time) {
final List<String> parts = time.split(':');
return int.tryParse(parts.first) ?? 0;
}
/// Returns a human-readable period label for a slot.
String _slotPeriodLabel(TimeSlot slot) {
final int hour = _parseHour(slot.startTime);
if (hour < 12) return 'Morning';
if (hour < 17) return 'Afternoon';
return 'Evening';
}
// ── Info Card ─────────────────────────────────────────────────────────
Widget _buildInfoCard() {
final i18n = Translations.of(context).staff.availability;
final dynamic i18n = Translations.of(context).staff.availability;
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
@@ -577,20 +575,20 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space3,
children: [
children: <Widget>[
const Icon(UiIcons.clock, size: 20, color: UiColors.primary),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space1,
children: [
children: <Widget>[
Text(
i18n.auto_match_title,
i18n.auto_match_title as String,
style: UiTypography.body2m,
),
const SizedBox(height: UiConstants.space1),
Text(
i18n.auto_match_description,
i18n.auto_match_description as String,
style: UiTypography.body3r.textSecondary,
),
],

View File

@@ -1,31 +1,49 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_availability/src/data/repositories_impl/availability_repository_impl.dart';
import 'package:staff_availability/src/domain/repositories/availability_repository.dart';
import 'package:staff_availability/src/domain/usecases/apply_quick_set_usecase.dart';
import 'package:staff_availability/src/domain/usecases/get_weekly_availability_usecase.dart';
import 'package:staff_availability/src/domain/usecases/update_day_availability_usecase.dart';
import 'package:staff_availability/src/presentation/blocs/availability_bloc.dart';
import 'package:staff_availability/src/presentation/pages/availability_page.dart';
import 'data/repositories_impl/availability_repository_impl.dart';
import 'domain/repositories/availability_repository.dart';
import 'domain/usecases/apply_quick_set_usecase.dart';
import 'domain/usecases/get_weekly_availability_usecase.dart';
import 'domain/usecases/update_day_availability_usecase.dart';
import 'presentation/blocs/availability_bloc.dart';
/// Module for the staff availability feature.
///
/// Uses the V2 REST API via [BaseApiService] for all backend access.
class StaffAvailabilityModule extends Module {
@override
List<Module> get imports => [DataConnectModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repository
i.addLazySingleton<AvailabilityRepository>(AvailabilityRepositoryImpl.new);
// Repository — V2 API
i.addLazySingleton<AvailabilityRepository>(
() => AvailabilityRepositoryImpl(
apiService: i.get<BaseApiService>(),
),
);
// UseCases
i.addLazySingleton(GetWeeklyAvailabilityUseCase.new);
i.addLazySingleton(UpdateDayAvailabilityUseCase.new);
i.addLazySingleton(ApplyQuickSetUseCase.new);
// Use cases
i.addLazySingleton<GetWeeklyAvailabilityUseCase>(
() => GetWeeklyAvailabilityUseCase(i.get<AvailabilityRepository>()),
);
i.addLazySingleton<UpdateDayAvailabilityUseCase>(
() => UpdateDayAvailabilityUseCase(i.get<AvailabilityRepository>()),
);
i.addLazySingleton<ApplyQuickSetUseCase>(
() => ApplyQuickSetUseCase(i.get<AvailabilityRepository>()),
);
// BLoC
i.add(AvailabilityBloc.new);
i.add<AvailabilityBloc>(
() => AvailabilityBloc(
getWeeklyAvailability: i.get<GetWeeklyAvailabilityUseCase>(),
updateDayAvailability: i.get<UpdateDayAvailabilityUseCase>(),
applyQuickSet: i.get<ApplyQuickSetUseCase>(),
),
);
}
@override

View File

@@ -19,8 +19,6 @@ dependencies:
path: ../../../design_system
krow_domain:
path: ../../../domain
krow_data_connect:
path: ../../../data_connect
krow_core:
path: ../../../core
@@ -28,8 +26,6 @@ dependencies:
equatable: ^2.0.5
intl: ^0.20.0
flutter_modular: ^6.3.2
firebase_data_connect: ^0.2.2+2
firebase_auth: ^6.1.4
dev_dependencies:
flutter_test: