diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 32c7a1a..daf1abd 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -18,7 +18,6 @@ import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/ui/customer_detail_screen.dart'; import 'package:flux/features/customers/ui/customer_form_screen.dart'; import 'package:flux/features/customers/ui/customers_list_screen.dart'; -import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart'; import 'package:flux/features/home/ui/home_screen.dart'; import 'package:flux/features/master_data/master_data_hub_content.dart'; import 'package:flux/features/master_data/products/blocs/product_cubit.dart'; @@ -43,8 +42,10 @@ import 'package:flux/features/operations/blocs/operation_form_cubit.dart'; import 'package:flux/features/operations/models/operation_model.dart'; import 'package:flux/features/operations/ui/operation_form_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/theme_settings_view.dart'; +import 'package:flux/features/settings/blocs/reminder_defaults_cubit.dart'; +import 'package:flux/features/settings/ui/reminder_settings_screen.dart'; +import 'package:flux/features/settings/ui/settings_screen.dart'; +import 'package:flux/features/settings/ui/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'; @@ -141,14 +142,7 @@ class AppRouter { path: '/', name: Routes.home, builder: (context, state) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => DashboardTaskListCubit(), - ), - ], - child: HomeScreen(), - ); + return const HomeScreen(); }, ), @@ -218,6 +212,16 @@ class AppRouter { name: Routes.themeSettings, builder: (context, state) => const ThemeSettingsView(), ), + GoRoute( + path: 'reminderSettings', + name: Routes.reminderSettings, + builder: (context, state) => + BlocProvider( + create: (context) => ReminderDefaultsCubit(), + + child: const ReminderSettingsScreen(), + ), + ), ], ), diff --git a/lib/core/routes/routes.dart b/lib/core/routes/routes.dart index 8f6d259..5622704 100644 --- a/lib/core/routes/routes.dart +++ b/lib/core/routes/routes.dart @@ -26,4 +26,5 @@ class Routes { static const String notes = 'notes'; static const String tasks = 'tasks'; static const String taskForm = 'task-form'; + static const String reminderSettings = 'reminder-settings'; } diff --git a/lib/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart b/lib/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart index f4064ad..ba0edb6 100644 --- a/lib/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart +++ b/lib/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart @@ -1,49 +1,50 @@ +import 'dart:async'; + 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/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 'dashboard_task_list_state.dart'; class DashboardTaskListCubit extends Cubit { final TaskRepository _repository = GetIt.I.get(); - final SupabaseClient _supabase = GetIt.I.get(); - RealtimeChannel? _taskChannel; + final String staffId; + final String companyId; + StreamSubscription? _taskSubscription; - DashboardTaskListCubit() : super(DashboardTaskListState()); - - void startListening({required String staffId}) async { - emit(state.copyWith(status: DashboardTaskListStatus.loading)); - await _loadTasks(staffId: staffId); - _taskChannel?.unsubscribe(); - _taskChannel = _supabase - .channel('public:tasks_staff_$staffId') - .onPostgresChanges( - event: PostgresChangeEvent.all, - schema: 'public', - table: 'tasks', - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'staff_id', - value: staffId, - ), - callback: (payload) { - _loadTasks(staffId: staffId); - }, - ); - _taskChannel?.subscribe(); + DashboardTaskListCubit({required this.staffId, required this.companyId}) + : super(const DashboardTaskListState()) { + _initRealtime(); } - Future _loadTasks({required String staffId}) async { + void _initRealtime() { + emit(state.copyWith(status: DashboardTaskListStatus.loading)); + + // Primo caricamento + _loadTasksSilently(); + + // Inizio ascolto campanello + _taskSubscription = _repository.watchCompanyTasks(companyId).listen((_) { + // Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo! + _loadTasksSilently(); + }); + } + + Future loadTasks() async { + emit(state.copyWith(status: DashboardTaskListStatus.loading)); + await _loadTasksSilently(); + } + + Future _loadTasksSilently() async { try { final tasks = await _repository.getTasks( - companyId: GetIt.I.get().state.company!.id!, + companyId: companyId, staffId: staffId, statuses: [TaskStatus.open, TaskStatus.inProgress], + limit: 10, ); emit( @@ -65,7 +66,8 @@ class DashboardTaskListCubit extends Cubit { @override Future close() { - _taskChannel?.unsubscribe(); + // Stacchiamo l'abbonamento. Il controller.onCancel nel Repo farà il resto! + _taskSubscription?.cancel(); return super.close(); } } diff --git a/lib/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart b/lib/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart index aea5bf1..9bbde6a 100644 --- a/lib/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart +++ b/lib/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart @@ -4,6 +4,7 @@ import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/routes/routes.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:flux/features/tasks/models/task_status.dart'; @@ -14,11 +15,12 @@ class DashboardTasksCard extends StatelessWidget { Widget build(BuildContext context) { // Recuperiamo lo staff (o l'utente) loggato // Adatta il getter in base a come è strutturato il tuo SessionState - final currentStaffId = context - .read() + final currentStaffId = GetIt.I + .get() .state .currentStaffMember ?.id; + final companyId = GetIt.I.get().state.company!.id!; if (currentStaffId == null) { return const SizedBox.shrink(); // Sicurezza se lo stato non è pronto @@ -26,7 +28,7 @@ class DashboardTasksCard extends StatelessWidget { return BlocProvider( create: (context) => - DashboardTaskListCubit()..startListening(staffId: currentStaffId), + DashboardTaskListCubit(staffId: currentStaffId, companyId: companyId), child: const _DashboardTasksCardContent(), ); } diff --git a/lib/features/settings/blocs/reminder_defaults_cubit.dart b/lib/features/settings/blocs/reminder_defaults_cubit.dart new file mode 100644 index 0000000..e2f2a97 --- /dev/null +++ b/lib/features/settings/blocs/reminder_defaults_cubit.dart @@ -0,0 +1,108 @@ +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/settings/data/settings_repository.dart'; // O dove hai messo i metodi del DB +import 'package:flux/features/tasks/models/reminder_default_model.dart'; +import 'package:get_it/get_it.dart'; + +part 'reminder_defaults_state.dart'; + +class ReminderDefaultsCubit extends Cubit { + final SettingsRepository _repository = GetIt.I.get(); + final SessionCubit _sessionCubit = GetIt.I.get(); + + ReminderDefaultsCubit() : super(const ReminderDefaultsState()); + + String get _companyId => _sessionCubit.state.company!.id!; + String get _staffId => _sessionCubit.state.currentStaffMember!.id!; + + Future loadReminders() async { + emit(state.copyWith(status: ReminderDefaultsStatus.loading)); + try { + final reminders = await _repository.getMyReminderDefaults( + companyId: _companyId, + staffId: _staffId, + ); + emit( + state.copyWith( + status: ReminderDefaultsStatus.success, + reminders: reminders, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: ReminderDefaultsStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future addReminder({ + required int minutesBefore, + required String channel, + }) async { + emit(state.copyWith(status: ReminderDefaultsStatus.loading)); + try { + final newReminder = ReminderDefaultModel( + companyId: _companyId, + staffId: _staffId, + minutesBefore: minutesBefore, + channel: channel, + ); + + final savedReminder = await _repository.addReminderDefault(newReminder); + + // Aggiungiamo alla lista locale e ordiniamo per minuti + final updatedList = List.from(state.reminders) + ..add(savedReminder); + updatedList.sort((a, b) => a.minutesBefore.compareTo(b.minutesBefore)); + + emit( + state.copyWith( + status: ReminderDefaultsStatus.success, + reminders: updatedList, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: ReminderDefaultsStatus.failure, + errorMessage: e.toString(), + ), + ); + // Ricarichiamo per sicurezza lo stato precedente + loadReminders(); + } + } + + Future deleteReminder(String reminderId) async { + // Salviamo la lista vecchia nel caso fallisca la cancellazione + final oldList = List.from(state.reminders); + + // Aggiornamento ottimistico (rimuoviamo subito dalla UI) + final optimisticList = state.reminders + .where((r) => r.id != reminderId) + .toList(); + emit( + state.copyWith( + status: ReminderDefaultsStatus.success, + reminders: optimisticList, + ), + ); + + try { + await _repository.deleteReminderDefault(reminderId); + } catch (e) { + // Rollback se il DB fallisce + emit( + state.copyWith( + status: ReminderDefaultsStatus.failure, + errorMessage: e.toString(), + reminders: oldList, + ), + ); + } + } +} diff --git a/lib/features/settings/blocs/reminder_defaults_state.dart b/lib/features/settings/blocs/reminder_defaults_state.dart new file mode 100644 index 0000000..6c09bca --- /dev/null +++ b/lib/features/settings/blocs/reminder_defaults_state.dart @@ -0,0 +1,33 @@ +part of 'reminder_defaults_cubit.dart'; + +enum ReminderDefaultsStatus { initial, loading, success, failure } + +class ReminderDefaultsState extends Equatable { + final ReminderDefaultsStatus status; + final List reminders; + final String? errorMessage; + + const ReminderDefaultsState({ + this.status = ReminderDefaultsStatus.initial, + this.reminders = const [], + this.errorMessage, + }); + + ReminderDefaultsState copyWith({ + ReminderDefaultsStatus? status, + List? reminders, + String? errorMessage, + }) { + return ReminderDefaultsState( + status: status ?? this.status, + reminders: reminders ?? this.reminders, + // Se passiamo un nuovo status di successo o loading, puliamo l'errore + errorMessage: + errorMessage ?? + (status != ReminderDefaultsStatus.failure ? null : this.errorMessage), + ); + } + + @override + List get props => [status, reminders, errorMessage]; +} diff --git a/lib/features/settings/data/settings_repository.dart b/lib/features/settings/data/settings_repository.dart new file mode 100644 index 0000000..47b0a3e --- /dev/null +++ b/lib/features/settings/data/settings_repository.dart @@ -0,0 +1,63 @@ +import 'package:flux/features/tasks/models/reminder_default_model.dart'; +import 'package:get_it/get_it.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class SettingsRepository { + final _supabase = GetIt.I.get(); + + // --- PREFERENZE REMINDER --- + + /// Legge i default dell'utente corrente + Future> getMyReminderDefaults({ + required String companyId, + required String staffId, + }) async { + try { + final response = await _supabase + .from('staff_task_reminder_defaults') + .select() + .eq('company_id', companyId) + .eq('staff_id', staffId) + .order('minutes_before', ascending: true); + + return (response as List) + .map((map) => ReminderDefaultModel.fromMap(map)) + .toList(); + } catch (e) { + throw Exception('Errore nel caricamento delle preferenze notifiche: $e'); + } + } + + /// Aggiunge una nuova regola (es. Push 15 min prima) + Future addReminderDefault( + ReminderDefaultModel reminder, + ) async { + try { + final response = await _supabase + .from('staff_task_reminder_defaults') + .insert(reminder.toMap()) + .select() + .single(); + + return ReminderDefaultModel.fromMap(response); + } catch (e) { + // Catturiamo l'errore UNIQUE se l'utente prova ad aggiungere due volte la stessa identica regola + if (e is PostgrestException && e.code == '23505') { + throw Exception('Hai già impostato questo identico promemoria.'); + } + throw Exception('Errore salvataggio promemoria: $e'); + } + } + + /// Elimina una regola + Future deleteReminderDefault(String reminderId) async { + try { + await _supabase + .from('staff_task_reminder_defaults') + .delete() + .eq('id', reminderId); + } catch (e) { + throw Exception('Errore durante l\'eliminazione: $e'); + } + } +} diff --git a/lib/features/settings/ui/reminder_settings_screen.dart b/lib/features/settings/ui/reminder_settings_screen.dart new file mode 100644 index 0000000..5ec4a31 --- /dev/null +++ b/lib/features/settings/ui/reminder_settings_screen.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/settings/blocs/reminder_defaults_cubit.dart'; + +class ReminderSettingsScreen extends StatefulWidget { + const ReminderSettingsScreen({super.key}); + + @override + State createState() => _ReminderSettingsScreenState(); +} + +class _ReminderSettingsScreenState extends State { + @override + void initState() { + super.initState(); + // Carichiamo i dati all'avvio + context.read().loadReminders(); + } + + void _showAddReminderBottomSheet(BuildContext context) { + // Valori preselezionati + int selectedMinutes = 15; + String selectedChannel = 'push'; + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (bottomSheetContext) { + return StatefulBuilder( + builder: (context, setModalState) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Nuova Regola di Avviso', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + + // --- SELEZIONE TEMPO --- + DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Quando vuoi essere avvisato?', + border: OutlineInputBorder(), + ), + initialValue: selectedMinutes, + 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: 120, + child: Text('2 ore prima'), + ), + DropdownMenuItem( + value: 1440, + child: Text('1 giorno prima'), + ), + ], + onChanged: (val) { + if (val != null) + setModalState(() => selectedMinutes = val); + }, + ), + const SizedBox(height: 16), + + // --- SELEZIONE CANALE --- + DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Come vuoi essere avvisato?', + border: OutlineInputBorder(), + ), + initialValue: selectedChannel, + items: const [ + DropdownMenuItem( + value: 'push', + child: Row( + children: [ + Icon( + Icons.notifications_active, + size: 20, + color: Colors.orange, + ), + SizedBox(width: 8), + Text('Notifica App (Push)'), + ], + ), + ), + DropdownMenuItem( + value: 'email', + child: Row( + children: [ + Icon(Icons.email, size: 20, color: Colors.blue), + SizedBox(width: 8), + Text('Email'), + ], + ), + ), + ], + onChanged: (val) { + if (val != null) + setModalState(() => selectedChannel = val); + }, + ), + const SizedBox(height: 32), + + // --- SALVATAGGIO --- + FilledButton( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + onPressed: () { + context.read().addReminder( + minutesBefore: selectedMinutes, + channel: selectedChannel, + ); + Navigator.pop(context); + }, + child: const Text('Aggiungi Regola'), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Preferenze Promemoria')), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showAddReminderBottomSheet(context), + icon: const Icon(Icons.add_alert), + label: const Text('Aggiungi'), + backgroundColor: Colors.orange, + ), + body: BlocConsumer( + listener: (context, state) { + if (state.status == ReminderDefaultsStatus.failure && + state.errorMessage != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage!), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + }, + builder: (context, state) { + if (state.status == ReminderDefaultsStatus.loading && + state.reminders.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.reminders.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.notifications_off_outlined, + size: 64, + color: Theme.of(context).dividerColor, + ), + const SizedBox(height: 16), + const Text( + 'Nessun promemoria predefinito.', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + 'Aggiungi una regola per ricevere in automatico le notifiche quando ti viene assegnato un task.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ], + ), + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.only( + top: 16, + bottom: 80, + left: 16, + right: 16, + ), + itemCount: state.reminders.length, + itemBuilder: (context, index) { + final reminder = state.reminders[index]; + final isPush = reminder.channel == 'push'; + + return Card( + elevation: 0, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Theme.of( + context, + ).dividerColor.withValues(alpha: 0.5), + ), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, + ), + leading: CircleAvatar( + backgroundColor: isPush + ? Colors.orange.withValues(alpha: 0.1) + : Colors.blue.withValues(alpha: 0.1), + child: Icon( + isPush ? Icons.notifications_active : Icons.email, + color: isPush ? Colors.orange : Colors.blue, + ), + ), + title: Text( + reminder.friendlyTime, // Usiamo l'helper del Model! + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + isPush ? 'Tramite Notifica App' : 'Tramite Email', + ), + trailing: IconButton( + icon: const Icon( + Icons.delete_outline, + color: Colors.redAccent, + ), + onPressed: () { + context.read().deleteReminder( + reminder.id!, + ); + }, + ), + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/features/settings/settings.dart b/lib/features/settings/ui/settings.dart similarity index 100% rename from lib/features/settings/settings.dart rename to lib/features/settings/ui/settings.dart diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/ui/settings_screen.dart similarity index 93% rename from lib/features/settings/settings_screen.dart rename to lib/features/settings/ui/settings_screen.dart index bbde1c6..3421cb8 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/ui/settings_screen.dart @@ -18,6 +18,15 @@ class SettingsScreen extends StatelessWidget { body: ListView( padding: const EdgeInsets.all(16), children: [ + _settingsSection('Utente', [ + _settingsTile( + title: 'Impostazioni Promemoria', + icon: Icons.notifications, + subtitle: 'Notifiche predefinite', + context: context, + onTap: () => context.pushNamed(Routes.reminderSettings), + ), + ]), _settingsSection('Azienda', [ _settingsTile( title: 'Impostazioni Azienda', @@ -83,6 +92,7 @@ class SettingsScreen extends StatelessWidget { onTap: () => context.pushNamed(Routes.themeSettings), ), ]), + const SizedBox(height: 24), TextButton.icon( onPressed: () => context.read().signOut(), diff --git a/lib/features/settings/theme_settings_view.dart b/lib/features/settings/ui/theme_settings_view.dart similarity index 100% rename from lib/features/settings/theme_settings_view.dart rename to lib/features/settings/ui/theme_settings_view.dart diff --git a/lib/features/tasks/blocs/task_form_cubit.dart b/lib/features/tasks/blocs/task_form_cubit.dart index 79d6d91..80cf2be 100644 --- a/lib/features/tasks/blocs/task_form_cubit.dart +++ b/lib/features/tasks/blocs/task_form_cubit.dart @@ -2,208 +2,159 @@ 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/settings/data/settings_repository.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_reminder_config.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 { - final TaskRepository _taskRepository = GetIt.I.get(); - final List _globalStaff; - final String currentCompanyId = GetIt.I().state.company!.id!; - final String? currentStoreId = GetIt.I().state.currentStore?.id; + final TaskRepository _repository = GetIt.I.get(); + final SettingsRepository _settingsRepository = GetIt.I + .get(); + final SessionCubit _sessionCubit = GetIt.I.get(); - TaskFormCubit({ - required List globalStaff, - - TaskModel? initialTask, // Arriva dalla navigazione interna (extra) - String? initialTaskId, // Arriva dal Deep Link (parametro URL) - }) : _globalStaff = globalStaff, - super(const TaskFormState()) { - _initForm(initialTask, initialTaskId); - } - - Future _initForm(TaskModel? task, String? taskId) async { - // 1. Mettiamo subito il form in caricamento - emit(state.copyWith(status: TaskFormStatus.loading)); - - TaskModel? taskToLoad = task; - - // 2. SCENARIO DEEP LINK: Non abbiamo l'oggetto, ma abbiamo un ID valido - if (taskToLoad == null && taskId != null && taskId != 'new') { - try { - taskToLoad = await _taskRepository.getTaskById(taskId); - } catch (e) { - emit( - state.copyWith( - status: TaskFormStatus.failure, - errorMessage: 'Impossibile caricare il task dal link: $e', - ), - ); - return; // Ci fermiamo qui - } - } - - // 3. Popoliamo lo stato con i dati (sia che arrivino dall'extra, sia dal DB, sia nulli) - final isGlobalMode = taskToLoad != null - ? taskToLoad.storeId == null - : currentStoreId == null; - final existingStaffIds = - taskToLoad?.assignedToStaff.map((s) => s.id!).toList() ?? - taskToLoad?.assignedToIds ?? - []; - - emit( - state.copyWith( - id: taskToLoad?.id, - title: taskToLoad?.title ?? '', - description: taskToLoad?.description ?? '', - dueDate: taskToLoad?.dueDate, - taskStatus: taskToLoad?.status ?? TaskStatus.open, - isGlobal: isGlobalMode, - selectedStaffIds: existingStaffIds, - status: TaskFormStatus.initial, // Caricamento finito, form pronto! - ), - ); - - _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: cerchiamo nell'array degli ID! - final filteredStaff = isGlobal - ? _globalStaff - : _globalStaff - .where((s) => s.assignedStoreIds.contains(currentStoreId)) - .toList(); - - // 2. Raggruppamento M2M (Ciclo manuale) - final Map> groupedStaff = {}; - - for (final staff in filteredStaff) { - // Se non ha nessun negozio assegnato, finisce in Direzione - if (staff.assignedStores.isEmpty) { - groupedStaff.putIfAbsent('Direzione / HQ', () => []).add(staff); - } else { - // Se ha più negozi, clona la sua presenza in ogni gruppo! - for (final store in staff.assignedStores) { - final storeName = store.name; - groupedStaff.putIfAbsent(storeName, () => []).add(staff); - } - } - } - - // 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.from(state.selectedStaffIds); - if (currentList.contains(staffId)) { - currentList.remove(staffId); + TaskFormCubit({TaskModel? existingTask}) + : super( + TaskFormState( + id: existingTask?.id, + title: existingTask?.title ?? '', + description: existingTask?.description ?? '', + dueDate: existingTask?.dueDate, + isGlobal: existingTask?.isGlobal ?? false, + selectedStaffIds: existingTask?.assignedToIds ?? [], + ), + ) { + if (existingTask == null) { + _initializeNewTaskReminders(); } else { - currentList.add(staffId); + _loadExistingTaskReminders(existingTask.id!); } - 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.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.from(state.reminders); - currentReminders.add(TaskReminder(type: type, minutesBefore: minutes)); - emit(state.copyWith(reminders: currentReminders)); - } - - void removeMockReminder(int index) { - final currentReminders = List.from(state.reminders); - currentReminders.removeAt(index); - emit(state.copyWith(reminders: currentReminders)); - } - - // --- 6. SALVATAGGIO FINALE --- - Future saveTask({required String currentUserId}) async { - if (!state.isFormValid) return; - - emit(state.copyWith(status: TaskFormStatus.submitting)); + String get _companyId => _sessionCubit.state.company!.id!; + String get _currentUserId => _sessionCubit.state.currentStaffMember!.id!; + // --- INIT REMINDER NUOVO TASK --- + Future _initializeNewTaskReminders() async { 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 + final defaults = await _settingsRepository.getMyReminderDefaults( + companyId: _companyId, + staffId: _currentUserId, ); - if (state.id == null) { - await _taskRepository.createTask(taskToSave); - } else { - await _taskRepository.updateTask(taskToSave); - } + final initialReminders = defaults + .map( + (d) => TaskReminderConfig( + minutesBefore: d.minutesBefore, + channel: d.channel, + ), + ) + .toList(); + emit(state.copyWith(reminders: initialReminders)); + } catch (e) { + // Fallback in caso di errore + emit( + state.copyWith( + reminders: const [ + TaskReminderConfig(minutesBefore: 15, channel: 'push'), + ], + ), + ); + } + } + + // --- INIT REMINDER TASK ESISTENTE --- + Future _loadExistingTaskReminders(String taskId) async { + try { + // Recuperiamo SOLO i reminder non forzati dell'utente loggato per popolare il form + final existingConfigs = await _repository.fetchPersonalReminders( + taskId: taskId, + staffId: _currentUserId, + ); + emit(state.copyWith(reminders: existingConfigs)); + } catch (e) { + print('Errore caricamento reminder: $e'); + } + } + + // --- UPDATE CAMPI BASE --- + void updateTitle(String t) => emit(state.copyWith(title: t)); + void updateDescription(String d) => emit(state.copyWith(description: d)); + void updateDueDate(DateTime? d) => emit(state.copyWith(dueDate: d)); + void toggleGlobalScope(bool g) => emit(state.copyWith(isGlobal: g)); + + void toggleStaffSelection(String staffId) { + final updated = List.from(state.selectedStaffIds); + updated.contains(staffId) ? updated.remove(staffId) : updated.add(staffId); + emit(state.copyWith(selectedStaffIds: updated)); + } + + // --- GESTIONE REMINDER NEL FORM --- + void addReminderRule(int minutesBefore, String channel) { + final updated = List.from(state.reminders); + final newConfig = TaskReminderConfig( + minutesBefore: minutesBefore, + channel: channel, + ); + + if (!updated.contains(newConfig)) { + updated.add(newConfig); + updated.sort((a, b) => a.minutesBefore.compareTo(b.minutesBefore)); + emit(state.copyWith(reminders: updated)); + } + } + + void removeReminderRule(int index) { + final updated = List.from(state.reminders) + ..removeAt(index); + emit(state.copyWith(reminders: updated)); + } + + // --- SALVATAGGIO FINALE --- + Future saveTask() async { + if (!state.isFormValid) return; + emit(state.copyWith(status: TaskFormStatus.submitting)); + + final taskToSave = TaskModel( + id: state.id, + companyId: _companyId, + createdBy: _currentUserId, + title: state.title.trim(), + description: state.description.trim(), + dueDate: state.dueDate, + isGlobal: state.isGlobal, + assignedToIds: state.selectedStaffIds, + ); + + try { + if (state.id == null) { + // NUOVO TASK -> CREATE + // Qui potresti passare un managerForcedOverride se implementi la UI per quello + await _repository.createTask( + task: taskToSave, + assignedStaffIds: state.selectedStaffIds, + currentUserId: _currentUserId, + currentUserCustomReminders: state.reminders, + ); + } else { + // VECCHIO TASK -> UPDATE + await _repository.updateTask( + task: taskToSave, + assignedStaffIds: state.selectedStaffIds, + currentUserId: _currentUserId, + currentUserCustomReminders: state.reminders, + ); + } emit(state.copyWith(status: TaskFormStatus.success)); } catch (e) { emit( state.copyWith( status: TaskFormStatus.failure, - errorMessage: 'Errore durante il salvataggio: $e', + errorMessage: e.toString(), ), ); } diff --git a/lib/features/tasks/blocs/task_form_state.dart b/lib/features/tasks/blocs/task_form_state.dart index 9aaa19d..9bf13d3 100644 --- a/lib/features/tasks/blocs/task_form_state.dart +++ b/lib/features/tasks/blocs/task_form_state.dart @@ -2,108 +2,66 @@ 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 get props => [type, minutesBefore]; -} - class TaskFormState extends Equatable { + final String? id; 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 selectedStaffIds; - final List availableStaff; - final Map> groupedAvailableStaff; - - // --- FUTURI ANCORAGGI --- - final List reminders; - final String? linkedTicketId; - final String? linkedEmailId; - + final List + reminders; // I promemoria (solo dell'utente loggato) final String? errorMessage; const TaskFormState({ - this.status = TaskFormStatus.initial, this.id, + this.status = TaskFormStatus.initial, 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, + TaskFormStatus? status, String? title, String? description, DateTime? dueDate, - bool clearDueDate = false, // Trucco Ninja per rimettere a null una data! - TaskStatus? taskStatus, bool? isGlobal, List? selectedStaffIds, - List? availableStaff, - Map>? groupedAvailableStaff, - List? reminders, - String? linkedTicketId, - String? linkedEmailId, + List? reminders, String? errorMessage, }) { return TaskFormState( - status: status ?? this.status, id: id ?? this.id, + status: status ?? this.status, title: title ?? this.title, description: description ?? this.description, - dueDate: clearDueDate ? null : (dueDate ?? this.dueDate), - taskStatus: taskStatus ?? this.taskStatus, + dueDate: dueDate ?? this.dueDate, 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 + errorMessage: errorMessage, ); } @override List get props => [ - status, id, + status, title, description, dueDate, - taskStatus, isGlobal, selectedStaffIds, - availableStaff, - groupedAvailableStaff, reminders, - linkedTicketId, - linkedEmailId, errorMessage, ]; } diff --git a/lib/features/tasks/data/task_repository.dart b/lib/features/tasks/data/task_repository.dart index d9301bc..9a94c50 100644 --- a/lib/features/tasks/data/task_repository.dart +++ b/lib/features/tasks/data/task_repository.dart @@ -1,7 +1,11 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/enums_and_consts/consts.dart'; +import 'package:flux/features/tasks/models/task_reminder_config.dart'; import 'package:flux/features/tasks/models/task_status.dart'; +import 'package:get_it/get_it.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; // Sostituisci con i percorsi corretti di FLUX import 'package:flux/features/tasks/models/task_model.dart'; @@ -103,35 +107,104 @@ class TaskRepository { } } - // --- 2. CREAZIONE DEL TASK --- - Future createTask(TaskModel task) async { + Future createTask({ + required TaskModel task, + required List assignedStaffIds, + required String currentUserId, + required List currentUserCustomReminders, + TaskReminderConfig? + managerForcedOverride, // Opzionale: l'avviso forzato del manager + }) async { try { - final taskData = task.toMap(); + // 1. Inserimento del Task principale -> otteniamo il taskId + // 2. Inserimento dei record in task_assignments + final String taskId = task.id!; - // 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'); + List> remindersToInsert = []; - // 1. Inseriamo il record base nella tabella tasks - final response = await _supabase - .from(Tables.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!); + // 3. Recuperiamo i default degli ALTRI utenti assegnati + final otherStaffIds = assignedStaffIds + .where((id) => id != currentUserId) + .toList(); + List otherDefaults = []; + if (otherStaffIds.isNotEmpty) { + otherDefaults = await _supabase + .from('staff_task_reminder_defaults') + .select() + .inFilter('staff_id', otherStaffIds); } - return newTask; + // 4. CICLO DI COSTRUZIONE DELLA CODA REMINDER + for (var staffId in assignedStaffIds) { + // CASO A: È l'utente loggato che sta creando/partecipando al task + if (staffId == currentUserId) { + for (var config in currentUserCustomReminders) { + final triggerAt = task.dueDate?.subtract( + Duration(minutes: config.minutesBefore), + ); + if (triggerAt != null && triggerAt.isAfter(DateTime.now())) { + remindersToInsert.add({ + 'company_id': task.companyId, + 'task_id': taskId, + 'staff_id': currentUserId, + 'minutes_before': config.minutesBefore, + 'channel': config.channel, + 'trigger_at': triggerAt.toIso8601String(), + 'is_forced': false, + }); + } + } + } + // CASO B: Sono gli altri assegnatari -> ereditano i loro default personali dal DB + else { + final staffRules = otherDefaults.where( + (row) => row['staff_id'] == staffId, + ); + for (var rule in staffRules) { + final minutesBefore = rule['minutes_before'] as int; + final triggerAt = task.dueDate?.subtract( + Duration(minutes: minutesBefore), + ); + if (triggerAt != null && triggerAt.isAfter(DateTime.now())) { + remindersToInsert.add({ + 'company_id': task.companyId, + 'task_id': taskId, + 'staff_id': staffId, + 'minutes_before': minutesBefore, + 'channel': rule['channel'], + 'trigger_at': triggerAt.toIso8601String(), + 'is_forced': false, + }); + } + } + } + + // CASO C: Il creatore ha impostato un avviso forzato (Override molto importante) + if (managerForcedOverride != null && task.dueDate != null) { + final triggerAt = task.dueDate!.subtract( + Duration(minutes: managerForcedOverride.minutesBefore), + ); + if (triggerAt.isAfter(DateTime.now())) { + remindersToInsert.add({ + 'company_id': task.companyId, + 'task_id': taskId, + 'staff_id': staffId, // Lo beccheranno tutti + 'minutes_before': managerForcedOverride.minutesBefore, + 'channel': managerForcedOverride.channel, + 'trigger_at': triggerAt.toIso8601String(), + 'is_forced': + true, // Chiude la possibilità di cancellarlo lato utente + }); + } + } + } + + // 5. Sparata unica di Bulk Insert su task_reminders + if (remindersToInsert.isNotEmpty) { + await _supabase.from('task_reminders').insert(remindersToInsert); + } } catch (e) { - throw Exception('Errore nella creazione del task: $e'); + throw Exception('Errore creazione task: $e'); } } @@ -160,6 +233,107 @@ class TaskRepository { } } + Future createTask({ + required TaskModel task, + required List assignedStaffIds, + required String currentUserId, + required List currentUserCustomReminders, + TaskReminderConfig? + managerForcedOverride, // Opzionale: l'avviso forzato del manager + }) async { + try { + // 1. Inserimento del Task principale -> otteniamo il taskId + // 2. Inserimento dei record in task_assignments + final String taskId = task.id; + + List> remindersToInsert = []; + + // 3. Recuperiamo i default degli ALTRI utenti assegnati + final otherStaffIds = assignedStaffIds + .where((id) => id != currentUserId) + .toList(); + List otherDefaults = []; + if (otherStaffIds.isNotEmpty) { + otherDefaults = await _supabase + .from('staff_task_reminder_defaults') + .select() + .inFilter('staff_id', otherStaffIds); + } + + // 4. CICLO DI COSTRUZIONE DELLA CODA REMINDER + for (var staffId in assignedStaffIds) { + // CASO A: È l'utente loggato che sta creando/partecipando al task + if (staffId == currentUserId) { + for (var config in currentUserCustomReminders) { + final triggerAt = task.dueDate?.subtract( + Duration(minutes: config.minutesBefore), + ); + if (triggerAt != null && triggerAt.isAfter(DateTime.now())) { + remindersToInsert.add({ + 'company_id': task.companyId, + 'task_id': taskId, + 'staff_id': currentUserId, + 'minutes_before': config.minutesBefore, + 'channel': config.channel, + 'trigger_at': triggerAt.toIso8601String(), + 'is_forced': false, + }); + } + } + } + // CASO B: Sono gli altri assegnatari -> ereditano i loro default personali dal DB + else { + final staffRules = otherDefaults.where( + (row) => row['staff_id'] == staffId, + ); + for (var rule in staffRules) { + final minutesBefore = rule['minutes_before'] as int; + final triggerAt = task.dueDate?.subtract( + Duration(minutes: minutesBefore), + ); + if (triggerAt != null && triggerAt.isAfter(DateTime.now())) { + remindersToInsert.add({ + 'company_id': task.companyId, + 'task_id': taskId, + 'staff_id': staffId, + 'minutes_before': minutesBefore, + 'channel': rule['channel'], + 'trigger_at': triggerAt.toIso8601String(), + 'is_forced': false, + }); + } + } + } + + // CASO C: Il creatore ha impostato un avviso forzato (Override molto importante) + if (managerForcedOverride != null && task.dueDate != null) { + final triggerAt = task.dueDate!.subtract( + Duration(minutes: managerForcedOverride.minutesBefore), + ); + if (triggerAt.isAfter(DateTime.now())) { + remindersToInsert.add({ + 'company_id': task.companyId, + 'task_id': taskId, + 'staff_id': staffId, // Lo beccheranno tutti + 'minutes_before': managerForcedOverride.minutesBefore, + 'channel': managerForcedOverride.channel, + 'trigger_at': triggerAt.toIso8601String(), + 'is_forced': + true, // Chiude la possibilità di cancellarlo lato utente + }); + } + } + } + + // 5. Sparata unica di Bulk Insert su task_reminders + if (remindersToInsert.isNotEmpty) { + await _supabase.from('task_reminders').insert(remindersToInsert); + } + } catch (e) { + throw Exception('Errore creazione task: $e'); + } + } + // --- 4. ELIMINAZIONE DEL TASK --- Future deleteTask(String taskId) async { try { @@ -202,10 +376,116 @@ class TaskRepository { // 2. Inseriamo le nuove assegnazioni (se ce ne sono) if (staffIds.isNotEmpty) { final List> assignments = staffIds - .map((staffId) => {'task_id': taskId, 'staff_id': staffId}) + .map( + (staffId) => { + 'task_id': taskId, + 'staff_id': staffId, + 'company_id': GetIt.I.get().state.company!.id!, + }, + ) .toList(); await _supabase.from(Tables.taskAssignments).insert(assignments); } } + + // --- IL MOTORE DELLA MAGIA --- + + Future generateTaskReminders({ + required String taskId, + required String companyId, + required List assignedStaffIds, + required DateTime? taskDueDate, + }) async { + if (assignedStaffIds.isEmpty) return; + + try { + // 1. Recuperiamo i default di TUTTI i collaboratori coinvolti in un colpo solo + final response = await _supabase + .from('staff_task_reminder_defaults') + .select() + .eq('company_id', companyId) + .inFilter('staff_id', assignedStaffIds); + + final List> remindersToInsert = []; + + for (var staffId in assignedStaffIds) { + // Cerchiamo le preferenze di questo specifico membro dello staff + final staffDefaults = response.where( + (row) => row['staff_id'] == staffId, + ); + + if (staffDefaults.isEmpty) { + // STRATEGIA FALLBACK: Se l'utente non ha mai configurato i suoi default, + // creiamo un reminder standard (es. una push 15 min prima) per non lasciarlo scoperto. + if (taskDueDate != null) { + remindersToInsert.add({ + 'company_id': companyId, + 'task_id': taskId, + 'staff_id': staffId, + 'minutes_before': 15, + 'channel': 'push', + 'trigger_at': taskDueDate + .subtract(const Duration(minutes: 15)) + .toIso8601String(), + }); + } + + // E spariamo la push di creazione immediata come comportamento standard + _triggerImmediateNotification(staffId, taskId, 'push_creation'); + continue; + } + + // 2. GESTIONE NOTIFICHE ISTANTANEE (EVENT-DRIVEN) + // Prendiamo la prima riga delle impostazioni dell'utente (tanto le colonne nuove sono speculari) + final userSetting = staffDefaults.first; + if (userSetting['notify_on_creation_push'] == true) { + _triggerImmediateNotification(staffId, taskId, 'push_creation'); + } + if (userSetting['notify_on_creation_email'] == true) { + _triggerImmediateNotification(staffId, taskId, 'email_creation'); + } + + // 3. GESTIONE REMINDER TEMPORIZZATI (TIME-DRIVEN) + if (taskDueDate != null) { + for (var rule in staffDefaults) { + final minutesBefore = rule['minutes_before'] as int; + final triggerAt = taskDueDate.subtract( + Duration(minutes: minutesBefore), + ); + + // Se il task scade tra 5 minuti e il reminder è impostato a 1 ora prima, + // il trigger_at sarebbe nel passato. Lo inseriamo solo se è nel futuro! + if (triggerAt.isAfter(DateTime.now())) { + remindersToInsert.add({ + 'company_id': companyId, + 'task_id': taskId, + 'staff_id': staffId, + 'minutes_before': minutesBefore, + 'channel': rule['channel'], + 'trigger_at': triggerAt.toIso8601String(), + }); + } + } + } + } + + // 4. Bulk Insert dei reminder temporizzati nella coda operativa + if (remindersToInsert.isNotEmpty) { + await _supabase.from('task_reminders').insert(remindersToInsert); + } + } catch (e) { + debugPrint('Errore nella generazione dei reminder: $e'); + } + } + + void _triggerImmediateNotification( + String staffId, + String taskId, + String type, + ) { + // Questa funzione chiamerà direttamente l'Edge Function di Supabase + // per far squillare il telefono o mandare la mail ADESSO. + // La implementeremo appena il backend sarà pronto! + } } diff --git a/lib/features/tasks/models/reminder_default_model.dart b/lib/features/tasks/models/reminder_default_model.dart new file mode 100644 index 0000000..03b3583 --- /dev/null +++ b/lib/features/tasks/models/reminder_default_model.dart @@ -0,0 +1,65 @@ +import 'package:equatable/equatable.dart'; + +class ReminderDefaultModel extends Equatable { + final String? id; + final String companyId; + final String staffId; + final int minutesBefore; + final String channel; // 'push' o 'email' + + const ReminderDefaultModel({ + this.id, + required this.companyId, + required this.staffId, + required this.minutesBefore, + required this.channel, + }); + + ReminderDefaultModel copyWith({ + String? id, + String? companyId, + String? staffId, + int? minutesBefore, + String? channel, + }) { + return ReminderDefaultModel( + id: id ?? this.id, + companyId: companyId ?? this.companyId, + staffId: staffId ?? this.staffId, + minutesBefore: minutesBefore ?? this.minutesBefore, + channel: channel ?? this.channel, + ); + } + + Map toMap() { + return { + if (id != null) 'id': id, + 'company_id': companyId, + 'staff_id': staffId, + 'minutes_before': minutesBefore, + 'channel': channel, + }; + } + + factory ReminderDefaultModel.fromMap(Map map) { + return ReminderDefaultModel( + id: map['id'] as String?, + companyId: map['company_id'] as String, + staffId: map['staff_id'] as String, + minutesBefore: map['minutes_before'] as int, + channel: map['channel'] as String, + ); + } + + @override + List get props => [id, companyId, staffId, minutesBefore, channel]; + + // Helper per la UI: formatta i minuti in qualcosa di leggibile (es. "1 ora prima") + String get friendlyTime { + if (minutesBefore < 60) return '$minutesBefore minuti prima'; + if (minutesBefore == 60) return '1 ora prima'; + if (minutesBefore < 1440) return '${minutesBefore ~/ 60} ore prima'; + if (minutesBefore == 1440) return '1 giorno prima'; + return '${minutesBefore ~/ 1440} giorni prima'; + } +} diff --git a/lib/features/tasks/models/task_reminder_config.dart b/lib/features/tasks/models/task_reminder_config.dart new file mode 100644 index 0000000..e48bc68 --- /dev/null +++ b/lib/features/tasks/models/task_reminder_config.dart @@ -0,0 +1,22 @@ +import 'package:equatable/equatable.dart'; + +class TaskReminderConfig extends Equatable { + final int minutesBefore; + final String channel; // 'push' o 'email' + + const TaskReminderConfig({ + required this.minutesBefore, + required this.channel, + }); + + String get friendlyTime { + if (minutesBefore < 60) return '$minutesBefore minuti prima'; + if (minutesBefore == 60) return '1 ora prima'; + if (minutesBefore < 1440) return '${minutesBefore ~/ 60} ore prima'; + if (minutesBefore == 1440) return '1 giorno prima'; + return '${minutesBefore ~/ 1440} giorni prima'; + } + + @override + List get props => [minutesBefore, channel]; +} diff --git a/lib/features/tasks/ui/task_form_screen.dart b/lib/features/tasks/ui/task_form_screen.dart index 31c3f76..f244508 100644 --- a/lib/features/tasks/ui/task_form_screen.dart +++ b/lib/features/tasks/ui/task_form_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/features/tasks/blocs/task_form_cubit.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; class TaskFormScreen extends StatefulWidget { @@ -31,6 +33,62 @@ class _TaskFormScreenState extends State { 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( + 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( + 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( @@ -79,7 +137,13 @@ class _TaskFormScreenState extends State { else TextButton.icon( onPressed: state.isFormValid - ? () => cubit.saveTask(currentUserId: 'TODO_USER_ID') + ? () => cubit.saveTask( + currentUserId: GetIt.I + .get() + .state + .currentStaffMember! + .id!, + ) : null, icon: const Icon(Icons.save), label: const Text('Salva'), @@ -162,84 +226,181 @@ class _TaskFormScreenState extends State { TaskFormState state, TaskFormCubit cubit, ) { - return 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), + 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), ), ), - 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), + const SizedBox(height: 24), - // Addio initialValue, benvenuto controller! - TextFormField( - controller: _titleController, - decoration: const InputDecoration( - labelText: 'Titolo del Task*', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.title), + // Addio initialValue, benvenuto controller! + TextFormField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Titolo del Task*', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.title), + ), + onChanged: cubit.updateTitle, ), - onChanged: cubit.updateTitle, - ), - const SizedBox(height: 16), - TextFormField( - controller: _descController, - maxLines: 4, - decoration: const InputDecoration( - labelText: 'Descrizione (opzionale)', - border: OutlineInputBorder(), - alignLabelWithHint: true, + const SizedBox(height: 16), + TextFormField( + controller: _descController, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'Descrizione (opzionale)', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + onChanged: cubit.updateDescription, ), - onChanged: cubit.updateDescription, - ), - const SizedBox(height: 24), + const SizedBox(height: 24), - // --- SCADENZA --- - ListTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide(color: Theme.of(context).dividerColor), + // --- 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); + }, ), - 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); - }, - ), - ], + 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, + ), + ), + ], + ), + ), + ), + ], + ], + ), ); } diff --git a/lib/main.dart b/lib/main.dart index e096af4..011d7d0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:flux/features/auth/bloc/auth_cubit.dart'; import 'package:flux/features/company/data/company_repository.dart'; import 'package:flux/features/notes/blocs/notes_bloc.dart'; import 'package:flux/features/notes/data/notes_repository.dart'; +import 'package:flux/features/settings/data/settings_repository.dart'; import 'package:flux/features/tasks/data/task_repository.dart'; import 'package:flux/features/tickets/data/tickets_shipping_repository.dart'; import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.dart'; @@ -40,7 +41,7 @@ import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart'; import 'package:flux/features/master_data/staff/data/staff_repository.dart'; import 'package:flux/features/master_data/store/bloc/store_cubit.dart'; import 'package:flux/features/master_data/store/data/store_repository.dart'; -import 'package:flux/features/settings/settings.dart'; +import 'package:flux/features/settings/ui/settings.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -138,6 +139,7 @@ Future setupLocator() async { ); getIt.registerLazySingleton(() => NotesRepository()); getIt.registerLazySingleton(() => TaskRepository()); + getIt.registerLazySingleton(() => SettingsRepository()); } class FluxApp extends StatefulWidget {