From 45455a16c42df403a54ee36ce27fd8a68469355b Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Tue, 26 May 2026 12:28:12 +0200 Subject: [PATCH] w --- lib/core/routes/app_router.dart | 45 +++- lib/core/routes/routes.dart | 2 + .../blocs/dashboard_task_list_cubit.dart | 71 ++++++ .../blocs/dashboard_task_list_state.dart | 30 +++ .../ui/dashboard_tasks_card.dart | 211 ++++++++++++++++++ lib/features/home/ui/home_screen.dart | 29 ++- lib/features/tasks/blocs/task_list_cubit.dart | 92 ++++++++ lib/features/tasks/blocs/task_list_state.dart | 31 +++ lib/features/tasks/data/task_repository.dart | 163 ++++++++++++++ lib/features/tasks/models/task_model.dart | 143 ++++++++++++ lib/features/tasks/models/task_status.dart | 40 ++++ lib/main.dart | 2 + 12 files changed, 851 insertions(+), 8 deletions(-) create mode 100644 lib/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart create mode 100644 lib/features/home/dashboard_task_list/blocs/dashboard_task_list_state.dart create mode 100644 lib/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart create mode 100644 lib/features/tasks/blocs/task_list_cubit.dart create mode 100644 lib/features/tasks/blocs/task_list_state.dart create mode 100644 lib/features/tasks/data/task_repository.dart create mode 100644 lib/features/tasks/models/task_model.dart create mode 100644 lib/features/tasks/models/task_status.dart diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index c957a90..3acd494 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -18,6 +18,7 @@ import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/ui/customer_detail_screen.dart'; import 'package:flux/features/customers/ui/customer_form_screen.dart'; import 'package:flux/features/customers/ui/customers_list_screen.dart'; +import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart'; import 'package:flux/features/home/ui/home_screen.dart'; import 'package:flux/features/master_data/master_data_hub_content.dart'; import 'package:flux/features/master_data/products/blocs/product_cubit.dart'; @@ -43,6 +44,7 @@ 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/models/task_model.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'; @@ -133,7 +135,16 @@ class AppRouter { GoRoute( path: '/', name: Routes.home, - builder: (context, state) => const HomeScreen(), + builder: (context, state) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => DashboardTaskListCubit(), + ), + ], + child: HomeScreen(), + ); + }, ), // ========================================== @@ -224,6 +235,11 @@ class AppRouter { name: Routes.notes, builder: (context, state) => const NotesListScreen(), ), + /* GoRoute( + path: '/tasks', + name: Routes.tasks, + builder: (context, state) => const TaskListScreen(), + ), */ ], ), @@ -482,6 +498,33 @@ class AppRouter { ); }, ), + /* GoRoute( + path: '/task/edit/:id', + name: Routes.taskForm, + builder: (context, state) { + final id = state.pathParameters['id']!; + final TaskModel task = state.extra as TaskModel; + + // 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, + ), + ) + ], + + child: TaskFormScreen(task: task), + ); + }, + ), */ ], ); } diff --git a/lib/core/routes/routes.dart b/lib/core/routes/routes.dart index 24818a9..8f6d259 100644 --- a/lib/core/routes/routes.dart +++ b/lib/core/routes/routes.dart @@ -24,4 +24,6 @@ class Routes { static const String ticketWorkspace = 'ticket-workspace'; static const String noteForm = 'note-form'; static const String notes = 'notes'; + static const String tasks = 'tasks'; + static const String taskForm = 'task-form'; } 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 new file mode 100644 index 0000000..d13b95b --- /dev/null +++ b/lib/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart @@ -0,0 +1,71 @@ +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/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'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +part 'dashboard_task_list_state.dart'; + +class DashboardTaskListCubit extends Cubit { + final TaskRepository _repository = GetIt.I.get(); + final SupabaseClient _supabase = GetIt.I.get(); + RealtimeChannel? _taskChannel; + + DashboardTaskListCubit() : super(DashboardTaskListState()); + + void startListening({required String staffId}) { + emit(state.copyWith(status: DashboardTaskListStatus.loading)); + _loadTasks(staffId: staffId); + _taskChannel?.unsubscribe(); + _taskChannel = _supabase + .channel('public:tasks_staff_$staffId') + .onPostgresChanges( + event: PostgresChangeEvent.all, + schema: 'public', + table: 'tasks', + filter: PostgresChangeFilter( + type: PostgresChangeFilterType.eq, + column: 'staff_id', + value: staffId, + ), + callback: (payload) { + _loadTasks(staffId: staffId); + }, + ); + _taskChannel?.subscribe(); + } + + Future _loadTasks({required String staffId}) async { + try { + final tasks = await _repository.getTasks( + companyId: GetIt.I.get().state.company!.id!, + staffId: staffId, + statuses: [TaskStatus.open, TaskStatus.inProgress], + ); + + emit( + state.copyWith( + status: DashboardTaskListStatus.success, + tasks: tasks, + errorMessage: null, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: DashboardTaskListStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + @override + Future close() { + _taskChannel?.unsubscribe(); + return super.close(); + } +} diff --git a/lib/features/home/dashboard_task_list/blocs/dashboard_task_list_state.dart b/lib/features/home/dashboard_task_list/blocs/dashboard_task_list_state.dart new file mode 100644 index 0000000..30b7266 --- /dev/null +++ b/lib/features/home/dashboard_task_list/blocs/dashboard_task_list_state.dart @@ -0,0 +1,30 @@ +part of 'dashboard_task_list_cubit.dart'; + +enum DashboardTaskListStatus { initial, loading, success, failure } + +class DashboardTaskListState extends Equatable { + final DashboardTaskListStatus status; + final List tasks; + final String? errorMessage; + + const DashboardTaskListState({ + this.status = DashboardTaskListStatus.initial, + this.tasks = const [], + this.errorMessage, + }); + + DashboardTaskListState copyWith({ + DashboardTaskListStatus? status, + List? tasks, + String? errorMessage, + }) { + return DashboardTaskListState( + status: status ?? this.status, + tasks: tasks ?? this.tasks, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [status, tasks, errorMessage]; +} 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 new file mode 100644 index 0000000..400039d --- /dev/null +++ b/lib/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/core/routes/routes.dart'; +import 'package:flux/core/theme/theme.dart'; +import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart'; +import 'package:go_router/go_router.dart'; +import 'package:flux/features/tasks/models/task_status.dart'; + +class DashboardTasksCard extends StatelessWidget { + const DashboardTasksCard({super.key}); + + @override + Widget build(BuildContext context) { + // Recuperiamo lo staff (o l'utente) loggato + // Adatta il getter in base a come è strutturato il tuo SessionState + final currentStaffId = context + .read() + .state + .currentStaffMember + ?.id; + + if (currentStaffId == null) { + return const SizedBox.shrink(); // Sicurezza se lo stato non è pronto + } + + return BlocProvider( + create: (context) => + DashboardTaskListCubit()..startListening(staffId: currentStaffId), + child: const _DashboardTasksCardContent(), + ); + } +} + +class _DashboardTasksCardContent extends StatelessWidget { + const _DashboardTasksCardContent(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const color = + Colors.orange; // Colore arancione per distinguerla dai Ticket blu + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)), + ), + child: InkWell( + onTap: () => context.pushNamed( + Routes.tasks, + ), // Porta alla lista completa (TaskListScreen) + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // --- HEADER DELLA CARD --- + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.assignment_outlined, // Icona a tema ToDo + color: color, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + "I Miei Task", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: context.primaryText, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 12), + + // --- CORPO DELLA CARD (LA LISTA REAL-TIME) --- + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state.status == DashboardTaskListStatus.loading || + state.status == DashboardTaskListStatus.initial) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.status == DashboardTaskListStatus.failure) { + return Center( + child: Text( + "Errore di caricamento", + style: TextStyle(color: theme.colorScheme.error), + ), + ); + } + + if (state.tasks.isEmpty) { + return Center( + child: Text( + "Nessun task in sospeso. Ottimo lavoro!", + style: TextStyle( + color: context.secondaryText, + fontStyle: FontStyle.italic, + ), + ), + ); + } + + return ListView.separated( + itemCount: state.tasks.length, + separatorBuilder: (context, index) => Divider( + height: 1, + color: theme.dividerColor.withValues(alpha: 0.3), + ), + itemBuilder: (context, index) { + final task = state.tasks[index]; + + // Definisci il colore in base allo stato del task + final statusColor = task.status == TaskStatus.inProgress + ? Colors.blue + : Colors.grey.shade400; + + // Formattiamo la data (o indichiamo se non c'è) + final dueDateString = task.dueDate != null + ? "${task.dueDate!.day}/${task.dueDate!.month}" + : "Nessuna"; + + // Controllo Ninja: Il task è già scaduto rispetto a oggi? + final isOverdue = + task.dueDate != null && + task.dueDate!.isBefore(DateTime.now()); + + return InkWell( + onTap: () => context.pushNamed( + Routes.taskForm, + extra: + task, // Passiamo direttamente il modello intero se il tuo router lo accetta! + pathParameters: {'id': task.id ?? 'new'}, + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 8, + height: 30, + decoration: BoxDecoration( + color: statusColor, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 7, + child: Text( + task.title, + style: TextStyle( + fontWeight: FontWeight.w600, + color: context.primaryText, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Expanded( + flex: 3, + child: Align( + alignment: Alignment.centerRight, + child: Text( + dueDateString, + style: TextStyle( + color: isOverdue + ? theme.colorScheme.error + : context.secondaryText, + fontSize: 12, + fontWeight: isOverdue + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/home/ui/home_screen.dart b/lib/features/home/ui/home_screen.dart index 63ab3b6..6826638 100644 --- a/lib/features/home/ui/home_screen.dart +++ b/lib/features/home/ui/home_screen.dart @@ -5,6 +5,7 @@ import 'package:flux/core/routes/routes.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/utils/extensions.dart'; import 'package:flux/core/widgets/staff_selector_modal.dart'; +import 'package:flux/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart'; import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_card.dart'; import 'package:flux/features/home/latest_store_tickets/ui/latest_store_tickets_card.dart'; import 'package:flux/features/home/ui/quick_actions_widget.dart'; @@ -13,6 +14,8 @@ import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; import 'package:flux/features/notes/data/notes_repository.dart'; import 'package:flux/features/notes/models/note_model.dart'; import 'package:flux/features/notes/ui/dashboard_notes_widget.dart'; +import 'package:flux/features/tasks/data/task_repository.dart'; +import 'package:flux/features/tasks/models/task_model.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; @@ -75,12 +78,7 @@ class HomeScreen extends StatelessWidget { context: context, ), DashboardNotesWidget(), - _buildDashboardWidget( - title: context.l10n.homeMyTasks, - icon: Icons.check_box_outlined, - color: Colors.green, - context: context, - ), + DashboardTasksCard(), ]), ), ), @@ -238,7 +236,24 @@ class HomeScreen extends StatelessWidget { label: context.l10n.commonTask, color: Colors.teal, onTap: () { - // TODO: Quando faremo i task + 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, + ); }, ), ], diff --git a/lib/features/tasks/blocs/task_list_cubit.dart b/lib/features/tasks/blocs/task_list_cubit.dart new file mode 100644 index 0000000..d6eb18e --- /dev/null +++ b/lib/features/tasks/blocs/task_list_cubit.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.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'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +part 'task_list_state.dart'; + +class TaskListCubit extends Cubit { + final SupabaseClient _supabase = GetIt.I.get(); + RealtimeChannel? _taskChannel; + final TaskRepository _repository = GetIt.I.get(); + + TaskListCubit() : super(TaskListState(status: TaskListStatus.initial)); + + // --- AVVIA L'ASCOLTO IN TEMPO REALE --- + void startListening({required String companyId, String? storeId}) { + emit(state.copyWith(status: TaskListStatus.loading)); + + // Facciamo subito il caricamento manuale, chiedendo SOLO quelli attivi + _loadTasks(companyId: companyId, storeId: storeId); + + _taskChannel?.unsubscribe(); + + _taskChannel = _supabase + .channel('public:tasks_company_$companyId') + .onPostgresChanges( + event: PostgresChangeEvent.all, + schema: 'public', + table: 'tasks', + filter: PostgresChangeFilter( + type: PostgresChangeFilterType.eq, + column: 'company_id', + value: companyId, + ), + callback: (payload) { + // Ricarica la lista applicando sempre i filtri di stato + _loadTasks(companyId: companyId, storeId: storeId); + }, + ); + + _taskChannel?.subscribe(); + } + + // --- HELPER DI CARICAMENTO --- + Future _loadTasks({required String companyId, String? storeId}) async { + try { + final tasks = await _repository.getTasks( + companyId: companyId, + storeId: storeId, + // CHICCA: Passiamo solo gli stati attivi! + statuses: [TaskStatus.open, TaskStatus.inProgress], + ); + + emit( + state.copyWith( + status: TaskListStatus.success, + tasks: tasks, + errorMessage: null, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: TaskListStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + // --- OPERAZIONI MANUALI --- + // (Le lasciamo gestire al repository o le metti qui se preferisci, + // tanto il risultato lo vedrai magicamente aggiornato dallo stream sopra!) + + /* + Future markAsCompleted(String taskId) async { ... } + Future deleteTask(String taskId) async { ... } + */ + + // --- PULIZIA FONDAMENTALE --- + @override + Future close() { + // Chiudiamo il rubinetto quando usciamo dalla pagina per non intasarci la memoria! + _taskChannel?.unsubscribe(); + return super.close(); + } +} diff --git a/lib/features/tasks/blocs/task_list_state.dart b/lib/features/tasks/blocs/task_list_state.dart new file mode 100644 index 0000000..d9d160b --- /dev/null +++ b/lib/features/tasks/blocs/task_list_state.dart @@ -0,0 +1,31 @@ +part of 'task_list_cubit.dart'; + +enum TaskListStatus { initial, loading, success, failure } + +class TaskListState extends Equatable { + final TaskListStatus status; + final List tasks; + final String? errorMessage; + + const TaskListState({ + this.status = TaskListStatus.initial, + this.tasks = const [], + this.errorMessage, + }); + + TaskListState copyWith({ + TaskListStatus? status, + List? tasks, + String? errorMessage, + }) { + return TaskListState( + status: status ?? this.status, + tasks: tasks ?? this.tasks, + // Se lo status è success o loading, puliamo eventuali errori precedenti + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [status, tasks, errorMessage]; +} diff --git a/lib/features/tasks/data/task_repository.dart b/lib/features/tasks/data/task_repository.dart new file mode 100644 index 0000000..e8272c2 --- /dev/null +++ b/lib/features/tasks/data/task_repository.dart @@ -0,0 +1,163 @@ +import 'package:flux/features/tasks/models/task_status.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +// Sostituisci con i percorsi corretti di FLUX +import 'package:flux/features/tasks/models/task_model.dart'; + +class TaskRepository { + final SupabaseClient _supabase; + + TaskRepository({SupabaseClient? supabase}) + : _supabase = supabase ?? Supabase.instance.client; + + // --- RECUPERO DEI TASK FILTRATI --- + Future> getTasks({ + required String companyId, + String? storeId, + String? staffId, + List? statuses, + int? limit, + }) async { + try { + // 1. FASE FILTRI (PostgrestFilterBuilder) + var filterBuilder = _supabase + .from('tasks') + .select('*, assigned_to_staff:staff_members!task_assignments(*)') + .eq('company_id', companyId); + + if (storeId != null) { + filterBuilder = filterBuilder.or( + 'store_id.eq.$storeId,store_id.is.null', + ); + } + + if (staffId != null) { + filterBuilder = filterBuilder.or( + 'staff_id.eq.$staffId,staff_id.is.null', + ); + } + + if (statuses != null && statuses.isNotEmpty) { + final statusValues = statuses.map((s) => s.toValue).toList(); + filterBuilder = filterBuilder.inFilter('status', statusValues); + } + + // 2. FASE TRASFORMAZIONI (PostgrestTransformBuilder) + // L'ordinamento lo facciamo sempre, quindi partiamo da qui + 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); + } + + // 3. ESECUZIONE DELLA QUERY + final response = await transformBuilder; + + return (response as List).map((json) => TaskModel.fromMap(json)).toList(); + } catch (e) { + throw Exception('Errore nel recupero dei task: $e'); + } + } + + // --- 2. CREAZIONE DEL TASK --- + Future createTask(TaskModel task) async { + try { + final taskData = task.toMap(); + + // Rimuoviamo l'array prima di inviare i dati alla tabella principale, + // la "Strada C" impone che la verità assoluta derivi dalla tabella di giunzione! + taskData.remove('assigned_to_ids'); + + // 1. Inseriamo il record base nella tabella tasks + final response = await _supabase + .from('tasks') + .insert(taskData) + .select() + .single(); + + final newTask = TaskModel.fromMap(response); + + // 2. Se l'utente ha assegnato il task a qualcuno, popoliamo la giunzione + if (task.assignedToIds.isNotEmpty && newTask.id != null) { + await _syncAssignments(newTask.id!, task.assignedToIds); + + // 3. Ricarichiamo il task appena creato per ottenere l'array e il JOIN aggiornati dal trigger! + return await getTaskById(newTask.id!); + } + + return newTask; + } catch (e) { + throw Exception('Errore nella creazione del task: $e'); + } + } + + // --- 3. AGGIORNAMENTO DEL TASK --- + Future updateTask(TaskModel task) async { + if (task.id == null) + throw Exception('ID Task mancante. Impossibile aggiornare.'); + + try { + final taskData = task.toMap(); + taskData.remove( + 'assigned_to_ids', + ); // Sempre via l'array dal body principale + + // 1. Aggiorniamo i dati base del task (titolo, stato, scadenza, ecc.) + await _supabase.from('tasks').update(taskData).eq('id', task.id!); + + // 2. Sincronizziamo in modo distruttivo gli assegnatari nella tabella di giunzione + await _syncAssignments(task.id!, task.assignedToIds); + + // 3. Ritorniamo il modello fresco di DB + return await getTaskById(task.id!); + } catch (e) { + throw Exception('Errore nell\'aggiornamento del task: $e'); + } + } + + // --- 4. ELIMINAZIONE DEL TASK --- + Future deleteTask(String taskId) async { + try { + // Eliminando il task, se la Foreign Key su Supabase ha "ON DELETE CASCADE", + // le righe nella tabella di giunzione si distruggeranno da sole in automatico! + await _supabase.from('tasks').delete().eq('id', taskId); + } catch (e) { + throw Exception('Errore nell\'eliminazione del task: $e'); + } + } + + // --- HELPER: RECUPERO SINGOLO TASK --- + Future getTaskById(String taskId) async { + try { + final response = await _supabase + .from('tasks') + .select('*, assigned_to_staff:staff!task_assignments(*)') + .eq('id', taskId) + .single(); + return TaskModel.fromMap(response); + } catch (e) { + throw Exception('Errore nel recupero del singolo task: $e'); + } + } + + // --- HELPER: SINCRONIZZAZIONE DELLA TABELLA DI GIUNZIONE --- + Future _syncAssignments(String taskId, List staffIds) async { + // TECNICA "WIPE & REPLACE": + // Invece di capire chi è stato aggiunto e chi tolto, eliminiamo tutti i record + // di questo task e li reinseriamo. È fulmineo ed esclude a priori bug di disallineamento. + + // 1. Facciamo piazza pulita + await _supabase.from('task_assignments').delete().eq('task_id', taskId); + + // 2. Inseriamo le nuove assegnazioni (se ce ne sono) + if (staffIds.isNotEmpty) { + final List> assignments = staffIds + .map((staffId) => {'task_id': taskId, 'staff_id': staffId}) + .toList(); + + await _supabase.from('task_assignments').insert(assignments); + } + } +} diff --git a/lib/features/tasks/models/task_model.dart b/lib/features/tasks/models/task_model.dart new file mode 100644 index 0000000..10cf0c7 --- /dev/null +++ b/lib/features/tasks/models/task_model.dart @@ -0,0 +1,143 @@ +import 'package:equatable/equatable.dart'; +import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; +import 'package:flux/features/tasks/models/task_status.dart'; + +class TaskModel extends Equatable { + final String? id; + final String? companyId; + final String title; + final String? description; + final List assignedToIds; + final List assignedToStaff; // I dati completi dal JOIN + final String? createdById; + final DateTime? dueDate; + final TaskStatus status; + final DateTime? createdAt; + final String? storeId; + + const TaskModel({ + this.id, + this.companyId, + required this.title, + this.description, + this.assignedToIds = const [], + this.assignedToStaff = const [], + this.createdById, + this.dueDate, + this.status = TaskStatus.open, + this.createdAt, + this.storeId, + }); + + // --- FACTORY: MODELLO VUOTO (Per le creazioni) --- + factory TaskModel.empty({String? companyId, String? createdById}) { + return TaskModel( + companyId: companyId, + title: '', + description: '', + assignedToIds: const [], + assignedToStaff: const [], + createdById: createdById, + status: TaskStatus.open, + createdAt: DateTime.now(), + ); + } + + // --- EQUATABLE: PROPRIETÀ DA COMPARARE --- + @override + List get props => [ + id, + companyId, + title, + description, + assignedToIds, + assignedToStaff, + createdById, + dueDate, + status, + createdAt, + storeId, + ]; + + // --- COPY WITH --- + TaskModel copyWith({ + String? id, + String? companyId, + String? title, + String? description, + List? assignedToIds, + List? assignedToStaff, + String? createdById, + DateTime? dueDate, + bool clearDueDate = false, // Flag ninja per resettare la scadenza + TaskStatus? status, + DateTime? createdAt, + String? storeId, + bool clearStoreId = false, + }) { + return TaskModel( + id: id ?? this.id, + companyId: companyId ?? this.companyId, + title: title ?? this.title, + description: description ?? this.description, + assignedToIds: assignedToIds ?? this.assignedToIds, + assignedToStaff: assignedToStaff ?? this.assignedToStaff, + createdById: createdById ?? this.createdById, + dueDate: clearDueDate ? null : (dueDate ?? this.dueDate), + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + storeId: clearStoreId ? null : (storeId ?? this.storeId), + ); + } + + // --- SERIALIZZAZIONE DA SUPABASE --- + factory TaskModel.fromMap(Map json) { + // 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']) + : []; + + // 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)) + .toList(); + } + + return TaskModel( + id: json['id'] as String?, + companyId: json['company_id'] as String?, + title: json['title'] as String? ?? '', + description: json['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() + : null, + status: TaskStatusExtension.fromString(json['status'] as String?), + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at'] as String).toLocal() + : null, + storeId: json['store_id'] as String?, + ); + } + + // --- SERIALIZZAZIONE VERSO SUPABASE --- + Map toMap() { + return { + if (id != null) 'id': id, + if (companyId != null) 'company_id': companyId, + 'title': title, + if (description != null) 'description': description, + // Passiamo l'array vuoto se non ci sono assegnazioni + 'assigned_to_ids': assignedToIds.isEmpty ? null : assignedToIds, + if (createdById != null) 'created_by_id': createdById, + 'due_date': dueDate?.toUtc().toIso8601String(), + 'status': status.toValue, + 'store_id': storeId, + }; + } +} diff --git a/lib/features/tasks/models/task_status.dart b/lib/features/tasks/models/task_status.dart new file mode 100644 index 0000000..e0bb286 --- /dev/null +++ b/lib/features/tasks/models/task_status.dart @@ -0,0 +1,40 @@ +// Enum per lo stato del task +enum TaskStatus { open, inProgress, completed } + +extension TaskStatusExtension on TaskStatus { + String get name { + switch (this) { + case TaskStatus.open: + return 'Da Iniziare'; + case TaskStatus.inProgress: + return 'In Lavorazione'; + case TaskStatus.completed: + return 'Completato'; + } + } + + // Comodo per mappare da Supabase + static TaskStatus fromString(String? status) { + switch (status) { + case 'in_progress': + return TaskStatus.inProgress; + case 'completed': + return TaskStatus.completed; + case 'open': + default: + return TaskStatus.open; + } + } + + // Comodo per salvare su Supabase + String get toValue { + switch (this) { + case TaskStatus.open: + return 'open'; + case TaskStatus.inProgress: + return 'in_progress'; + case TaskStatus.completed: + return 'completed'; + } + } +} diff --git a/lib/main.dart b/lib/main.dart index aa23b45..e096af4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:flux/features/auth/bloc/auth_cubit.dart'; import 'package:flux/features/company/data/company_repository.dart'; import 'package:flux/features/notes/blocs/notes_bloc.dart'; import 'package:flux/features/notes/data/notes_repository.dart'; +import 'package:flux/features/tasks/data/task_repository.dart'; import 'package:flux/features/tickets/data/tickets_shipping_repository.dart'; import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.dart'; import 'package:flux/features/operations/blocs/operation_list_cubit.dart'; @@ -136,6 +137,7 @@ Future setupLocator() async { () => TicketsShippingRepository(), ); getIt.registerLazySingleton(() => NotesRepository()); + getIt.registerLazySingleton(() => TaskRepository()); } class FluxApp extends StatefulWidget {