This commit is contained in:
2026-05-26 12:28:12 +02:00
parent 2afe97c6db
commit 45455a16c4
12 changed files with 851 additions and 8 deletions

View File

@@ -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<TaskListState> {
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
RealtimeChannel? _taskChannel;
final TaskRepository _repository = GetIt.I.get<TaskRepository>();
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<void> _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<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();
return super.close();
}
}

View File

@@ -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<TaskModel> tasks;
final String? errorMessage;
const TaskListState({
this.status = TaskListStatus.initial,
this.tasks = const [],
this.errorMessage,
});
TaskListState copyWith({
TaskListStatus? status,
List<TaskModel>? 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<Object?> get props => [status, tasks, errorMessage];
}

View File

@@ -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<List<TaskModel>> getTasks({
required String companyId,
String? storeId,
String? staffId,
List<TaskStatus>? 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<TaskModel> 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<TaskModel> 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<void> 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<TaskModel> 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<void> _syncAssignments(String taskId, List<String> 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<Map<String, dynamic>> assignments = staffIds
.map((staffId) => {'task_id': taskId, 'staff_id': staffId})
.toList();
await _supabase.from('task_assignments').insert(assignments);
}
}
}

View File

@@ -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<String> assignedToIds;
final List<StaffMemberModel> 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<Object?> 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<String>? assignedToIds,
List<StaffMemberModel>? 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<String, dynamic> json) {
// 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota
final List<String> parsedAssignedToIds = json['assigned_to_ids'] != null
? List<String>.from(json['assigned_to_ids'])
: [];
// 2. Mappiamo il JOIN dello staff, se presente
List<StaffMemberModel> parsedAssignedToStaff = [];
if (json['assigned_to_staff'] != null) {
final staffList = json['assigned_to_staff'] as List;
parsedAssignedToStaff = staffList
.map((s) => StaffMemberModel.fromMap(s as Map<String, dynamic>))
.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<String, dynamic> 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,
};
}
}

View File

@@ -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';
}
}
}