This commit is contained in:
2026-05-26 19:31:25 +02:00
parent 45455a16c4
commit 9d796d6e41
12 changed files with 785 additions and 62 deletions

View File

@@ -16,6 +16,8 @@ class Tables {
static const String staffInStores = 'staff_in_stores'; static const String staffInStores = 'staff_in_stores';
static const String staffMembers = 'staff_members'; static const String staffMembers = 'staff_members';
static const String stores = 'stores'; static const String stores = 'stores';
static const String tasks = 'tasks';
static const String taskAssignments = 'task_assignments';
static const String tickets = 'tickets'; static const String tickets = 'tickets';
static const String trackings = 'trackings'; static const String trackings = 'trackings';
} }

View File

@@ -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/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_form_screen.dart';
import 'package:flux/features/master_data/providers/ui/provider_list_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/models/staff_member_model.dart';
import 'package:flux/features/master_data/staff/ui/staff_screen.dart'; import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
import 'package:flux/features/master_data/store/bloc/store_cubit.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/operations/ui/operation_list_screen.dart';
import 'package:flux/features/settings/settings_screen.dart'; import 'package:flux/features/settings/settings_screen.dart';
import 'package:flux/features/settings/theme_settings_view.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/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/blocs/ticket_form_cubit.dart';
import 'package:flux/features/tickets/models/ticket_model.dart'; import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/features/tickets/ui/ticket_form_screen.dart'; import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
@@ -498,33 +501,39 @@ class AppRouter {
); );
}, },
), ),
/* GoRoute( GoRoute(
path: '/task/edit/:id', path: '/tasks/form/:id',
name: Routes.taskForm, name: Routes.taskForm,
builder: (context, state) { builder: (context, state) {
final id = state.pathParameters['id']!; final String pathId = state.pathParameters['id'] ?? 'new';
final TaskModel task = state.extra as TaskModel; 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<StaffCubit>().state.allStaff;
// Creiamo il BLoC "al volo" solo per questa schermata // Creiamo il BLoC "al volo" solo per questa schermata
return MultiBlocProvider( return MultiBlocProvider(
providers: [ providers: [
BlocProvider<AttachmentsBloc>(
create: (context) => AttachmentsBloc(
parentId: id,
parentType: AttachmentParentType.note,
),
),
BlocProvider<TaskFormCubit>( BlocProvider<TaskFormCubit>(
create: (context) => TaskFormCubit( create: (context) => TaskFormCubit(
existingTask: task, globalStaff: allStaffList,
initialTask: task,
initialTaskId: realTaskId,
),
), ),
)
], ],
child: TaskFormScreen(task: task), child: TaskFormScreen(),
); );
}, },
), */ ),
], ],
); );
} }

View File

@@ -16,9 +16,9 @@ class DashboardTaskListCubit extends Cubit<DashboardTaskListState> {
DashboardTaskListCubit() : super(DashboardTaskListState()); DashboardTaskListCubit() : super(DashboardTaskListState());
void startListening({required String staffId}) { void startListening({required String staffId}) async {
emit(state.copyWith(status: DashboardTaskListStatus.loading)); emit(state.copyWith(status: DashboardTaskListStatus.loading));
_loadTasks(staffId: staffId); await _loadTasks(staffId: staffId);
_taskChannel?.unsubscribe(); _taskChannel?.unsubscribe();
_taskChannel = _supabase _taskChannel = _supabase
.channel('public:tasks_staff_$staffId') .channel('public:tasks_staff_$staffId')

View File

@@ -100,7 +100,7 @@ class _DashboardTasksCardContent extends StatelessWidget {
if (state.status == DashboardTaskListStatus.failure) { if (state.status == DashboardTaskListStatus.failure) {
return Center( return Center(
child: Text( child: Text(
"Errore di caricamento", "Errore di caricamento ${state.errorMessage}",
style: TextStyle(color: theme.colorScheme.error), style: TextStyle(color: theme.colorScheme.error),
), ),
); );

View File

@@ -236,23 +236,10 @@ class HomeScreen extends StatelessWidget {
label: context.l10n.commonTask, label: context.l10n.commonTask,
color: Colors.teal, color: Colors.teal,
onTap: () { onTap: () {
final companyId = context.read<SessionCubit>().state.company!.id!;
final currentStaffId = context
.read<SessionCubit>()
.state
.currentStaffMember!
.id!;
final emptyTask = TaskModel.empty(
companyId: companyId,
createdById: currentStaffId,
);
final savedTask = GetIt.I.get<TaskRepository>().createTask(
emptyTask,
);
context.pushNamed( context.pushNamed(
Routes.taskForm, Routes.taskForm,
pathParameters: {'id': 'new'}, pathParameters: {'id': 'new'},
extra: savedTask, extra: (task: null),
); );
}, },
), ),

View File

@@ -13,7 +13,10 @@ class StaffRepository {
Future<List<StaffMemberModel>> getStaffMembers(String companyId) async { Future<List<StaffMemberModel>> getStaffMembers(String companyId) async {
final response = await _supabase final response = await _supabase
.from(Tables.staffMembers) .from(Tables.staffMembers)
.select() .select('''
*,
stores (*)
''')
.eq('company_id', companyId) .eq('company_id', companyId)
.order('name', ascending: true); .order('name', ascending: true);

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/features/master_data/store/models/store_model.dart';
// L'Enum magico e blindato per il sistema // L'Enum magico e blindato per il sistema
enum SystemRole { enum SystemRole {
@@ -26,6 +27,7 @@ class StaffMemberModel extends Equatable {
final SystemRole systemRole; final SystemRole systemRole;
final bool isActive; final bool isActive;
final bool hasJoined; final bool hasJoined;
final StoreModel? store;
const StaffMemberModel({ const StaffMemberModel({
this.id, this.id,
@@ -38,6 +40,7 @@ class StaffMemberModel extends Equatable {
this.systemRole = SystemRole.user, this.systemRole = SystemRole.user,
this.isActive = true, this.isActive = true,
this.hasJoined = false, this.hasJoined = false,
this.store,
}); });
StaffMemberModel copyWith({ StaffMemberModel copyWith({
@@ -52,6 +55,7 @@ class StaffMemberModel extends Equatable {
SystemRole? systemRole, SystemRole? systemRole,
bool? isActive, bool? isActive,
bool? hasJoined, bool? hasJoined,
StoreModel? store,
}) { }) {
return StaffMemberModel( return StaffMemberModel(
id: id ?? this.id, id: id ?? this.id,
@@ -64,6 +68,7 @@ class StaffMemberModel extends Equatable {
systemRole: systemRole ?? this.systemRole, systemRole: systemRole ?? this.systemRole,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
hasJoined: hasJoined ?? this.hasJoined, hasJoined: hasJoined ?? this.hasJoined,
store: store ?? this.store,
); );
} }
@@ -90,6 +95,7 @@ class StaffMemberModel extends Equatable {
systemRole: SystemRole.fromString(map['system_role']), systemRole: SystemRole.fromString(map['system_role']),
isActive: map['is_active'] ?? true, isActive: map['is_active'] ?? true,
hasJoined: map['has_joined'] ?? false, hasJoined: map['has_joined'] ?? false,
store: map['store'] != null ? StoreModel.fromMap(map['store']) : null,
); );
} }
@@ -120,5 +126,6 @@ class StaffMemberModel extends Equatable {
systemRole, systemRole,
isActive, isActive,
hasJoined, hasJoined,
store,
]; ];
} }

View File

@@ -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<TaskFormState> {
final TaskRepository _taskRepository = GetIt.I.get<TaskRepository>();
final TaskModel? _initialTask;
final String? _initialTaskId;
// Cache in memoria proveniente dallo StaffCubit!
final List<StaffMemberModel> _globalStaff;
final String currentCompanyId = GetIt.I
.get<SessionCubit>()
.state
.company!
.id!;
final String? currentStoreId = GetIt.I
.get<SessionCubit>()
.state
.currentStore
?.id;
TaskFormCubit({
required List<StaffMemberModel> 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<String>.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<String>.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<TaskReminder>.from(state.reminders);
currentReminders.add(TaskReminder(type: type, minutesBefore: minutes));
emit(state.copyWith(reminders: currentReminders));
}
void removeMockReminder(int index) {
final currentReminders = List<TaskReminder>.from(state.reminders);
currentReminders.removeAt(index);
emit(state.copyWith(reminders: currentReminders));
}
// --- 6. SALVATAGGIO FINALE ---
Future<void> 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',
),
);
}
}
}

View File

@@ -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<Object?> 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<String> selectedStaffIds;
final List<StaffMemberModel> availableStaff;
final Map<String, List<StaffMemberModel>> groupedAvailableStaff;
// --- FUTURI ANCORAGGI ---
final List<TaskReminder> 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<String>? selectedStaffIds,
List<StaffMemberModel>? availableStaff,
Map<String, List<StaffMemberModel>>? groupedAvailableStaff,
List<TaskReminder>? 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<Object?> get props => [
status,
id,
title,
description,
dueDate,
taskStatus,
isGlobal,
selectedStaffIds,
availableStaff,
groupedAvailableStaff,
reminders,
linkedTicketId,
linkedEmailId,
errorMessage,
];
}

View File

@@ -1,3 +1,4 @@
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/features/tasks/models/task_status.dart'; import 'package:flux/features/tasks/models/task_status.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
// Sostituisci con i percorsi corretti di FLUX // Sostituisci con i percorsi corretti di FLUX
@@ -18,10 +19,15 @@ class TaskRepository {
int? limit, int? limit,
}) async { }) async {
try { try {
// 1. FASE FILTRI (PostgrestFilterBuilder) // 1. FASE FILTRI: Usa il join esplicito stile "Notes"
var filterBuilder = _supabase var filterBuilder = _supabase
.from('tasks') .from('tasks')
.select('*, assigned_to_staff:staff_members!task_assignments(*)') .select('''
*,
task_assignments (
staff_members (*)
)
''')
.eq('company_id', companyId); .eq('company_id', companyId);
if (storeId != null) { if (storeId != null) {
@@ -31,9 +37,8 @@ class TaskRepository {
} }
if (staffId != null) { if (staffId != null) {
filterBuilder = filterBuilder.or( // Grazie al trigger, hai l'array pronto per il filtro senza impazzire!
'staff_id.eq.$staffId,staff_id.is.null', filterBuilder = filterBuilder.contains('assigned_to_ids', [staffId]);
);
} }
if (statuses != null && statuses.isNotEmpty) { if (statuses != null && statuses.isNotEmpty) {
@@ -41,13 +46,11 @@ class TaskRepository {
filterBuilder = filterBuilder.inFilter('status', statusValues); filterBuilder = filterBuilder.inFilter('status', statusValues);
} }
// 2. FASE TRASFORMAZIONI (PostgrestTransformBuilder) // 2. FASE TRASFORMAZIONI
// L'ordinamento lo facciamo sempre, quindi partiamo da qui
var transformBuilder = filterBuilder var transformBuilder = filterBuilder
.order('due_date', ascending: true, nullsFirst: false) .order('due_date', ascending: true, nullsFirst: false)
.order('created_at', ascending: false, nullsFirst: false); .order('created_at', ascending: false, nullsFirst: false);
// Aggiungiamo il limite se richiesto
if (limit != null) { if (limit != null) {
transformBuilder = transformBuilder.limit(limit); transformBuilder = transformBuilder.limit(limit);
} }
@@ -55,6 +58,7 @@ class TaskRepository {
// 3. ESECUZIONE DELLA QUERY // 3. ESECUZIONE DELLA QUERY
final response = await transformBuilder; final response = await transformBuilder;
// 4. PARSING DEI DATI
return (response as List).map((json) => TaskModel.fromMap(json)).toList(); return (response as List).map((json) => TaskModel.fromMap(json)).toList();
} catch (e) { } catch (e) {
throw Exception('Errore nel recupero dei task: $e'); throw Exception('Errore nel recupero dei task: $e');
@@ -95,8 +99,9 @@ class TaskRepository {
// --- 3. AGGIORNAMENTO DEL TASK --- // --- 3. AGGIORNAMENTO DEL TASK ---
Future<TaskModel> updateTask(TaskModel task) async { Future<TaskModel> updateTask(TaskModel task) async {
if (task.id == null) if (task.id == null) {
throw Exception('ID Task mancante. Impossibile aggiornare.'); throw Exception('ID Task mancante. Impossibile aggiornare.');
}
try { try {
final taskData = task.toMap(); final taskData = task.toMap();
@@ -133,7 +138,7 @@ class TaskRepository {
try { try {
final response = await _supabase final response = await _supabase
.from('tasks') .from('tasks')
.select('*, assigned_to_staff:staff!task_assignments(*)') .select('*, assigned_to_staff:staff_members!task_assignments(*)')
.eq('id', taskId) .eq('id', taskId)
.single(); .single();
return TaskModel.fromMap(response); return TaskModel.fromMap(response);

View File

@@ -91,37 +91,46 @@ class TaskModel extends Equatable {
} }
// --- SERIALIZZAZIONE DA SUPABASE --- // --- SERIALIZZAZIONE DA SUPABASE ---
factory TaskModel.fromMap(Map<String, dynamic> json) { factory TaskModel.fromMap(Map<String, dynamic> map) {
// 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota // 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota
final List<String> parsedAssignedToIds = json['assigned_to_ids'] != null final List<String> parsedAssignedToIds = map['assigned_to_ids'] != null
? List<String>.from(json['assigned_to_ids']) ? List<String>.from(map['assigned_to_ids'])
: []; : [];
// 2. Mappiamo il JOIN dello staff, se presente // 2. Mappiamo il JOIN dello staff, se presente
List<StaffMemberModel> parsedAssignedToStaff = []; List<StaffMemberModel> staffList = [];
if (json['assigned_to_staff'] != null) {
final staffList = json['assigned_to_staff'] as List; // Gestione del JSON proveniente dal Join nidificato (es. task_assignments -> staff_members)
parsedAssignedToStaff = staffList if (map['task_assignments'] != null) {
.map((s) => StaffMemberModel.fromMap(s as Map<String, dynamic>)) 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(); .toList();
} }
return TaskModel( return TaskModel(
id: json['id'] as String?, id: map['id'] as String?,
companyId: json['company_id'] as String?, companyId: map['company_id'] as String?,
title: json['title'] as String? ?? '', title: map['title'] as String? ?? '',
description: json['description'] as String?, description: map['description'] as String?,
assignedToIds: parsedAssignedToIds, assignedToIds: parsedAssignedToIds,
assignedToStaff: parsedAssignedToStaff, assignedToStaff: staffList,
createdById: json['created_by_id'] as String?, createdById: map['created_by_id'] as String?,
dueDate: json['due_date'] != null dueDate: map['due_date'] != null
? DateTime.parse(json['due_date'] as String).toLocal() ? DateTime.parse(map['due_date'] as String).toLocal()
: null, : null,
status: TaskStatusExtension.fromString(json['status'] as String?), status: TaskStatusExtension.fromString(map['status'] as String?),
createdAt: json['created_at'] != null createdAt: map['created_at'] != null
? DateTime.parse(json['created_at'] as String).toLocal() ? DateTime.parse(map['created_at'] as String).toLocal()
: null, : null,
storeId: json['store_id'] as String?, storeId: map['store_id'] as String?,
); );
} }

View File

@@ -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<TaskFormCubit, TaskFormState>(
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<TaskFormCubit>();
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<TaskFormCubit, TaskFormState>(
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<Widget> _buildGroupedStaffList(
BuildContext context,
TaskFormState state,
TaskFormCubit cubit,
) {
final widgets = <Widget>[];
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;
}
}