This commit is contained in:
2026-05-28 13:55:28 +02:00
parent b298509178
commit 83988597d5
5 changed files with 371 additions and 56 deletions

View File

@@ -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<SessionCubit>().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(),
);
},
),
],
),

View File

@@ -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<TaskListState> {
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
RealtimeChannel? _taskChannel;
final TaskRepository _repository = GetIt.I.get<TaskRepository>();
final String currentCompanyId;
final String? currentStoreId;
TaskListCubit() : super(TaskListState(status: TaskListStatus.initial));
// Il nostro abbonamento allo stream del repository
StreamSubscription<void>? _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<void> _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<void> loadTasks() async {
emit(state.copyWith(status: TaskListStatus.loading));
await _loadTasksSilently();
}
Future<void> _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<TaskListState> {
}
}
// --- OPERAZIONI MANUALI ---
// (Le lasciamo gestire al repository o le metti qui se preferisci,
// tanto il risultato lo vedrai magicamente aggiornato dallo stream sopra!)
/*
Future<void> markAsCompleted(String taskId) async { ... }
Future<void> deleteTask(String taskId) async { ... }
*/
// --- PULIZIA FONDAMENTALE ---
@override
Future<void> 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();
}
}

View File

@@ -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,
);
}

View File

@@ -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<void> watchCompanyTasks(String companyId) {
// Usiamo un broadcast nel caso più bloc volessero ascoltarlo in futuro
final controller = StreamController<void>.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<List<TaskModel>> getTasks({
required String companyId,

View File

@@ -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<TaskListCubit>().loadTasks(),
),
],
),
body: BlocBuilder<TaskListCubit, TaskListState>(
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<TaskListCubit>().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<TaskModel> 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<TaskListCubit>().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,
),
),
],
),
],
),
),
),
);
},
),
);
}
}