diff --git a/lib/core/enums_and_consts/consts.dart b/lib/core/enums_and_consts/consts.dart index 4f61c4f..9458082 100644 --- a/lib/core/enums_and_consts/consts.dart +++ b/lib/core/enums_and_consts/consts.dart @@ -16,6 +16,8 @@ class Tables { static const String staffInStores = 'staff_in_stores'; static const String staffMembers = 'staff_members'; static const String stores = 'stores'; + static const String tasks = 'tasks'; + static const String taskAssignments = 'task_assignments'; static const String tickets = 'tickets'; static const String trackings = 'trackings'; } diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 3acd494..85efbd3 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -28,6 +28,7 @@ import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.da import 'package:flux/features/master_data/providers/models/provider_model.dart'; import 'package:flux/features/master_data/providers/ui/provider_form_screen.dart'; import 'package:flux/features/master_data/providers/ui/provider_list_screen.dart'; +import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart'; import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; import 'package:flux/features/master_data/staff/ui/staff_screen.dart'; import 'package:flux/features/master_data/store/bloc/store_cubit.dart'; @@ -44,7 +45,9 @@ import 'package:flux/features/operations/ui/operation_form_screen.dart'; import 'package:flux/features/operations/ui/operation_list_screen.dart'; import 'package:flux/features/settings/settings_screen.dart'; import 'package:flux/features/settings/theme_settings_view.dart'; +import 'package:flux/features/tasks/blocs/task_form_cubit.dart'; import 'package:flux/features/tasks/models/task_model.dart'; +import 'package:flux/features/tasks/ui/task_form_screen.dart'; import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart'; import 'package:flux/features/tickets/models/ticket_model.dart'; import 'package:flux/features/tickets/ui/ticket_form_screen.dart'; @@ -498,33 +501,39 @@ class AppRouter { ); }, ), - /* GoRoute( - path: '/task/edit/:id', + GoRoute( + path: '/tasks/form/:id', name: Routes.taskForm, builder: (context, state) { - final id = state.pathParameters['id']!; - final TaskModel task = state.extra as TaskModel; + final String pathId = state.pathParameters['id'] ?? 'new'; + final TaskModel? task = state.extra as TaskModel?; + final String? realTaskId; + if (pathId == 'new') { + realTaskId = null; + } else if (task?.id != null) { + realTaskId = task!.id; + } else { + realTaskId = pathId; + } + + final allStaffList = context.read().state.allStaff; // Creiamo il BLoC "al volo" solo per questa schermata return MultiBlocProvider( providers: [ - BlocProvider( - create: (context) => AttachmentsBloc( - parentId: id, - parentType: AttachmentParentType.note, - ), - ), BlocProvider( create: (context) => TaskFormCubit( - existingTask: task, + globalStaff: allStaffList, + initialTask: task, + initialTaskId: realTaskId, ), - ) + ), ], - child: TaskFormScreen(task: task), + child: TaskFormScreen(), ); }, - ), */ + ), ], ); } diff --git a/lib/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart b/lib/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart index d13b95b..f4064ad 100644 --- a/lib/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart +++ b/lib/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart @@ -16,9 +16,9 @@ class DashboardTaskListCubit extends Cubit { DashboardTaskListCubit() : super(DashboardTaskListState()); - void startListening({required String staffId}) { + void startListening({required String staffId}) async { emit(state.copyWith(status: DashboardTaskListStatus.loading)); - _loadTasks(staffId: staffId); + await _loadTasks(staffId: staffId); _taskChannel?.unsubscribe(); _taskChannel = _supabase .channel('public:tasks_staff_$staffId') diff --git a/lib/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart b/lib/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart index 400039d..aea5bf1 100644 --- a/lib/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart +++ b/lib/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart @@ -100,7 +100,7 @@ class _DashboardTasksCardContent extends StatelessWidget { if (state.status == DashboardTaskListStatus.failure) { return Center( child: Text( - "Errore di caricamento", + "Errore di caricamento ${state.errorMessage}", style: TextStyle(color: theme.colorScheme.error), ), ); diff --git a/lib/features/home/ui/home_screen.dart b/lib/features/home/ui/home_screen.dart index 6826638..8f8f520 100644 --- a/lib/features/home/ui/home_screen.dart +++ b/lib/features/home/ui/home_screen.dart @@ -236,23 +236,10 @@ class HomeScreen extends StatelessWidget { label: context.l10n.commonTask, color: Colors.teal, onTap: () { - final companyId = context.read().state.company!.id!; - final currentStaffId = context - .read() - .state - .currentStaffMember! - .id!; - final emptyTask = TaskModel.empty( - companyId: companyId, - createdById: currentStaffId, - ); - final savedTask = GetIt.I.get().createTask( - emptyTask, - ); context.pushNamed( Routes.taskForm, pathParameters: {'id': 'new'}, - extra: savedTask, + extra: (task: null), ); }, ), diff --git a/lib/features/master_data/staff/data/staff_repository.dart b/lib/features/master_data/staff/data/staff_repository.dart index dd119a8..ab30490 100644 --- a/lib/features/master_data/staff/data/staff_repository.dart +++ b/lib/features/master_data/staff/data/staff_repository.dart @@ -13,7 +13,10 @@ class StaffRepository { Future> getStaffMembers(String companyId) async { final response = await _supabase .from(Tables.staffMembers) - .select() + .select(''' + *, + stores (*) + ''') .eq('company_id', companyId) .order('name', ascending: true); diff --git a/lib/features/master_data/staff/models/staff_member_model.dart b/lib/features/master_data/staff/models/staff_member_model.dart index ac3b3d3..7f7678d 100644 --- a/lib/features/master_data/staff/models/staff_member_model.dart +++ b/lib/features/master_data/staff/models/staff_member_model.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:flux/features/master_data/store/models/store_model.dart'; // L'Enum magico e blindato per il sistema enum SystemRole { @@ -26,6 +27,7 @@ class StaffMemberModel extends Equatable { final SystemRole systemRole; final bool isActive; final bool hasJoined; + final StoreModel? store; const StaffMemberModel({ this.id, @@ -38,6 +40,7 @@ class StaffMemberModel extends Equatable { this.systemRole = SystemRole.user, this.isActive = true, this.hasJoined = false, + this.store, }); StaffMemberModel copyWith({ @@ -52,6 +55,7 @@ class StaffMemberModel extends Equatable { SystemRole? systemRole, bool? isActive, bool? hasJoined, + StoreModel? store, }) { return StaffMemberModel( id: id ?? this.id, @@ -64,6 +68,7 @@ class StaffMemberModel extends Equatable { systemRole: systemRole ?? this.systemRole, isActive: isActive ?? this.isActive, hasJoined: hasJoined ?? this.hasJoined, + store: store ?? this.store, ); } @@ -90,6 +95,7 @@ class StaffMemberModel extends Equatable { systemRole: SystemRole.fromString(map['system_role']), isActive: map['is_active'] ?? true, hasJoined: map['has_joined'] ?? false, + store: map['store'] != null ? StoreModel.fromMap(map['store']) : null, ); } @@ -120,5 +126,6 @@ class StaffMemberModel extends Equatable { systemRole, isActive, hasJoined, + store, ]; } diff --git a/lib/features/tasks/blocs/task_form_cubit.dart b/lib/features/tasks/blocs/task_form_cubit.dart new file mode 100644 index 0000000..9060d78 --- /dev/null +++ b/lib/features/tasks/blocs/task_form_cubit.dart @@ -0,0 +1,195 @@ +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; +import 'package:flux/features/tasks/data/task_repository.dart'; +import 'package:flux/features/tasks/models/task_model.dart'; +import 'package:flux/features/tasks/models/task_status.dart'; +import 'package:get_it/get_it.dart'; +part 'task_form_state.dart'; + +class TaskFormCubit extends Cubit { + final TaskRepository _taskRepository = GetIt.I.get(); + final TaskModel? _initialTask; + final String? _initialTaskId; + // Cache in memoria proveniente dallo StaffCubit! + final List _globalStaff; + + final String currentCompanyId = GetIt.I + .get() + .state + .company! + .id!; + final String? currentStoreId = GetIt.I + .get() + .state + .currentStore + ?.id; + + TaskFormCubit({ + required List globalStaff, // Iniettiamo la lista qui + TaskModel? initialTask, + String? initialTaskId, + }) : _globalStaff = globalStaff, + _initialTask = initialTask, + _initialTaskId = initialTaskId, + super(const TaskFormState()) { + _initForm(task: initialTask, initialTaskId: initialTaskId); + } + + // --- 1. INIZIALIZZAZIONE SINCRONA --- + void _initForm({TaskModel? task, String? initialTaskId}) { + final isGlobalMode = task != null + ? task.storeId == null + : currentStoreId == null; + + // MAGIA: Estraiamo gli ID dagli oggetti staff, o facciamo fallback su assignedToIds se c'è + final existingStaffIds = + task?.assignedToStaff.map((s) => s.id!).toList() ?? + task?.assignedToIds ?? + []; + + emit( + state.copyWith( + id: task?.id, + title: task?.title ?? '', + description: task?.description ?? '', + dueDate: task?.dueDate, + taskStatus: task?.status ?? TaskStatus.open, + isGlobal: isGlobalMode, + selectedStaffIds: + existingStaffIds, // Ora non si perde più i dipendenti! + ), + ); + + _updateStaffScope(isGlobalMode); + } + + // --- 2. SWITCH SCOPE E RAGGRUPPAMENTO --- + void toggleGlobalScope(bool isGlobal) { + emit( + state.copyWith( + isGlobal: isGlobal, + selectedStaffIds: [], // Resettiamo la selezione se si cambia scope + ), + ); + _updateStaffScope(isGlobal); + } + + void _updateStaffScope(bool isGlobal) { + // 1. Filtriamo in memoria + final filteredStaff = isGlobal + ? _globalStaff + : _globalStaff.where((s) => s.store?.id == currentStoreId).toList(); + + // 2. Raggruppiamo per nome negozio (usando groupBy del pacchetto collection) + final groupedStaff = groupBy( + filteredStaff, + (StaffMemberModel s) => s.store?.name ?? 'Direzione / HQ', + ); + + // 3. Emettiamo il nuovo stato all'istante + emit( + state.copyWith( + status: TaskFormStatus.initial, + groupedAvailableStaff: groupedStaff, + ), + ); + } + + // --- 3. SELEZIONE AVANZATA (SINGOLA E PER NEGOZIO) --- + void toggleStaffSelection(String staffId) { + final currentList = List.from(state.selectedStaffIds); + if (currentList.contains(staffId)) { + currentList.remove(staffId); + } else { + currentList.add(staffId); + } + emit(state.copyWith(selectedStaffIds: currentList)); + } + + void toggleStoreSelection(String storeName, bool selectAll) { + // Recupera tutti i membri di quel gruppo + final staffInStore = state.groupedAvailableStaff[storeName] ?? []; + final idsInStore = staffInStore.map((s) => s.id!).toList(); + + final currentSelection = Set.from(state.selectedStaffIds); + + if (selectAll) { + currentSelection.addAll(idsInStore); + } else { + currentSelection.removeAll(idsInStore); + } + + emit(state.copyWith(selectedStaffIds: currentSelection.toList())); + } + + // --- 4. AGGIORNAMENTO CAMPI STANDARD --- + void updateTitle(String title) => emit(state.copyWith(title: title)); + + void updateDescription(String desc) => + emit(state.copyWith(description: desc)); + + void updateDueDate(DateTime? date) => + emit(state.copyWith(dueDate: date, clearDueDate: date == null)); + + void updateLinkedTicket(String? ticketId) => + emit(state.copyWith(linkedTicketId: ticketId)); + + // --- 5. LOGICA DEI REMINDER FINTI --- + void addMockReminder(String type, int minutes) { + final currentReminders = List.from(state.reminders); + currentReminders.add(TaskReminder(type: type, minutesBefore: minutes)); + emit(state.copyWith(reminders: currentReminders)); + } + + void removeMockReminder(int index) { + final currentReminders = List.from(state.reminders); + currentReminders.removeAt(index); + emit(state.copyWith(reminders: currentReminders)); + } + + // --- 6. SALVATAGGIO FINALE --- + Future saveTask({required String currentUserId}) async { + if (!state.isFormValid) return; + + emit(state.copyWith(status: TaskFormStatus.submitting)); + + try { + final taskToSave = TaskModel( + id: state.id, + companyId: currentCompanyId, + storeId: state.isGlobal + ? null + : currentStoreId, // La vera discriminante Globale/Store! + createdById: state.id == null + ? currentUserId + : null, // Lo settiamo solo alla creazione + title: state.title.trim(), + description: state.description.trim().isEmpty + ? null + : state.description.trim(), + dueDate: state.dueDate, + status: state.taskStatus, + assignedToIds: state + .selectedStaffIds, // L'array che andrà a popolare la tabella di giunzione + ); + + if (state.id == null) { + await _taskRepository.createTask(taskToSave); + } else { + await _taskRepository.updateTask(taskToSave); + } + + emit(state.copyWith(status: TaskFormStatus.success)); + } catch (e) { + emit( + state.copyWith( + status: TaskFormStatus.failure, + errorMessage: 'Errore durante il salvataggio: $e', + ), + ); + } + } +} diff --git a/lib/features/tasks/blocs/task_form_state.dart b/lib/features/tasks/blocs/task_form_state.dart new file mode 100644 index 0000000..9aaa19d --- /dev/null +++ b/lib/features/tasks/blocs/task_form_state.dart @@ -0,0 +1,109 @@ +part of 'task_form_cubit.dart'; + +enum TaskFormStatus { initial, loading, submitting, success, failure } + +/// Placeholder finto per i futuri reminder (pg_cron) +class TaskReminder extends Equatable { + final String type; // es. 'email', 'push' + final int minutesBefore; + const TaskReminder({required this.type, required this.minutesBefore}); + @override + List get props => [type, minutesBefore]; +} + +class TaskFormState extends Equatable { + final TaskFormStatus status; + final String? id; // Null se stiamo creando un nuovo task + final String title; + final String description; + final DateTime? dueDate; + final TaskStatus taskStatus; + + // --- SCOPING & ASSIGNMENTS --- + final bool isGlobal; + final List selectedStaffIds; + final List availableStaff; + final Map> groupedAvailableStaff; + + // --- FUTURI ANCORAGGI --- + final List reminders; + final String? linkedTicketId; + final String? linkedEmailId; + + final String? errorMessage; + + const TaskFormState({ + this.status = TaskFormStatus.initial, + this.id, + this.title = '', + this.description = '', + this.dueDate, + this.taskStatus = TaskStatus.open, + this.isGlobal = false, + this.selectedStaffIds = const [], + this.availableStaff = const [], + this.groupedAvailableStaff = const {}, + this.reminders = const [], + this.linkedTicketId, + this.linkedEmailId, + this.errorMessage, + }); + + // MAGIA: Il form è valido solo se c'è un titolo e, opzionalmente, altre regole. + bool get isFormValid => title.trim().isNotEmpty; + + TaskFormState copyWith({ + TaskFormStatus? status, + String? id, + String? title, + String? description, + DateTime? dueDate, + bool clearDueDate = false, // Trucco Ninja per rimettere a null una data! + TaskStatus? taskStatus, + bool? isGlobal, + List? selectedStaffIds, + List? availableStaff, + Map>? groupedAvailableStaff, + List? reminders, + String? linkedTicketId, + String? linkedEmailId, + String? errorMessage, + }) { + return TaskFormState( + status: status ?? this.status, + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + dueDate: clearDueDate ? null : (dueDate ?? this.dueDate), + taskStatus: taskStatus ?? this.taskStatus, + isGlobal: isGlobal ?? this.isGlobal, + selectedStaffIds: selectedStaffIds ?? this.selectedStaffIds, + availableStaff: availableStaff ?? this.availableStaff, + groupedAvailableStaff: + groupedAvailableStaff ?? this.groupedAvailableStaff, + reminders: reminders ?? this.reminders, + linkedTicketId: linkedTicketId ?? this.linkedTicketId, + linkedEmailId: linkedEmailId ?? this.linkedEmailId, + errorMessage: + errorMessage, // Se copyWith è chiamato senza errore, lo pulisce in automatico + ); + } + + @override + List get props => [ + status, + id, + title, + description, + dueDate, + taskStatus, + isGlobal, + selectedStaffIds, + availableStaff, + groupedAvailableStaff, + reminders, + linkedTicketId, + linkedEmailId, + errorMessage, + ]; +} diff --git a/lib/features/tasks/data/task_repository.dart b/lib/features/tasks/data/task_repository.dart index e8272c2..f37ec44 100644 --- a/lib/features/tasks/data/task_repository.dart +++ b/lib/features/tasks/data/task_repository.dart @@ -1,3 +1,4 @@ +import 'package:flux/core/enums_and_consts/consts.dart'; import 'package:flux/features/tasks/models/task_status.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; // Sostituisci con i percorsi corretti di FLUX @@ -18,10 +19,15 @@ class TaskRepository { int? limit, }) async { try { - // 1. FASE FILTRI (PostgrestFilterBuilder) + // 1. FASE FILTRI: Usa il join esplicito stile "Notes" var filterBuilder = _supabase .from('tasks') - .select('*, assigned_to_staff:staff_members!task_assignments(*)') + .select(''' + *, + task_assignments ( + staff_members (*) + ) + ''') .eq('company_id', companyId); if (storeId != null) { @@ -31,9 +37,8 @@ class TaskRepository { } if (staffId != null) { - filterBuilder = filterBuilder.or( - 'staff_id.eq.$staffId,staff_id.is.null', - ); + // Grazie al trigger, hai l'array pronto per il filtro senza impazzire! + filterBuilder = filterBuilder.contains('assigned_to_ids', [staffId]); } if (statuses != null && statuses.isNotEmpty) { @@ -41,13 +46,11 @@ class TaskRepository { filterBuilder = filterBuilder.inFilter('status', statusValues); } - // 2. FASE TRASFORMAZIONI (PostgrestTransformBuilder) - // L'ordinamento lo facciamo sempre, quindi partiamo da qui + // 2. FASE TRASFORMAZIONI var transformBuilder = filterBuilder .order('due_date', ascending: true, nullsFirst: false) .order('created_at', ascending: false, nullsFirst: false); - // Aggiungiamo il limite se richiesto if (limit != null) { transformBuilder = transformBuilder.limit(limit); } @@ -55,6 +58,7 @@ class TaskRepository { // 3. ESECUZIONE DELLA QUERY final response = await transformBuilder; + // 4. PARSING DEI DATI return (response as List).map((json) => TaskModel.fromMap(json)).toList(); } catch (e) { throw Exception('Errore nel recupero dei task: $e'); @@ -95,8 +99,9 @@ class TaskRepository { // --- 3. AGGIORNAMENTO DEL TASK --- Future updateTask(TaskModel task) async { - if (task.id == null) + if (task.id == null) { throw Exception('ID Task mancante. Impossibile aggiornare.'); + } try { final taskData = task.toMap(); @@ -133,7 +138,7 @@ class TaskRepository { try { final response = await _supabase .from('tasks') - .select('*, assigned_to_staff:staff!task_assignments(*)') + .select('*, assigned_to_staff:staff_members!task_assignments(*)') .eq('id', taskId) .single(); return TaskModel.fromMap(response); diff --git a/lib/features/tasks/models/task_model.dart b/lib/features/tasks/models/task_model.dart index 10cf0c7..a0a7f99 100644 --- a/lib/features/tasks/models/task_model.dart +++ b/lib/features/tasks/models/task_model.dart @@ -91,37 +91,46 @@ class TaskModel extends Equatable { } // --- SERIALIZZAZIONE DA SUPABASE --- - factory TaskModel.fromMap(Map json) { + factory TaskModel.fromMap(Map map) { // 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota - final List parsedAssignedToIds = json['assigned_to_ids'] != null - ? List.from(json['assigned_to_ids']) + final List parsedAssignedToIds = map['assigned_to_ids'] != null + ? List.from(map['assigned_to_ids']) : []; // 2. Mappiamo il JOIN dello staff, se presente - List parsedAssignedToStaff = []; - if (json['assigned_to_staff'] != null) { - final staffList = json['assigned_to_staff'] as List; - parsedAssignedToStaff = staffList - .map((s) => StaffMemberModel.fromMap(s as Map)) + List staffList = []; + + // Gestione del JSON proveniente dal Join nidificato (es. task_assignments -> staff_members) + if (map['task_assignments'] != null) { + staffList = (map['task_assignments'] as List) + .map((a) => a['staff_members']) + .where((s) => s != null) + .map((s) => StaffMemberModel.fromMap(s)) + .toList(); + } + // Gestione del JSON piatto (se mai lo userai in altre chiamate RPC o viste) + else if (map['assigned_to_staff'] != null) { + staffList = (map['assigned_to_staff'] as List) + .map((s) => StaffMemberModel.fromMap(s)) .toList(); } return TaskModel( - id: json['id'] as String?, - companyId: json['company_id'] as String?, - title: json['title'] as String? ?? '', - description: json['description'] as String?, + id: map['id'] as String?, + companyId: map['company_id'] as String?, + title: map['title'] as String? ?? '', + description: map['description'] as String?, assignedToIds: parsedAssignedToIds, - assignedToStaff: parsedAssignedToStaff, - createdById: json['created_by_id'] as String?, - dueDate: json['due_date'] != null - ? DateTime.parse(json['due_date'] as String).toLocal() + assignedToStaff: staffList, + createdById: map['created_by_id'] as String?, + dueDate: map['due_date'] != null + ? DateTime.parse(map['due_date'] as String).toLocal() : null, - status: TaskStatusExtension.fromString(json['status'] as String?), - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at'] as String).toLocal() + status: TaskStatusExtension.fromString(map['status'] as String?), + createdAt: map['created_at'] != null + ? DateTime.parse(map['created_at'] as String).toLocal() : null, - storeId: json['store_id'] as String?, + storeId: map['store_id'] as String?, ); } diff --git a/lib/features/tasks/ui/task_form_screen.dart b/lib/features/tasks/ui/task_form_screen.dart new file mode 100644 index 0000000..da4ab37 --- /dev/null +++ b/lib/features/tasks/ui/task_form_screen.dart @@ -0,0 +1,397 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/tasks/blocs/task_form_cubit.dart'; +import 'package:go_router/go_router.dart'; + +class TaskFormScreen extends StatelessWidget { + const TaskFormScreen({super.key}); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (previous, current) => previous.status != current.status, + listener: (context, state) { + if (state.status == TaskFormStatus.success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Task salvato con successo! 🎉')), + ); + context.pop(); // Usciamo dalla pagina + } else if (state.status == TaskFormStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? 'Errore di salvataggio'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + }, + builder: (context, state) { + final cubit = context.read(); + final isEditing = state.id != null; + + return Scaffold( + appBar: AppBar( + title: Text(isEditing ? 'Modifica Task' : 'Nuovo Task'), + actions: [ + // Tasto Salva (abilitato solo se il form è valido) + if (state.status == TaskFormStatus.submitting) + const Padding( + padding: EdgeInsets.all(16.0), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + TextButton.icon( + onPressed: state.isFormValid + ? () => + cubit.saveTask( + currentUserId: 'TODO_USER_ID', + ) // Passa l'id utente loggato dal SessionCubit + : null, + icon: const Icon(Icons.save), + label: const Text('Salva'), + style: TextButton.styleFrom( + foregroundColor: Colors.orange, // Il tuo colore primario + disabledForegroundColor: Colors.grey, + ), + ), + ], + ), + body: LayoutBuilder( + builder: (context, constraints) { + final isWideScreen = constraints.maxWidth > 800; + + if (isWideScreen) { + // --- VISTA DESKTOP / TABLET LARGHI (2 Colonne) --- + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 6, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: _buildFormFields(context, state, cubit), + ), + ), + VerticalDivider(color: Theme.of(context).dividerColor), + Expanded( + flex: 4, + child: _buildStaffSelectorInline(context, state, cubit), + ), + ], + ); + } + + // --- VISTA MOBILE (1 Colonna + BottomSheet) --- + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildFormFields(context, state, cubit), + const SizedBox(height: 30), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () => _showStaffBottomSheet(context, cubit), + icon: const Icon(Icons.group_add), + label: Text( + state.selectedStaffIds.isEmpty + ? 'Assegna Staff' + : 'Assegnato a ${state.selectedStaffIds.length} persone', + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); + } + + // ========================================================================= + // 1. I CAMPI DEL FORM PRINCIPALE + // ========================================================================= + Widget _buildFormFields( + BuildContext context, + TaskFormState state, + TaskFormCubit cubit, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // --- SCOPE TOGGLE --- + Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Theme.of(context).dividerColor.withValues(alpha: 0.2), + ), + ), + child: SwitchListTile( + title: const Text( + 'Task Globale Aziendale', + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: const Text( + 'Visibile a tutta l\'azienda, non legato a un negozio specifico.', + ), + value: state.isGlobal, + activeThumbColor: Colors.orange, + onChanged: (val) => cubit.toggleGlobalScope(val), + ), + ), + const SizedBox(height: 24), + + // --- TITOLO E DESCRIZIONE --- + TextFormField( + initialValue: state.title, + decoration: const InputDecoration( + labelText: 'Titolo del Task*', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.title), + ), + onChanged: cubit.updateTitle, + ), + const SizedBox(height: 16), + TextFormField( + initialValue: state.description, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'Descrizione (opzionale)', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + onChanged: cubit.updateDescription, + ), + const SizedBox(height: 24), + + // --- SCADENZA --- + ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Theme.of(context).dividerColor), + ), + leading: const Icon(Icons.calendar_today, color: Colors.orange), + title: Text( + state.dueDate != null + ? 'Scadenza: ${state.dueDate!.day}/${state.dueDate!.month}/${state.dueDate!.year}' + : 'Nessuna scadenza impostata', + ), + trailing: state.dueDate != null + ? IconButton( + icon: const Icon(Icons.close), + onPressed: () => cubit.updateDueDate(null), + ) + : null, + onTap: () async { + final date = await showDatePicker( + context: context, + initialDate: state.dueDate ?? DateTime.now(), + firstDate: DateTime.now(), + lastDate: DateTime(2100), + ); + if (date != null) cubit.updateDueDate(date); + }, + ), + ], + ); + } + + // ========================================================================= + // 2. SELEZIONE STAFF INLINE (PER DESKTOP/WIDE) + // ========================================================================= + Widget _buildStaffSelectorInline( + BuildContext context, + TaskFormState state, + TaskFormCubit cubit, + ) { + return Container( + color: Theme.of(context).cardColor, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.all(24.0), + child: Text( + 'Assegnazione Staff', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + children: _buildGroupedStaffList(context, state, cubit), + ), + ), + ], + ), + ); + } + + // ========================================================================= + // 3. BOTTOM SHEET SELEZIONE STAFF (PER MOBILE) + // ========================================================================= + void _showStaffBottomSheet(BuildContext context, TaskFormCubit cubit) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (bottomSheetContext) { + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.7, // Occupa il 70% dello schermo in altezza + minChildSize: 0.5, + maxChildSize: 0.9, + builder: (_, controller) { + return BlocBuilder( + bloc: + cubit, // Passiamo il cubit esistente per mantenere lo stato! + builder: (context, state) { + return Column( + children: [ + const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'Assegna Staff', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + const Divider(height: 1), + Expanded( + child: ListView( + controller: controller, + padding: const EdgeInsets.only(bottom: 24), + children: _buildGroupedStaffList(context, state, cubit), + ), + ), + ], + ); + }, + ); + }, + ); + }, + ); + } + + // ========================================================================= + // 4. GENERATORE DELLA LISTA RAGGRUPPATA (RIUTILIZZABILE) + // ========================================================================= + List _buildGroupedStaffList( + BuildContext context, + TaskFormState state, + TaskFormCubit cubit, + ) { + final widgets = []; + + if (state.groupedAvailableStaff.isEmpty) { + return [ + const Padding( + padding: EdgeInsets.all(32.0), + child: Center( + child: Text( + 'Nessun membro dello staff trovato.', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ), + ), + ]; + } + + // Iteriamo sulla mappa { "Nome Negozio" : [Lista Dipendenti] } + for (final entry in state.groupedAvailableStaff.entries) { + final storeName = entry.key; + final staffList = entry.value; + + // Verifichiamo se TUTTI i membri di questo negozio sono selezionati + final allSelectedInStore = staffList.every( + (staff) => state.selectedStaffIds.contains(staff.id), + ); + + widgets.add( + Padding( + padding: const EdgeInsets.only( + top: 24.0, + bottom: 8.0, + left: 16, + right: 8, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + storeName.toUpperCase(), + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + // IL MAGICO BOTTONE "SELEZIONA TUTTI" DEL NEGOZIO + TextButton.icon( + onPressed: () => + cubit.toggleStoreSelection(storeName, !allSelectedInStore), + icon: Icon( + allSelectedInStore ? Icons.deselect : Icons.select_all, + size: 18, + ), + label: Text( + allSelectedInStore ? 'Deseleziona' : 'Seleziona Tutti', + ), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + foregroundColor: allSelectedInStore + ? Colors.grey + : Colors.orange, + ), + ), + ], + ), + ), + ); + + // Renderizziamo i dipendenti di questo negozio usando dei Wrap con FilterChip + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: staffList.map((staff) { + final isSelected = state.selectedStaffIds.contains(staff.id); + return FilterChip( + label: Text(staff.name), + selected: isSelected, + selectedColor: Colors.orange.withValues(alpha: 0.2), + checkmarkColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + onSelected: (_) => cubit.toggleStaffSelection(staff.id!), + ); + }).toList(), + ), + ), + ); + } + + return widgets; + } +}