diff --git a/lib/pages/access_management/bloc/access_bloc.dart b/lib/pages/access_management/bloc/access_bloc.dart index dd82d739..11c42f3b 100644 --- a/lib/pages/access_management/bloc/access_bloc.dart +++ b/lib/pages/access_management/bloc/access_bloc.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncrow_web/pages/access_management/bloc/access_event.dart'; import 'package:syncrow_web/pages/access_management/bloc/access_state.dart'; -import 'package:syncrow_web/pages/access_management/model/password_model.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/model/password_model.dart'; import 'package:syncrow_web/pages/common/bloc/project_manager.dart'; import 'package:syncrow_web/pages/common/hour_picker_dialog.dart'; import 'package:syncrow_web/services/access_mang_api.dart'; diff --git a/lib/pages/access_management/bloc/access_state.dart b/lib/pages/access_management/bloc/access_state.dart index 0790a735..cdaf9aad 100644 --- a/lib/pages/access_management/bloc/access_state.dart +++ b/lib/pages/access_management/bloc/access_state.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import 'package:syncrow_web/pages/access_management/model/password_model.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/model/password_model.dart'; abstract class AccessState extends Equatable { const AccessState(); diff --git a/lib/pages/access_management/booking_system/data/services/remote_bookable_spaces_service.dart b/lib/pages/access_management/booking_system/data/services/remote_bookable_spaces_service.dart new file mode 100644 index 00000000..3c2610db --- /dev/null +++ b/lib/pages/access_management/booking_system/data/services/remote_bookable_spaces_service.dart @@ -0,0 +1,52 @@ +import 'package:dio/dio.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/domain/load_bookable_spaces_param.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/domain/models/paginated_bookable_spaces.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/domain/services/bookable_system_service.dart'; +import 'package:syncrow_web/services/api/api_exception.dart'; +import 'package:syncrow_web/services/api/http_service.dart'; +import 'package:syncrow_web/utils/constants/api_const.dart'; + +class RemoteBookableSpacesService implements BookableSystemService { + const RemoteBookableSpacesService(this._httpService); + + final HTTPService _httpService; + static const _defaultErrorMessage = 'Failed to load bookable spaces'; + + @override + Future getBookableSpaces({ + required LoadBookableSpacesParam param, + }) async { + try { + final response = await _httpService.get( + path: ApiEndpoints.getBookableSpaces, + queryParameters: { + 'page': param.page, + 'size': param.size, + 'active': true, + 'configured': true, + if (param.search != null && + param.search.isNotEmpty && + param.search != 'null') + 'search': param.search, + }, + expectedResponseModel: (json) { + return PaginatedBookableSpaces.fromJson( + json as Map, + ); + }, + ); + return response; + } on DioException catch (e) { + final responseData = e.response?.data; + if (responseData is Map) { + final errorMessage = responseData['error']?['message'] as String? ?? + responseData['message'] as String? ?? + _defaultErrorMessage; + throw APIException(errorMessage); + } + throw APIException(_defaultErrorMessage); + } catch (e) { + throw APIException('$_defaultErrorMessage: ${e.toString()}'); + } + } +} diff --git a/lib/pages/access_management/booking_system/domain/load_bookable_spaces_param.dart b/lib/pages/access_management/booking_system/domain/load_bookable_spaces_param.dart new file mode 100644 index 00000000..f2b2e5fe --- /dev/null +++ b/lib/pages/access_management/booking_system/domain/load_bookable_spaces_param.dart @@ -0,0 +1,36 @@ +import 'package:equatable/equatable.dart'; + +class LoadBookableSpacesParam extends Equatable { + const LoadBookableSpacesParam({ + this.page = 1, + this.size = 25, + this.search = '', + this.active = true, + this.configured = true, + }); + + final int page; + final int size; + final String search; + final bool active; + final bool configured; + + LoadBookableSpacesParam copyWith({ + int? page, + int? size, + String? search, + bool? active, + bool? configured, + }) { + return LoadBookableSpacesParam( + page: page ?? this.page, + size: size ?? this.size, + search: search ?? this.search, + active: active ?? this.active, + configured: configured ?? this.configured, + ); + } + + @override + List get props => [page, size, search, active, configured]; +} diff --git a/lib/pages/access_management/booking_system/domain/models/bookable_room.dart b/lib/pages/access_management/booking_system/domain/models/bookable_room.dart new file mode 100644 index 00000000..c7dbd0aa --- /dev/null +++ b/lib/pages/access_management/booking_system/domain/models/bookable_room.dart @@ -0,0 +1,52 @@ +class BookableSpaceModel { + final String uuid; + final String spaceName; + final String virtualLocation; + final BookableConfig bookableConfig; + + BookableSpaceModel({ + required this.uuid, + required this.spaceName, + required this.virtualLocation, + required this.bookableConfig, + }); + + factory BookableSpaceModel.fromJson(Map json) { + return BookableSpaceModel( + uuid: json['uuid'] as String, + spaceName: json['spaceName'] as String, + virtualLocation: json['virtualLocation'] as String, + bookableConfig: BookableConfig.fromJson( + json['bookableConfig'] as Map), + ); + } +} + +class BookableConfig { + final String uuid; + final List daysAvailable; + final String startTime; + final String endTime; + final bool active; + final int points; + + BookableConfig({ + required this.uuid, + required this.daysAvailable, + required this.startTime, + required this.endTime, + required this.active, + required this.points, + }); + + factory BookableConfig.fromJson(Map json) { + return BookableConfig( + uuid: json['uuid'] as String, + daysAvailable: (json['daysAvailable'] as List).cast(), + startTime: json['startTime'] as String, + endTime: json['endTime'] as String, + active: json['active'] as bool, + points: json['points'] as int, + ); + } +} \ No newline at end of file diff --git a/lib/pages/access_management/booking_system/domain/models/paginated_bookable_spaces.dart b/lib/pages/access_management/booking_system/domain/models/paginated_bookable_spaces.dart new file mode 100644 index 00000000..b4b79bc2 --- /dev/null +++ b/lib/pages/access_management/booking_system/domain/models/paginated_bookable_spaces.dart @@ -0,0 +1,40 @@ + + +import 'package:syncrow_web/pages/access_management/booking_system/domain/models/bookable_room.dart'; + +class PaginatedBookableSpaces { + final List data; + final String message; + final int page; + final int size; + final int totalItem; + final int totalPage; + final bool hasNext; + final bool hasPrevious; + + PaginatedBookableSpaces({ + required this.data, + required this.message, + required this.page, + required this.size, + required this.totalItem, + required this.totalPage, + required this.hasNext, + required this.hasPrevious, + }); + + factory PaginatedBookableSpaces.fromJson(Map json) { + return PaginatedBookableSpaces( + data: (json['data'] as List) + .map((item) => BookableSpaceModel.fromJson(item)) + .toList(), + message: json['message'] as String, + page: json['page'] as int, + size: json['size'] as int, + totalItem: json['totalItem'] as int, + totalPage: json['totalPage'] as int, + hasNext: json['hasNext'] as bool, + hasPrevious: json['hasPrevious'] as bool, + ); + } +} \ No newline at end of file diff --git a/lib/pages/access_management/booking_system/domain/services/bookable_system_service.dart b/lib/pages/access_management/booking_system/domain/services/bookable_system_service.dart new file mode 100644 index 00000000..c3b0bfb7 --- /dev/null +++ b/lib/pages/access_management/booking_system/domain/services/bookable_system_service.dart @@ -0,0 +1,8 @@ +import 'package:syncrow_web/pages/access_management/booking_system/domain/load_bookable_spaces_param.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/domain/models/paginated_bookable_spaces.dart'; + +abstract class BookableSystemService { + Future getBookableSpaces({ + required LoadBookableSpacesParam param, + }); +} diff --git a/lib/pages/access_management/booking_system/domain/services/debounced_bookable_spaces_service.dart b/lib/pages/access_management/booking_system/domain/services/debounced_bookable_spaces_service.dart new file mode 100644 index 00000000..bea3c103 --- /dev/null +++ b/lib/pages/access_management/booking_system/domain/services/debounced_bookable_spaces_service.dart @@ -0,0 +1,45 @@ +import 'dart:async'; +import 'package:syncrow_web/pages/access_management/booking_system/domain/load_bookable_spaces_param.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/domain/models/paginated_bookable_spaces.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/domain/services/bookable_system_service.dart'; + +class DebouncedBookableSpacesService implements BookableSystemService { + final BookableSystemService _inner; + final Duration debounceDuration; + + Timer? _debounceTimer; + Completer? _lastCompleter; + + DebouncedBookableSpacesService( + this._inner, { + this.debounceDuration = const Duration(milliseconds: 500), + }); + + @override + Future getBookableSpaces({ + required LoadBookableSpacesParam param, + }) { + _debounceTimer?.cancel(); + if (_lastCompleter != null && !_lastCompleter!.isCompleted) { + _lastCompleter!.completeError(StateError("Cancelled by new search")); + } + + final completer = Completer(); + _lastCompleter = completer; + + _debounceTimer = Timer(debounceDuration, () async { + try { + final result = await _inner.getBookableSpaces(param: param); + if (!completer.isCompleted) { + completer.complete(result); + } + } catch (e, st) { + if (!completer.isCompleted) { + completer.completeError(e, st); + } + } + }); + + return completer.future; + } +} diff --git a/lib/pages/access_management/booking_system/presentation/bloc/calendar/events_bloc.dart b/lib/pages/access_management/booking_system/presentation/bloc/calendar/events_bloc.dart new file mode 100644 index 00000000..431720af --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/bloc/calendar/events_bloc.dart @@ -0,0 +1,143 @@ +import 'dart:async'; +import 'package:bloc/bloc.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:flutter/material.dart'; +part 'events_event.dart'; +part 'events_state.dart'; + +class CalendarEventsBloc extends Bloc { + final EventController eventController = EventController(); + + CalendarEventsBloc() : super(EventsInitial()) { + on(_onLoadEvents); + on(_onAddEvent); + on(_onStartTimer); + on(_onDisposeResources); + on(_onGoToWeek); + } + + Future _onLoadEvents( + LoadEvents event, + Emitter emit, + ) async { + emit(EventsLoading()); + try { + final events = _generateDummyEventsForWeek(event.weekStart); + eventController.addAll(events); + emit(EventsLoaded( + events: events, + initialDate: event.weekStart, + weekDays: _getWeekDays(event.weekStart), + )); + } catch (e) { + emit(EventsError('Failed to load events')); + } + } + + List _generateDummyEventsForWeek(DateTime weekStart) { + final events = []; + + for (int i = 0; i < 7; i++) { + final date = weekStart.add(Duration(days: i)); + + events.add(CalendarEventData( + date: date, + startTime: date.copyWith(hour: 9, minute: 0), + endTime: date.copyWith(hour: 10, minute: 30), + title: 'Team Meeting', + description: 'Daily standup', + color: Colors.blue, + )); + events.add(CalendarEventData( + date: date, + startTime: date.copyWith(hour: 14, minute: 0), + endTime: date.copyWith(hour: 15, minute: 0), + title: 'Client Call', + description: 'Project discussion', + color: Colors.green, + )); + } + + return events; + } + + void _onAddEvent(AddEvent event, Emitter emit) { + eventController.add(event.event); + if (state is EventsLoaded) { + final loaded = state as EventsLoaded; + emit(EventsLoaded( + events: [...eventController.events], + initialDate: loaded.initialDate, + weekDays: loaded.weekDays, + )); + } + } + + void _onStartTimer(StartTimer event, Emitter emit) {} + + void _onDisposeResources( + DisposeResources event, Emitter emit) { + eventController.dispose(); + } + + void _onGoToWeek(GoToWeek event, Emitter emit) { + if (state is EventsLoaded) { + final loaded = state as EventsLoaded; + final newWeekDays = _getWeekDays(event.weekDate); + emit(EventsLoaded( + events: loaded.events, + initialDate: event.weekDate, + weekDays: newWeekDays, + )); + } + } + + List _generateDummyEvents() { + final now = DateTime.now(); + return [ + CalendarEventData( + date: now, + startTime: now.copyWith(hour: 8, minute: 00, second: 0), + endTime: now.copyWith(hour: 9, minute: 00, second: 0), + title: 'Team Meeting', + description: 'Weekly team sync', + color: Colors.blue, + ), + CalendarEventData( + date: now, + startTime: now.copyWith(hour: 9, minute: 00, second: 0), + endTime: now.copyWith(hour: 10, minute: 30, second: 0), + title: 'Team Meeting', + description: 'Weekly team sync', + color: Colors.blue, + ), + CalendarEventData( + date: now.add(const Duration(days: 1)), + startTime: now.copyWith(hour: 14, day: now.day + 1), + endTime: now.copyWith(hour: 15, day: now.day + 1), + title: 'Client Call', + description: 'Project discussion', + color: Colors.green, + ), + CalendarEventData( + date: now.add(const Duration(days: 2)), + startTime: now.copyWith(hour: 11, day: now.day + 2), + endTime: now.copyWith(hour: 12, day: now.day + 2), + title: 'Lunch with Team', + color: Colors.orange, + ), + ]; + } + + List _getWeekDays(DateTime date) { + final int weekday = date.weekday; + final DateTime monday = date.subtract(Duration(days: weekday - 1)); + return List.generate(7, (i) => monday.add(Duration(days: i))); + } + + @override + Future close() { + eventController.dispose(); + return super.close(); + } +} diff --git a/lib/pages/access_management/booking_system/presentation/bloc/calendar/events_event.dart b/lib/pages/access_management/booking_system/presentation/bloc/calendar/events_event.dart new file mode 100644 index 00000000..e23e65de --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/bloc/calendar/events_event.dart @@ -0,0 +1,25 @@ +part of 'events_bloc.dart'; + +@immutable +abstract class CalendarEventsEvent { + const CalendarEventsEvent(); +} + +class LoadEvents extends CalendarEventsEvent { + final DateTime weekStart; + const LoadEvents({required this.weekStart}); +} + +class AddEvent extends CalendarEventsEvent { + final CalendarEventData event; + AddEvent(this.event); +} + +class StartTimer extends CalendarEventsEvent {} + +class DisposeResources extends CalendarEventsEvent {} + +class GoToWeek extends CalendarEventsEvent { + final DateTime weekDate; + GoToWeek(this.weekDate); +} diff --git a/lib/pages/access_management/booking_system/presentation/bloc/calendar/events_state.dart b/lib/pages/access_management/booking_system/presentation/bloc/calendar/events_state.dart new file mode 100644 index 00000000..b7263ec8 --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/bloc/calendar/events_state.dart @@ -0,0 +1,25 @@ +part of 'events_bloc.dart'; + +@immutable +abstract class CalendarEventState {} + +class EventsInitial extends CalendarEventState {} + +class EventsLoading extends CalendarEventState {} + +class EventsLoaded extends CalendarEventState { + final List events; + final DateTime initialDate; + final List weekDays; + + EventsLoaded({ + required this.events, + required this.initialDate, + required this.weekDays, + }); +} + +class EventsError extends CalendarEventState { + final String message; + EventsError(this.message); +} diff --git a/lib/pages/access_management/booking_system/presentation/bloc/date_selection/date_selection_bloc.dart b/lib/pages/access_management/booking_system/presentation/bloc/date_selection/date_selection_bloc.dart new file mode 100644 index 00000000..d6687087 --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/bloc/date_selection/date_selection_bloc.dart @@ -0,0 +1,37 @@ +import 'package:bloc/bloc.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/date_selection/date_selection_event.dart'; +import 'date_selection_state.dart'; + +class DateSelectionBloc extends Bloc { + DateSelectionBloc() : super(DateSelectionState.initial()) { + on((event, emit) { + final newWeekStart = _getStartOfWeek(event.selectedDate); + emit(DateSelectionState( + selectedDate: event.selectedDate, + weekStart: newWeekStart, + )); + }); + + on((event, emit) { + final newWeekStart = state.weekStart.add(const Duration(days: 7)); + final inNewWeek = state.selectedDate + .isAfter(newWeekStart.subtract(const Duration(days: 1))) && + state.selectedDate + .isBefore(newWeekStart.add(const Duration(days: 7))); + emit(DateSelectionState( + selectedDate: state.selectedDate, + weekStart: newWeekStart, + )); + }); + on((event, emit) { + emit(DateSelectionState( + selectedDate: state.selectedDate!.subtract(const Duration(days: 7)), + weekStart: state.weekStart.subtract(const Duration(days: 7)), + )); + }); + } + + static DateTime _getStartOfWeek(DateTime date) { + return date.subtract(Duration(days: date.weekday - 1)); + } +} diff --git a/lib/pages/access_management/booking_system/presentation/bloc/date_selection/date_selection_event.dart b/lib/pages/access_management/booking_system/presentation/bloc/date_selection/date_selection_event.dart new file mode 100644 index 00000000..8ed0a8a0 --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/bloc/date_selection/date_selection_event.dart @@ -0,0 +1,13 @@ + +abstract class DateSelectionEvent { + const DateSelectionEvent(); +} + +class SelectDate extends DateSelectionEvent { + final DateTime selectedDate; + const SelectDate(this.selectedDate); +} + +class NextWeek extends DateSelectionEvent {} + +class PreviousWeek extends DateSelectionEvent {} \ No newline at end of file diff --git a/lib/pages/access_management/booking_system/presentation/bloc/date_selection/date_selection_state.dart b/lib/pages/access_management/booking_system/presentation/bloc/date_selection/date_selection_state.dart new file mode 100644 index 00000000..3b35ce25 --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/bloc/date_selection/date_selection_state.dart @@ -0,0 +1,21 @@ +class DateSelectionState { + final DateTime selectedDate; + final DateTime weekStart; + + const DateSelectionState({ + required this.selectedDate, + required this.weekStart, + }); + + factory DateSelectionState.initial() { + final now = DateTime.now(); + return DateSelectionState( + selectedDate: now, + weekStart: _getStartOfWeek(now), + ); + } + + static DateTime _getStartOfWeek(DateTime date) { + return date.subtract(Duration(days: date.weekday - 1)); + } +} diff --git a/lib/pages/access_management/booking_system/presentation/bloc/selected_bookable_space_bloc/selected_bookable_space_bloc.dart b/lib/pages/access_management/booking_system/presentation/bloc/selected_bookable_space_bloc/selected_bookable_space_bloc.dart new file mode 100644 index 00000000..70b46c1a --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/bloc/selected_bookable_space_bloc/selected_bookable_space_bloc.dart @@ -0,0 +1,14 @@ +import 'package:bloc/bloc.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/domain/models/bookable_room.dart'; +part 'selected_bookable_space_event.dart'; +part 'selected_bookable_space_state.dart'; + +class SelectedBookableSpaceBloc + extends Bloc { + SelectedBookableSpaceBloc() : super(const SelectedBookableSpaceState()) { + on((event, emit) { + emit(SelectedBookableSpaceState( + selectedBookableSpace: event.bookableSpace)); + }); + } +} diff --git a/lib/pages/access_management/booking_system/presentation/bloc/selected_bookable_space_bloc/selected_bookable_space_event.dart b/lib/pages/access_management/booking_system/presentation/bloc/selected_bookable_space_bloc/selected_bookable_space_event.dart new file mode 100644 index 00000000..c74c13df --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/bloc/selected_bookable_space_bloc/selected_bookable_space_event.dart @@ -0,0 +1,11 @@ +part of 'selected_bookable_space_bloc.dart'; + +abstract class SelectedBookableSpaceEvent { + const SelectedBookableSpaceEvent(); +} + +class SelectBookableSpace extends SelectedBookableSpaceEvent { + final BookableSpaceModel bookableSpace; + + const SelectBookableSpace(this.bookableSpace); +} diff --git a/lib/pages/access_management/booking_system/presentation/bloc/selected_bookable_space_bloc/selected_bookable_space_state.dart b/lib/pages/access_management/booking_system/presentation/bloc/selected_bookable_space_bloc/selected_bookable_space_state.dart new file mode 100644 index 00000000..8509d5c3 --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/bloc/selected_bookable_space_bloc/selected_bookable_space_state.dart @@ -0,0 +1,9 @@ +part of 'selected_bookable_space_bloc.dart'; + +class SelectedBookableSpaceState { + final BookableSpaceModel? selectedBookableSpace; + + const SelectedBookableSpaceState( + { this.selectedBookableSpace,} + ); +} diff --git a/lib/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_bloc.dart b/lib/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_bloc.dart new file mode 100644 index 00000000..1b6f41fa --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_bloc.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/domain/load_bookable_spaces_param.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/domain/services/bookable_system_service.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_event.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_state.dart'; + +class SidebarBloc extends Bloc { + final BookableSystemService _bookingService; + int _currentPage = 1; + final int _pageSize = 20; + String _currentSearch = ''; + + SidebarBloc(this._bookingService) + : super(SidebarState( + allRooms: [], + displayedRooms: [], + isLoading: true, + hasMore: true, + )) { + on(_onLoadBookableSpaces); + on(_onLoadMoreSpaces); + on(_onSelectRoom); + on(_onSearchRooms); + on(_onResetSearch); + } + + Future _onLoadBookableSpaces( + LoadBookableSpaces event, + Emitter emit, + ) async { + try { + emit(state.copyWith(isLoading: true, errorMessage: null)); + _currentPage = 1; + _currentSearch = ''; + + final paginatedSpaces = await _bookingService.getBookableSpaces( + param: LoadBookableSpacesParam( + page: _currentPage, + size: _pageSize, + search: _currentSearch, + ), + ); + + emit(state.copyWith( + allRooms: paginatedSpaces.data, + displayedRooms: paginatedSpaces.data, + isLoading: false, + hasMore: paginatedSpaces.hasNext, + totalPages: paginatedSpaces.totalPage, + currentPage: _currentPage, + )); + } catch (e) { + emit(state.copyWith( + isLoading: false, + errorMessage: 'Failed to load rooms: ${e.toString()}', + )); + } + } + + Future _onLoadMoreSpaces( + LoadMoreSpaces event, + Emitter emit, + ) async { + if (!state.hasMore || state.isLoadingMore) return; + + try { + emit(state.copyWith(isLoadingMore: true)); + _currentPage++; + + final paginatedSpaces = await _bookingService.getBookableSpaces( + param: LoadBookableSpacesParam( + page: _currentPage, + size: _pageSize, + search: _currentSearch, + ), + ); + + final updatedRooms = [...state.allRooms, ...paginatedSpaces.data]; + + emit(state.copyWith( + allRooms: updatedRooms, + displayedRooms: updatedRooms, + isLoadingMore: false, + hasMore: paginatedSpaces.hasNext, + totalPages: paginatedSpaces.totalPage, + currentPage: _currentPage, + )); + } catch (e) { + _currentPage--; + emit(state.copyWith( + isLoadingMore: false, + errorMessage: 'Failed to load more rooms: ${e.toString()}', + )); + } + } + + Future _onSearchRooms( + SearchRoomsEvent event, + Emitter emit, + ) async { + try { + _currentSearch = event.query; + _currentPage = 1; + emit(state.copyWith(isLoading: true, errorMessage: null)); + final paginatedSpaces = await _bookingService.getBookableSpaces( + param: LoadBookableSpacesParam( + page: _currentPage, + size: _pageSize, + search: _currentSearch, + ), + ); + emit(state.copyWith( + allRooms: paginatedSpaces.data, + displayedRooms: paginatedSpaces.data, + isLoading: false, + hasMore: paginatedSpaces.hasNext, + totalPages: paginatedSpaces.totalPage, + currentPage: _currentPage, + )); + } catch (e) { + emit(state.copyWith( + isLoading: false, + errorMessage: 'Search failed: ${e.toString()}', + )); + } + } + + void _onResetSearch( + ResetSearch event, + Emitter emit, + ) { + _currentSearch = ''; + add(LoadBookableSpaces()); + } + + void _onSelectRoom( + SelectRoomEvent event, + Emitter emit, + ) { + emit(state.copyWith(selectedRoomId: event.roomId)); + } + + @override + Future close() { + return super.close(); + } +} diff --git a/lib/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_event.dart b/lib/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_event.dart new file mode 100644 index 00000000..770e7b7e --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_event.dart @@ -0,0 +1,25 @@ +abstract class SidebarEvent {} + +class LoadBookableSpaces extends SidebarEvent {} + +class SelectRoomEvent extends SidebarEvent { + final String roomId; + + SelectRoomEvent(this.roomId); +} + +class SearchRoomsEvent extends SidebarEvent { + final String query; + + SearchRoomsEvent(this.query); +} + +class LoadMoreSpaces extends SidebarEvent {} + +class ResetSearch extends SidebarEvent {} + +class ExecuteSearch extends SidebarEvent { + final String query; + + ExecuteSearch(this.query); +} diff --git a/lib/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_state.dart b/lib/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_state.dart new file mode 100644 index 00000000..7b35474e --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_state.dart @@ -0,0 +1,49 @@ +import 'package:syncrow_web/pages/access_management/booking_system/domain/models/bookable_room.dart'; + +class SidebarState { + final List allRooms; + final List displayedRooms; + final bool isLoading; + final bool isLoadingMore; + final String? errorMessage; + final String? selectedRoomId; + final bool hasMore; + final int totalPages; + final int currentPage; + + SidebarState({ + required this.allRooms, + required this.displayedRooms, + required this.isLoading, + this.isLoadingMore = false, + this.errorMessage, + this.selectedRoomId, + this.hasMore = true, + this.totalPages = 0, + this.currentPage = 1, + }); + + SidebarState copyWith({ + List? allRooms, + List? displayedRooms, + bool? isLoading, + bool? isLoadingMore, + String? errorMessage, + String? selectedRoomId, + bool? hasMore, + int? totalPages, + int? currentPage, + }) { + return SidebarState( + allRooms: allRooms ?? this.allRooms, + displayedRooms: displayedRooms ?? this.displayedRooms, + isLoading: isLoading ?? this.isLoading, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + errorMessage: errorMessage ?? this.errorMessage, + selectedRoomId: selectedRoomId ?? this.selectedRoomId, + hasMore: hasMore ?? this.hasMore, + totalPages: totalPages ?? this.totalPages, + currentPage: currentPage ?? this.currentPage, + ); + } +} diff --git a/lib/pages/access_management/model/password_model.dart b/lib/pages/access_management/booking_system/presentation/model/password_model.dart similarity index 100% rename from lib/pages/access_management/model/password_model.dart rename to lib/pages/access_management/booking_system/presentation/model/password_model.dart diff --git a/lib/pages/access_management/booking_system/presentation/view/booking_page.dart b/lib/pages/access_management/booking_system/presentation/view/booking_page.dart new file mode 100644 index 00000000..c1c81ef4 --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/view/booking_page.dart @@ -0,0 +1,259 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/date_selection/date_selection_bloc.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/date_selection/date_selection_event.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/date_selection/date_selection_state.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/selected_bookable_space_bloc/selected_bookable_space_bloc.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/view/widgets/booking_sidebar.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/view/widgets/custom_calendar_page.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/view/widgets/icon_text_button.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/view/widgets/weekly_calendar_page.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/constants/assets.dart'; + +class BookingPage extends StatefulWidget { + const BookingPage({super.key}); + + @override + State createState() => _BookingPageState(); +} + +class _BookingPageState extends State { + late final EventController _eventController; + + @override + void initState() { + super.initState(); + _eventController = EventController(); + } + + @override + void dispose() { + _eventController.dispose(); + super.dispose(); + } + + List _generateDummyEventsForWeek(DateTime weekStart) { + final List events = []; + for (int i = 0; i < 7; i++) { + final date = weekStart.add(Duration(days: i)); + events.add(CalendarEventData( + date: date, + startTime: date.copyWith(hour: 9, minute: 0), + endTime: date.copyWith(hour: 10, minute: 30), + title: 'Team Meeting', + description: 'Daily standup', + color: Colors.blue, + )); + events.add(CalendarEventData( + date: date, + startTime: date.copyWith(hour: 14, minute: 0), + endTime: date.copyWith(hour: 15, minute: 0), + title: 'Client Call', + description: 'Project discussion', + color: Colors.green, + )); + } + return events; + } + + void _loadEventsForWeek(DateTime weekStart) { + _eventController.removeWhere((_) => true); + _eventController.addAll(_generateDummyEventsForWeek(weekStart)); + } + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => SelectedBookableSpaceBloc()), + BlocProvider(create: (_) => DateSelectionBloc()), + ], + child: BlocListener( + listenWhen: (previous, current) => + previous.weekStart != current.weekStart, + listener: (context, state) { + _loadEventsForWeek(state.weekStart); + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: ColorsManager.whiteColors, + boxShadow: [ + BoxShadow( + color: ColorsManager.blackColor.withOpacity(0.1), + offset: const Offset(3, 0), + blurRadius: 6, + spreadRadius: 0, + ), + ], + ), + child: Column( + children: [ + Expanded( + flex: 2, + child: BlocBuilder( + builder: (context, state) { + return BookingSidebar( + onRoomSelected: (selectedRoom) { + context + .read() + .add(SelectBookableSpace(selectedRoom)); + }, + ); + }, + ), + ), + Expanded( + child: BlocBuilder( + builder: (context, dateState) { + return CustomCalendarPage( + selectedDate: dateState.selectedDate, + onDateChanged: (day, month, year) { + final newDate = DateTime(year, month, day); + context + .read() + .add(SelectDate(newDate)); + }, + ); + }, + ), + ), + ], + ), + ), + ), + Expanded( + flex: 4, + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + SvgTextButton( + svgAsset: Assets.homeIcon, + label: 'Manage Bookable Spaces', + onPressed: () {}, + ), + const SizedBox(width: 20), + SvgTextButton( + svgAsset: Assets.groupIcon, + label: 'Manage Users', + onPressed: () {}, + ), + ], + ), + BlocBuilder( + builder: (context, state) { + final weekStart = state.weekStart; + final weekEnd = + weekStart.add(const Duration(days: 6)); + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: ColorsManager.circleRolesBackground, + borderRadius: BorderRadius.circular(10), + boxShadow: const [ + BoxShadow( + color: ColorsManager.lightGrayColor, + blurRadius: 4, + offset: Offset(0, 1), + ), + ], + ), + child: Row( + children: [ + IconButton( + iconSize: 15, + icon: const Icon(Icons.arrow_back_ios, + color: ColorsManager.lightGrayColor), + onPressed: () { + context + .read() + .add(PreviousWeek()); + }, + ), + const SizedBox(width: 10), + Text( + _getMonthYearText(weekStart, weekEnd), + style: const TextStyle( + color: ColorsManager.lightGrayColor, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(width: 10), + IconButton( + iconSize: 15, + icon: const Icon(Icons.arrow_forward_ios, + color: ColorsManager.lightGrayColor), + onPressed: () { + context + .read() + .add(NextWeek()); + }, + ), + ], + ), + ); + }, + ), + ], + ), + Expanded( + child: BlocBuilder( + builder: (context, roomState) { + final selectedRoom = roomState.selectedBookableSpace; + return BlocBuilder( + builder: (context, dateState) { + return WeeklyCalendarPage( + startTime: + selectedRoom?.bookableConfig.startTime, + endTime: selectedRoom?.bookableConfig.endTime, + weekStart: dateState.weekStart, + selectedDate: dateState.selectedDate, + eventController: _eventController, + ); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + String _getMonthYearText(DateTime start, DateTime end) { + final startMonth = DateFormat('MMM').format(start); + final endMonth = DateFormat('MMM').format(end); + final year = start.year == end.year + ? start.year.toString() + : '${start.year}-${end.year}'; + + if (start.month == end.month) { + return '$startMonth $year'; + } else { + return '$startMonth - $endMonth $year'; + } + } +} diff --git a/lib/pages/access_management/booking_system/presentation/view/widgets/booking_sidebar.dart b/lib/pages/access_management/booking_system/presentation/view/widgets/booking_sidebar.dart new file mode 100644 index 00000000..e3d84924 --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/view/widgets/booking_sidebar.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/data/services/remote_bookable_spaces_service.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/domain/models/bookable_room.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_bloc.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_event.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_state.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/view/widgets/room_list_item.dart'; +import 'package:syncrow_web/services/api/http_service.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/constants/assets.dart'; + +class BookingSidebar extends StatelessWidget { + final void Function(BookableSpaceModel) onRoomSelected; + + const BookingSidebar({ + super.key, + required this.onRoomSelected, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SidebarBloc(RemoteBookableSpacesService( + HTTPService(), + )) + ..add(LoadBookableSpaces()), + child: _SidebarContent(onRoomSelected: onRoomSelected), + ); + } +} + +class _SidebarContent extends StatefulWidget { + final void Function(BookableSpaceModel) onRoomSelected; + + const _SidebarContent({ + required this.onRoomSelected, + }); + + @override + State<_SidebarContent> createState() => __SidebarContentState(); +} + +class __SidebarContentState extends State<_SidebarContent> { + final TextEditingController searchController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_scrollListener); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _scrollListener() { + if (_scrollController.position.pixels == + _scrollController.position.maxScrollExtent) { + context.read().add(LoadMoreSpaces()); + } + } + + void _handleSearch(String value) { + context.read().add(SearchRoomsEvent(value)); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + if (state.currentPage == 1 && searchController.text.isNotEmpty) { + searchController.clear(); + } + }, + builder: (context, state) { + return Column( + children: [ + const _SidebarHeader(title: 'Spaces'), + Container( + decoration: BoxDecoration( + color: ColorsManager.whiteColors, + borderRadius: BorderRadius.circular(8.0), + boxShadow: [ + BoxShadow( + color: ColorsManager.blackColor.withOpacity(0.1), + offset: const Offset(0, -2), + blurRadius: 4, + spreadRadius: 0, + ), + BoxShadow( + color: ColorsManager.blackColor.withOpacity(0.1), + offset: const Offset(0, 2), + blurRadius: 4, + spreadRadius: 0, + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 8.0), + child: Container( + decoration: BoxDecoration( + color: ColorsManager.counterBackgroundColor, + borderRadius: BorderRadius.circular(8.0), + ), + child: Row( + children: [ + Expanded( + child: TextField( + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: ColorsManager.blackColor, + ), + controller: searchController, + onChanged: _handleSearch, + decoration: InputDecoration( + hintText: 'Search', + suffixIcon: Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + width: 20, + height: 20, + child: SvgPicture.asset( + Assets.searchIconUser, + color: ColorsManager.primaryTextColor, + ), + ), + ), + contentPadding: const EdgeInsets.symmetric( + vertical: 8, horizontal: 12), + border: const OutlineInputBorder( + borderSide: BorderSide.none), + ), + ), + ), + if (searchController.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + context.read().add(ResetSearch()); + }, + ), + ], + ), + ), + ), + ), + ), + ), + if (state.isLoading) + const Expanded( + child: Center(child: CircularProgressIndicator()), + ) + else if (state.errorMessage != null) + Expanded( + child: Center(child: Text(state.errorMessage!)), + ) + else + Expanded( + child: ListView.builder( + controller: _scrollController, + itemCount: + state.displayedRooms.length + (state.hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == state.displayedRooms.length) { + return _buildLoadMoreIndicator(state); + } + + final room = state.displayedRooms[index]; + return RoomListItem( + room: room, + isSelected: state.selectedRoomId == room.uuid, + onTap: () { + context + .read() + .add(SelectRoomEvent(room.uuid)); + widget.onRoomSelected(room); + }, + ); + }, + ), + ), + ], + ); + }, + ); + } + + Widget _buildLoadMoreIndicator(SidebarState state) { + if (state.isLoadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Center(child: CircularProgressIndicator()), + ); + } else if (state.hasMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Center(child: Text('Scroll to load more')), + ); + } else { + return const SizedBox.shrink(); + } + } +} + +class _SidebarHeader extends StatelessWidget { + final String title; + + const _SidebarHeader({ + required this.title, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w400, + color: ColorsManager.primaryTextColor, + fontSize: 20, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/access_management/booking_system/presentation/view/widgets/custom_calendar_page.dart b/lib/pages/access_management/booking_system/presentation/view/widgets/custom_calendar_page.dart new file mode 100644 index 00000000..eb758311 --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/view/widgets/custom_calendar_page.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:calendar_date_picker2/calendar_date_picker2.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; + +class CustomCalendarPage extends StatefulWidget { + final DateTime selectedDate; + final Function(int day, int month, int year) onDateChanged; + + const CustomCalendarPage({ + super.key, + required this.selectedDate, + required this.onDateChanged, + }); + + @override + State createState() => _CustomCalendarPageState(); +} + +class _CustomCalendarPageState extends State { + late DateTime _selectedDate; + + @override + void initState() { + super.initState(); + _selectedDate = widget.selectedDate; + } + + @override + void didUpdateWidget(CustomCalendarPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.selectedDate != oldWidget.selectedDate) { + setState(() { + _selectedDate = widget.selectedDate; + }); + } + } + + @override + Widget build(BuildContext context) { + final config = CalendarDatePicker2Config( + calendarType: CalendarDatePicker2Type.single, + selectedDayHighlightColor: const Color(0xFF3B82F6), + selectedDayTextStyle: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w400, + fontSize: 14, + ), + dayTextStyle: const TextStyle( + color: ColorsManager.blackColor, + fontWeight: FontWeight.w400, + fontSize: 14, + ), + weekdayLabelTextStyle: const TextStyle( + color: ColorsManager.grey50, + fontWeight: FontWeight.w400, + fontSize: 14, + ), + controlsTextStyle: const TextStyle( + color: Color(0xFF232D3A), + fontWeight: FontWeight.w400, + fontSize: 18, + ), + centerAlignModePicker: false, + disableMonthPicker: true, + firstDayOfWeek: 1, + weekdayLabels: const ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'], + ); + + return CalendarDatePicker2( + config: config, + value: [_selectedDate], + onValueChanged: (dates) { + final picked = dates.first; + if (picked != null) { + setState(() { + _selectedDate = picked; + }); + widget.onDateChanged(picked.day, picked.month, picked.year); + } + }, + ); + } +} diff --git a/lib/pages/access_management/booking_system/view/widgets/icon_text_button.dart b/lib/pages/access_management/booking_system/presentation/view/widgets/icon_text_button.dart similarity index 87% rename from lib/pages/access_management/booking_system/view/widgets/icon_text_button.dart rename to lib/pages/access_management/booking_system/presentation/view/widgets/icon_text_button.dart index afccafdb..c7c660c1 100644 --- a/lib/pages/access_management/booking_system/view/widgets/icon_text_button.dart +++ b/lib/pages/access_management/booking_system/presentation/view/widgets/icon_text_button.dart @@ -20,13 +20,13 @@ class SvgTextButton extends StatelessWidget { required this.onPressed, this.backgroundColor = ColorsManager.circleRolesBackground, this.svgColor = const Color(0xFF496EFF), - this.labelColor = Colors.black87, + this.labelColor = Colors.black, this.borderRadius = 10.0, this.boxShadow = const [ BoxShadow( - color: ColorsManager.textGray, - blurRadius: 12, - offset: Offset(0, 4), + color: ColorsManager.lightGrayColor, + blurRadius: 4, + offset: Offset(0, 1), ), ], this.svgSize = 24.0, @@ -53,15 +53,14 @@ class SvgTextButton extends StatelessWidget { svgAsset, width: svgSize, height: svgSize, - color: svgColor, ), const SizedBox(width: 12), Text( label, style: TextStyle( color: labelColor, - fontSize: 16, - fontWeight: FontWeight.w500, + fontSize: 12, + fontWeight: FontWeight.w400, ), ), ], diff --git a/lib/pages/access_management/booking_system/presentation/view/widgets/room_list_item.dart b/lib/pages/access_management/booking_system/presentation/view/widgets/room_list_item.dart new file mode 100644 index 00000000..4a4b608d --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/view/widgets/room_list_item.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/domain/models/bookable_room.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; + +class RoomListItem extends StatelessWidget { + final BookableSpaceModel room; + final bool isSelected; + final VoidCallback onTap; + + const RoomListItem({ + required this.room, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return RadioListTile( + value: room.uuid, + contentPadding: const EdgeInsetsDirectional.symmetric(horizontal: 16), + groupValue: isSelected ? room.uuid : null, + visualDensity: const VisualDensity(vertical: -4), + onChanged: (value) => onTap(), + activeColor: ColorsManager.primaryColor, + title: Text( + room.spaceName, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: ColorsManager.lightGrayColor, + fontWeight: FontWeight.w700, + fontSize: 12), + ), + subtitle: Text( + room.virtualLocation, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 10, + fontWeight: FontWeight.w400, + color: ColorsManager.textGray, + ), + ), + ); + } +} diff --git a/lib/pages/access_management/booking_system/presentation/view/widgets/weekly_calendar_page.dart b/lib/pages/access_management/booking_system/presentation/view/widgets/weekly_calendar_page.dart new file mode 100644 index 00000000..8a6d898f --- /dev/null +++ b/lib/pages/access_management/booking_system/presentation/view/widgets/weekly_calendar_page.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:intl/intl.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; + +class WeeklyCalendarPage extends StatelessWidget { + final DateTime weekStart; + final DateTime selectedDate; + final EventController eventController; + final String? startTime; + final String? endTime; + + const WeeklyCalendarPage({ + super.key, + required this.weekStart, + required this.selectedDate, + required this.eventController, + this.startTime, + this.endTime, + }); + + @override + Widget build(BuildContext context) { + final startHour = _parseHour(startTime, defaultValue: 0); + final endHour = _parseHour(endTime, defaultValue: 24); + if (endTime == null || endTime!.isEmpty) { + return const Center( + child: Text( + 'Please select a bookable space to view the calendar.', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + ); + } + + final weekDays = _getWeekDays(weekStart); + + return LayoutBuilder( + builder: (context, constraints) { + final double calendarWidth = constraints.maxWidth; + const double timeLineWidth = 80; + const int totalDays = 7; + final double dayColumnWidth = + (calendarWidth - timeLineWidth) / totalDays - 0.1; + final selectedDayIndex = + weekDays.indexWhere((d) => isSameDay(d, selectedDate)); + return Padding( + padding: const EdgeInsets.only(left: 25.0, right: 25.0, top: 25), + child: Stack( + children: [ + WeekView( + pageViewPhysics: const NeverScrollableScrollPhysics(), + key: ValueKey(weekStart), + controller: eventController, + initialDay: weekStart, + startHour: startHour - 1, + endHour: endHour, + heightPerMinute: 1.1, + showLiveTimeLineInAllDays: false, + showVerticalLines: true, + emulateVerticalOffsetBy: -80, + startDay: WeekDays.monday, + liveTimeIndicatorSettings: const LiveTimeIndicatorSettings( + showBullet: false, + height: 0, + ), + weekDayBuilder: (date) { + final weekDays = _getWeekDays(weekStart); + final selectedDayIndex = + weekDays.indexWhere((d) => isSameDay(d, selectedDate)); + final index = weekDays.indexWhere((d) => isSameDay(d, date)); + final isSelectedDay = index == selectedDayIndex; + final isToday = isSameDay(date, DateTime.now()); + + return Column( + children: [ + Text( + DateFormat('EEE').format(date).toUpperCase(), + style: TextStyle( + fontWeight: FontWeight.w400, + fontSize: 14, + color: isSelectedDay ? Colors.blue : Colors.black, + ), + ), + Text( + DateFormat('d').format(date), + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 20, + color: isSelectedDay + ? ColorsManager.blue1 + : ColorsManager.blackColor, + ), + ), + ], + ); + }, + timeLineBuilder: (date) { + int hour = date.hour == 0 + ? 12 + : (date.hour > 12 ? date.hour - 12 : date.hour); + String period = date.hour >= 12 ? 'PM' : 'AM'; + return Container( + height: 60, + alignment: Alignment.center, + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: '$hour', + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 24, + color: ColorsManager.blackColor, + ), + ), + WidgetSpan( + child: Padding( + padding: const EdgeInsets.only(left: 2, top: 6), + child: Text( + period, + style: const TextStyle( + fontWeight: FontWeight.w400, + fontSize: 12, + color: ColorsManager.blackColor, + letterSpacing: 1, + ), + ), + ), + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + ), + ], + ), + ), + ); + }, + timeLineWidth: timeLineWidth, + weekPageHeaderBuilder: (start, end) => Container(), + weekTitleHeight: 60, + weekNumberBuilder: (firstDayOfWeek) => Padding( + padding: const EdgeInsets.only( + right: 15, + bottom: 10, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + firstDayOfWeek.timeZoneName.replaceAll(':00', ''), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 12, + color: ColorsManager.blackColor, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + eventTileBuilder: (date, events, boundary, start, end) { + return Container( + margin: + const EdgeInsets.symmetric(vertical: 2, horizontal: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: events.map((event) { + final bool isEventEnded = event.endTime != null && + event.endTime!.isBefore(DateTime.now()); + return Expanded( + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: isEventEnded + ? ColorsManager.lightGrayBorderColor + : ColorsManager.blue1.withOpacity(0.25), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat('h:mm a').format(event.startTime!), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: Colors.black87, + ), + ), + const SizedBox(height: 2), + Text( + event.title, + style: const TextStyle( + fontSize: 12, + color: ColorsManager.blackColor, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ); + }, + ), + if (selectedDayIndex >= 0) + Positioned( + left: (timeLineWidth + 3) + + (dayColumnWidth - 8) * (selectedDayIndex - 0.01), + top: 0, + bottom: 0, + width: dayColumnWidth, + child: IgnorePointer( + child: Container( + margin: const EdgeInsets.symmetric( + vertical: 0, horizontal: 4), + color: ColorsManager.spaceColor.withOpacity(0.07), + ), + ), + ), + Positioned( + right: 0, + top: 50, + bottom: 0, + child: IgnorePointer( + child: Container( + width: 1, + color: Theme.of(context).scaffoldBackgroundColor, + ), + ), + ), + ], + ), + ); + }, + ); + } + + List _getWeekDays(DateTime date) { + final int weekday = date.weekday; + final DateTime monday = date.subtract(Duration(days: weekday - 1)); + return List.generate(7, (i) => monday.add(Duration(days: i))); + } +} + +bool isSameDay(DateTime d1, DateTime d2) { + return d1.year == d2.year && d1.month == d2.month && d1.day == d2.day; +} + +int _parseHour(String? time, {required int defaultValue}) { + if (time == null || time.isEmpty || !time.contains(':')) { + return defaultValue; + } + try { + return int.parse(time.split(':')[0]); + } catch (e) { + return defaultValue; + } +} diff --git a/lib/pages/access_management/booking_system/view/booking_page.dart b/lib/pages/access_management/booking_system/view/booking_page.dart deleted file mode 100644 index 6fdb53bd..00000000 --- a/lib/pages/access_management/booking_system/view/booking_page.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:syncrow_web/pages/access_management/booking_system/view/widgets/icon_text_button.dart'; -import 'package:syncrow_web/utils/constants/assets.dart'; - -class BookingPage extends StatelessWidget { - const BookingPage({super.key}); - - @override - Widget build(BuildContext context) { - return Container( - child: Row( - children: [ - Expanded( - child: Container( - color: Colors.blueGrey[100], - child: const Center( - child: Text( - 'Side bar', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ), - ), - )), - Expanded( - flex: 4, - child: Padding( - padding: const EdgeInsets.all(20.0), - child: SizedBox( - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - SvgTextButton( - svgAsset: Assets.homeIcon, - label: 'Manage Bookable Spaces', - onPressed: () {}), - SizedBox(width: 20), - SvgTextButton( - svgAsset: Assets.groupIcon, - label: 'Manage Users', - onPressed: () {}) - ], - ) - ], - ), - ), - )) - ], - ), - ); - } -} diff --git a/lib/pages/access_management/view/access_management.dart b/lib/pages/access_management/view/access_management.dart index e035d252..4e31f23f 100644 --- a/lib/pages/access_management/view/access_management.dart +++ b/lib/pages/access_management/view/access_management.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncrow_web/pages/access_management/bloc/access_bloc.dart'; import 'package:syncrow_web/pages/access_management/bloc/access_event.dart'; -import 'package:syncrow_web/pages/access_management/booking_system/view/booking_page.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/view/booking_page.dart'; import 'package:syncrow_web/pages/access_management/view/access_overview_content.dart'; import 'package:syncrow_web/pages/device_managment/shared/navigate_home_grid_view.dart'; import 'package:syncrow_web/utils/extension/build_context_x.dart'; diff --git a/lib/services/access_mang_api.dart b/lib/services/access_mang_api.dart index a780a12b..db3cb554 100644 --- a/lib/services/access_mang_api.dart +++ b/lib/services/access_mang_api.dart @@ -1,5 +1,5 @@ import 'dart:convert'; -import 'package:syncrow_web/pages/access_management/model/password_model.dart'; +import 'package:syncrow_web/pages/access_management/booking_system/presentation/model/password_model.dart'; import 'package:syncrow_web/pages/visitor_password/model/device_model.dart'; import 'package:syncrow_web/pages/visitor_password/model/schedule_model.dart'; import 'package:syncrow_web/services/api/http_service.dart'; diff --git a/lib/utils/color_manager.dart b/lib/utils/color_manager.dart index 40fca1fa..a36d1193 100644 --- a/lib/utils/color_manager.dart +++ b/lib/utils/color_manager.dart @@ -84,4 +84,5 @@ abstract class ColorsManager { static const Color minBlue = Color(0xFF93AAFD); static const Color minBlueDot = Color(0xFF023DFE); static const Color grey25 = Color(0xFFF9F9F9); + static const Color grey50 = Color(0xFF718096); } diff --git a/lib/utils/constants/api_const.dart b/lib/utils/constants/api_const.dart index eb7b6a3e..f908db85 100644 --- a/lib/utils/constants/api_const.dart +++ b/lib/utils/constants/api_const.dart @@ -46,7 +46,8 @@ abstract class ApiEndpoints { // Community Module static const String createCommunity = '/projects/{projectId}/communities'; static const String getCommunityList = '/projects/{projectId}/communities'; - static const String getCommunityListv2 = '/projects/{projectId}/communities/v2'; + static const String getCommunityListv2 = + '/projects/{projectId}/communities/v2'; static const String getCommunityById = '/projects/{projectId}/communities/{communityId}'; static const String updateCommunity = @@ -138,4 +139,6 @@ abstract class ApiEndpoints { static const String assignDeviceToRoom = '/projects/{projectUuid}/communities/{communityUuid}/spaces/{spaceUuid}/subspaces/{subSpaceUuid}/devices/{deviceUuid}'; static const String saveSchedule = '/schedule/{deviceUuid}'; + + static const String getBookableSpaces = '/bookable-spaces'; } diff --git a/pubspec.yaml b/pubspec.yaml index c4692ac4..67bf5328 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,9 @@ dependencies: bloc: ^9.0.0 geocoding: ^4.0.0 gauge_indicator: ^0.4.3 + calendar_view: ^1.4.0 + calendar_date_picker2: ^2.0.1 + dev_dependencies: