584 lines
21 KiB
Dart
584 lines
21 KiB
Dart
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 StatefulWidget {
|
|
const TaskFormScreen({super.key});
|
|
|
|
@override
|
|
State<TaskFormScreen> createState() => _TaskFormScreenState();
|
|
}
|
|
|
|
class _TaskFormScreenState extends State<TaskFormScreen> {
|
|
late final TextEditingController _titleController;
|
|
late final TextEditingController _descController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Leggiamo lo stato iniziale dal Cubit (che ha già i dati del task esistente)
|
|
final initialState = context.read<TaskFormCubit>().state;
|
|
|
|
_titleController = TextEditingController(text: initialState.title);
|
|
_descController = TextEditingController(text: initialState.description);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_titleController.dispose();
|
|
_descController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _showAddReminderDialog(BuildContext context, TaskFormCubit cubit) {
|
|
int minutes = 15;
|
|
String channel = 'push';
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AlertDialog(
|
|
title: const Text('Aggiungi Promemoria'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
DropdownButtonFormField<int>(
|
|
initialValue: minutes,
|
|
decoration: const InputDecoration(labelText: 'Preavviso'),
|
|
items: const [
|
|
DropdownMenuItem(value: 5, child: Text('5 minuti prima')),
|
|
DropdownMenuItem(value: 15, child: Text('15 minuti prima')),
|
|
DropdownMenuItem(value: 60, child: Text('1 ora prima')),
|
|
DropdownMenuItem(value: 1440, child: Text('1 giorno prima')),
|
|
],
|
|
onChanged: (v) => {if (v != null) minutes = v},
|
|
),
|
|
const SizedBox(height: 16),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: channel,
|
|
decoration: const InputDecoration(labelText: 'Canale'),
|
|
items: const [
|
|
DropdownMenuItem(value: 'push', child: Text('Notifica Push')),
|
|
DropdownMenuItem(value: 'email', child: Text('Email')),
|
|
],
|
|
onChanged: (v) => {if (v != null) channel = v},
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text('Annulla'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
cubit.addReminderRule(minutes, channel);
|
|
Navigator.pop(dialogContext);
|
|
},
|
|
child: const Text(
|
|
'Inserisci',
|
|
style: TextStyle(color: Colors.orange),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocConsumer<TaskFormCubit, TaskFormState>(
|
|
listenWhen: (previous, current) => previous.status != current.status,
|
|
listener: (context, state) {
|
|
// GESTIONE DEEP LINK: Se eravamo in caricamento e ora siamo pronti, popoliamo i controller!
|
|
if (state.status == TaskFormStatus.initial) {
|
|
if (_titleController.text != state.title) {
|
|
_titleController.text = state.title;
|
|
}
|
|
if (_descController.text != state.description) {
|
|
_descController.text = state.description;
|
|
}
|
|
}
|
|
if (state.status == TaskFormStatus.success) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Task salvato con successo! 🎉')),
|
|
);
|
|
context.pop();
|
|
} 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: [
|
|
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() : null,
|
|
icon: const Icon(Icons.save),
|
|
label: const Text('Salva'),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Colors.orange,
|
|
disabledForegroundColor: Colors.grey,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: state.status == TaskFormStatus.loading
|
|
// Se sta scaricando i dati dal Deep Link, mostriamo un bel loader centrato
|
|
? const Center(child: CircularProgressIndicator())
|
|
: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final isWideScreen = constraints.maxWidth > 800;
|
|
|
|
if (isWideScreen) {
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// --- I CAMPI DEL FORM (Aggiornati con i Controller) ---
|
|
Widget _buildFormFields(
|
|
BuildContext context,
|
|
TaskFormState state,
|
|
TaskFormCubit cubit,
|
|
) {
|
|
return FocusTraversalGroup(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
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),
|
|
|
|
// Addio initialValue, benvenuto controller!
|
|
TextFormField(
|
|
controller: _titleController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Titolo del Task*',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.title),
|
|
),
|
|
onChanged: cubit.updateTitle,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _descController,
|
|
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
|
|
// Formattiamo aggiungendo gli zeri (es. 05/09/2026 alle 09:05)
|
|
? 'Scadenza: ${state.dueDate!.day.toString().padLeft(2, '0')}/${state.dueDate!.month.toString().padLeft(2, '0')}/${state.dueDate!.year} alle ${state.dueDate!.hour.toString().padLeft(2, '0')}:${state.dueDate!.minute.toString().padLeft(2, '0')}'
|
|
: 'Nessuna scadenza impostata',
|
|
),
|
|
trailing: state.dueDate != null
|
|
? IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => cubit.updateDueDate(null),
|
|
)
|
|
: null,
|
|
onTap: () async {
|
|
// 1. Chiediamo prima la Data
|
|
final date = await showDatePicker(
|
|
context: context,
|
|
initialDate: state.dueDate ?? DateTime.now(),
|
|
firstDate: DateTime.now(),
|
|
lastDate: DateTime(2100),
|
|
);
|
|
|
|
// Se l'utente chiude il calendario senza scegliere, ci fermiamo
|
|
if (date == null || !context.mounted) return;
|
|
|
|
// 2. Chiediamo subito dopo l'Orario
|
|
final time = await showTimePicker(
|
|
context: context,
|
|
initialTime: state.dueDate != null
|
|
? TimeOfDay.fromDateTime(state.dueDate!)
|
|
: const TimeOfDay(hour: 9, minute: 0), // Default ore 09:00
|
|
);
|
|
|
|
// Se l'utente chiude l'orologio senza scegliere, ci fermiamo
|
|
if (time == null) return;
|
|
|
|
// 3. Fondiamo Data e Ora in un nuovo oggetto DateTime
|
|
final finalDateTime = DateTime(
|
|
date.year,
|
|
date.month,
|
|
date.day,
|
|
time.hour,
|
|
time.minute,
|
|
);
|
|
|
|
// Aggiorniamo lo stato tramite il Cubit
|
|
cubit.updateDueDate(finalDateTime);
|
|
},
|
|
),
|
|
if (state.dueDate != null) ...[
|
|
const SizedBox(height: 24),
|
|
Text(
|
|
'Promemoria del Task',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Card(
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: BorderSide(
|
|
color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: Column(
|
|
children: [
|
|
// Elenco dei promemoria attuali del form
|
|
ListView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: state.reminders.length,
|
|
itemBuilder: (context, index) {
|
|
final reminder = state.reminders[index];
|
|
final isPush = reminder.channel == 'push';
|
|
|
|
return ListTile(
|
|
dense: true,
|
|
leading: Icon(
|
|
isPush
|
|
? Icons.notifications_active_outlined
|
|
: Icons.mail_outline,
|
|
color: isPush ? Colors.orange : Colors.blue,
|
|
),
|
|
title: Text(
|
|
reminder.friendlyTime,
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
trailing: IconButton(
|
|
icon: const Icon(
|
|
Icons.close,
|
|
size: 18,
|
|
color: Colors.redAccent,
|
|
),
|
|
onPressed: () => cubit.removeReminderRule(index),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const Divider(),
|
|
// Tasto di aggiunta rapida promemoria
|
|
TextButton.icon(
|
|
onPressed: () => _showAddReminderDialog(context, cubit),
|
|
icon: const Icon(Icons.add, size: 18),
|
|
label: const Text('Aggiungi un promemoria a questo task'),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Colors.orange,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 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;
|
|
}
|
|
}
|