From 83988597d59f737df39699ed63127003155cf1ea Mon Sep 17 00:00:00 2001 From: mark-cachy Date: Thu, 28 May 2026 13:55:28 +0200 Subject: [PATCH] tasks --- lib/core/routes/app_router.dart | 31 +- lib/features/tasks/blocs/task_list_cubit.dart | 83 +++--- lib/features/tasks/blocs/task_list_state.dart | 3 +- lib/features/tasks/data/task_repository.dart | 38 +++ lib/features/tasks/ui/task_list_screen.dart | 272 ++++++++++++++++++ 5 files changed, 371 insertions(+), 56 deletions(-) create mode 100644 lib/features/tasks/ui/task_list_screen.dart diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 85efbd3..32c7a1a 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -46,8 +46,10 @@ 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/blocs/task_list_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/tasks/ui/task_list_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'; @@ -238,11 +240,34 @@ class AppRouter { name: Routes.notes, builder: (context, state) => const NotesListScreen(), ), - /* GoRoute( + GoRoute( path: '/tasks', name: Routes.tasks, - builder: (context, state) => const TaskListScreen(), - ), */ + builder: (context, state) { + // 1. Recuperiamo lo stato della sessione per le dipendenze + final sessionState = context.read().state; + + // Sicurezza: Se per qualche motivo non abbiamo l'azienda, + // qui potresti reindirizzare o gestire l'errore + final companyId = sessionState.company?.id; + if (companyId == null) { + return const Scaffold( + body: Center(child: Text("Errore: Azienda non trovata")), + ); + } + + // 2. Iniettiamo il Cubit con tutto ciò che gli serve + return BlocProvider( + create: (context) => TaskListCubit( + currentCompanyId: companyId, + currentStoreId: sessionState + .currentStore + ?.id, // Opzionale: filtra per negozio se l'utente è "dentro" uno store + ), + child: const TaskListScreen(), + ); + }, + ), ], ), diff --git a/lib/features/tasks/blocs/task_list_cubit.dart b/lib/features/tasks/blocs/task_list_cubit.dart index d6eb18e..bb9dd63 100644 --- a/lib/features/tasks/blocs/task_list_cubit.dart +++ b/lib/features/tasks/blocs/task_list_cubit.dart @@ -1,59 +1,50 @@ 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(); + final String currentCompanyId; + final String? currentStoreId; - TaskListCubit() : super(TaskListState(status: TaskListStatus.initial)); + // Il nostro abbonamento allo stream del repository + StreamSubscription? _taskSubscription; - // --- 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(); + TaskListCubit({required this.currentCompanyId, this.currentStoreId}) + : super(const TaskListState()) { + _initRealtime(); } - // --- HELPER DI CARICAMENTO --- - Future _loadTasks({required String companyId, String? storeId}) async { + void _initRealtime() { + emit(state.copyWith(status: TaskListStatus.loading)); + + // Primo caricamento + _loadTasksSilently(); + + // Ci mettiamo in ascolto del campanello del Repository + _taskSubscription = _repository.watchCompanyTasks(currentCompanyId).listen(( + _, + ) { + // Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo! + _loadTasksSilently(); + }); + } + + Future loadTasks() async { + emit(state.copyWith(status: TaskListStatus.loading)); + await _loadTasksSilently(); + } + + Future _loadTasksSilently() async { try { final tasks = await _repository.getTasks( - companyId: companyId, - storeId: storeId, - // CHICCA: Passiamo solo gli stati attivi! - statuses: [TaskStatus.open, TaskStatus.inProgress], + companyId: currentCompanyId, + storeId: currentStoreId, ); emit( @@ -73,20 +64,10 @@ class TaskListCubit extends Cubit { } } - // --- 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(); + // Stacchiamo l'abbonamento. Il controller.onCancel nel Repo farà il resto! + _taskSubscription?.cancel(); return super.close(); } } diff --git a/lib/features/tasks/blocs/task_list_state.dart b/lib/features/tasks/blocs/task_list_state.dart index d9d160b..1cda1b5 100644 --- a/lib/features/tasks/blocs/task_list_state.dart +++ b/lib/features/tasks/blocs/task_list_state.dart @@ -21,8 +21,7 @@ class TaskListState extends Equatable { return TaskListState( status: status ?? this.status, tasks: tasks ?? this.tasks, - // Se lo status è success o loading, puliamo eventuali errori precedenti - errorMessage: errorMessage ?? this.errorMessage, + errorMessage: errorMessage, ); } diff --git a/lib/features/tasks/data/task_repository.dart b/lib/features/tasks/data/task_repository.dart index 32c963f..d9301bc 100644 --- a/lib/features/tasks/data/task_repository.dart +++ b/lib/features/tasks/data/task_repository.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + 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'; @@ -10,6 +12,42 @@ class TaskRepository { TaskRepository({SupabaseClient? supabase}) : _supabase = supabase ?? Supabase.instance.client; + // --- LOGICA REAL-TIME (Il Campanello) --- + Stream watchCompanyTasks(String companyId) { + // Usiamo un broadcast nel caso più bloc volessero ascoltarlo in futuro + final controller = StreamController.broadcast(); + + final channel = _supabase.channel('public:tasks_company_$companyId'); + + channel + .onPostgresChanges( + event: PostgresChangeEvent.all, + schema: 'public', + table: Tables.tasks, + filter: PostgresChangeFilter( + type: PostgresChangeFilterType.eq, + column: 'company_id', + value: companyId, + ), + callback: (payload) { + if (!controller.isClosed) { + controller.add( + null, + ); // Suoniamo il campanello! Nessun dato, solo il "ding" + } + }, + ) + .subscribe(); + + // Quando il Cubit smette di ascoltare, puliamo il canale Supabase in automatico + controller.onCancel = () { + channel.unsubscribe(); + controller.close(); + }; + + return controller.stream; + } + // --- RECUPERO DEI TASK FILTRATI --- Future> getTasks({ required String companyId, diff --git a/lib/features/tasks/ui/task_list_screen.dart b/lib/features/tasks/ui/task_list_screen.dart new file mode 100644 index 0000000..ef60050 --- /dev/null +++ b/lib/features/tasks/ui/task_list_screen.dart @@ -0,0 +1,272 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/tasks/blocs/task_list_cubit.dart'; +import 'package:go_router/go_router.dart'; +import 'package:flux/core/routes/routes.dart'; +import 'package:flux/features/tasks/models/task_status.dart'; // Adegua al tuo path +import 'package:flux/features/tasks/models/task_model.dart'; + +class TaskListScreen extends StatelessWidget { + const TaskListScreen({super.key}); + + @override + Widget build(BuildContext context) { + // Usiamo 3 tab per gli stati principali + return DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('Gestione Task'), + bottom: const TabBar( + indicatorColor: Colors.orange, + labelColor: Colors.orange, + tabs: [ + Tab(text: 'Da Fare'), + Tab(text: 'In Corso'), + Tab(text: 'Completati'), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => context.read().loadTasks(), + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + if (state.status == TaskListStatus.loading || + state.status == TaskListStatus.initial) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.status == TaskListStatus.failure) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text(state.errorMessage ?? 'Errore sconosciuto'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => + context.read().loadTasks(), + child: const Text('Riprova'), + ), + ], + ), + ); + } + + // Filtriamo le 3 liste in memoria per ogni Tab + final todoTasks = state.tasks + .where((t) => t.status == TaskStatus.open) + .toList(); + final inProgressTasks = state.tasks + .where((t) => t.status == TaskStatus.inProgress) + .toList(); + final doneTasks = state.tasks + .where((t) => t.status == TaskStatus.completed) + .toList(); // Adegua in base ai tuoi enum + + return TabBarView( + children: [ + _buildTaskList(context, todoTasks, 'Nessun task da fare. 🎉'), + _buildTaskList( + context, + inProgressTasks, + 'Nessun task in lavorazione.', + ), + _buildTaskList(context, doneTasks, 'Nessun task completato.'), + ], + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + backgroundColor: Colors.orange, + onPressed: () => + context.pushNamed(Routes.taskForm, pathParameters: {'id': 'new'}), + icon: const Icon(Icons.add, color: Colors.white), + label: const Text( + 'Nuovo Task', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + ), + ); + } + + // --- WIDGET LISTA --- + Widget _buildTaskList( + BuildContext context, + List tasks, + String emptyMessage, + ) { + if (tasks.isEmpty) { + return Center( + child: Text( + emptyMessage, + style: TextStyle( + color: Theme.of(context).textTheme.bodySmall?.color, + fontStyle: FontStyle.italic, + ), + ), + ); + } + + return RefreshIndicator( + onRefresh: () => context.read().loadTasks(), + child: ListView.separated( + padding: const EdgeInsets.only( + top: 16, + bottom: 80, + left: 16, + right: 16, + ), // Padding bottom per il FAB + itemCount: tasks.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final task = tasks[index]; + final isOverdue = + task.dueDate != null && task.dueDate!.isBefore(DateTime.now()); + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Theme.of(context).dividerColor.withValues(alpha: 0.3), + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => context.pushNamed( + Routes.taskForm, + pathParameters: {'id': task.id!}, + extra: task, + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Riga 1: Badge Globale/Store + Data Scadenza + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: task.storeId == null + ? Colors.purple.withValues(alpha: 0.1) + : Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + task.storeId == null ? 'GLOBALE' : 'STORE', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: task.storeId == null + ? Colors.purple + : Colors.blue, + ), + ), + ), + if (task.dueDate != null) + Row( + children: [ + Icon( + Icons.access_time, + size: 14, + color: isOverdue ? Colors.red : Colors.grey, + ), + const SizedBox(width: 4), + Text( + '${task.dueDate!.day}/${task.dueDate!.month}/${task.dueDate!.year}', + style: TextStyle( + fontSize: 12, + fontWeight: isOverdue + ? FontWeight.bold + : FontWeight.normal, + color: isOverdue ? Colors.red : Colors.grey, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + + // Riga 2: Titolo + Text( + task.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + + // Riga 3 (Opzionale): Descrizione breve + if (task.description != null && + task.description!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + task.description!, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + + const SizedBox(height: 16), + + // Riga 4: Assegnatari + Row( + children: [ + const Icon( + Icons.people_outline, + size: 16, + color: Colors.grey, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + (task.assignedToStaff.isEmpty) + ? 'Nessun assegnatario' + : task.assignedToStaff + .map((s) => s.name) + .join(', '), + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +}