mirror of
https://github.com/SyncrowIOT/web.git
synced 2025-07-10 15:17:31 +00:00
Compare commits
33 Commits
bb846f797f
...
implement-
Author | SHA1 | Date | |
---|---|---|---|
645a07287e | |||
e55e793081 | |||
885ef61114 | |||
bfd6b5c3a0 | |||
2b638940ae | |||
3e95bf4473 | |||
2d16bda61d | |||
5c90d5f6b9 | |||
d6a48850a7 | |||
6cac94a1c4 | |||
9f28e1ccef | |||
6534bfae5b | |||
4cfb984d2c | |||
4c06479469 | |||
3101960201 | |||
ddfd4ee153 | |||
7f0484eec6 | |||
dc7064d142 | |||
e523a83912 | |||
e917225c3d | |||
66ed30b50c | |||
47bd6ff89e | |||
138390496c | |||
df87e41d61 | |||
f0bfe085a4 | |||
46a7add90d | |||
73de1e6ff9 | |||
826dea8054 | |||
fdea4b1cd0 | |||
823d86fd80 | |||
dd735032ea | |||
6dcc851d97 | |||
15b36fd052 |
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:syncrow_web/pages/access_management/bloc/access_event.dart';
|
||||
import 'package:syncrow_web/pages/access_management/bloc/access_state.dart';
|
||||
import 'package:syncrow_web/pages/access_management/model/password_model.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/presentation/model/password_model.dart';
|
||||
import 'package:syncrow_web/pages/common/bloc/project_manager.dart';
|
||||
import 'package:syncrow_web/pages/common/hour_picker_dialog.dart';
|
||||
import 'package:syncrow_web/services/access_mang_api.dart';
|
||||
|
@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:syncrow_web/pages/access_management/model/password_model.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/presentation/model/password_model.dart';
|
||||
|
||||
abstract class AccessState extends Equatable {
|
||||
const AccessState();
|
||||
|
@ -0,0 +1,52 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/domain/load_bookable_spaces_param.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/domain/models/paginated_bookable_spaces.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/domain/services/bookable_system_service.dart';
|
||||
import 'package:syncrow_web/services/api/api_exception.dart';
|
||||
import 'package:syncrow_web/services/api/http_service.dart';
|
||||
import 'package:syncrow_web/utils/constants/api_const.dart';
|
||||
|
||||
class RemoteBookableSpacesService implements BookableSystemService {
|
||||
const RemoteBookableSpacesService(this._httpService);
|
||||
|
||||
final HTTPService _httpService;
|
||||
static const _defaultErrorMessage = 'Failed to load bookable spaces';
|
||||
|
||||
@override
|
||||
Future<PaginatedBookableSpaces> getBookableSpaces({
|
||||
required LoadBookableSpacesParam param,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _httpService.get(
|
||||
path: ApiEndpoints.getBookableSpaces,
|
||||
queryParameters: {
|
||||
'page': param.page,
|
||||
'size': param.size,
|
||||
'active': true,
|
||||
'configured': true,
|
||||
if (param.search != null &&
|
||||
param.search.isNotEmpty &&
|
||||
param.search != 'null')
|
||||
'search': param.search,
|
||||
},
|
||||
expectedResponseModel: (json) {
|
||||
return PaginatedBookableSpaces.fromJson(
|
||||
json as Map<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,36 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class LoadBookableSpacesParam extends Equatable {
|
||||
const LoadBookableSpacesParam({
|
||||
this.page = 1,
|
||||
this.size = 25,
|
||||
this.search = '',
|
||||
this.active = true,
|
||||
this.configured = true,
|
||||
});
|
||||
|
||||
final int page;
|
||||
final int size;
|
||||
final String search;
|
||||
final bool active;
|
||||
final bool configured;
|
||||
|
||||
LoadBookableSpacesParam copyWith({
|
||||
int? page,
|
||||
int? size,
|
||||
String? search,
|
||||
bool? active,
|
||||
bool? configured,
|
||||
}) {
|
||||
return LoadBookableSpacesParam(
|
||||
page: page ?? this.page,
|
||||
size: size ?? this.size,
|
||||
search: search ?? this.search,
|
||||
active: active ?? this.active,
|
||||
configured: configured ?? this.configured,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size, search, active, configured];
|
||||
}
|
@ -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,8 @@
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/domain/load_bookable_spaces_param.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/domain/models/paginated_bookable_spaces.dart';
|
||||
|
||||
abstract class BookableSystemService {
|
||||
Future<PaginatedBookableSpaces> getBookableSpaces({
|
||||
required LoadBookableSpacesParam param,
|
||||
});
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
import 'dart:async';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/domain/load_bookable_spaces_param.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/domain/models/paginated_bookable_spaces.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/domain/services/bookable_system_service.dart';
|
||||
|
||||
class DebouncedBookableSpacesService implements BookableSystemService {
|
||||
final BookableSystemService _inner;
|
||||
final Duration debounceDuration;
|
||||
|
||||
Timer? _debounceTimer;
|
||||
Completer<PaginatedBookableSpaces>? _lastCompleter;
|
||||
|
||||
DebouncedBookableSpacesService(
|
||||
this._inner, {
|
||||
this.debounceDuration = const Duration(milliseconds: 500),
|
||||
});
|
||||
|
||||
@override
|
||||
Future<PaginatedBookableSpaces> getBookableSpaces({
|
||||
required LoadBookableSpacesParam param,
|
||||
}) {
|
||||
_debounceTimer?.cancel();
|
||||
if (_lastCompleter != null && !_lastCompleter!.isCompleted) {
|
||||
_lastCompleter!.completeError(StateError("Cancelled by new search"));
|
||||
}
|
||||
|
||||
final completer = Completer<PaginatedBookableSpaces>();
|
||||
_lastCompleter = completer;
|
||||
|
||||
_debounceTimer = Timer(debounceDuration, () async {
|
||||
try {
|
||||
final result = await _inner.getBookableSpaces(param: param);
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(result);
|
||||
}
|
||||
} catch (e, st) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(e, st);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
}
|
@ -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,40 @@
|
||||
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(state.copyWith(
|
||||
selectedDate: event.selectedDate,
|
||||
weekStart: newWeekStart,
|
||||
));
|
||||
});
|
||||
|
||||
on<NextWeek>((event, emit) {
|
||||
final newWeekStart = state.weekStart.add(const Duration(days: 7));
|
||||
emit(state.copyWith(
|
||||
weekStart: newWeekStart,
|
||||
));
|
||||
});
|
||||
|
||||
on<PreviousWeek>((event, emit) {
|
||||
final newWeekStart = state.weekStart.subtract(const Duration(days: 7));
|
||||
emit(state.copyWith(
|
||||
weekStart: newWeekStart,
|
||||
));
|
||||
});
|
||||
|
||||
on<SelectDateFromSidebarCalendar>((event, emit) {
|
||||
emit(state.copyWith(
|
||||
selectedDateFromSideBarCalender: event.selectedDate,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
static DateTime _getStartOfWeek(DateTime date) {
|
||||
return date.subtract(Duration(days: date.weekday - 1));
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
|
||||
abstract class DateSelectionEvent {
|
||||
const DateSelectionEvent();
|
||||
}
|
||||
|
||||
class SelectDate extends DateSelectionEvent {
|
||||
final DateTime selectedDate;
|
||||
const SelectDate(this.selectedDate);
|
||||
}
|
||||
|
||||
class NextWeek extends DateSelectionEvent {}
|
||||
|
||||
class PreviousWeek extends DateSelectionEvent {}
|
||||
|
||||
class SelectDateFromSidebarCalendar extends DateSelectionEvent {
|
||||
final DateTime selectedDate;
|
||||
SelectDateFromSidebarCalendar(this.selectedDate);
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
|
||||
class DateSelectionState {
|
||||
final DateTime selectedDate;
|
||||
final DateTime weekStart;
|
||||
final DateTime? selectedDateFromSideBarCalender;
|
||||
|
||||
DateSelectionState({
|
||||
required this.selectedDate,
|
||||
required this.weekStart,
|
||||
this.selectedDateFromSideBarCalender,
|
||||
});
|
||||
|
||||
factory DateSelectionState.initial() {
|
||||
final now = DateTime.now();
|
||||
final weekStart = now.subtract(Duration(days: now.weekday - 1));
|
||||
return DateSelectionState(
|
||||
selectedDate: now,
|
||||
weekStart: weekStart,
|
||||
selectedDateFromSideBarCalender: null,
|
||||
);
|
||||
}
|
||||
|
||||
DateSelectionState copyWith({
|
||||
DateTime? selectedDate,
|
||||
DateTime? weekStart,
|
||||
DateTime? selectedDateFromSideBarCalender,
|
||||
}) {
|
||||
return DateSelectionState(
|
||||
selectedDate: selectedDate ?? this.selectedDate,
|
||||
weekStart: weekStart ?? this.weekStart,
|
||||
selectedDateFromSideBarCalender: selectedDateFromSideBarCalender ?? this.selectedDateFromSideBarCalender,
|
||||
);
|
||||
}
|
||||
}
|
@ -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,148 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/domain/load_bookable_spaces_param.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/domain/services/bookable_system_service.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_event.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_state.dart';
|
||||
|
||||
class SidebarBloc extends Bloc<SidebarEvent, SidebarState> {
|
||||
final BookableSystemService _bookingService;
|
||||
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(
|
||||
param: LoadBookableSpacesParam(
|
||||
page: _currentPage,
|
||||
size: _pageSize,
|
||||
search: _currentSearch,
|
||||
),
|
||||
);
|
||||
|
||||
emit(state.copyWith(
|
||||
allRooms: paginatedSpaces.data,
|
||||
displayedRooms: paginatedSpaces.data,
|
||||
isLoading: false,
|
||||
hasMore: paginatedSpaces.hasNext,
|
||||
totalPages: paginatedSpaces.totalPage,
|
||||
currentPage: _currentPage,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: 'Failed to load rooms: ${e.toString()}',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<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(
|
||||
param: LoadBookableSpacesParam(
|
||||
page: _currentPage,
|
||||
size: _pageSize,
|
||||
search: _currentSearch,
|
||||
),
|
||||
);
|
||||
|
||||
final updatedRooms = [...state.allRooms, ...paginatedSpaces.data];
|
||||
|
||||
emit(state.copyWith(
|
||||
allRooms: updatedRooms,
|
||||
displayedRooms: updatedRooms,
|
||||
isLoadingMore: false,
|
||||
hasMore: paginatedSpaces.hasNext,
|
||||
totalPages: paginatedSpaces.totalPage,
|
||||
currentPage: _currentPage,
|
||||
));
|
||||
} catch (e) {
|
||||
_currentPage--;
|
||||
emit(state.copyWith(
|
||||
isLoadingMore: false,
|
||||
errorMessage: 'Failed to load more rooms: ${e.toString()}',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<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(
|
||||
param: LoadBookableSpacesParam(
|
||||
page: _currentPage,
|
||||
size: _pageSize,
|
||||
search: _currentSearch,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(
|
||||
allRooms: paginatedSpaces.data,
|
||||
displayedRooms: paginatedSpaces.data,
|
||||
isLoading: false,
|
||||
hasMore: paginatedSpaces.hasNext,
|
||||
totalPages: paginatedSpaces.totalPage,
|
||||
currentPage: _currentPage,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: 'Search failed: ${e.toString()}',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _onResetSearch(
|
||||
ResetSearch event,
|
||||
Emitter<SidebarState> emit,
|
||||
) {
|
||||
_currentSearch = '';
|
||||
add(LoadBookableSpaces());
|
||||
}
|
||||
|
||||
void _onSelectRoom(
|
||||
SelectRoomEvent event,
|
||||
Emitter<SidebarState> emit,
|
||||
) {
|
||||
emit(state.copyWith(selectedRoomId: event.roomId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
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,266 @@
|
||||
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));
|
||||
context
|
||||
.read<DateSelectionBloc>()
|
||||
.add(SelectDateFromSidebarCalendar(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,
|
||||
selectedDateFromSideBarCalender: context
|
||||
.watch<DateSelectionBloc>()
|
||||
.state
|
||||
.selectedDateFromSideBarCalender,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,242 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/data/services/remote_bookable_spaces_service.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/domain/models/bookable_room.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_bloc.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_event.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/presentation/bloc/sidebar/sidebar_state.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/presentation/view/widgets/room_list_item.dart';
|
||||
import 'package:syncrow_web/services/api/http_service.dart';
|
||||
import 'package:syncrow_web/utils/color_manager.dart';
|
||||
import 'package:syncrow_web/utils/constants/assets.dart';
|
||||
|
||||
class BookingSidebar extends StatelessWidget {
|
||||
final void Function(BookableSpaceModel) onRoomSelected;
|
||||
|
||||
const BookingSidebar({
|
||||
super.key,
|
||||
required this.onRoomSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => SidebarBloc(RemoteBookableSpacesService(
|
||||
HTTPService(),
|
||||
))
|
||||
..add(LoadBookableSpaces()),
|
||||
child: _SidebarContent(onRoomSelected: onRoomSelected),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarContent extends StatefulWidget {
|
||||
final void Function(BookableSpaceModel) onRoomSelected;
|
||||
|
||||
const _SidebarContent({
|
||||
required this.onRoomSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SidebarContent> createState() => __SidebarContentState();
|
||||
}
|
||||
|
||||
class __SidebarContentState extends State<_SidebarContent> {
|
||||
final TextEditingController searchController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_scrollListener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollListener() {
|
||||
if (_scrollController.position.pixels ==
|
||||
_scrollController.position.maxScrollExtent) {
|
||||
context.read<SidebarBloc>().add(LoadMoreSpaces());
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSearch(String value) {
|
||||
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,
|
||||
this.backgroundColor = ColorsManager.circleRolesBackground,
|
||||
this.svgColor = const Color(0xFF496EFF),
|
||||
this.labelColor = Colors.black87,
|
||||
this.labelColor = Colors.black,
|
||||
this.borderRadius = 10.0,
|
||||
this.boxShadow = const [
|
||||
BoxShadow(
|
||||
color: ColorsManager.textGray,
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 4),
|
||||
color: ColorsManager.lightGrayColor,
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
this.svgSize = 24.0,
|
||||
@ -53,15 +53,14 @@ class SvgTextButton extends StatelessWidget {
|
||||
svgAsset,
|
||||
width: svgSize,
|
||||
height: svgSize,
|
||||
color: svgColor,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: labelColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
@ -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,292 @@
|
||||
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;
|
||||
final DateTime? selectedDateFromSideBarCalender;
|
||||
|
||||
const WeeklyCalendarPage({
|
||||
super.key,
|
||||
required this.weekStart,
|
||||
required this.selectedDate,
|
||||
required this.eventController,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.selectedDateFromSideBarCalender,
|
||||
});
|
||||
|
||||
@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: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
color: ColorsManager.lightGrayColor,
|
||||
size: 80,
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
Text(
|
||||
'Please select a bookable space to view the calendar.',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorsManager.lightGrayColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final weekDays = _getWeekDays(weekStart);
|
||||
|
||||
final selectedDayIndex =
|
||||
weekDays.indexWhere((d) => isSameDay(d, selectedDate));
|
||||
final selectedSidebarIndex = selectedDateFromSideBarCalender == null
|
||||
? -1
|
||||
: weekDays
|
||||
.indexWhere((d) => isSameDay(d, selectedDateFromSideBarCalender!));
|
||||
|
||||
const double timeLineWidth = 80;
|
||||
const int totalDays = 7;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double calendarWidth = constraints.maxWidth;
|
||||
final double dayColumnWidth =
|
||||
(calendarWidth - timeLineWidth) / totalDays - 0.1;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 25.0, right: 25.0, top: 25),
|
||||
child: Stack(
|
||||
children: [
|
||||
WeekView(
|
||||
pageViewPhysics: const NeverScrollableScrollPhysics(),
|
||||
key: ValueKey(weekStart),
|
||||
controller: eventController,
|
||||
initialDay: weekStart,
|
||||
startHour: startHour - 1,
|
||||
endHour: endHour,
|
||||
heightPerMinute: 1.1,
|
||||
showLiveTimeLineInAllDays: false,
|
||||
showVerticalLines: true,
|
||||
emulateVerticalOffsetBy: -80,
|
||||
startDay: WeekDays.monday,
|
||||
liveTimeIndicatorSettings: const LiveTimeIndicatorSettings(
|
||||
showBullet: false,
|
||||
height: 0,
|
||||
),
|
||||
weekDayBuilder: (date) {
|
||||
final index = weekDays.indexWhere((d) => isSameDay(d, date));
|
||||
final isSelectedDay = index == selectedDayIndex;
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
DateFormat('EEE').format(date).toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 14,
|
||||
color: isSelectedDay ? Colors.blue : Colors.black,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
DateFormat('d').format(date),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 20,
|
||||
color: isSelectedDay
|
||||
? ColorsManager.blue1
|
||||
: ColorsManager.blackColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
timeLineBuilder: (date) {
|
||||
int hour = date.hour == 0
|
||||
? 12
|
||||
: (date.hour > 12 ? date.hour - 12 : date.hour);
|
||||
String period = date.hour >= 12 ? 'PM' : 'AM';
|
||||
return Container(
|
||||
height: 60,
|
||||
alignment: Alignment.center,
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '$hour',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 24,
|
||||
color: ColorsManager.blackColor,
|
||||
),
|
||||
),
|
||||
WidgetSpan(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 2, top: 6),
|
||||
child: Text(
|
||||
period,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 12,
|
||||
color: ColorsManager.blackColor,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
timeLineWidth: timeLineWidth,
|
||||
weekPageHeaderBuilder: (start, end) => Container(),
|
||||
weekTitleHeight: 60,
|
||||
weekNumberBuilder: (firstDayOfWeek) => Padding(
|
||||
padding: const EdgeInsets.only(right: 15, bottom: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
firstDayOfWeek.timeZoneName.replaceAll(':00', ''),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontSize: 12,
|
||||
color: ColorsManager.blackColor,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
eventTileBuilder: (date, events, boundary, start, end) {
|
||||
return Container(
|
||||
margin:
|
||||
const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: events.map((event) {
|
||||
final bool isEventEnded = event.endTime != null &&
|
||||
event.endTime!.isBefore(DateTime.now());
|
||||
return Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: isEventEnded
|
||||
? ColorsManager.lightGrayBorderColor
|
||||
: ColorsManager.blue1.withOpacity(0.25),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
DateFormat('h:mm a').format(event.startTime!),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
event.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: ColorsManager.blackColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (selectedDayIndex >= 0)
|
||||
Positioned(
|
||||
left: (timeLineWidth + 3) +
|
||||
(dayColumnWidth - 8) * (selectedDayIndex - 0.01),
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: dayColumnWidth,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
vertical: 0, horizontal: 4),
|
||||
color: ColorsManager.spaceColor.withOpacity(0.07),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selectedSidebarIndex >= 0 &&
|
||||
selectedSidebarIndex != selectedDayIndex)
|
||||
Positioned(
|
||||
left: (timeLineWidth + 3) +
|
||||
(dayColumnWidth - 8) * (selectedSidebarIndex - 0.01),
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: dayColumnWidth,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
vertical: 0, horizontal: 4),
|
||||
color: Colors.orange.withOpacity(0.14),
|
||||
),
|
||||
),
|
||||
),
|
||||
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:syncrow_web/pages/access_management/bloc/access_bloc.dart';
|
||||
import 'package:syncrow_web/pages/access_management/bloc/access_event.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/view/booking_page.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/presentation/view/booking_page.dart';
|
||||
import 'package:syncrow_web/pages/access_management/view/access_overview_content.dart';
|
||||
import 'package:syncrow_web/pages/device_managment/shared/navigate_home_grid_view.dart';
|
||||
import 'package:syncrow_web/utils/extension/build_context_x.dart';
|
||||
|
@ -1,24 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_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/domain/models/community_model.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/create_community_dialog.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/update_community/presentation/edit_community_dialog.dart';
|
||||
|
||||
abstract final class SpaceManagementCommunityDialogHelper {
|
||||
static void showCreateDialog(BuildContext context) {
|
||||
static void showCreateDialog(BuildContext context) => showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const CreateCommunityDialog(),
|
||||
);
|
||||
|
||||
static void showEditDialog(
|
||||
BuildContext context,
|
||||
CommunityModel community,
|
||||
) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => CreateCommunityDialog(
|
||||
title: const SelectableText('Community Name'),
|
||||
onCreateCommunity: (community) {
|
||||
context.read<CommunitiesBloc>().add(
|
||||
InsertCommunity(community),
|
||||
);
|
||||
context.read<CommunitiesTreeSelectionBloc>().add(
|
||||
SelectCommunityEvent(community: community),
|
||||
);
|
||||
},
|
||||
builder: (_) => EditCommunityDialog(
|
||||
community: community,
|
||||
parentContext: context,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void showLoadingDialog(BuildContext context) => showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
|
||||
static void showSuccessSnackBar(BuildContext context, String message) =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -1,28 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:syncrow_web/pages/common/buttons/cancel_button.dart';
|
||||
import 'package:syncrow_web/pages/common/buttons/default_button.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/domain/param/create_community_param.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/bloc/create_community_bloc.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/create_community_name_text_field.dart';
|
||||
import 'package:syncrow_web/utils/color_manager.dart';
|
||||
import 'package:syncrow_web/utils/extension/build_context_x.dart';
|
||||
|
||||
class CreateCommunityDialogWidget extends StatefulWidget {
|
||||
class CommunityDialog extends StatefulWidget {
|
||||
final String? initialName;
|
||||
final Widget title;
|
||||
final void Function(String name) onSubmit;
|
||||
final String? errorMessage;
|
||||
|
||||
const CreateCommunityDialogWidget({
|
||||
super.key,
|
||||
const CommunityDialog({
|
||||
required this.title,
|
||||
required this.onSubmit,
|
||||
this.initialName,
|
||||
this.errorMessage,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CreateCommunityDialogWidget> createState() =>
|
||||
_CreateCommunityDialogWidgetState();
|
||||
State<CommunityDialog> createState() => _CommunityDialogState();
|
||||
}
|
||||
|
||||
class _CreateCommunityDialogWidgetState extends State<CreateCommunityDialogWidget> {
|
||||
class _CommunityDialogState extends State<CommunityDialog> {
|
||||
late final TextEditingController _nameController;
|
||||
|
||||
@override
|
||||
@ -63,35 +64,20 @@ class _CreateCommunityDialogWidgetState extends State<CreateCommunityDialogWidge
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: BlocBuilder<CreateCommunityBloc, CreateCommunityState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.headlineMedium!,
|
||||
child: widget.title,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
CreateCommunityNameTextField(
|
||||
nameController: _nameController,
|
||||
),
|
||||
if (state case CreateCommunityFailure(:final message))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 18),
|
||||
child: SelectableText(
|
||||
'* $message',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildActionButtons(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.headlineMedium!,
|
||||
child: widget.title,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
CreateCommunityNameTextField(nameController: _nameController),
|
||||
_buildErrorMessage(),
|
||||
const SizedBox(height: 24),
|
||||
_buildActionButtons(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -132,13 +118,22 @@ class _CreateCommunityDialogWidgetState extends State<CreateCommunityDialogWidge
|
||||
|
||||
void _onSubmit(BuildContext context) {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
context.read<CreateCommunityBloc>().add(
|
||||
CreateCommunity(
|
||||
CreateCommunityParam(
|
||||
name: _nameController.text.trim(),
|
||||
),
|
||||
),
|
||||
);
|
||||
widget.onSubmit.call(_nameController.text.trim());
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildErrorMessage() {
|
||||
return Visibility(
|
||||
visible: widget.errorMessage != null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsetsDirectional.symmetric(vertical: 18),
|
||||
child: SelectableText(
|
||||
'* ${widget.errorMessage}',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -10,6 +10,7 @@ import 'package:syncrow_web/pages/space_management_v2/modules/communities/presen
|
||||
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/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/services/api/http_service.dart';
|
||||
import 'package:syncrow_web/utils/theme/responsive_text_theme.dart';
|
||||
@ -32,7 +33,9 @@ class SpaceManagementPage extends StatelessWidget {
|
||||
BlocProvider(create: (context) => CommunitiesTreeSelectionBloc()),
|
||||
BlocProvider(
|
||||
create: (context) => SpaceDetailsBloc(
|
||||
RemoteSpaceDetailsService(httpService: HTTPService()),
|
||||
UniqueSubspacesDecorator(
|
||||
RemoteSpaceDetailsService(httpService: HTTPService()),
|
||||
),
|
||||
),
|
||||
),
|
||||
BlocProvider(
|
||||
|
@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/main_module/shared/helpers/space_management_community_dialog_helper.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/community_structure_header_action_buttons.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/create_community/presentation/create_community_dialog.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/helpers/space_details_dialog_helper.dart';
|
||||
import 'package:syncrow_web/utils/color_manager.dart';
|
||||
import 'package:syncrow_web/utils/constants/assets.dart';
|
||||
@ -44,18 +44,6 @@ class CommunityStructureHeader extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreateCommunityDialog(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => CreateCommunityDialog(
|
||||
title: const Text('Edit Community'),
|
||||
onCreateCommunity: (community) {
|
||||
// TODO(FarisArmoush): Implement
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommunityInfo(
|
||||
BuildContext context, ThemeData theme, double screenWidth) {
|
||||
final selectedCommunity =
|
||||
@ -86,7 +74,12 @@ class CommunityStructureHeader extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
GestureDetector(
|
||||
onTap: () => _showCreateCommunityDialog(context),
|
||||
onTap: () {
|
||||
SpaceManagementCommunityDialogHelper.showEditDialog(
|
||||
context,
|
||||
selectedCommunity,
|
||||
);
|
||||
},
|
||||
child: SvgPicture.asset(
|
||||
Assets.iconEdit,
|
||||
width: 16,
|
||||
|
@ -39,6 +39,26 @@ class CommunityModel extends Equatable {
|
||||
.toList();
|
||||
}
|
||||
|
||||
CommunityModel copyWith({
|
||||
String? uuid,
|
||||
String? name,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
String? description,
|
||||
String? externalId,
|
||||
List<SpaceModel>? spaces,
|
||||
}) {
|
||||
return CommunityModel(
|
||||
uuid: uuid ?? this.uuid,
|
||||
name: name ?? this.name,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
description: description ?? this.description,
|
||||
externalId: externalId ?? this.externalId,
|
||||
spaces: spaces ?? this.spaces,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [uuid, name, spaces];
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ class CommunitiesBloc extends Bloc<CommunitiesEvent, CommunitiesState> {
|
||||
on<LoadCommunities>(_onLoadCommunities);
|
||||
on<LoadMoreCommunities>(_onLoadMoreCommunities);
|
||||
on<InsertCommunity>(_onInsertCommunity);
|
||||
on<CommunitiesUpdateCommunity>(_onCommunitiesUpdateCommunity);
|
||||
}
|
||||
|
||||
final CommunitiesService _communitiesService;
|
||||
@ -114,4 +115,18 @@ class CommunitiesBloc extends Bloc<CommunitiesEvent, CommunitiesState> {
|
||||
) {
|
||||
emit(state.copyWith(communities: [event.community, ...state.communities]));
|
||||
}
|
||||
|
||||
void _onCommunitiesUpdateCommunity(
|
||||
CommunitiesUpdateCommunity event,
|
||||
Emitter<CommunitiesState> emit,
|
||||
) {
|
||||
final updatedCommunities = state.communities
|
||||
.map((e) => e.uuid == event.community.uuid ? event.community : e)
|
||||
.toList();
|
||||
emit(
|
||||
state.copyWith(
|
||||
communities: updatedCommunities,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -31,3 +31,12 @@ final class InsertCommunity extends CommunitiesEvent {
|
||||
@override
|
||||
List<Object?> get props => [community];
|
||||
}
|
||||
|
||||
final class CommunitiesUpdateCommunity extends CommunitiesEvent {
|
||||
const CommunitiesUpdateCommunity(this.community);
|
||||
|
||||
final CommunityModel community;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [community];
|
||||
}
|
||||
|
@ -1,57 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/main_module/shared/helpers/space_management_community_dialog_helper.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/main_module/shared/widgets/community_dialog.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/create_community/data/services/remote_create_community_service.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/domain/param/create_community_param.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/bloc/create_community_bloc.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/create_community_dialog_widget.dart';
|
||||
import 'package:syncrow_web/services/api/http_service.dart';
|
||||
|
||||
class CreateCommunityDialog extends StatelessWidget {
|
||||
final void Function(CommunityModel community) onCreateCommunity;
|
||||
final String? initialName;
|
||||
final Widget title;
|
||||
|
||||
const CreateCommunityDialog({
|
||||
super.key,
|
||||
required this.onCreateCommunity,
|
||||
required this.title,
|
||||
this.initialName,
|
||||
});
|
||||
const CreateCommunityDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => CreateCommunityBloc(RemoteCreateCommunityService(HTTPService())),
|
||||
child: BlocListener<CreateCommunityBloc, CreateCommunityState>(
|
||||
create: (_) => CreateCommunityBloc(
|
||||
RemoteCreateCommunityService(HTTPService()),
|
||||
),
|
||||
child: BlocConsumer<CreateCommunityBloc, CreateCommunityState>(
|
||||
listener: (context, state) {
|
||||
switch (state) {
|
||||
case CreateCommunityLoading():
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
case CreateCommunityLoading() || CreateCommunityInitial():
|
||||
SpaceManagementCommunityDialogHelper.showLoadingDialog(context);
|
||||
break;
|
||||
case CreateCommunitySuccess(:final community):
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Community created successfully')),
|
||||
SpaceManagementCommunityDialogHelper.showSuccessSnackBar(
|
||||
context,
|
||||
'${community.name} community created successfully',
|
||||
);
|
||||
onCreateCommunity.call(community);
|
||||
context.read<CommunitiesBloc>().add(
|
||||
InsertCommunity(community),
|
||||
);
|
||||
context.read<CommunitiesTreeSelectionBloc>().add(
|
||||
SelectCommunityEvent(community: community),
|
||||
);
|
||||
break;
|
||||
case CreateCommunityFailure():
|
||||
Navigator.of(context).pop();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
child: CreateCommunityDialogWidget(
|
||||
title: title,
|
||||
initialName: initialName,
|
||||
),
|
||||
builder: (BuildContext context, CreateCommunityState state) {
|
||||
return CommunityDialog(
|
||||
title: const Text('Create Community'),
|
||||
initialName: null,
|
||||
onSubmit: (name) => context.read<CreateCommunityBloc>().add(
|
||||
CreateCommunity(CreateCommunityParam(name: name)),
|
||||
),
|
||||
errorMessage: state is CreateCommunityFailure ? state.message : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -1,15 +1,19 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:syncrow_web/utils/constants/assets.dart';
|
||||
|
||||
class Product extends Equatable {
|
||||
final String uuid;
|
||||
final String name;
|
||||
final String productType;
|
||||
const Product({
|
||||
required this.uuid,
|
||||
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) {
|
||||
return Product(
|
||||
uuid: json['uuid'] as String? ?? '',
|
||||
@ -26,6 +30,33 @@ class Product extends Equatable {
|
||||
};
|
||||
}
|
||||
|
||||
static String _mapIconToProduct(String prodType) {
|
||||
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];
|
||||
}
|
||||
|
@ -0,0 +1,27 @@
|
||||
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/params/load_space_details_param.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/services/space_details_service.dart';
|
||||
|
||||
class UniqueSubspacesDecorator implements SpaceDetailsService {
|
||||
final SpaceDetailsService _decoratee;
|
||||
|
||||
const UniqueSubspacesDecorator(this._decoratee);
|
||||
|
||||
@override
|
||||
Future<SpaceDetailsModel> getSpaceDetails(LoadSpaceDetailsParam param) async {
|
||||
final response = await _decoratee.getSpaceDetails(param);
|
||||
|
||||
final uniqueSubspaces = <String, Subspace>{};
|
||||
|
||||
for (final subspace in response.subspaces) {
|
||||
final normalizedName = subspace.name.trim().toLowerCase();
|
||||
if (!uniqueSubspaces.containsKey(normalizedName)) {
|
||||
uniqueSubspaces[normalizedName] = subspace;
|
||||
}
|
||||
}
|
||||
|
||||
return response.copyWith(
|
||||
subspaces: uniqueSubspaces.values.toList(),
|
||||
);
|
||||
}
|
||||
}
|
@ -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/tags/domain/models/tag.dart';
|
||||
import 'package:syncrow_web/utils/constants/assets.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class SpaceDetailsModel extends Equatable {
|
||||
final String uuid;
|
||||
@ -31,8 +32,8 @@ class SpaceDetailsModel extends Equatable {
|
||||
spaceName: json['spaceName'] as String,
|
||||
icon: json['icon'] as String,
|
||||
productAllocations: (json['productAllocations'] as List)
|
||||
.map((e) => ProductAllocation.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
.map((e) => ProductAllocation.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
subspaces: (json['subspaces'] as List)
|
||||
.map((e) => Subspace.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
@ -70,16 +71,19 @@ class SpaceDetailsModel extends Equatable {
|
||||
}
|
||||
|
||||
class ProductAllocation extends Equatable {
|
||||
final String uuid;
|
||||
final Product product;
|
||||
final Tag tag;
|
||||
|
||||
const ProductAllocation({
|
||||
required this.uuid,
|
||||
required this.product,
|
||||
required this.tag,
|
||||
});
|
||||
|
||||
factory ProductAllocation.fromJson(Map<String, dynamic> json) {
|
||||
return ProductAllocation(
|
||||
uuid: json['uuid'] as String? ?? const Uuid().v4(),
|
||||
product: Product.fromJson(json['product'] 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() {
|
||||
return {
|
||||
'uuid': uuid,
|
||||
'product': product.toJson(),
|
||||
'tag': tag.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
ProductAllocation copyWith({
|
||||
String? uuid,
|
||||
Product? product,
|
||||
Tag? tag,
|
||||
}) {
|
||||
return ProductAllocation(
|
||||
uuid: uuid ?? this.uuid,
|
||||
product: product ?? this.product,
|
||||
tag: tag ?? this.tag,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [product, tag];
|
||||
List<Object?> get props => [uuid, product, tag];
|
||||
}
|
||||
|
||||
class Subspace extends Equatable {
|
||||
|
@ -16,9 +16,9 @@ abstract final class SpaceDetailsDialogHelper {
|
||||
),
|
||||
child: SpaceDetailsDialog(
|
||||
context: context,
|
||||
title: const Text('Create Space'),
|
||||
title: const SelectableText('Create Space'),
|
||||
spaceModel: SpaceModel.empty(),
|
||||
onSave: print,
|
||||
onSave: (space) {},
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -36,7 +36,7 @@ abstract final class SpaceDetailsDialogHelper {
|
||||
),
|
||||
child: SpaceDetailsDialog(
|
||||
context: context,
|
||||
title: const Text('Edit Space'),
|
||||
title: const SelectableText('Edit Space'),
|
||||
spaceModel: spaceModel,
|
||||
onSave: (space) {},
|
||||
),
|
||||
|
@ -1,9 +1,11 @@
|
||||
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/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/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/constants/assets.dart';
|
||||
import 'package:syncrow_web/utils/enum/device_types.dart';
|
||||
@ -19,11 +21,18 @@ class SpaceDetailsDevicesBox extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final productAllocations = space.productAllocations;
|
||||
final subspaces = space.subspaces;
|
||||
final isAnySubspaceHasProductAllocations =
|
||||
subspaces.any((subspace) => subspace.productAllocations.isNotEmpty);
|
||||
if (productAllocations.isNotEmpty || isAnySubspaceHasProductAllocations) {
|
||||
final allAllocations = [
|
||||
...space.productAllocations,
|
||||
...space.subspaces.expand((s) => s.productAllocations),
|
||||
];
|
||||
|
||||
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(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(8),
|
||||
@ -39,20 +48,23 @@ class SpaceDetailsDevicesBox extends StatelessWidget {
|
||||
spacing: 8.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
...productAllocations.map(
|
||||
(entry) => Chip(
|
||||
...productCounts.entries.map((entry) {
|
||||
final productType = entry.key;
|
||||
final count = entry.value;
|
||||
return Chip(
|
||||
avatar: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: SvgPicture.asset(
|
||||
_getDeviceIcon(entry.product.productType),
|
||||
_getDeviceIcon(productType),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
label: Text(
|
||||
entry.product.productType,
|
||||
style: context.textTheme.bodySmall
|
||||
?.copyWith(color: ColorsManager.spaceColor),
|
||||
'x$count',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: ColorsManager.spaceColor,
|
||||
),
|
||||
),
|
||||
backgroundColor: ColorsManager.whiteColors,
|
||||
shape: RoundedRectangleBorder(
|
||||
@ -61,11 +73,9 @@ class SpaceDetailsDevicesBox extends StatelessWidget {
|
||||
color: ColorsManager.spaceColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
EditChip(
|
||||
onTap: () => _showAssignTagsDialog(context),
|
||||
),
|
||||
);
|
||||
}),
|
||||
EditChip(onTap: () => _showAssignTagsDialog(context)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -87,10 +97,16 @@ class SpaceDetailsDevicesBox extends StatelessWidget {
|
||||
}
|
||||
|
||||
void _showAssignTagsDialog(BuildContext context) {
|
||||
showDialog<void>(
|
||||
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) =>
|
||||
|
@ -78,7 +78,11 @@ class _SpaceDetailsDialogState extends State<SpaceDetailsDialog> {
|
||||
return AlertDialog(
|
||||
title: widget.title,
|
||||
backgroundColor: ColorsManager.whiteColors,
|
||||
content: const Center(child: CircularProgressIndicator()),
|
||||
content: SizedBox(
|
||||
height: context.screenHeight * 0.3,
|
||||
width: context.screenWidth * 0.5,
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -28,7 +28,7 @@ class SpaceDetailsForm extends StatelessWidget {
|
||||
create: (context) => SpaceDetailsModelBloc(initialState: space),
|
||||
child: BlocBuilder<SpaceDetailsModelBloc, SpaceDetailsModel>(
|
||||
buildWhen: (previous, current) => previous != current,
|
||||
builder: (context, state) {
|
||||
builder: (context, space) {
|
||||
return AlertDialog(
|
||||
title: title,
|
||||
backgroundColor: ColorsManager.whiteColors,
|
||||
@ -39,25 +39,24 @@ class SpaceDetailsForm extends StatelessWidget {
|
||||
spacing: 20,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: SpaceIconPicker(iconPath: state.icon)),
|
||||
Expanded(child: SpaceIconPicker(iconPath: space.icon)),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
SpaceNameTextField(
|
||||
initialValue: state.spaceName,
|
||||
isNameFieldExist: (value) => state.subspaces.any(
|
||||
initialValue: space.spaceName,
|
||||
isNameFieldExist: (value) => space.subspaces.any(
|
||||
(subspace) => subspace.name == value,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(height: 32),
|
||||
SpaceSubSpacesBox(
|
||||
subspaces: state.subspaces,
|
||||
subspaces: space.subspaces,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SpaceDetailsDevicesBox(space: state),
|
||||
SpaceDetailsDevicesBox(space: space),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -66,7 +65,7 @@ class SpaceDetailsForm extends StatelessWidget {
|
||||
),
|
||||
actions: [
|
||||
SpaceDetailsActionButtons(
|
||||
onSave: () => onSave(state),
|
||||
onSave: () => onSave(space),
|
||||
onCancel: Navigator.of(context).pop,
|
||||
),
|
||||
],
|
||||
|
@ -56,8 +56,9 @@ class _SpaceSubSpacesDialogState extends State<SpaceSubSpacesDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Create Sub Spaces'),
|
||||
title: const SelectableText('Create Sub Spaces'),
|
||||
content: Column(
|
||||
spacing: 12,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SubSpacesInput(
|
||||
@ -70,7 +71,7 @@ class _SpaceSubSpacesDialogState extends State<SpaceSubSpacesDialog> {
|
||||
child: Visibility(
|
||||
key: ValueKey(_hasDuplicateNames),
|
||||
visible: _hasDuplicateNames,
|
||||
child: const Text(
|
||||
child: const SelectableText(
|
||||
'Error: Duplicate subspace names are not allowed.',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
|
@ -1,10 +1,9 @@
|
||||
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/params/load_tags_param.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/http_service.dart';
|
||||
import 'package:syncrow_web/utils/constants/api_const.dart';
|
||||
|
||||
final class RemoteTagsService implements TagsService {
|
||||
const RemoteTagsService(this._httpService);
|
||||
@ -14,17 +13,10 @@ final class RemoteTagsService implements TagsService {
|
||||
static const _defaultErrorMessage = 'Failed to load tags';
|
||||
|
||||
@override
|
||||
Future<List<Tag>> loadTags(LoadTagsParam param) async {
|
||||
if (param.projectUuid == null) {
|
||||
throw Exception('Project UUID is required');
|
||||
}
|
||||
|
||||
Future<List<Tag>> loadTags() async {
|
||||
try {
|
||||
final response = await _httpService.get(
|
||||
path: ApiEndpoints.listTags.replaceAll(
|
||||
'{projectUuid}',
|
||||
param.projectUuid!,
|
||||
),
|
||||
path: await _makeUrl(),
|
||||
expectedResponseModel: (json) {
|
||||
final result = json as Map<String, dynamic>;
|
||||
final data = result['data'] as List<dynamic>;
|
||||
@ -46,4 +38,12 @@ final class RemoteTagsService implements TagsService {
|
||||
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,
|
||||
});
|
||||
|
||||
factory Tag.empty() => const Tag(
|
||||
uuid: '',
|
||||
name: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
);
|
||||
|
||||
factory Tag.fromJson(Map<String, dynamic> json) {
|
||||
return Tag(
|
||||
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/params/load_tags_param.dart';
|
||||
|
||||
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: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/params/load_tags_param.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';
|
||||
|
||||
@ -21,7 +20,7 @@ class TagsBloc extends Bloc<TagsEvent, TagsState> {
|
||||
) async {
|
||||
emit(TagsLoading());
|
||||
try {
|
||||
final tags = await _tagsService.loadTags(event.param);
|
||||
final tags = await _tagsService.loadTags();
|
||||
emit(TagsLoaded(tags));
|
||||
} on APIException catch (e) {
|
||||
emit(TagsFailure(e.message));
|
||||
|
@ -8,10 +8,5 @@ abstract class TagsEvent extends Equatable {
|
||||
}
|
||||
|
||||
class LoadTags extends TagsEvent {
|
||||
final LoadTagsParam param;
|
||||
|
||||
const LoadTags(this.param);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [param];
|
||||
const LoadTags();
|
||||
}
|
||||
|
@ -1,85 +1,98 @@
|
||||
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/tags/presentation/widgets/product_type_card.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 StatelessWidget {
|
||||
class AddDeviceTypeWidget extends StatefulWidget {
|
||||
const AddDeviceTypeWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final crossAxisCount = switch (context.screenWidth) {
|
||||
> 1200 => 8,
|
||||
> 800 => 5,
|
||||
_ => 3,
|
||||
};
|
||||
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 Text('Add Devices'),
|
||||
title: const SelectableText('Add Devices'),
|
||||
backgroundColor: ColorsManager.whiteColors,
|
||||
content: BlocBuilder<ProductsBloc, ProductsState>(
|
||||
builder: (context, state) {
|
||||
return switch (state) {
|
||||
ProductsInitial() => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
ProductsLoading() => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
ProductsLoaded(:final products) => SingleChildScrollView(
|
||||
child: Container(
|
||||
width: size.width * 0.9,
|
||||
height: size.height * 0.65,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorsManager.textFieldGreyColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
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) => ProductTypeCard(
|
||||
product: products[index],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
ProductsFailure(:final errorMessage) => Center(
|
||||
child: Text(
|
||||
errorMessage,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -1,38 +1,230 @@
|
||||
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 StatelessWidget {
|
||||
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 Text('Assign Tags'),
|
||||
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: AssignTagsTable(productAllocations: space.productAllocations),
|
||||
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: () {},
|
||||
onCancel: () {
|
||||
showDialog<void>(
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,169 +1,203 @@
|
||||
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';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class AssignTagsTable extends StatefulWidget {
|
||||
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;
|
||||
|
||||
@override
|
||||
State<AssignTagsTable> createState() => _AssignTagsTableState();
|
||||
}
|
||||
|
||||
class _AssignTagsTableState extends State<AssignTagsTable> {
|
||||
List<TextEditingController> _controllers = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controllers = List.generate(
|
||||
widget.productAllocations.length,
|
||||
(index) => TextEditingController(
|
||||
text: widget.productAllocations[index].product.name,
|
||||
),
|
||||
DataColumn _buildDataColumn(BuildContext context, String label) {
|
||||
return DataColumn(
|
||||
label: SelectableText(label, style: context.textTheme.bodyMedium),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _controllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
DataColumn _buildDataColumn(String label) {
|
||||
return DataColumn(label: Text(label, style: context.textTheme.bodyMedium));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: DataTable(
|
||||
headingRowColor: WidgetStateProperty.all(ColorsManager.dataHeaderGrey),
|
||||
key: ValueKey(widget.productAllocations.length),
|
||||
border: TableBorder.all(
|
||||
color: ColorsManager.dataHeaderGrey,
|
||||
width: 1,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
columns: [
|
||||
_buildDataColumn('#'),
|
||||
_buildDataColumn('Device'),
|
||||
_buildDataColumn('Tag'),
|
||||
_buildDataColumn('Location'),
|
||||
],
|
||||
rows: widget.productAllocations.isEmpty
|
||||
? [
|
||||
DataRow(
|
||||
cells: [
|
||||
DataCell(
|
||||
Center(
|
||||
child: Text(
|
||||
'No Devices Available',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: ColorsManager.lightGrayColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell.empty,
|
||||
DataCell.empty,
|
||||
DataCell.empty,
|
||||
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'),
|
||||
],
|
||||
),
|
||||
]
|
||||
: List.generate(widget.productAllocations.length, (index) {
|
||||
final productAllocation = widget.productAllocations[index];
|
||||
final controller = _controllers[index];
|
||||
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;
|
||||
|
||||
return DataRow(
|
||||
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,
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
color: ColorsManager.lightGreyColor,
|
||||
size: 16,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
// TODO: Delete the product allocation
|
||||
},
|
||||
tooltip: 'Delete Tag',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
width: double.infinity,
|
||||
child: ProductTagField(
|
||||
key: ValueKey('dropdown_${const Uuid().v4()}_$index'),
|
||||
productName: productAllocation.product.uuid,
|
||||
initialValue: null,
|
||||
onSelected: (value) {
|
||||
controller.text = value.name;
|
||||
},
|
||||
items: const [
|
||||
Tag(
|
||||
uuid: '',
|
||||
name: 'Tag',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: DialogDropdown(
|
||||
items: const [],
|
||||
// items: widget.locations,
|
||||
selectedValue: productAllocation.tag.name.isEmpty
|
||||
? 'Main Space'
|
||||
: productAllocation.tag.name,
|
||||
onSelected: (value) {},
|
||||
)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
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(),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -26,42 +26,44 @@ class _ProductTagFieldState extends State<ProductTagField> {
|
||||
OverlayEntry? _overlayEntry;
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
List<Tag> _filteredItems = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.text = widget.initialValue?.name ?? '';
|
||||
|
||||
_filterItems();
|
||||
|
||||
_focusNode.addListener(() {
|
||||
if (!_focusNode.hasFocus) {
|
||||
final selectedTag = _filteredItems.firstWhere(
|
||||
(tag) => tag.name == _controller.text,
|
||||
orElse: () => Tag(
|
||||
name: _controller.text,
|
||||
uuid: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
),
|
||||
);
|
||||
widget.onSelected(selectedTag);
|
||||
_closeDropdown();
|
||||
}
|
||||
});
|
||||
_focusNode.addListener(_handleFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_handleFocusChange);
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
_isOpen = false;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _filterItems() => setState(() => _filteredItems = widget.items);
|
||||
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) {
|
||||
@ -74,14 +76,14 @@ class _ProductTagFieldState extends State<ProductTagField> {
|
||||
void _openDropdown() {
|
||||
_overlayEntry = _createOverlayEntry();
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
_isOpen = true;
|
||||
setState(() => _isOpen = true);
|
||||
}
|
||||
|
||||
void _closeDropdown() {
|
||||
if (_isOpen && _overlayEntry != null) {
|
||||
_overlayEntry!.remove();
|
||||
if (_isOpen) {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
_isOpen = false;
|
||||
setState(() => _isOpen = false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,24 +105,7 @@ class _ProductTagFieldState extends State<ProductTagField> {
|
||||
child: TextFormField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
onFieldSubmitted: (value) {
|
||||
final selectedTag = _filteredItems.firstWhere(
|
||||
(tag) => tag.name == value,
|
||||
orElse: () =>
|
||||
Tag(name: value, uuid: '', createdAt: '', updatedAt: ''));
|
||||
widget.onSelected(selectedTag);
|
||||
_closeDropdown();
|
||||
},
|
||||
onTapOutside: (event) {
|
||||
widget.onSelected(_filteredItems.firstWhere(
|
||||
(tag) => tag.name == _controller.text,
|
||||
orElse: () => Tag(
|
||||
name: _controller.text,
|
||||
uuid: '',
|
||||
createdAt: '',
|
||||
updatedAt: '')));
|
||||
_closeDropdown();
|
||||
},
|
||||
onFieldSubmitted: _submit,
|
||||
style: context.textTheme.bodyMedium,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Enter or Select a tag',
|
||||
@ -159,41 +144,33 @@ class _ProductTagFieldState extends State<ProductTagField> {
|
||||
child: Container(
|
||||
color: ColorsManager.whiteColors,
|
||||
constraints: const BoxConstraints(maxHeight: 200.0),
|
||||
child: StatefulBuilder(
|
||||
builder: (context, setStateDropdown) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _filteredItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final tag = _filteredItems[index];
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: ColorsManager.lightGrayBorderColor,
|
||||
width: 1.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;
|
||||
widget.onSelected(tag);
|
||||
setState(() {
|
||||
_filteredItems.remove(tag);
|
||||
});
|
||||
_closeDropdown();
|
||||
},
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
tag.name,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: ColorsManager.textPrimaryColor,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
_controller.text = tag.name;
|
||||
_submit(tag.name);
|
||||
_closeDropdown();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -1,13 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
|
||||
import 'package:syncrow_web/pages/spaces_management/all_spaces/widgets/counter_widget.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({super.key, required this.product});
|
||||
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) {
|
||||
@ -18,22 +28,18 @@ class ProductTypeCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DeviceIconWidget(
|
||||
icon: product.name,
|
||||
),
|
||||
),
|
||||
Expanded(child: DeviceIconWidget(icon: product.icon)),
|
||||
_buildName(context, product.name),
|
||||
CounterWidget(
|
||||
isCreate: false,
|
||||
initialCount: 0,
|
||||
onCountChanged: (newCount) {},
|
||||
ProductTypeCardCounter(
|
||||
onIncrement: onIncrement,
|
||||
onDecrement: onDecrement,
|
||||
count: count,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
|
@ -1,7 +1,8 @@
|
||||
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 StatefulWidget {
|
||||
class ProductTypeCardCounter extends StatelessWidget {
|
||||
const ProductTypeCardCounter({
|
||||
super.key,
|
||||
required this.onIncrement,
|
||||
@ -10,19 +11,11 @@ class ProductTypeCardCounter extends StatefulWidget {
|
||||
});
|
||||
|
||||
final int count;
|
||||
|
||||
final void Function() onIncrement;
|
||||
final void Function() onDecrement;
|
||||
|
||||
@override
|
||||
State<ProductTypeCardCounter> createState() => _ProductTypeCardCounterState();
|
||||
}
|
||||
|
||||
class _ProductTypeCardCounterState extends State<ProductTypeCardCounter> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
@ -35,15 +28,15 @@ class _ProductTypeCardCounterState extends State<ProductTypeCardCounter> {
|
||||
children: [
|
||||
_buildCounterButton(
|
||||
Icons.remove,
|
||||
widget.onDecrement,
|
||||
onDecrement,
|
||||
),
|
||||
Text(
|
||||
widget.count.toString(),
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
count.toString(),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: ColorsManager.spaceColor,
|
||||
),
|
||||
),
|
||||
_buildCounterButton(Icons.add, widget.onIncrement),
|
||||
_buildCounterButton(Icons.add, onIncrement),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:syncrow_web/pages/common/bloc/project_manager.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/update_community/domain/params/update_community_param.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/update_community/domain/services/update_community_service.dart';
|
||||
import 'package:syncrow_web/services/api/api_exception.dart';
|
||||
import 'package:syncrow_web/services/api/http_service.dart';
|
||||
@ -13,15 +13,15 @@ class RemoteUpdateCommunityService implements UpdateCommunityService {
|
||||
static const _defaultErrorMessage = 'Failed to update community';
|
||||
|
||||
@override
|
||||
Future<CommunityModel> updateCommunity(UpdateCommunityParam param) async {
|
||||
Future<CommunityModel> updateCommunity(CommunityModel param) async {
|
||||
final endpoint = await _makeUrl(param.uuid);
|
||||
try {
|
||||
final response = await _httpService.put(
|
||||
path: 'endpoint',
|
||||
expectedResponseModel: (data) => CommunityModel.fromJson(
|
||||
data as Map<String, dynamic>,
|
||||
),
|
||||
await _httpService.put(
|
||||
path: endpoint,
|
||||
body: {'name': param.name},
|
||||
expectedResponseModel: (data) => null,
|
||||
);
|
||||
return response;
|
||||
return param;
|
||||
} on DioException catch (e) {
|
||||
final message = e.response?.data as Map<String, dynamic>?;
|
||||
final error = message?['error'] as Map<String, dynamic>?;
|
||||
@ -36,4 +36,12 @@ class RemoteUpdateCommunityService implements UpdateCommunityService {
|
||||
throw APIException(formattedErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _makeUrl(String communityUuid) async {
|
||||
final projectUuid = await ProjectManager.getProjectUUID();
|
||||
if (projectUuid == null) {
|
||||
throw APIException('Project UUID is not set');
|
||||
}
|
||||
return '/projects/$projectUuid/communities/$communityUuid';
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class UpdateCommunityParam extends Equatable {
|
||||
const UpdateCommunityParam({required this.name});
|
||||
|
||||
final String name;
|
||||
|
||||
@override
|
||||
List<Object> get props => [name];
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/update_community/domain/params/update_community_param.dart';
|
||||
|
||||
abstract class UpdateCommunityService {
|
||||
Future<CommunityModel> updateCommunity(UpdateCommunityParam param);
|
||||
Future<CommunityModel> updateCommunity(CommunityModel community);
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/update_community/domain/params/update_community_param.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/update_community/domain/services/update_community_service.dart';
|
||||
import 'package:syncrow_web/services/api/api_exception.dart';
|
||||
|
||||
@ -24,7 +23,7 @@ class UpdateCommunityBloc extends Bloc<UpdateCommunityEvent, UpdateCommunityStat
|
||||
emit(UpdateCommunityLoading());
|
||||
try {
|
||||
final updatedCommunity = await _updateCommunityService.updateCommunity(
|
||||
event.param,
|
||||
event.communityModel,
|
||||
);
|
||||
emit(UpdateCommunitySuccess(updatedCommunity));
|
||||
} on APIException catch (e) {
|
||||
|
@ -8,10 +8,10 @@ sealed class UpdateCommunityEvent extends Equatable {
|
||||
}
|
||||
|
||||
final class UpdateCommunity extends UpdateCommunityEvent {
|
||||
const UpdateCommunity(this.param);
|
||||
const UpdateCommunity(this.communityModel);
|
||||
|
||||
final UpdateCommunityParam param;
|
||||
final CommunityModel communityModel ;
|
||||
|
||||
@override
|
||||
List<Object> get props => [param];
|
||||
List<Object> get props => [communityModel];
|
||||
}
|
||||
|
@ -21,10 +21,10 @@ final class UpdateCommunitySuccess extends UpdateCommunityState {
|
||||
}
|
||||
|
||||
final class UpdateCommunityFailure extends UpdateCommunityState {
|
||||
final String message;
|
||||
final String errorMessage;
|
||||
|
||||
const UpdateCommunityFailure(this.message);
|
||||
const UpdateCommunityFailure(this.errorMessage);
|
||||
|
||||
@override
|
||||
List<Object> get props => [message];
|
||||
List<Object> get props => [errorMessage];
|
||||
}
|
||||
|
@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/main_module/shared/helpers/space_management_community_dialog_helper.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/main_module/shared/widgets/community_dialog.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.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/update_community/data/services/remote_update_community_service.dart';
|
||||
import 'package:syncrow_web/pages/space_management_v2/modules/update_community/presentation/bloc/update_community_bloc.dart';
|
||||
import 'package:syncrow_web/services/api/http_service.dart';
|
||||
|
||||
class EditCommunityDialog extends StatelessWidget {
|
||||
const EditCommunityDialog({
|
||||
required this.community,
|
||||
required this.parentContext,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final CommunityModel community;
|
||||
final BuildContext parentContext;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => UpdateCommunityBloc(
|
||||
RemoteUpdateCommunityService(HTTPService()),
|
||||
),
|
||||
child: BlocConsumer<UpdateCommunityBloc, UpdateCommunityState>(
|
||||
listener: (context, state) {
|
||||
switch (state) {
|
||||
case UpdateCommunityInitial() || UpdateCommunityLoading():
|
||||
SpaceManagementCommunityDialogHelper.showLoadingDialog(context);
|
||||
break;
|
||||
case UpdateCommunitySuccess(:final community):
|
||||
_onUpdateCommunitySuccess(context, community);
|
||||
break;
|
||||
case UpdateCommunityFailure():
|
||||
Navigator.of(context).pop();
|
||||
break;
|
||||
}
|
||||
},
|
||||
builder: (context, state) => CommunityDialog(
|
||||
title: const Text('Edit Community'),
|
||||
initialName: community.name,
|
||||
errorMessage: state is UpdateCommunityFailure ? state.errorMessage : null,
|
||||
onSubmit: (name) => context.read<UpdateCommunityBloc>().add(
|
||||
UpdateCommunity(community.copyWith(name: name)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onUpdateCommunitySuccess(
|
||||
BuildContext context,
|
||||
CommunityModel community,
|
||||
) {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop();
|
||||
SpaceManagementCommunityDialogHelper.showSuccessSnackBar(
|
||||
context,
|
||||
'${community.name} community updated successfully',
|
||||
);
|
||||
parentContext.read<CommunitiesBloc>().add(
|
||||
CommunitiesUpdateCommunity(community),
|
||||
);
|
||||
parentContext.read<CommunitiesTreeSelectionBloc>().add(
|
||||
SelectCommunityEvent(community: community),
|
||||
);
|
||||
}
|
||||
}
|
@ -13,6 +13,7 @@ class SpaceDetailsModelBloc extends Bloc<SpaceDetailsModelEvent, SpaceDetailsMod
|
||||
on<UpdateSpaceDetailsSubspaces>(_onUpdateSpaceDetailsSubspaces);
|
||||
on<UpdateSpaceDetailsProductAllocations>(
|
||||
_onUpdateSpaceDetailsProductAllocations);
|
||||
on<UpdateSpaceDetails>(_onUpdateSpaceDetails);
|
||||
}
|
||||
|
||||
void _onUpdateSpaceDetailsIcon(
|
||||
@ -42,4 +43,11 @@ class SpaceDetailsModelBloc extends Bloc<SpaceDetailsModelEvent, SpaceDetailsMod
|
||||
) {
|
||||
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
|
||||
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 'package:syncrow_web/pages/access_management/model/password_model.dart';
|
||||
import 'package:syncrow_web/pages/access_management/booking_system/presentation/model/password_model.dart';
|
||||
import 'package:syncrow_web/pages/visitor_password/model/device_model.dart';
|
||||
import 'package:syncrow_web/pages/visitor_password/model/schedule_model.dart';
|
||||
import 'package:syncrow_web/services/api/http_service.dart';
|
||||
|
@ -84,4 +84,5 @@ abstract class ColorsManager {
|
||||
static const Color minBlue = Color(0xFF93AAFD);
|
||||
static const Color minBlueDot = Color(0xFF023DFE);
|
||||
static const Color grey25 = Color(0xFFF9F9F9);
|
||||
static const Color grey50 = Color(0xFF718096);
|
||||
}
|
||||
|
@ -46,7 +46,8 @@ abstract class ApiEndpoints {
|
||||
// Community Module
|
||||
static const String createCommunity = '/projects/{projectId}/communities';
|
||||
static const String getCommunityList = '/projects/{projectId}/communities';
|
||||
static const String getCommunityListv2 = '/projects/{projectId}/communities/v2';
|
||||
static const String getCommunityListv2 =
|
||||
'/projects/{projectId}/communities/v2';
|
||||
static const String getCommunityById =
|
||||
'/projects/{projectId}/communities/{communityId}';
|
||||
static const String updateCommunity =
|
||||
@ -138,4 +139,6 @@ abstract class ApiEndpoints {
|
||||
static const String assignDeviceToRoom =
|
||||
'/projects/{projectUuid}/communities/{communityUuid}/spaces/{spaceUuid}/subspaces/{subSpaceUuid}/devices/{deviceUuid}';
|
||||
static const String saveSchedule = '/schedule/{deviceUuid}';
|
||||
|
||||
static const String getBookableSpaces = '/bookable-spaces';
|
||||
}
|
||||
|
@ -63,6 +63,9 @@ dependencies:
|
||||
bloc: ^9.0.0
|
||||
geocoding: ^4.0.0
|
||||
gauge_indicator: ^0.4.3
|
||||
calendar_view: ^1.4.0
|
||||
calendar_date_picker2: ^2.0.1
|
||||
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
|
Reference in New Issue
Block a user