w
This commit is contained in:
@@ -18,6 +18,7 @@ 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_detail_screen.dart';
|
||||||
import 'package:flux/features/customers/ui/customer_form_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/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/home/ui/home_screen.dart';
|
||||||
import 'package:flux/features/master_data/master_data_hub_content.dart';
|
import 'package:flux/features/master_data/master_data_hub_content.dart';
|
||||||
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
||||||
@@ -43,6 +44,7 @@ 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/models/task_model.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';
|
||||||
@@ -133,7 +135,16 @@ class AppRouter {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
name: Routes.home,
|
name: Routes.home,
|
||||||
builder: (context, state) => const HomeScreen(),
|
builder: (context, state) {
|
||||||
|
return MultiBlocProvider(
|
||||||
|
providers: [
|
||||||
|
BlocProvider<DashboardTaskListCubit>(
|
||||||
|
create: (context) => DashboardTaskListCubit(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: HomeScreen(),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -224,6 +235,11 @@ class AppRouter {
|
|||||||
name: Routes.notes,
|
name: Routes.notes,
|
||||||
builder: (context, state) => const NotesListScreen(),
|
builder: (context, state) => const NotesListScreen(),
|
||||||
),
|
),
|
||||||
|
/* GoRoute(
|
||||||
|
path: '/tasks',
|
||||||
|
name: Routes.tasks,
|
||||||
|
builder: (context, state) => const TaskListScreen(),
|
||||||
|
), */
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -482,6 +498,33 @@ class AppRouter {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
/* GoRoute(
|
||||||
|
path: '/task/edit/:id',
|
||||||
|
name: Routes.taskForm,
|
||||||
|
builder: (context, state) {
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
final TaskModel task = state.extra as TaskModel;
|
||||||
|
|
||||||
|
// Creiamo il BLoC "al volo" solo per questa schermata
|
||||||
|
return MultiBlocProvider(
|
||||||
|
providers: [
|
||||||
|
BlocProvider<AttachmentsBloc>(
|
||||||
|
create: (context) => AttachmentsBloc(
|
||||||
|
parentId: id,
|
||||||
|
parentType: AttachmentParentType.note,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlocProvider<TaskFormCubit>(
|
||||||
|
create: (context) => TaskFormCubit(
|
||||||
|
existingTask: task,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
|
||||||
|
child: TaskFormScreen(task: task),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
), */
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,6 @@ class Routes {
|
|||||||
static const String ticketWorkspace = 'ticket-workspace';
|
static const String ticketWorkspace = 'ticket-workspace';
|
||||||
static const String noteForm = 'note-form';
|
static const String noteForm = 'note-form';
|
||||||
static const String notes = 'notes';
|
static const String notes = 'notes';
|
||||||
|
static const String tasks = 'tasks';
|
||||||
|
static const String taskForm = 'task-form';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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<DashboardTaskListState> {
|
||||||
|
final TaskRepository _repository = GetIt.I.get<TaskRepository>();
|
||||||
|
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
|
||||||
|
RealtimeChannel? _taskChannel;
|
||||||
|
|
||||||
|
DashboardTaskListCubit() : super(DashboardTaskListState());
|
||||||
|
|
||||||
|
void startListening({required String staffId}) {
|
||||||
|
emit(state.copyWith(status: DashboardTaskListStatus.loading));
|
||||||
|
_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();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTasks({required String staffId}) async {
|
||||||
|
try {
|
||||||
|
final tasks = await _repository.getTasks(
|
||||||
|
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
|
||||||
|
staffId: staffId,
|
||||||
|
statuses: [TaskStatus.open, TaskStatus.inProgress],
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: DashboardTaskListStatus.success,
|
||||||
|
tasks: tasks,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: DashboardTaskListStatus.failure,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_taskChannel?.unsubscribe();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
part of 'dashboard_task_list_cubit.dart';
|
||||||
|
|
||||||
|
enum DashboardTaskListStatus { initial, loading, success, failure }
|
||||||
|
|
||||||
|
class DashboardTaskListState extends Equatable {
|
||||||
|
final DashboardTaskListStatus status;
|
||||||
|
final List<TaskModel> tasks;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const DashboardTaskListState({
|
||||||
|
this.status = DashboardTaskListStatus.initial,
|
||||||
|
this.tasks = const [],
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
DashboardTaskListState copyWith({
|
||||||
|
DashboardTaskListStatus? status,
|
||||||
|
List<TaskModel>? tasks,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return DashboardTaskListState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
tasks: tasks ?? this.tasks,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [status, tasks, errorMessage];
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
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:go_router/go_router.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_status.dart';
|
||||||
|
|
||||||
|
class DashboardTasksCard extends StatelessWidget {
|
||||||
|
const DashboardTasksCard({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
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<SessionCubit>()
|
||||||
|
.state
|
||||||
|
.currentStaffMember
|
||||||
|
?.id;
|
||||||
|
|
||||||
|
if (currentStaffId == null) {
|
||||||
|
return const SizedBox.shrink(); // Sicurezza se lo stato non è pronto
|
||||||
|
}
|
||||||
|
|
||||||
|
return BlocProvider(
|
||||||
|
create: (context) =>
|
||||||
|
DashboardTaskListCubit()..startListening(staffId: currentStaffId),
|
||||||
|
child: const _DashboardTasksCardContent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DashboardTasksCardContent extends StatelessWidget {
|
||||||
|
const _DashboardTasksCardContent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
const color =
|
||||||
|
Colors.orange; // Colore arancione per distinguerla dai Ticket blu
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => context.pushNamed(
|
||||||
|
Routes.tasks,
|
||||||
|
), // Porta alla lista completa (TaskListScreen)
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// --- HEADER DELLA CARD ---
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.assignment_outlined, // Icona a tema ToDo
|
||||||
|
color: color,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
"I Miei Task",
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
color: context.primaryText,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// --- CORPO DELLA CARD (LA LISTA REAL-TIME) ---
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<DashboardTaskListCubit, DashboardTaskListState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.status == DashboardTaskListStatus.loading ||
|
||||||
|
state.status == DashboardTaskListStatus.initial) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status == DashboardTaskListStatus.failure) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
"Errore di caricamento",
|
||||||
|
style: TextStyle(color: theme.colorScheme.error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.tasks.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
"Nessun task in sospeso. Ottimo lavoro!",
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.secondaryText,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: state.tasks.length,
|
||||||
|
separatorBuilder: (context, index) => Divider(
|
||||||
|
height: 1,
|
||||||
|
color: theme.dividerColor.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final task = state.tasks[index];
|
||||||
|
|
||||||
|
// Definisci il colore in base allo stato del task
|
||||||
|
final statusColor = task.status == TaskStatus.inProgress
|
||||||
|
? Colors.blue
|
||||||
|
: Colors.grey.shade400;
|
||||||
|
|
||||||
|
// Formattiamo la data (o indichiamo se non c'è)
|
||||||
|
final dueDateString = task.dueDate != null
|
||||||
|
? "${task.dueDate!.day}/${task.dueDate!.month}"
|
||||||
|
: "Nessuna";
|
||||||
|
|
||||||
|
// Controllo Ninja: Il task è già scaduto rispetto a oggi?
|
||||||
|
final isOverdue =
|
||||||
|
task.dueDate != null &&
|
||||||
|
task.dueDate!.isBefore(DateTime.now());
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => context.pushNamed(
|
||||||
|
Routes.taskForm,
|
||||||
|
extra:
|
||||||
|
task, // Passiamo direttamente il modello intero se il tuo router lo accetta!
|
||||||
|
pathParameters: {'id': task.id ?? 'new'},
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 30,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
flex: 7,
|
||||||
|
child: Text(
|
||||||
|
task.title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: context.primaryText,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Text(
|
||||||
|
dueDateString,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isOverdue
|
||||||
|
? theme.colorScheme.error
|
||||||
|
: context.secondaryText,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: isOverdue
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:flux/core/routes/routes.dart';
|
|||||||
import 'package:flux/core/theme/theme.dart';
|
import 'package:flux/core/theme/theme.dart';
|
||||||
import 'package:flux/core/utils/extensions.dart';
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
import 'package:flux/core/widgets/staff_selector_modal.dart';
|
import 'package:flux/core/widgets/staff_selector_modal.dart';
|
||||||
|
import 'package:flux/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart';
|
||||||
import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_card.dart';
|
import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_card.dart';
|
||||||
import 'package:flux/features/home/latest_store_tickets/ui/latest_store_tickets_card.dart';
|
import 'package:flux/features/home/latest_store_tickets/ui/latest_store_tickets_card.dart';
|
||||||
import 'package:flux/features/home/ui/quick_actions_widget.dart';
|
import 'package:flux/features/home/ui/quick_actions_widget.dart';
|
||||||
@@ -13,6 +14,8 @@ import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
|||||||
import 'package:flux/features/notes/data/notes_repository.dart';
|
import 'package:flux/features/notes/data/notes_repository.dart';
|
||||||
import 'package:flux/features/notes/models/note_model.dart';
|
import 'package:flux/features/notes/models/note_model.dart';
|
||||||
import 'package:flux/features/notes/ui/dashboard_notes_widget.dart';
|
import 'package:flux/features/notes/ui/dashboard_notes_widget.dart';
|
||||||
|
import 'package:flux/features/tasks/data/task_repository.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_model.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -75,12 +78,7 @@ class HomeScreen extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
),
|
),
|
||||||
DashboardNotesWidget(),
|
DashboardNotesWidget(),
|
||||||
_buildDashboardWidget(
|
DashboardTasksCard(),
|
||||||
title: context.l10n.homeMyTasks,
|
|
||||||
icon: Icons.check_box_outlined,
|
|
||||||
color: Colors.green,
|
|
||||||
context: context,
|
|
||||||
),
|
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -238,7 +236,24 @@ class HomeScreen extends StatelessWidget {
|
|||||||
label: context.l10n.commonTask,
|
label: context.l10n.commonTask,
|
||||||
color: Colors.teal,
|
color: Colors.teal,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// TODO: Quando faremo i task
|
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(
|
||||||
|
Routes.taskForm,
|
||||||
|
pathParameters: {'id': 'new'},
|
||||||
|
extra: savedTask,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
92
lib/features/tasks/blocs/task_list_cubit.dart
Normal file
92
lib/features/tasks/blocs/task_list_cubit.dart
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/features/tasks/data/task_repository.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_model.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_status.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
|
part 'task_list_state.dart';
|
||||||
|
|
||||||
|
class TaskListCubit extends Cubit<TaskListState> {
|
||||||
|
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
|
||||||
|
RealtimeChannel? _taskChannel;
|
||||||
|
final TaskRepository _repository = GetIt.I.get<TaskRepository>();
|
||||||
|
|
||||||
|
TaskListCubit() : super(TaskListState(status: TaskListStatus.initial));
|
||||||
|
|
||||||
|
// --- AVVIA L'ASCOLTO IN TEMPO REALE ---
|
||||||
|
void startListening({required String companyId, String? storeId}) {
|
||||||
|
emit(state.copyWith(status: TaskListStatus.loading));
|
||||||
|
|
||||||
|
// Facciamo subito il caricamento manuale, chiedendo SOLO quelli attivi
|
||||||
|
_loadTasks(companyId: companyId, storeId: storeId);
|
||||||
|
|
||||||
|
_taskChannel?.unsubscribe();
|
||||||
|
|
||||||
|
_taskChannel = _supabase
|
||||||
|
.channel('public:tasks_company_$companyId')
|
||||||
|
.onPostgresChanges(
|
||||||
|
event: PostgresChangeEvent.all,
|
||||||
|
schema: 'public',
|
||||||
|
table: 'tasks',
|
||||||
|
filter: PostgresChangeFilter(
|
||||||
|
type: PostgresChangeFilterType.eq,
|
||||||
|
column: 'company_id',
|
||||||
|
value: companyId,
|
||||||
|
),
|
||||||
|
callback: (payload) {
|
||||||
|
// Ricarica la lista applicando sempre i filtri di stato
|
||||||
|
_loadTasks(companyId: companyId, storeId: storeId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_taskChannel?.subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HELPER DI CARICAMENTO ---
|
||||||
|
Future<void> _loadTasks({required String companyId, String? storeId}) async {
|
||||||
|
try {
|
||||||
|
final tasks = await _repository.getTasks(
|
||||||
|
companyId: companyId,
|
||||||
|
storeId: storeId,
|
||||||
|
// CHICCA: Passiamo solo gli stati attivi!
|
||||||
|
statuses: [TaskStatus.open, TaskStatus.inProgress],
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: TaskListStatus.success,
|
||||||
|
tasks: tasks,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: TaskListStatus.failure,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OPERAZIONI MANUALI ---
|
||||||
|
// (Le lasciamo gestire al repository o le metti qui se preferisci,
|
||||||
|
// tanto il risultato lo vedrai magicamente aggiornato dallo stream sopra!)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Future<void> markAsCompleted(String taskId) async { ... }
|
||||||
|
Future<void> deleteTask(String taskId) async { ... }
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- PULIZIA FONDAMENTALE ---
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
// Chiudiamo il rubinetto quando usciamo dalla pagina per non intasarci la memoria!
|
||||||
|
_taskChannel?.unsubscribe();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
31
lib/features/tasks/blocs/task_list_state.dart
Normal file
31
lib/features/tasks/blocs/task_list_state.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
part of 'task_list_cubit.dart';
|
||||||
|
|
||||||
|
enum TaskListStatus { initial, loading, success, failure }
|
||||||
|
|
||||||
|
class TaskListState extends Equatable {
|
||||||
|
final TaskListStatus status;
|
||||||
|
final List<TaskModel> tasks;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const TaskListState({
|
||||||
|
this.status = TaskListStatus.initial,
|
||||||
|
this.tasks = const [],
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
TaskListState copyWith({
|
||||||
|
TaskListStatus? status,
|
||||||
|
List<TaskModel>? tasks,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return TaskListState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
tasks: tasks ?? this.tasks,
|
||||||
|
// Se lo status è success o loading, puliamo eventuali errori precedenti
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [status, tasks, errorMessage];
|
||||||
|
}
|
||||||
163
lib/features/tasks/data/task_repository.dart
Normal file
163
lib/features/tasks/data/task_repository.dart
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import 'package:flux/features/tasks/models/task_status.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
// Sostituisci con i percorsi corretti di FLUX
|
||||||
|
import 'package:flux/features/tasks/models/task_model.dart';
|
||||||
|
|
||||||
|
class TaskRepository {
|
||||||
|
final SupabaseClient _supabase;
|
||||||
|
|
||||||
|
TaskRepository({SupabaseClient? supabase})
|
||||||
|
: _supabase = supabase ?? Supabase.instance.client;
|
||||||
|
|
||||||
|
// --- RECUPERO DEI TASK FILTRATI ---
|
||||||
|
Future<List<TaskModel>> getTasks({
|
||||||
|
required String companyId,
|
||||||
|
String? storeId,
|
||||||
|
String? staffId,
|
||||||
|
List<TaskStatus>? statuses,
|
||||||
|
int? limit,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// 1. FASE FILTRI (PostgrestFilterBuilder)
|
||||||
|
var filterBuilder = _supabase
|
||||||
|
.from('tasks')
|
||||||
|
.select('*, assigned_to_staff:staff_members!task_assignments(*)')
|
||||||
|
.eq('company_id', companyId);
|
||||||
|
|
||||||
|
if (storeId != null) {
|
||||||
|
filterBuilder = filterBuilder.or(
|
||||||
|
'store_id.eq.$storeId,store_id.is.null',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staffId != null) {
|
||||||
|
filterBuilder = filterBuilder.or(
|
||||||
|
'staff_id.eq.$staffId,staff_id.is.null',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statuses != null && statuses.isNotEmpty) {
|
||||||
|
final statusValues = statuses.map((s) => s.toValue).toList();
|
||||||
|
filterBuilder = filterBuilder.inFilter('status', statusValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. FASE TRASFORMAZIONI (PostgrestTransformBuilder)
|
||||||
|
// L'ordinamento lo facciamo sempre, quindi partiamo da qui
|
||||||
|
var transformBuilder = filterBuilder
|
||||||
|
.order('due_date', ascending: true, nullsFirst: false)
|
||||||
|
.order('created_at', ascending: false, nullsFirst: false);
|
||||||
|
|
||||||
|
// Aggiungiamo il limite se richiesto
|
||||||
|
if (limit != null) {
|
||||||
|
transformBuilder = transformBuilder.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ESECUZIONE DELLA QUERY
|
||||||
|
final response = await transformBuilder;
|
||||||
|
|
||||||
|
return (response as List).map((json) => TaskModel.fromMap(json)).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Errore nel recupero dei task: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. CREAZIONE DEL TASK ---
|
||||||
|
Future<TaskModel> createTask(TaskModel task) async {
|
||||||
|
try {
|
||||||
|
final taskData = task.toMap();
|
||||||
|
|
||||||
|
// Rimuoviamo l'array prima di inviare i dati alla tabella principale,
|
||||||
|
// la "Strada C" impone che la verità assoluta derivi dalla tabella di giunzione!
|
||||||
|
taskData.remove('assigned_to_ids');
|
||||||
|
|
||||||
|
// 1. Inseriamo il record base nella tabella tasks
|
||||||
|
final response = await _supabase
|
||||||
|
.from('tasks')
|
||||||
|
.insert(taskData)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
final newTask = TaskModel.fromMap(response);
|
||||||
|
|
||||||
|
// 2. Se l'utente ha assegnato il task a qualcuno, popoliamo la giunzione
|
||||||
|
if (task.assignedToIds.isNotEmpty && newTask.id != null) {
|
||||||
|
await _syncAssignments(newTask.id!, task.assignedToIds);
|
||||||
|
|
||||||
|
// 3. Ricarichiamo il task appena creato per ottenere l'array e il JOIN aggiornati dal trigger!
|
||||||
|
return await getTaskById(newTask.id!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newTask;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Errore nella creazione del task: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. AGGIORNAMENTO DEL TASK ---
|
||||||
|
Future<TaskModel> updateTask(TaskModel task) async {
|
||||||
|
if (task.id == null)
|
||||||
|
throw Exception('ID Task mancante. Impossibile aggiornare.');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final taskData = task.toMap();
|
||||||
|
taskData.remove(
|
||||||
|
'assigned_to_ids',
|
||||||
|
); // Sempre via l'array dal body principale
|
||||||
|
|
||||||
|
// 1. Aggiorniamo i dati base del task (titolo, stato, scadenza, ecc.)
|
||||||
|
await _supabase.from('tasks').update(taskData).eq('id', task.id!);
|
||||||
|
|
||||||
|
// 2. Sincronizziamo in modo distruttivo gli assegnatari nella tabella di giunzione
|
||||||
|
await _syncAssignments(task.id!, task.assignedToIds);
|
||||||
|
|
||||||
|
// 3. Ritorniamo il modello fresco di DB
|
||||||
|
return await getTaskById(task.id!);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Errore nell\'aggiornamento del task: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. ELIMINAZIONE DEL TASK ---
|
||||||
|
Future<void> deleteTask(String taskId) async {
|
||||||
|
try {
|
||||||
|
// Eliminando il task, se la Foreign Key su Supabase ha "ON DELETE CASCADE",
|
||||||
|
// le righe nella tabella di giunzione si distruggeranno da sole in automatico!
|
||||||
|
await _supabase.from('tasks').delete().eq('id', taskId);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Errore nell\'eliminazione del task: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HELPER: RECUPERO SINGOLO TASK ---
|
||||||
|
Future<TaskModel> getTaskById(String taskId) async {
|
||||||
|
try {
|
||||||
|
final response = await _supabase
|
||||||
|
.from('tasks')
|
||||||
|
.select('*, assigned_to_staff:staff!task_assignments(*)')
|
||||||
|
.eq('id', taskId)
|
||||||
|
.single();
|
||||||
|
return TaskModel.fromMap(response);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Errore nel recupero del singolo task: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HELPER: SINCRONIZZAZIONE DELLA TABELLA DI GIUNZIONE ---
|
||||||
|
Future<void> _syncAssignments(String taskId, List<String> staffIds) async {
|
||||||
|
// TECNICA "WIPE & REPLACE":
|
||||||
|
// Invece di capire chi è stato aggiunto e chi tolto, eliminiamo tutti i record
|
||||||
|
// di questo task e li reinseriamo. È fulmineo ed esclude a priori bug di disallineamento.
|
||||||
|
|
||||||
|
// 1. Facciamo piazza pulita
|
||||||
|
await _supabase.from('task_assignments').delete().eq('task_id', taskId);
|
||||||
|
|
||||||
|
// 2. Inseriamo le nuove assegnazioni (se ce ne sono)
|
||||||
|
if (staffIds.isNotEmpty) {
|
||||||
|
final List<Map<String, dynamic>> assignments = staffIds
|
||||||
|
.map((staffId) => {'task_id': taskId, 'staff_id': staffId})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
await _supabase.from('task_assignments').insert(assignments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
lib/features/tasks/models/task_model.dart
Normal file
143
lib/features/tasks/models/task_model.dart
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_status.dart';
|
||||||
|
|
||||||
|
class TaskModel extends Equatable {
|
||||||
|
final String? id;
|
||||||
|
final String? companyId;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
final List<String> assignedToIds;
|
||||||
|
final List<StaffMemberModel> assignedToStaff; // I dati completi dal JOIN
|
||||||
|
final String? createdById;
|
||||||
|
final DateTime? dueDate;
|
||||||
|
final TaskStatus status;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final String? storeId;
|
||||||
|
|
||||||
|
const TaskModel({
|
||||||
|
this.id,
|
||||||
|
this.companyId,
|
||||||
|
required this.title,
|
||||||
|
this.description,
|
||||||
|
this.assignedToIds = const [],
|
||||||
|
this.assignedToStaff = const [],
|
||||||
|
this.createdById,
|
||||||
|
this.dueDate,
|
||||||
|
this.status = TaskStatus.open,
|
||||||
|
this.createdAt,
|
||||||
|
this.storeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- FACTORY: MODELLO VUOTO (Per le creazioni) ---
|
||||||
|
factory TaskModel.empty({String? companyId, String? createdById}) {
|
||||||
|
return TaskModel(
|
||||||
|
companyId: companyId,
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
assignedToIds: const [],
|
||||||
|
assignedToStaff: const [],
|
||||||
|
createdById: createdById,
|
||||||
|
status: TaskStatus.open,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- EQUATABLE: PROPRIETÀ DA COMPARARE ---
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
id,
|
||||||
|
companyId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
assignedToIds,
|
||||||
|
assignedToStaff,
|
||||||
|
createdById,
|
||||||
|
dueDate,
|
||||||
|
status,
|
||||||
|
createdAt,
|
||||||
|
storeId,
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- COPY WITH ---
|
||||||
|
TaskModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? companyId,
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
List<String>? assignedToIds,
|
||||||
|
List<StaffMemberModel>? assignedToStaff,
|
||||||
|
String? createdById,
|
||||||
|
DateTime? dueDate,
|
||||||
|
bool clearDueDate = false, // Flag ninja per resettare la scadenza
|
||||||
|
TaskStatus? status,
|
||||||
|
DateTime? createdAt,
|
||||||
|
String? storeId,
|
||||||
|
bool clearStoreId = false,
|
||||||
|
}) {
|
||||||
|
return TaskModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
companyId: companyId ?? this.companyId,
|
||||||
|
title: title ?? this.title,
|
||||||
|
description: description ?? this.description,
|
||||||
|
assignedToIds: assignedToIds ?? this.assignedToIds,
|
||||||
|
assignedToStaff: assignedToStaff ?? this.assignedToStaff,
|
||||||
|
createdById: createdById ?? this.createdById,
|
||||||
|
dueDate: clearDueDate ? null : (dueDate ?? this.dueDate),
|
||||||
|
status: status ?? this.status,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
storeId: clearStoreId ? null : (storeId ?? this.storeId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SERIALIZZAZIONE DA SUPABASE ---
|
||||||
|
factory TaskModel.fromMap(Map<String, dynamic> json) {
|
||||||
|
// 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota
|
||||||
|
final List<String> parsedAssignedToIds = json['assigned_to_ids'] != null
|
||||||
|
? List<String>.from(json['assigned_to_ids'])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 2. Mappiamo il JOIN dello staff, se presente
|
||||||
|
List<StaffMemberModel> parsedAssignedToStaff = [];
|
||||||
|
if (json['assigned_to_staff'] != null) {
|
||||||
|
final staffList = json['assigned_to_staff'] as List;
|
||||||
|
parsedAssignedToStaff = staffList
|
||||||
|
.map((s) => StaffMemberModel.fromMap(s as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskModel(
|
||||||
|
id: json['id'] as String?,
|
||||||
|
companyId: json['company_id'] as String?,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
description: json['description'] as String?,
|
||||||
|
assignedToIds: parsedAssignedToIds,
|
||||||
|
assignedToStaff: parsedAssignedToStaff,
|
||||||
|
createdById: json['created_by_id'] as String?,
|
||||||
|
dueDate: json['due_date'] != null
|
||||||
|
? DateTime.parse(json['due_date'] as String).toLocal()
|
||||||
|
: null,
|
||||||
|
status: TaskStatusExtension.fromString(json['status'] as String?),
|
||||||
|
createdAt: json['created_at'] != null
|
||||||
|
? DateTime.parse(json['created_at'] as String).toLocal()
|
||||||
|
: null,
|
||||||
|
storeId: json['store_id'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SERIALIZZAZIONE VERSO SUPABASE ---
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
if (companyId != null) 'company_id': companyId,
|
||||||
|
'title': title,
|
||||||
|
if (description != null) 'description': description,
|
||||||
|
// Passiamo l'array vuoto se non ci sono assegnazioni
|
||||||
|
'assigned_to_ids': assignedToIds.isEmpty ? null : assignedToIds,
|
||||||
|
if (createdById != null) 'created_by_id': createdById,
|
||||||
|
'due_date': dueDate?.toUtc().toIso8601String(),
|
||||||
|
'status': status.toValue,
|
||||||
|
'store_id': storeId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
40
lib/features/tasks/models/task_status.dart
Normal file
40
lib/features/tasks/models/task_status.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Enum per lo stato del task
|
||||||
|
enum TaskStatus { open, inProgress, completed }
|
||||||
|
|
||||||
|
extension TaskStatusExtension on TaskStatus {
|
||||||
|
String get name {
|
||||||
|
switch (this) {
|
||||||
|
case TaskStatus.open:
|
||||||
|
return 'Da Iniziare';
|
||||||
|
case TaskStatus.inProgress:
|
||||||
|
return 'In Lavorazione';
|
||||||
|
case TaskStatus.completed:
|
||||||
|
return 'Completato';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comodo per mappare da Supabase
|
||||||
|
static TaskStatus fromString(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'in_progress':
|
||||||
|
return TaskStatus.inProgress;
|
||||||
|
case 'completed':
|
||||||
|
return TaskStatus.completed;
|
||||||
|
case 'open':
|
||||||
|
default:
|
||||||
|
return TaskStatus.open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comodo per salvare su Supabase
|
||||||
|
String get toValue {
|
||||||
|
switch (this) {
|
||||||
|
case TaskStatus.open:
|
||||||
|
return 'open';
|
||||||
|
case TaskStatus.inProgress:
|
||||||
|
return 'in_progress';
|
||||||
|
case TaskStatus.completed:
|
||||||
|
return 'completed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/company/data/company_repository.dart';
|
||||||
import 'package:flux/features/notes/blocs/notes_bloc.dart';
|
import 'package:flux/features/notes/blocs/notes_bloc.dart';
|
||||||
import 'package:flux/features/notes/data/notes_repository.dart';
|
import 'package:flux/features/notes/data/notes_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/tickets/data/tickets_shipping_repository.dart';
|
||||||
import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.dart';
|
import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.dart';
|
||||||
import 'package:flux/features/operations/blocs/operation_list_cubit.dart';
|
import 'package:flux/features/operations/blocs/operation_list_cubit.dart';
|
||||||
@@ -136,6 +137,7 @@ Future<void> setupLocator() async {
|
|||||||
() => TicketsShippingRepository(),
|
() => TicketsShippingRepository(),
|
||||||
);
|
);
|
||||||
getIt.registerLazySingleton<NotesRepository>(() => NotesRepository());
|
getIt.registerLazySingleton<NotesRepository>(() => NotesRepository());
|
||||||
|
getIt.registerLazySingleton<TaskRepository>(() => TaskRepository());
|
||||||
}
|
}
|
||||||
|
|
||||||
class FluxApp extends StatefulWidget {
|
class FluxApp extends StatefulWidget {
|
||||||
|
|||||||
Reference in New Issue
Block a user