mirror of
https://github.com/SyncrowIOT/web.git
synced 2025-07-10 15:17:31 +00:00
Compare commits
24 Commits
SP-1716-FE
...
2d16bda61d
Author | SHA1 | Date | |
---|---|---|---|
2d16bda61d | |||
5c90d5f6b9 | |||
d6a48850a7 | |||
6cac94a1c4 | |||
9f28e1ccef | |||
6534bfae5b | |||
4cfb984d2c | |||
4c06479469 | |||
3101960201 | |||
ddfd4ee153 | |||
7f0484eec6 | |||
dc7064d142 | |||
e523a83912 | |||
e917225c3d | |||
66ed30b50c | |||
47bd6ff89e | |||
138390496c | |||
df87e41d61 | |||
f0bfe085a4 | |||
bb846f797f | |||
e234c9f3b2 | |||
bcd0ae4a2a | |||
cebce2ce7f | |||
97e3fb68bf |
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.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_event.dart';
|
||||||
import 'package:syncrow_web/pages/access_management/bloc/access_state.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/bloc/project_manager.dart';
|
||||||
import 'package:syncrow_web/pages/common/hour_picker_dialog.dart';
|
import 'package:syncrow_web/pages/common/hour_picker_dialog.dart';
|
||||||
import 'package:syncrow_web/services/access_mang_api.dart';
|
import 'package:syncrow_web/services/access_mang_api.dart';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
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 {
|
abstract class AccessState extends Equatable {
|
||||||
const AccessState();
|
const AccessState();
|
||||||
|
@ -0,0 +1,49 @@
|
|||||||
|
import 'package:dio/dio.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/booking_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 BookableSpacesService implements BookingSystemService {
|
||||||
|
const BookableSpacesService(this._httpService);
|
||||||
|
|
||||||
|
final HTTPService _httpService;
|
||||||
|
static const _defaultErrorMessage = 'Failed to load bookable spaces';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PaginatedBookableSpaces> getBookableSpaces({
|
||||||
|
required int page,
|
||||||
|
required int size,
|
||||||
|
required String search,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _httpService.get(
|
||||||
|
path: ApiEndpoints.getBookableSpaces,
|
||||||
|
queryParameters: {
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
'active': true,
|
||||||
|
'configured': true,
|
||||||
|
if (search.isNotEmpty && search != 'null') 'search': search,
|
||||||
|
},
|
||||||
|
expectedResponseModel: (json) {
|
||||||
|
return PaginatedBookableSpaces.fromJson(
|
||||||
|
json as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final responseData = e.response?.data;
|
||||||
|
if (responseData is Map<String, dynamic>) {
|
||||||
|
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()}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<String, dynamic> 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<String, dynamic>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BookableConfig {
|
||||||
|
final String uuid;
|
||||||
|
final List<String> 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<String, dynamic> json) {
|
||||||
|
return BookableConfig(
|
||||||
|
uuid: json['uuid'] as String,
|
||||||
|
daysAvailable: (json['daysAvailable'] as List).cast<String>(),
|
||||||
|
startTime: json['startTime'] as String,
|
||||||
|
endTime: json['endTime'] as String,
|
||||||
|
active: json['active'] as bool,
|
||||||
|
points: json['points'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import 'package:syncrow_web/pages/access_management/booking_system/domain/models/bookable_room.dart';
|
||||||
|
|
||||||
|
class PaginatedBookableSpaces {
|
||||||
|
final List<BookableSpaceModel> 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<String, dynamic> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,57 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
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/booking_system_service.dart';
|
||||||
|
|
||||||
|
class DebouncedBookingSystemService implements BookingSystemService {
|
||||||
|
final BookingSystemService _inner;
|
||||||
|
final Duration debounceDuration;
|
||||||
|
|
||||||
|
Timer? _debounceTimer;
|
||||||
|
Completer<PaginatedBookableSpaces>? _lastCompleter;
|
||||||
|
|
||||||
|
int? _lastPage;
|
||||||
|
int? _lastSize;
|
||||||
|
bool? _lastIncludeSpaces;
|
||||||
|
String? _lastSearch;
|
||||||
|
|
||||||
|
DebouncedBookingSystemService(
|
||||||
|
this._inner, {
|
||||||
|
this.debounceDuration = const Duration(milliseconds: 500),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PaginatedBookableSpaces> getBookableSpaces({
|
||||||
|
required int page,
|
||||||
|
required int size,
|
||||||
|
required String search,
|
||||||
|
}) {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_lastCompleter?.completeError(StateError("Cancelled by new search"));
|
||||||
|
|
||||||
|
final completer = Completer<PaginatedBookableSpaces>();
|
||||||
|
_lastCompleter = completer;
|
||||||
|
|
||||||
|
_lastPage = page;
|
||||||
|
_lastSize = size;
|
||||||
|
_lastSearch = search;
|
||||||
|
|
||||||
|
_debounceTimer = Timer(debounceDuration, () async {
|
||||||
|
try {
|
||||||
|
final result = await _inner.getBookableSpaces(
|
||||||
|
page: _lastPage!,
|
||||||
|
size: _lastSize!,
|
||||||
|
search: _lastSearch!,
|
||||||
|
);
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(result);
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.completeError(e, st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
import 'package:syncrow_web/pages/access_management/booking_system/domain/models/paginated_bookable_spaces.dart';
|
||||||
|
|
||||||
|
abstract class BookingSystemService {
|
||||||
|
Future<PaginatedBookableSpaces> getBookableSpaces({
|
||||||
|
required int page,
|
||||||
|
required int size,
|
||||||
|
required String search,
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
@ -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<CalendarEventsEvent, CalendarEventState> {
|
||||||
|
final EventController eventController = EventController();
|
||||||
|
|
||||||
|
CalendarEventsBloc() : super(EventsInitial()) {
|
||||||
|
on<LoadEvents>(_onLoadEvents);
|
||||||
|
on<AddEvent>(_onAddEvent);
|
||||||
|
on<StartTimer>(_onStartTimer);
|
||||||
|
on<DisposeResources>(_onDisposeResources);
|
||||||
|
on<GoToWeek>(_onGoToWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoadEvents(
|
||||||
|
LoadEvents event,
|
||||||
|
Emitter<CalendarEventState> 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<CalendarEventData> _generateDummyEventsForWeek(DateTime weekStart) {
|
||||||
|
final events = <CalendarEventData>[];
|
||||||
|
|
||||||
|
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<CalendarEventState> 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<CalendarEventState> emit) {}
|
||||||
|
|
||||||
|
void _onDisposeResources(
|
||||||
|
DisposeResources event, Emitter<CalendarEventState> emit) {
|
||||||
|
eventController.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onGoToWeek(GoToWeek event, Emitter<CalendarEventState> 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<CalendarEventData> _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<DateTime> _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<void> close() {
|
||||||
|
eventController.dispose();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
@ -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<CalendarEventData> events;
|
||||||
|
final DateTime initialDate;
|
||||||
|
final List<DateTime> weekDays;
|
||||||
|
|
||||||
|
EventsLoaded({
|
||||||
|
required this.events,
|
||||||
|
required this.initialDate,
|
||||||
|
required this.weekDays,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventsError extends CalendarEventState {
|
||||||
|
final String message;
|
||||||
|
EventsError(this.message);
|
||||||
|
}
|
@ -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<DateSelectionEvent, DateSelectionState> {
|
||||||
|
DateSelectionBloc() : super(DateSelectionState.initial()) {
|
||||||
|
on<SelectDate>((event, emit) {
|
||||||
|
final newWeekStart = _getStartOfWeek(event.selectedDate);
|
||||||
|
emit(DateSelectionState(
|
||||||
|
selectedDate: event.selectedDate,
|
||||||
|
weekStart: newWeekStart,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
on<NextWeek>((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<PreviousWeek>((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));
|
||||||
|
}
|
||||||
|
}
|
@ -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 {}
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
@ -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<SelectedBookableSpaceEvent, SelectedBookableSpaceState> {
|
||||||
|
SelectedBookableSpaceBloc() : super(const SelectedBookableSpaceState()) {
|
||||||
|
on<SelectBookableSpace>((event, emit) {
|
||||||
|
emit(SelectedBookableSpaceState(
|
||||||
|
selectedBookableSpace: event.bookableSpace));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
part of 'selected_bookable_space_bloc.dart';
|
||||||
|
|
||||||
|
class SelectedBookableSpaceState {
|
||||||
|
final BookableSpaceModel? selectedBookableSpace;
|
||||||
|
|
||||||
|
const SelectedBookableSpaceState(
|
||||||
|
{ this.selectedBookableSpace,}
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,143 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:syncrow_web/pages/access_management/booking_system/domain/services/booking_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<SidebarEvent, SidebarState> {
|
||||||
|
final BookingSystemService _bookingService;
|
||||||
|
Timer? _searchDebounce;
|
||||||
|
int _currentPage = 1;
|
||||||
|
final int _pageSize = 20;
|
||||||
|
String _currentSearch = '';
|
||||||
|
|
||||||
|
SidebarBloc(this._bookingService)
|
||||||
|
: super(SidebarState(
|
||||||
|
allRooms: [],
|
||||||
|
displayedRooms: [],
|
||||||
|
isLoading: true,
|
||||||
|
hasMore: true,
|
||||||
|
)) {
|
||||||
|
on<LoadBookableSpaces>(_onLoadBookableSpaces);
|
||||||
|
on<LoadMoreSpaces>(_onLoadMoreSpaces);
|
||||||
|
on<SelectRoomEvent>(_onSelectRoom);
|
||||||
|
on<SearchRoomsEvent>(_onSearchRooms);
|
||||||
|
on<ResetSearch>(_onResetSearch);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoadBookableSpaces(
|
||||||
|
LoadBookableSpaces event,
|
||||||
|
Emitter<SidebarState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
emit(state.copyWith(isLoading: true, errorMessage: null));
|
||||||
|
_currentPage = 1;
|
||||||
|
_currentSearch = '';
|
||||||
|
|
||||||
|
final paginatedSpaces = await _bookingService.getBookableSpaces(
|
||||||
|
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<void> _onLoadMoreSpaces(
|
||||||
|
LoadMoreSpaces event,
|
||||||
|
Emitter<SidebarState> emit,
|
||||||
|
) async {
|
||||||
|
if (!state.hasMore || state.isLoadingMore) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
emit(state.copyWith(isLoadingMore: true));
|
||||||
|
_currentPage++;
|
||||||
|
|
||||||
|
final paginatedSpaces = await _bookingService.getBookableSpaces(
|
||||||
|
page: _currentPage,
|
||||||
|
size: _pageSize,
|
||||||
|
search: _currentSearch,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedRooms = [...state.allRooms, ...paginatedSpaces.data];
|
||||||
|
|
||||||
|
emit(state.copyWith(
|
||||||
|
allRooms: updatedRooms,
|
||||||
|
displayedRooms: updatedRooms,
|
||||||
|
isLoadingMore: false,
|
||||||
|
hasMore: paginatedSpaces.hasNext,
|
||||||
|
currentPage: _currentPage,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
_currentPage--;
|
||||||
|
emit(state.copyWith(
|
||||||
|
isLoadingMore: false,
|
||||||
|
errorMessage: 'Failed to load more rooms: ${e.toString()}',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSearchRooms(
|
||||||
|
SearchRoomsEvent event,
|
||||||
|
Emitter<SidebarState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
_currentSearch = event.query;
|
||||||
|
_currentPage = 1;
|
||||||
|
emit(state.copyWith(isLoading: true, errorMessage: null));
|
||||||
|
final paginatedSpaces = await _bookingService.getBookableSpaces(
|
||||||
|
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<SidebarState> emit,
|
||||||
|
) {
|
||||||
|
_currentSearch = '';
|
||||||
|
add(LoadBookableSpaces());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSelectRoom(
|
||||||
|
SelectRoomEvent event,
|
||||||
|
Emitter<SidebarState> emit,
|
||||||
|
) {
|
||||||
|
emit(state.copyWith(selectedRoomId: event.roomId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_searchDebounce?.cancel();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
import 'package:syncrow_web/pages/access_management/booking_system/domain/models/bookable_room.dart';
|
||||||
|
|
||||||
|
class SidebarState {
|
||||||
|
final List<BookableSpaceModel> allRooms;
|
||||||
|
final List<BookableSpaceModel> 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<BookableSpaceModel>? allRooms,
|
||||||
|
List<BookableSpaceModel>? 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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<BookingPage> createState() => _BookingPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BookingPageState extends State<BookingPage> {
|
||||||
|
late final EventController _eventController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_eventController = EventController();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_eventController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CalendarEventData> _generateDummyEventsForWeek(DateTime weekStart) {
|
||||||
|
final List<CalendarEventData> 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<DateSelectionBloc, DateSelectionState>(
|
||||||
|
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<SelectedBookableSpaceBloc,
|
||||||
|
SelectedBookableSpaceState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
return BookingSidebar(
|
||||||
|
onRoomSelected: (selectedRoom) {
|
||||||
|
context
|
||||||
|
.read<SelectedBookableSpaceBloc>()
|
||||||
|
.add(SelectBookableSpace(selectedRoom));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<DateSelectionBloc, DateSelectionState>(
|
||||||
|
builder: (context, dateState) {
|
||||||
|
return CustomCalendarPage(
|
||||||
|
selectedDate: dateState.selectedDate,
|
||||||
|
onDateChanged: (day, month, year) {
|
||||||
|
final newDate = DateTime(year, month, day);
|
||||||
|
context
|
||||||
|
.read<DateSelectionBloc>()
|
||||||
|
.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<DateSelectionBloc, DateSelectionState>(
|
||||||
|
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<DateSelectionBloc>()
|
||||||
|
.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<DateSelectionBloc>()
|
||||||
|
.add(NextWeek());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<SelectedBookableSpaceBloc,
|
||||||
|
SelectedBookableSpaceState>(
|
||||||
|
builder: (context, roomState) {
|
||||||
|
final selectedRoom = roomState.selectedBookableSpace;
|
||||||
|
return BlocBuilder<DateSelectionBloc,
|
||||||
|
DateSelectionState>(
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,248 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
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/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(BookableSpacesService(
|
||||||
|
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();
|
||||||
|
Timer? _searchDebounce;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController.addListener(_scrollListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
_searchDebounce?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollListener() {
|
||||||
|
if (_scrollController.position.pixels ==
|
||||||
|
_scrollController.position.maxScrollExtent) {
|
||||||
|
context.read<SidebarBloc>().add(LoadMoreSpaces());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSearch(String value) {
|
||||||
|
_searchDebounce?.cancel();
|
||||||
|
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
||||||
|
context.read<SidebarBloc>().add(SearchRoomsEvent(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocConsumer<SidebarBloc, SidebarState>(
|
||||||
|
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<SidebarBloc>().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<SidebarBloc>()
|
||||||
|
.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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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<CustomCalendarPage> createState() => _CustomCalendarPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomCalendarPageState extends State<CustomCalendarPage> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -20,13 +20,13 @@ class SvgTextButton extends StatelessWidget {
|
|||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
this.backgroundColor = ColorsManager.circleRolesBackground,
|
this.backgroundColor = ColorsManager.circleRolesBackground,
|
||||||
this.svgColor = const Color(0xFF496EFF),
|
this.svgColor = const Color(0xFF496EFF),
|
||||||
this.labelColor = Colors.black87,
|
this.labelColor = Colors.black,
|
||||||
this.borderRadius = 10.0,
|
this.borderRadius = 10.0,
|
||||||
this.boxShadow = const [
|
this.boxShadow = const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: ColorsManager.textGray,
|
color: ColorsManager.lightGrayColor,
|
||||||
blurRadius: 12,
|
blurRadius: 4,
|
||||||
offset: Offset(0, 4),
|
offset: Offset(0, 1),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
this.svgSize = 24.0,
|
this.svgSize = 24.0,
|
||||||
@ -53,15 +53,14 @@ class SvgTextButton extends StatelessWidget {
|
|||||||
svgAsset,
|
svgAsset,
|
||||||
width: svgSize,
|
width: svgSize,
|
||||||
height: svgSize,
|
height: svgSize,
|
||||||
color: svgColor,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: labelColor,
|
color: labelColor,
|
||||||
fontSize: 16,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w400,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
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(
|
||||||
|
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 Container(
|
||||||
|
decoration: isSelectedDay
|
||||||
|
? BoxDecoration(
|
||||||
|
color: ColorsManager.blue1.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
)
|
||||||
|
: isToday
|
||||||
|
? BoxDecoration(
|
||||||
|
color: ColorsManager.blue1.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
child: 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) => Text(
|
||||||
|
firstDayOfWeek.timeZoneName,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.black,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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.grayColor
|
||||||
|
: ColorsManager.lightGrayColor
|
||||||
|
.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 + dayColumnWidth * selectedDayIndex,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: dayColumnWidth,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(
|
||||||
|
vertical: 0, horizontal: 2),
|
||||||
|
color: ColorsManager.blue1.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
top: 50,
|
||||||
|
bottom: 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Container(
|
||||||
|
width: 1,
|
||||||
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DateTime> _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;
|
||||||
|
}
|
||||||
|
}
|
@ -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: () {})
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart';
|
||||||
import 'package:syncrow_web/pages/access_management/bloc/access_event.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/access_management/view/access_overview_content.dart';
|
||||||
import 'package:syncrow_web/pages/device_managment/shared/navigate_home_grid_view.dart';
|
import 'package:syncrow_web/pages/device_managment/shared/navigate_home_grid_view.dart';
|
||||||
import 'package:syncrow_web/utils/extension/build_context_x.dart';
|
import 'package:syncrow_web/utils/extension/build_context_x.dart';
|
||||||
|
@ -7,6 +7,8 @@ import 'package:syncrow_web/pages/space_management_v2/modules/communities/data/s
|
|||||||
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/products/data/services/remote_products_service.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/products/presentation/bloc/products_bloc.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/data/services/remote_space_details_service.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/data/services/remote_space_details_service.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/data/services/unique_subspaces_decorator.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/data/services/unique_subspaces_decorator.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_bloc.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_bloc.dart';
|
||||||
@ -36,6 +38,11 @@ class SpaceManagementPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
BlocProvider(
|
||||||
|
create: (context) => ProductsBloc(
|
||||||
|
RemoteProductsService(HTTPService()),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: WebScaffold(
|
child: WebScaffold(
|
||||||
appBarTitle: Text(
|
appBarTitle: Text(
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/params/load_products_param.dart';
|
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/services/products_service.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/services/products_service.dart';
|
||||||
import 'package:syncrow_web/services/api/api_exception.dart';
|
import 'package:syncrow_web/services/api/api_exception.dart';
|
||||||
import 'package:syncrow_web/services/api/http_service.dart';
|
import 'package:syncrow_web/services/api/http_service.dart';
|
||||||
|
import 'package:syncrow_web/utils/constants/api_const.dart';
|
||||||
|
|
||||||
class RemoteProductsService implements ProductsService {
|
class RemoteProductsService implements ProductsService {
|
||||||
const RemoteProductsService(this._httpService);
|
const RemoteProductsService(this._httpService);
|
||||||
@ -13,17 +13,14 @@ class RemoteProductsService implements ProductsService {
|
|||||||
static const _defaultErrorMessage = 'Failed to load devices';
|
static const _defaultErrorMessage = 'Failed to load devices';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Product>> getProducts(LoadProductsParam param) async {
|
Future<List<Product>> getProducts() async {
|
||||||
try {
|
try {
|
||||||
final response = await _httpService.get(
|
final response = await _httpService.get(
|
||||||
path: 'devices',
|
path: ApiEndpoints.listProducts,
|
||||||
queryParameters: {
|
|
||||||
'spaceUuid': param.spaceUuid,
|
|
||||||
if (param.type != null) 'type': param.type,
|
|
||||||
if (param.status != null) 'status': param.status,
|
|
||||||
},
|
|
||||||
expectedResponseModel: (data) {
|
expectedResponseModel: (data) {
|
||||||
return (data as List)
|
final json = data as Map<String, dynamic>;
|
||||||
|
final products = json['data'] as List<dynamic>;
|
||||||
|
return products
|
||||||
.map((e) => Product.fromJson(e as Map<String, dynamic>))
|
.map((e) => Product.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
},
|
},
|
||||||
|
@ -1,18 +1,24 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:syncrow_web/utils/constants/assets.dart';
|
||||||
|
|
||||||
class Product extends Equatable {
|
class Product extends Equatable {
|
||||||
final String uuid;
|
|
||||||
final String name;
|
|
||||||
|
|
||||||
const Product({
|
const Product({
|
||||||
required this.uuid,
|
required this.uuid,
|
||||||
required this.name,
|
required this.name,
|
||||||
|
required this.productType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final String uuid;
|
||||||
|
final String name;
|
||||||
|
final String productType;
|
||||||
|
|
||||||
|
String get icon => _mapIconToProduct(productType);
|
||||||
|
|
||||||
factory Product.fromJson(Map<String, dynamic> json) {
|
factory Product.fromJson(Map<String, dynamic> json) {
|
||||||
return Product(
|
return Product(
|
||||||
uuid: json['uuid'] as String,
|
uuid: json['uuid'] as String? ?? '',
|
||||||
name: json['name'] as String,
|
name: json['name'] as String? ?? '',
|
||||||
|
productType: json['prodType'] as String? ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,9 +26,37 @@ class Product extends Equatable {
|
|||||||
return {
|
return {
|
||||||
'uuid': uuid,
|
'uuid': uuid,
|
||||||
'name': name,
|
'name': name,
|
||||||
|
'productType': productType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
static String _mapIconToProduct(String prodType) {
|
||||||
List<Object?> get props => [uuid, name];
|
const iconMapping = {
|
||||||
|
'1G': Assets.Gang1SwitchIcon,
|
||||||
|
'1GT': Assets.oneTouchSwitch,
|
||||||
|
'2G': Assets.Gang2SwitchIcon,
|
||||||
|
'2GT': Assets.twoTouchSwitch,
|
||||||
|
'3G': Assets.Gang3SwitchIcon,
|
||||||
|
'3GT': Assets.threeTouchSwitch,
|
||||||
|
'CUR': Assets.curtain,
|
||||||
|
'CUR_2': Assets.curtain,
|
||||||
|
'GD': Assets.garageDoor,
|
||||||
|
'GW': Assets.SmartGatewayIcon,
|
||||||
|
'DL': Assets.DoorLockIcon,
|
||||||
|
'WL': Assets.waterLeakSensor,
|
||||||
|
'WH': Assets.waterHeater,
|
||||||
|
'WM': Assets.waterLeakSensor,
|
||||||
|
'SOS': Assets.sos,
|
||||||
|
'AC': Assets.ac,
|
||||||
|
'CPS': Assets.presenceSensor,
|
||||||
|
'PC': Assets.powerClamp,
|
||||||
|
'WPS': Assets.presenceSensor,
|
||||||
|
'DS': Assets.doorSensor
|
||||||
|
};
|
||||||
|
|
||||||
|
return iconMapping[prodType] ?? Assets.presenceSensor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [uuid, name, productType];
|
||||||
}
|
}
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
class LoadProductsParam {
|
|
||||||
final String spaceUuid;
|
|
||||||
final String? type;
|
|
||||||
final String? status;
|
|
||||||
|
|
||||||
const LoadProductsParam({
|
|
||||||
required this.spaceUuid,
|
|
||||||
this.type,
|
|
||||||
this.status,
|
|
||||||
});
|
|
||||||
}
|
|
@ -1,6 +1,5 @@
|
|||||||
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/params/load_products_param.dart';
|
|
||||||
|
|
||||||
abstract class ProductsService {
|
abstract class ProductsService {
|
||||||
Future<List<Product>> getProducts(LoadProductsParam param);
|
Future<List<Product>> getProducts();
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/params/load_products_param.dart';
|
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/services/products_service.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/services/products_service.dart';
|
||||||
import 'package:syncrow_web/services/api/api_exception.dart';
|
import 'package:syncrow_web/services/api/api_exception.dart';
|
||||||
|
|
||||||
@ -9,20 +8,20 @@ part 'products_event.dart';
|
|||||||
part 'products_state.dart';
|
part 'products_state.dart';
|
||||||
|
|
||||||
class ProductsBloc extends Bloc<ProductsEvent, ProductsState> {
|
class ProductsBloc extends Bloc<ProductsEvent, ProductsState> {
|
||||||
final ProductsService _deviceService;
|
ProductsBloc(this._productsService) : super(ProductsInitial()) {
|
||||||
|
|
||||||
ProductsBloc(this._deviceService) : super(ProductsInitial()) {
|
|
||||||
on<LoadProducts>(_onLoadProducts);
|
on<LoadProducts>(_onLoadProducts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final ProductsService _productsService;
|
||||||
|
|
||||||
Future<void> _onLoadProducts(
|
Future<void> _onLoadProducts(
|
||||||
LoadProducts event,
|
LoadProducts event,
|
||||||
Emitter<ProductsState> emit,
|
Emitter<ProductsState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(ProductsLoading());
|
emit(ProductsLoading());
|
||||||
try {
|
try {
|
||||||
final devices = await _deviceService.getProducts(event.param);
|
final products = await _productsService.getProducts();
|
||||||
emit(ProductsLoaded(devices));
|
emit(ProductsLoaded(products));
|
||||||
} on APIException catch (e) {
|
} on APIException catch (e) {
|
||||||
emit(ProductsFailure(e.message));
|
emit(ProductsFailure(e.message));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -8,10 +8,5 @@ sealed class ProductsEvent extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class LoadProducts extends ProductsEvent {
|
final class LoadProducts extends ProductsEvent {
|
||||||
const LoadProducts(this.param);
|
const LoadProducts();
|
||||||
|
|
||||||
final LoadProductsParam param;
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object> get props => [param];
|
|
||||||
}
|
}
|
||||||
|
@ -21,10 +21,10 @@ final class ProductsLoaded extends ProductsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class ProductsFailure extends ProductsState {
|
final class ProductsFailure extends ProductsState {
|
||||||
final String message;
|
final String errorMessage;
|
||||||
|
|
||||||
const ProductsFailure(this.message);
|
const ProductsFailure(this.errorMessage);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [message];
|
List<Object> get props => [errorMessage];
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
|
|||||||
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
|
||||||
import 'package:syncrow_web/utils/constants/assets.dart';
|
import 'package:syncrow_web/utils/constants/assets.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
class SpaceDetailsModel extends Equatable {
|
class SpaceDetailsModel extends Equatable {
|
||||||
final String uuid;
|
final String uuid;
|
||||||
@ -21,7 +22,7 @@ class SpaceDetailsModel extends Equatable {
|
|||||||
factory SpaceDetailsModel.empty() => const SpaceDetailsModel(
|
factory SpaceDetailsModel.empty() => const SpaceDetailsModel(
|
||||||
uuid: '',
|
uuid: '',
|
||||||
spaceName: '',
|
spaceName: '',
|
||||||
icon: Assets.villa,
|
icon: Assets.location,
|
||||||
productAllocations: [],
|
productAllocations: [],
|
||||||
subspaces: [],
|
subspaces: [],
|
||||||
);
|
);
|
||||||
@ -70,16 +71,19 @@ class SpaceDetailsModel extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ProductAllocation extends Equatable {
|
class ProductAllocation extends Equatable {
|
||||||
|
final String uuid;
|
||||||
final Product product;
|
final Product product;
|
||||||
final Tag tag;
|
final Tag tag;
|
||||||
|
|
||||||
const ProductAllocation({
|
const ProductAllocation({
|
||||||
|
required this.uuid,
|
||||||
required this.product,
|
required this.product,
|
||||||
required this.tag,
|
required this.tag,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ProductAllocation.fromJson(Map<String, dynamic> json) {
|
factory ProductAllocation.fromJson(Map<String, dynamic> json) {
|
||||||
return ProductAllocation(
|
return ProductAllocation(
|
||||||
|
uuid: json['uuid'] as String? ?? const Uuid().v4(),
|
||||||
product: Product.fromJson(json['product'] as Map<String, dynamic>),
|
product: Product.fromJson(json['product'] as Map<String, dynamic>),
|
||||||
tag: Tag.fromJson(json['tag'] as Map<String, dynamic>),
|
tag: Tag.fromJson(json['tag'] as Map<String, dynamic>),
|
||||||
);
|
);
|
||||||
@ -87,23 +91,26 @@ class ProductAllocation extends Equatable {
|
|||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
|
'uuid': uuid,
|
||||||
'product': product.toJson(),
|
'product': product.toJson(),
|
||||||
'tag': tag.toJson(),
|
'tag': tag.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
ProductAllocation copyWith({
|
ProductAllocation copyWith({
|
||||||
|
String? uuid,
|
||||||
Product? product,
|
Product? product,
|
||||||
Tag? tag,
|
Tag? tag,
|
||||||
}) {
|
}) {
|
||||||
return ProductAllocation(
|
return ProductAllocation(
|
||||||
|
uuid: uuid ?? this.uuid,
|
||||||
product: product ?? this.product,
|
product: product ?? this.product,
|
||||||
tag: tag ?? this.tag,
|
tag: tag ?? this.tag,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [product, tag];
|
List<Object?> get props => [uuid, product, tag];
|
||||||
}
|
}
|
||||||
|
|
||||||
class Subspace extends Equatable {
|
class Subspace extends Equatable {
|
||||||
|
@ -18,7 +18,7 @@ abstract final class SpaceDetailsDialogHelper {
|
|||||||
context: context,
|
context: context,
|
||||||
title: const SelectableText('Create Space'),
|
title: const SelectableText('Create Space'),
|
||||||
spaceModel: SpaceModel.empty(),
|
spaceModel: SpaceModel.empty(),
|
||||||
onSave: print,
|
onSave: (space) {},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -8,10 +8,14 @@ class SpaceDetailsActionButtons extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
required this.onCancel,
|
required this.onCancel,
|
||||||
|
this.saveButtonLabel = 'OK',
|
||||||
|
this.cancelButtonLabel = 'Cancel',
|
||||||
});
|
});
|
||||||
|
|
||||||
final VoidCallback onCancel;
|
final VoidCallback onCancel;
|
||||||
final VoidCallback? onSave;
|
final VoidCallback? onSave;
|
||||||
|
final String saveButtonLabel;
|
||||||
|
final String cancelButtonLabel;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -27,10 +31,7 @@ class SpaceDetailsActionButtons extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCancelButton(BuildContext context) {
|
Widget _buildCancelButton(BuildContext context) {
|
||||||
return CancelButton(
|
return CancelButton(onPressed: onCancel, label: cancelButtonLabel);
|
||||||
onPressed: onCancel,
|
|
||||||
label: 'Cancel',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSaveButton() {
|
Widget _buildSaveButton() {
|
||||||
@ -39,7 +40,7 @@ class SpaceDetailsActionButtons extends StatelessWidget {
|
|||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
backgroundColor: ColorsManager.secondaryColor,
|
backgroundColor: ColorsManager.secondaryColor,
|
||||||
foregroundColor: ColorsManager.whiteColors,
|
foregroundColor: ColorsManager.whiteColors,
|
||||||
child: const Text('OK'),
|
child: Text(saveButtonLabel),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_svg/svg.dart';
|
||||||
import 'package:syncrow_web/common/edit_chip.dart';
|
import 'package:syncrow_web/common/edit_chip.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/button_content_widget.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/button_content_widget.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_dialog.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/update_space/presentation/bloc/space_details_model_bloc/space_details_model_bloc.dart';
|
||||||
import 'package:syncrow_web/utils/color_manager.dart';
|
import 'package:syncrow_web/utils/color_manager.dart';
|
||||||
import 'package:syncrow_web/utils/constants/assets.dart';
|
import 'package:syncrow_web/utils/constants/assets.dart';
|
||||||
|
import 'package:syncrow_web/utils/enum/device_types.dart';
|
||||||
|
import 'package:syncrow_web/utils/extension/build_context_x.dart';
|
||||||
|
|
||||||
class SpaceDetailsDevicesBox extends StatelessWidget {
|
class SpaceDetailsDevicesBox extends StatelessWidget {
|
||||||
const SpaceDetailsDevicesBox({
|
const SpaceDetailsDevicesBox({
|
||||||
@ -15,11 +21,18 @@ class SpaceDetailsDevicesBox extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final productAllocations = space.productAllocations;
|
final allAllocations = [
|
||||||
final subspaces = space.subspaces;
|
...space.productAllocations,
|
||||||
final isAnySubspaceHasProductAllocations =
|
...space.subspaces.expand((s) => s.productAllocations),
|
||||||
subspaces.any((subspace) => subspace.productAllocations.isNotEmpty);
|
];
|
||||||
if (productAllocations.isNotEmpty || isAnySubspaceHasProductAllocations) {
|
|
||||||
|
if (allAllocations.isNotEmpty) {
|
||||||
|
final productCounts = <String, int>{};
|
||||||
|
for (final allocation in allAllocations) {
|
||||||
|
final productType = allocation.product.productType;
|
||||||
|
productCounts[productType] = (productCounts[productType] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@ -35,46 +48,40 @@ class SpaceDetailsDevicesBox extends StatelessWidget {
|
|||||||
spacing: 8.0,
|
spacing: 8.0,
|
||||||
runSpacing: 8.0,
|
runSpacing: 8.0,
|
||||||
children: [
|
children: [
|
||||||
// Combine tags from spaceModel and subspaces
|
...productCounts.entries.map((entry) {
|
||||||
// ...TagHelper.groupTags([
|
final productType = entry.key;
|
||||||
// ...?tags,
|
final count = entry.value;
|
||||||
// ...?subspaces?.expand((subspace) => subspace.tags ?? [])
|
return Chip(
|
||||||
// ]).entries.map(
|
avatar: SizedBox(
|
||||||
// (entry) => Chip(
|
width: 24,
|
||||||
// avatar: SizedBox(
|
height: 24,
|
||||||
// width: 24,
|
child: SvgPicture.asset(
|
||||||
// height: 24,
|
_getDeviceIcon(productType),
|
||||||
// child: SvgPicture.asset(
|
fit: BoxFit.contain,
|
||||||
// entry.key.icon ?? 'assets/icons/gateway.svg',
|
|
||||||
// fit: BoxFit.contain,
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
// label: Text(
|
|
||||||
// 'x${entry.value}',
|
|
||||||
// style: Theme.of(context)
|
|
||||||
// .textTheme
|
|
||||||
// .bodySmall
|
|
||||||
// ?.copyWith(color: ColorsManager.spaceColor),
|
|
||||||
// ),
|
|
||||||
// backgroundColor: ColorsManager.whiteColors,
|
|
||||||
// shape: RoundedRectangleBorder(
|
|
||||||
// borderRadius: BorderRadius.circular(16),
|
|
||||||
// side: const BorderSide(
|
|
||||||
// color: ColorsManager.spaceColor,
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
|
|
||||||
EditChip(
|
|
||||||
onTap: () {},
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
'x$count',
|
||||||
|
style: context.textTheme.bodySmall?.copyWith(
|
||||||
|
color: ColorsManager.spaceColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: ColorsManager.whiteColors,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: const BorderSide(
|
||||||
|
color: ColorsManager.spaceColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
EditChip(onTap: () => _showAssignTagsDialog(context)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return TextButton(
|
return TextButton(
|
||||||
onPressed: () {},
|
onPressed: () => _showAssignTagsDialog(context),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
@ -83,10 +90,50 @@ class SpaceDetailsDevicesBox extends StatelessWidget {
|
|||||||
child: ButtonContentWidget(
|
child: ButtonContentWidget(
|
||||||
svgAssets: Assets.addIcon,
|
svgAssets: Assets.addIcon,
|
||||||
label: 'Add Devices',
|
label: 'Add Devices',
|
||||||
// disabled: isTagsAndSubspaceModelDisabled,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showAssignTagsDialog(BuildContext context) {
|
||||||
|
showDialog<SpaceDetailsModel>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AssignTagsDialog(space: space),
|
||||||
|
).then((resultSpace) {
|
||||||
|
if (resultSpace != null) {
|
||||||
|
if (context.mounted) {
|
||||||
|
context.read<SpaceDetailsModelBloc>().add(UpdateSpaceDetails(resultSpace));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getDeviceIcon(String productType) =>
|
||||||
|
switch (devicesTypesMap[productType]) {
|
||||||
|
DeviceType.LightBulb => Assets.lightBulb,
|
||||||
|
DeviceType.CeilingSensor => Assets.sensors,
|
||||||
|
DeviceType.AC => Assets.ac,
|
||||||
|
DeviceType.DoorLock => Assets.doorLock,
|
||||||
|
DeviceType.Curtain => Assets.curtain,
|
||||||
|
DeviceType.ThreeGang => Assets.gangSwitch,
|
||||||
|
DeviceType.Gateway => Assets.gateway,
|
||||||
|
DeviceType.OneGang => Assets.oneGang,
|
||||||
|
DeviceType.TwoGang => Assets.twoGang,
|
||||||
|
DeviceType.WH => Assets.waterHeater,
|
||||||
|
DeviceType.DoorSensor => Assets.openCloseDoor,
|
||||||
|
DeviceType.GarageDoor => Assets.openedDoor,
|
||||||
|
DeviceType.WaterLeak => Assets.waterLeakNormal,
|
||||||
|
DeviceType.Curtain2 => Assets.curtainIcon,
|
||||||
|
DeviceType.Blind => Assets.curtainIcon,
|
||||||
|
DeviceType.WallSensor => Assets.sensors,
|
||||||
|
DeviceType.DS => Assets.openCloseDoor,
|
||||||
|
DeviceType.OneTouch => Assets.gangSwitch,
|
||||||
|
DeviceType.TowTouch => Assets.gangSwitch,
|
||||||
|
DeviceType.ThreeTouch => Assets.gangSwitch,
|
||||||
|
DeviceType.NCPS => Assets.sensors,
|
||||||
|
DeviceType.PC => Assets.powerClamp,
|
||||||
|
DeviceType.Other => Assets.blackLogo,
|
||||||
|
null => Assets.blackLogo,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -42,9 +42,8 @@ class SpaceDetailsForm extends StatelessWidget {
|
|||||||
Expanded(child: SpaceIconPicker(iconPath: space.icon)),
|
Expanded(child: SpaceIconPicker(iconPath: space.icon)),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: Column(
|
child: ListView(
|
||||||
mainAxisSize: MainAxisSize.min,
|
shrinkWrap: true,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
SpaceNameTextField(
|
SpaceNameTextField(
|
||||||
initialValue: space.spaceName,
|
initialValue: space.spaceName,
|
||||||
@ -52,7 +51,7 @@ class SpaceDetailsForm extends StatelessWidget {
|
|||||||
(subspace) => subspace.name == value,
|
(subspace) => subspace.name == value,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const SizedBox(height: 32),
|
||||||
SpaceSubSpacesBox(
|
SpaceSubSpacesBox(
|
||||||
subspaces: space.subspaces,
|
subspaces: space.subspaces,
|
||||||
),
|
),
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:syncrow_web/pages/common/bloc/project_manager.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/params/load_tags_param.dart';
|
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/services/tags_service.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/services/tags_service.dart';
|
||||||
import 'package:syncrow_web/services/api/api_exception.dart';
|
import 'package:syncrow_web/services/api/api_exception.dart';
|
||||||
import 'package:syncrow_web/services/api/http_service.dart';
|
import 'package:syncrow_web/services/api/http_service.dart';
|
||||||
import 'package:syncrow_web/utils/constants/api_const.dart';
|
|
||||||
|
|
||||||
final class RemoteTagsService implements TagsService {
|
final class RemoteTagsService implements TagsService {
|
||||||
const RemoteTagsService(this._httpService);
|
const RemoteTagsService(this._httpService);
|
||||||
@ -14,17 +13,10 @@ final class RemoteTagsService implements TagsService {
|
|||||||
static const _defaultErrorMessage = 'Failed to load tags';
|
static const _defaultErrorMessage = 'Failed to load tags';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Tag>> loadTags(LoadTagsParam param) async {
|
Future<List<Tag>> loadTags() async {
|
||||||
if (param.projectUuid == null) {
|
|
||||||
throw Exception('Project UUID is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _httpService.get(
|
final response = await _httpService.get(
|
||||||
path: ApiEndpoints.listTags.replaceAll(
|
path: await _makeUrl(),
|
||||||
'{projectUuid}',
|
|
||||||
param.projectUuid!,
|
|
||||||
),
|
|
||||||
expectedResponseModel: (json) {
|
expectedResponseModel: (json) {
|
||||||
final result = json as Map<String, dynamic>;
|
final result = json as Map<String, dynamic>;
|
||||||
final data = result['data'] as List<dynamic>;
|
final data = result['data'] as List<dynamic>;
|
||||||
@ -46,4 +38,12 @@ final class RemoteTagsService implements TagsService {
|
|||||||
throw APIException(formattedErrorMessage);
|
throw APIException(formattedErrorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> _makeUrl() async {
|
||||||
|
final projectUuid = await ProjectManager.getProjectUUID();
|
||||||
|
if (projectUuid == null || projectUuid.isEmpty) {
|
||||||
|
throw APIException('Project UUID is required');
|
||||||
|
}
|
||||||
|
return '/projects/$projectUuid/tags';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,13 @@ class Tag extends Equatable {
|
|||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
factory Tag.empty() => const Tag(
|
||||||
|
uuid: '',
|
||||||
|
name: '',
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
);
|
||||||
|
|
||||||
factory Tag.fromJson(Map<String, dynamic> json) {
|
factory Tag.fromJson(Map<String, dynamic> json) {
|
||||||
return Tag(
|
return Tag(
|
||||||
uuid: json['uuid'] as String,
|
uuid: json['uuid'] as String,
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/params/load_tags_param.dart';
|
|
||||||
|
|
||||||
abstract interface class TagsService {
|
abstract interface class TagsService {
|
||||||
Future<List<Tag>> loadTags(LoadTagsParam param);
|
Future<List<Tag>> loadTags();
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/params/load_tags_param.dart';
|
|
||||||
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/services/tags_service.dart';
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/services/tags_service.dart';
|
||||||
import 'package:syncrow_web/services/api/api_exception.dart';
|
import 'package:syncrow_web/services/api/api_exception.dart';
|
||||||
|
|
||||||
@ -21,7 +20,7 @@ class TagsBloc extends Bloc<TagsEvent, TagsState> {
|
|||||||
) async {
|
) async {
|
||||||
emit(TagsLoading());
|
emit(TagsLoading());
|
||||||
try {
|
try {
|
||||||
final tags = await _tagsService.loadTags(event.param);
|
final tags = await _tagsService.loadTags();
|
||||||
emit(TagsLoaded(tags));
|
emit(TagsLoaded(tags));
|
||||||
} on APIException catch (e) {
|
} on APIException catch (e) {
|
||||||
emit(TagsFailure(e.message));
|
emit(TagsFailure(e.message));
|
||||||
|
@ -8,10 +8,5 @@ abstract class TagsEvent extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class LoadTags extends TagsEvent {
|
class LoadTags extends TagsEvent {
|
||||||
final LoadTagsParam param;
|
const LoadTags();
|
||||||
|
|
||||||
const LoadTags(this.param);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [param];
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,100 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/products/data/services/remote_products_service.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/products/presentation/bloc/products_bloc.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_action_buttons.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/products_grid.dart';
|
||||||
|
import 'package:syncrow_web/services/api/http_service.dart';
|
||||||
|
import 'package:syncrow_web/utils/color_manager.dart';
|
||||||
|
import 'package:syncrow_web/utils/extension/build_context_x.dart';
|
||||||
|
|
||||||
|
class AddDeviceTypeWidget extends StatefulWidget {
|
||||||
|
const AddDeviceTypeWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddDeviceTypeWidget> createState() => _AddDeviceTypeWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddDeviceTypeWidgetState extends State<AddDeviceTypeWidget> {
|
||||||
|
final Map<Product, int> _selectedProducts = {};
|
||||||
|
|
||||||
|
void _onIncrement(Product product) {
|
||||||
|
setState(() {
|
||||||
|
_selectedProducts[product] = (_selectedProducts[product] ?? 0) + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDecrement(Product product) {
|
||||||
|
setState(() {
|
||||||
|
if ((_selectedProducts[product] ?? 0) > 0) {
|
||||||
|
_selectedProducts[product] = _selectedProducts[product]! - 1;
|
||||||
|
if (_selectedProducts[product] == 0) {
|
||||||
|
_selectedProducts.remove(product);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider(
|
||||||
|
create: (_) => ProductsBloc(RemoteProductsService(HTTPService()))
|
||||||
|
..add(const LoadProducts()),
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const SelectableText('Add Devices'),
|
||||||
|
backgroundColor: ColorsManager.whiteColors,
|
||||||
|
content: BlocBuilder<ProductsBloc, ProductsState>(
|
||||||
|
builder: (context, state) => switch (state) {
|
||||||
|
ProductsInitial() || ProductsLoading() => _buildLoading(context),
|
||||||
|
ProductsLoaded(:final products) => ProductsGrid(
|
||||||
|
products: products,
|
||||||
|
selectedProducts: _selectedProducts,
|
||||||
|
onIncrement: _onIncrement,
|
||||||
|
onDecrement: _onDecrement,
|
||||||
|
),
|
||||||
|
ProductsFailure(:final errorMessage) => _buildFailure(
|
||||||
|
context,
|
||||||
|
errorMessage,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
SpaceDetailsActionButtons(
|
||||||
|
onSave: () {
|
||||||
|
final result = _selectedProducts.entries
|
||||||
|
.expand((entry) => List.generate(entry.value, (_) => entry.key))
|
||||||
|
.toList();
|
||||||
|
Navigator.of(context).pop(result);
|
||||||
|
},
|
||||||
|
onCancel: Navigator.of(context).pop,
|
||||||
|
saveButtonLabel: 'Next',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoading(BuildContext context) => SizedBox(
|
||||||
|
width: context.screenWidth * 0.9,
|
||||||
|
height: context.screenHeight * 0.65,
|
||||||
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _buildFailure(BuildContext context, String errorMessage) {
|
||||||
|
return SizedBox(
|
||||||
|
width: context.screenWidth * 0.9,
|
||||||
|
height: context.screenHeight * 0.65,
|
||||||
|
child: Center(
|
||||||
|
child: SelectableText(
|
||||||
|
errorMessage,
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: context.theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,231 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_action_buttons.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/add_device_type_widget.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_error_messages.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_table.dart';
|
||||||
|
import 'package:syncrow_web/utils/extension/build_context_x.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
class AssignTagsDialog extends StatefulWidget {
|
||||||
|
const AssignTagsDialog({required this.space, super.key});
|
||||||
|
|
||||||
|
final SpaceDetailsModel space;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AssignTagsDialog> createState() => _AssignTagsDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssignTagsDialogState extends State<AssignTagsDialog> {
|
||||||
|
late SpaceDetailsModel _space;
|
||||||
|
final Map<String, String> _validationErrors = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_space = widget.space.copyWith(
|
||||||
|
productAllocations:
|
||||||
|
widget.space.productAllocations.map((e) => e.copyWith()).toList(),
|
||||||
|
subspaces: widget.space.subspaces
|
||||||
|
.map(
|
||||||
|
(s) => s.copyWith(
|
||||||
|
productAllocations:
|
||||||
|
s.productAllocations.map((e) => e.copyWith()).toList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
_validateAllTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateAllTags() {
|
||||||
|
final newErrors = <String, String>{};
|
||||||
|
final allAllocations = [
|
||||||
|
..._space.productAllocations,
|
||||||
|
..._space.subspaces.expand((s) => s.productAllocations),
|
||||||
|
];
|
||||||
|
|
||||||
|
final allocationsByProductType = <String, List<ProductAllocation>>{};
|
||||||
|
for (final allocation in allAllocations) {
|
||||||
|
(allocationsByProductType[allocation.product.productType] ??= [])
|
||||||
|
.add(allocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final productType in allocationsByProductType.keys) {
|
||||||
|
final allocations = allocationsByProductType[productType]!;
|
||||||
|
final tagCounts = <String, int>{};
|
||||||
|
|
||||||
|
for (final allocation in allocations) {
|
||||||
|
final tagName = allocation.tag.name.trim().toLowerCase();
|
||||||
|
if (tagName.isEmpty) {
|
||||||
|
newErrors[allocation.uuid] =
|
||||||
|
'Tag for ${allocation.product.name} cannot be empty.';
|
||||||
|
} else {
|
||||||
|
tagCounts[tagName] = (tagCounts[tagName] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final allocation in allocations) {
|
||||||
|
final tagName = allocation.tag.name.trim().toLowerCase();
|
||||||
|
if (tagName.isNotEmpty && (tagCounts[tagName] ?? 0) > 1) {
|
||||||
|
newErrors[allocation.uuid] =
|
||||||
|
'Tag "${allocation.tag.name}" is used by multiple $productType devices.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_validationErrors
|
||||||
|
..clear()
|
||||||
|
..addAll(newErrors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTagChange(String allocationUuid, Tag newTag) {
|
||||||
|
setState(() {
|
||||||
|
var index =
|
||||||
|
_space.productAllocations.indexWhere((pa) => pa.uuid == allocationUuid);
|
||||||
|
if (index != -1) {
|
||||||
|
final allocation = _space.productAllocations[index];
|
||||||
|
_space.productAllocations[index] = allocation.copyWith(tag: newTag);
|
||||||
|
} else {
|
||||||
|
for (final subspace in _space.subspaces) {
|
||||||
|
index = subspace.productAllocations
|
||||||
|
.indexWhere((pa) => pa.uuid == allocationUuid);
|
||||||
|
if (index != -1) {
|
||||||
|
final allocation = subspace.productAllocations[index];
|
||||||
|
subspace.productAllocations[index] = allocation.copyWith(tag: newTag);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_validateAllTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleLocationChange(String allocationUuid, String? newSubspaceUuid) {
|
||||||
|
setState(() {
|
||||||
|
ProductAllocation? allocationToMove;
|
||||||
|
|
||||||
|
var index =
|
||||||
|
_space.productAllocations.indexWhere((pa) => pa.uuid == allocationUuid);
|
||||||
|
if (index != -1) {
|
||||||
|
allocationToMove = _space.productAllocations.removeAt(index);
|
||||||
|
} else {
|
||||||
|
for (final subspace in _space.subspaces) {
|
||||||
|
index = subspace.productAllocations
|
||||||
|
.indexWhere((pa) => pa.uuid == allocationUuid);
|
||||||
|
if (index != -1) {
|
||||||
|
allocationToMove = subspace.productAllocations.removeAt(index);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allocationToMove == null) return;
|
||||||
|
|
||||||
|
if (newSubspaceUuid == null) {
|
||||||
|
_space.productAllocations.add(allocationToMove);
|
||||||
|
} else {
|
||||||
|
_space.subspaces
|
||||||
|
.firstWhere((s) => s.uuid == newSubspaceUuid)
|
||||||
|
.productAllocations
|
||||||
|
.add(allocationToMove);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleProductDelete(String allocationUuid) {
|
||||||
|
setState(() {
|
||||||
|
_space.productAllocations.removeWhere((pa) => pa.uuid == allocationUuid);
|
||||||
|
|
||||||
|
for (final subspace in _space.subspaces) {
|
||||||
|
subspace.productAllocations.removeWhere(
|
||||||
|
(pa) => pa.uuid == allocationUuid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_validateAllTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final allProductAllocations = [
|
||||||
|
..._space.productAllocations,
|
||||||
|
..._space.subspaces.expand((s) => s.productAllocations),
|
||||||
|
];
|
||||||
|
|
||||||
|
final productLocations = <String, String?>{};
|
||||||
|
for (final pa in _space.productAllocations) {
|
||||||
|
productLocations[pa.uuid] = null;
|
||||||
|
}
|
||||||
|
for (final subspace in _space.subspaces) {
|
||||||
|
for (final pa in subspace.productAllocations) {
|
||||||
|
productLocations[pa.uuid] = subspace.uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasErrors = _validationErrors.isNotEmpty;
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const SelectableText('Assign Tags'),
|
||||||
|
content: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: context.screenWidth * 0.6,
|
||||||
|
minWidth: context.screenWidth * 0.6,
|
||||||
|
maxHeight: context.screenHeight * 0.8,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: AssignTagsTable(
|
||||||
|
productAllocations: allProductAllocations,
|
||||||
|
subspaces: _space.subspaces,
|
||||||
|
productLocations: productLocations,
|
||||||
|
onTagSelected: _handleTagChange,
|
||||||
|
onLocationSelected: _handleLocationChange,
|
||||||
|
onProductDeleted: _handleProductDelete,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasErrors)
|
||||||
|
AssignTagsErrorMessages(
|
||||||
|
errorMessages: _validationErrors.values.toSet().toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
SpaceDetailsActionButtons(
|
||||||
|
onSave: hasErrors ? null : () => Navigator.of(context).pop(_space),
|
||||||
|
onCancel: () async {
|
||||||
|
final newProducts = await showDialog<List<Product>>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const AddDeviceTypeWidget(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newProducts == null || newProducts.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
for (final product in newProducts) {
|
||||||
|
_space.productAllocations.add(
|
||||||
|
ProductAllocation(
|
||||||
|
uuid: const Uuid().v4(),
|
||||||
|
product: product,
|
||||||
|
tag: Tag.empty(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_validateAllTags();
|
||||||
|
},
|
||||||
|
cancelButtonLabel: 'Add New Device',
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncrow_web/utils/extension/build_context_x.dart';
|
||||||
|
|
||||||
|
class AssignTagsErrorMessages extends StatelessWidget {
|
||||||
|
const AssignTagsErrorMessages({super.key, required this.errorMessages});
|
||||||
|
|
||||||
|
final List<String> errorMessages;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: errorMessages
|
||||||
|
.map(
|
||||||
|
(error) => Text(
|
||||||
|
'- $error',
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: context.theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,204 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:syncrow_web/common/dialog_dropdown.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/data/services/remote_tags_service.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/bloc/tags_bloc.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/product_tag_field.dart';
|
||||||
|
import 'package:syncrow_web/services/api/http_service.dart';
|
||||||
|
import 'package:syncrow_web/utils/color_manager.dart';
|
||||||
|
import 'package:syncrow_web/utils/extension/build_context_x.dart';
|
||||||
|
|
||||||
|
class AssignTagsTable extends StatelessWidget {
|
||||||
|
const AssignTagsTable({
|
||||||
|
required this.productAllocations,
|
||||||
|
required this.subspaces,
|
||||||
|
required this.productLocations,
|
||||||
|
required this.onTagSelected,
|
||||||
|
required this.onLocationSelected,
|
||||||
|
required this.onProductDeleted,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<ProductAllocation> productAllocations;
|
||||||
|
final List<Subspace> subspaces;
|
||||||
|
final Map<String, String?> productLocations;
|
||||||
|
final void Function(String, Tag) onTagSelected;
|
||||||
|
final void Function(String, String?) onLocationSelected;
|
||||||
|
final void Function(String) onProductDeleted;
|
||||||
|
|
||||||
|
DataColumn _buildDataColumn(BuildContext context, String label) {
|
||||||
|
return DataColumn(
|
||||||
|
label: SelectableText(label, style: context.textTheme.bodyMedium),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider<TagsBloc>(
|
||||||
|
create: (BuildContext context) => TagsBloc(
|
||||||
|
RemoteTagsService(HTTPService()),
|
||||||
|
)..add(const LoadTags()),
|
||||||
|
child: BlocBuilder<TagsBloc, TagsState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
return switch (state) {
|
||||||
|
TagsLoading() || TagsInitial() => const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
TagsFailure(:final message) => Center(
|
||||||
|
child: Text(message),
|
||||||
|
),
|
||||||
|
TagsLoaded(:final tags) => ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: DataTable(
|
||||||
|
headingRowColor: WidgetStateProperty.all(
|
||||||
|
ColorsManager.dataHeaderGrey,
|
||||||
|
),
|
||||||
|
key: ValueKey(productAllocations.length),
|
||||||
|
border: TableBorder.all(
|
||||||
|
color: ColorsManager.dataHeaderGrey,
|
||||||
|
width: 1,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
columns: [
|
||||||
|
_buildDataColumn(context, '#'),
|
||||||
|
_buildDataColumn(context, 'Device'),
|
||||||
|
_buildDataColumn(context, 'Tag'),
|
||||||
|
_buildDataColumn(context, 'Location'),
|
||||||
|
],
|
||||||
|
rows: productAllocations.isEmpty
|
||||||
|
? [
|
||||||
|
DataRow(
|
||||||
|
cells: [
|
||||||
|
DataCell(
|
||||||
|
Center(
|
||||||
|
child: SelectableText(
|
||||||
|
'No Devices Available',
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: ColorsManager.lightGrayColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataCell.empty,
|
||||||
|
DataCell.empty,
|
||||||
|
DataCell.empty,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: List.generate(productAllocations.length, (index) {
|
||||||
|
final productAllocation = productAllocations[index];
|
||||||
|
final allocationUuid = productAllocation.uuid;
|
||||||
|
|
||||||
|
final availableTags = tags
|
||||||
|
.where(
|
||||||
|
(tag) =>
|
||||||
|
!productAllocations
|
||||||
|
.where((p) =>
|
||||||
|
p.product.productType ==
|
||||||
|
productAllocation.product.productType)
|
||||||
|
.map((p) => p.tag.name.toLowerCase())
|
||||||
|
.contains(tag.name.toLowerCase()) ||
|
||||||
|
tag.uuid == productAllocation.tag.uuid,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final currentLocationUuid =
|
||||||
|
productLocations[allocationUuid];
|
||||||
|
final currentLocationName = currentLocationUuid == null
|
||||||
|
? 'Main Space'
|
||||||
|
: subspaces
|
||||||
|
.firstWhere((s) => s.uuid == currentLocationUuid)
|
||||||
|
.name;
|
||||||
|
|
||||||
|
return DataRow(
|
||||||
|
key: ValueKey(allocationUuid),
|
||||||
|
cells: [
|
||||||
|
DataCell(Text((index + 1).toString())),
|
||||||
|
DataCell(
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
productAllocation.product.name,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Container(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: ColorsManager.lightGrayColor,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
color: ColorsManager.lightGreyColor,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
onProductDeleted(allocationUuid);
|
||||||
|
},
|
||||||
|
tooltip: 'Delete Tag',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataCell(
|
||||||
|
Container(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
width: double.infinity,
|
||||||
|
child: ProductTagField(
|
||||||
|
key: ValueKey('dropdown_$allocationUuid'),
|
||||||
|
productName: productAllocation.product.uuid,
|
||||||
|
initialValue: productAllocation.tag,
|
||||||
|
onSelected: (newTag) {
|
||||||
|
onTagSelected(allocationUuid, newTag);
|
||||||
|
},
|
||||||
|
items: availableTags,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataCell(
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: DialogDropdown(
|
||||||
|
items: [
|
||||||
|
'Main Space',
|
||||||
|
...subspaces.map((s) => s.name)
|
||||||
|
],
|
||||||
|
selectedValue: currentLocationName,
|
||||||
|
onSelected: (newLocationName) {
|
||||||
|
final newSubspaceUuid = newLocationName ==
|
||||||
|
'Main Space'
|
||||||
|
? null
|
||||||
|
: subspaces
|
||||||
|
.firstWhere(
|
||||||
|
(s) => s.name == newLocationName)
|
||||||
|
.uuid;
|
||||||
|
onLocationSelected(
|
||||||
|
allocationUuid, newSubspaceUuid);
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ => const SizedBox.shrink(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,186 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
|
||||||
|
import 'package:syncrow_web/utils/color_manager.dart';
|
||||||
|
import 'package:syncrow_web/utils/extension/build_context_x.dart';
|
||||||
|
|
||||||
|
class ProductTagField extends StatefulWidget {
|
||||||
|
final List<Tag> items;
|
||||||
|
final ValueChanged<Tag> onSelected;
|
||||||
|
final Tag? initialValue;
|
||||||
|
final String productName;
|
||||||
|
|
||||||
|
const ProductTagField({
|
||||||
|
super.key,
|
||||||
|
required this.items,
|
||||||
|
required this.onSelected,
|
||||||
|
this.initialValue,
|
||||||
|
required this.productName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProductTagField> createState() => _ProductTagFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProductTagFieldState extends State<ProductTagField> {
|
||||||
|
bool _isOpen = false;
|
||||||
|
OverlayEntry? _overlayEntry;
|
||||||
|
final TextEditingController _controller = TextEditingController();
|
||||||
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller.text = widget.initialValue?.name ?? '';
|
||||||
|
_focusNode.addListener(_handleFocusChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_focusNode.removeListener(_handleFocusChange);
|
||||||
|
_controller.dispose();
|
||||||
|
_focusNode.dispose();
|
||||||
|
_overlayEntry?.remove();
|
||||||
|
_overlayEntry = null;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleFocusChange() {
|
||||||
|
if (!_focusNode.hasFocus) {
|
||||||
|
_submit(_controller.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit(String value) {
|
||||||
|
final lowerCaseValue = value.toLowerCase();
|
||||||
|
final selectedTag = widget.items.firstWhere(
|
||||||
|
(tag) => tag.name.toLowerCase() == lowerCaseValue,
|
||||||
|
orElse: () => Tag(
|
||||||
|
name: value,
|
||||||
|
uuid: '',
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widget.onSelected(selectedTag);
|
||||||
|
_closeDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleDropdown() {
|
||||||
|
if (_isOpen) {
|
||||||
|
_closeDropdown();
|
||||||
|
} else {
|
||||||
|
_openDropdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openDropdown() {
|
||||||
|
_overlayEntry = _createOverlayEntry();
|
||||||
|
Overlay.of(context).insert(_overlayEntry!);
|
||||||
|
setState(() => _isOpen = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _closeDropdown() {
|
||||||
|
if (_isOpen) {
|
||||||
|
_overlayEntry?.remove();
|
||||||
|
_overlayEntry = null;
|
||||||
|
setState(() => _isOpen = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => FocusScope.of(context).unfocus(),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: ColorsManager.transparentColor),
|
||||||
|
borderRadius: BorderRadius.circular(8.0),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
onFieldSubmitted: _submit,
|
||||||
|
style: context.textTheme.bodyMedium,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Enter or Select a tag',
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _toggleDropdown,
|
||||||
|
child: const Icon(Icons.arrow_drop_down),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
OverlayEntry _createOverlayEntry() {
|
||||||
|
final renderBox = context.findRenderObject()! as RenderBox;
|
||||||
|
final size = renderBox.size;
|
||||||
|
final offset = renderBox.localToGlobal(Offset.zero);
|
||||||
|
|
||||||
|
return OverlayEntry(
|
||||||
|
builder: (context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _closeDropdown,
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
left: offset.dx,
|
||||||
|
top: offset.dy + size.height,
|
||||||
|
width: size.width,
|
||||||
|
child: Material(
|
||||||
|
elevation: 4.0,
|
||||||
|
child: Container(
|
||||||
|
color: ColorsManager.whiteColors,
|
||||||
|
constraints: const BoxConstraints(maxHeight: 200.0),
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: widget.items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final tag = widget.items[index];
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: ColorsManager.lightGrayBorderColor,
|
||||||
|
width: 1.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(
|
||||||
|
tag.name,
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: ColorsManager.textPrimaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
_controller.text = tag.name;
|
||||||
|
_submit(tag.name);
|
||||||
|
_closeDropdown();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/product_type_card_counter.dart';
|
||||||
|
import 'package:syncrow_web/pages/spaces_management/tag_model/widgets/device_icon_widget.dart';
|
||||||
|
import 'package:syncrow_web/utils/color_manager.dart';
|
||||||
|
import 'package:syncrow_web/utils/extension/build_context_x.dart';
|
||||||
|
|
||||||
|
class ProductTypeCard extends StatelessWidget {
|
||||||
|
const ProductTypeCard({
|
||||||
|
required this.product,
|
||||||
|
required this.count,
|
||||||
|
required this.onIncrement,
|
||||||
|
required this.onDecrement,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Product product;
|
||||||
|
final int count;
|
||||||
|
final void Function() onIncrement;
|
||||||
|
final void Function() onDecrement;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
color: ColorsManager.whiteColors,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: Column(
|
||||||
|
spacing: 8,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(child: DeviceIconWidget(icon: product.icon)),
|
||||||
|
_buildName(context, product.name),
|
||||||
|
ProductTypeCardCounter(
|
||||||
|
onIncrement: onIncrement,
|
||||||
|
onDecrement: onDecrement,
|
||||||
|
count: count,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildName(BuildContext context, String name) {
|
||||||
|
return Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 35,
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
style: context.textTheme.bodySmall?.copyWith(
|
||||||
|
color: ColorsManager.blackColor,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncrow_web/utils/color_manager.dart';
|
||||||
|
import 'package:syncrow_web/utils/extension/build_context_x.dart';
|
||||||
|
|
||||||
|
class ProductTypeCardCounter extends StatelessWidget {
|
||||||
|
const ProductTypeCardCounter({
|
||||||
|
super.key,
|
||||||
|
required this.onIncrement,
|
||||||
|
required this.onDecrement,
|
||||||
|
required this.count,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int count;
|
||||||
|
final void Function() onIncrement;
|
||||||
|
final void Function() onDecrement;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ColorsManager.counterBackgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
_buildCounterButton(
|
||||||
|
Icons.remove,
|
||||||
|
onDecrement,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
count.toString(),
|
||||||
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: ColorsManager.spaceColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildCounterButton(Icons.add, onIncrement),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCounterButton(
|
||||||
|
IconData icon,
|
||||||
|
VoidCallback onPressed,
|
||||||
|
) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onPressed,
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: ColorsManager.spaceColor.withValues(alpha: 0.3),
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
|
||||||
|
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/product_type_card.dart';
|
||||||
|
import 'package:syncrow_web/utils/color_manager.dart';
|
||||||
|
import 'package:syncrow_web/utils/extension/build_context_x.dart';
|
||||||
|
|
||||||
|
class ProductsGrid extends StatelessWidget {
|
||||||
|
const ProductsGrid({
|
||||||
|
required this.products,
|
||||||
|
required this.selectedProducts,
|
||||||
|
required this.onIncrement,
|
||||||
|
required this.onDecrement,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<Product> products;
|
||||||
|
final Map<Product, int> selectedProducts;
|
||||||
|
final void Function(Product) onIncrement;
|
||||||
|
final void Function(Product) onDecrement;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final size = MediaQuery.of(context).size;
|
||||||
|
final crossAxisCount = switch (context.screenWidth) {
|
||||||
|
> 1200 => 8,
|
||||||
|
> 800 => 5,
|
||||||
|
_ => 3,
|
||||||
|
};
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Container(
|
||||||
|
width: size.width * 0.9,
|
||||||
|
height: size.height * 0.65,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ColorsManager.textFieldGreyColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: GridView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
),
|
||||||
|
shrinkWrap: true,
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: crossAxisCount,
|
||||||
|
mainAxisSpacing: 6,
|
||||||
|
crossAxisSpacing: 4,
|
||||||
|
childAspectRatio: 0.8,
|
||||||
|
),
|
||||||
|
itemCount: products.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final product = products[index];
|
||||||
|
return ProductTypeCard(
|
||||||
|
product: product,
|
||||||
|
count: selectedProducts[product] ?? 0,
|
||||||
|
onIncrement: () => onIncrement(product),
|
||||||
|
onDecrement: () => onDecrement(product),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -13,6 +13,7 @@ class SpaceDetailsModelBloc extends Bloc<SpaceDetailsModelEvent, SpaceDetailsMod
|
|||||||
on<UpdateSpaceDetailsSubspaces>(_onUpdateSpaceDetailsSubspaces);
|
on<UpdateSpaceDetailsSubspaces>(_onUpdateSpaceDetailsSubspaces);
|
||||||
on<UpdateSpaceDetailsProductAllocations>(
|
on<UpdateSpaceDetailsProductAllocations>(
|
||||||
_onUpdateSpaceDetailsProductAllocations);
|
_onUpdateSpaceDetailsProductAllocations);
|
||||||
|
on<UpdateSpaceDetails>(_onUpdateSpaceDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onUpdateSpaceDetailsIcon(
|
void _onUpdateSpaceDetailsIcon(
|
||||||
@ -42,4 +43,11 @@ class SpaceDetailsModelBloc extends Bloc<SpaceDetailsModelEvent, SpaceDetailsMod
|
|||||||
) {
|
) {
|
||||||
emit(state.copyWith(productAllocations: event.productAllocations));
|
emit(state.copyWith(productAllocations: event.productAllocations));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onUpdateSpaceDetails(
|
||||||
|
UpdateSpaceDetails event,
|
||||||
|
Emitter<SpaceDetailsModel> emit,
|
||||||
|
) {
|
||||||
|
emit(event.space);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -42,3 +42,12 @@ final class UpdateSpaceDetailsProductAllocations extends SpaceDetailsModelEvent
|
|||||||
@override
|
@override
|
||||||
List<Object> get props => [productAllocations];
|
List<Object> get props => [productAllocations];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class UpdateSpaceDetails extends SpaceDetailsModelEvent {
|
||||||
|
const UpdateSpaceDetails(this.space);
|
||||||
|
|
||||||
|
final SpaceDetailsModel space;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [space];
|
||||||
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import 'dart:convert';
|
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/device_model.dart';
|
||||||
import 'package:syncrow_web/pages/visitor_password/model/schedule_model.dart';
|
import 'package:syncrow_web/pages/visitor_password/model/schedule_model.dart';
|
||||||
import 'package:syncrow_web/services/api/http_service.dart';
|
import 'package:syncrow_web/services/api/http_service.dart';
|
||||||
|
@ -84,4 +84,5 @@ abstract class ColorsManager {
|
|||||||
static const Color minBlue = Color(0xFF93AAFD);
|
static const Color minBlue = Color(0xFF93AAFD);
|
||||||
static const Color minBlueDot = Color(0xFF023DFE);
|
static const Color minBlueDot = Color(0xFF023DFE);
|
||||||
static const Color grey25 = Color(0xFFF9F9F9);
|
static const Color grey25 = Color(0xFFF9F9F9);
|
||||||
|
static const Color grey50 = Color(0xFF718096);
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,8 @@ abstract class ApiEndpoints {
|
|||||||
// Community Module
|
// Community Module
|
||||||
static const String createCommunity = '/projects/{projectId}/communities';
|
static const String createCommunity = '/projects/{projectId}/communities';
|
||||||
static const String getCommunityList = '/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 =
|
static const String getCommunityById =
|
||||||
'/projects/{projectId}/communities/{communityId}';
|
'/projects/{projectId}/communities/{communityId}';
|
||||||
static const String updateCommunity =
|
static const String updateCommunity =
|
||||||
@ -138,4 +139,6 @@ abstract class ApiEndpoints {
|
|||||||
static const String assignDeviceToRoom =
|
static const String assignDeviceToRoom =
|
||||||
'/projects/{projectUuid}/communities/{communityUuid}/spaces/{spaceUuid}/subspaces/{subSpaceUuid}/devices/{deviceUuid}';
|
'/projects/{projectUuid}/communities/{communityUuid}/spaces/{spaceUuid}/subspaces/{subSpaceUuid}/devices/{deviceUuid}';
|
||||||
static const String saveSchedule = '/schedule/{deviceUuid}';
|
static const String saveSchedule = '/schedule/{deviceUuid}';
|
||||||
|
|
||||||
|
static const String getBookableSpaces = '/bookable-spaces';
|
||||||
}
|
}
|
||||||
|
@ -63,6 +63,9 @@ dependencies:
|
|||||||
bloc: ^9.0.0
|
bloc: ^9.0.0
|
||||||
geocoding: ^4.0.0
|
geocoding: ^4.0.0
|
||||||
gauge_indicator: ^0.4.3
|
gauge_indicator: ^0.4.3
|
||||||
|
calendar_view: ^1.4.0
|
||||||
|
calendar_date_picker2: ^2.0.1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
Reference in New Issue
Block a user