Compare commits
6 Commits
2afe97c6db
...
feat-tasks
| Author | SHA1 | Date | |
|---|---|---|---|
| 83988597d5 | |||
| b298509178 | |||
| b6e5f9acbe | |||
| f6ecb33729 | |||
| 9d796d6e41 | |||
| 45455a16c4 |
@@ -16,6 +16,8 @@ class Tables {
|
|||||||
static const String staffInStores = 'staff_in_stores';
|
static const String staffInStores = 'staff_in_stores';
|
||||||
static const String staffMembers = 'staff_members';
|
static const String staffMembers = 'staff_members';
|
||||||
static const String stores = 'stores';
|
static const String stores = 'stores';
|
||||||
|
static const String tasks = 'tasks';
|
||||||
|
static const String taskAssignments = 'task_assignments';
|
||||||
static const String tickets = 'tickets';
|
static const String tickets = 'tickets';
|
||||||
static const String trackings = 'trackings';
|
static const String trackings = 'trackings';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -27,6 +28,7 @@ import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.da
|
|||||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||||
import 'package:flux/features/master_data/providers/ui/provider_form_screen.dart';
|
import 'package:flux/features/master_data/providers/ui/provider_form_screen.dart';
|
||||||
import 'package:flux/features/master_data/providers/ui/provider_list_screen.dart';
|
import 'package:flux/features/master_data/providers/ui/provider_list_screen.dart';
|
||||||
|
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
||||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||||
import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
|
import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
|
||||||
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
|
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
|
||||||
@@ -43,6 +45,11 @@ import 'package:flux/features/operations/ui/operation_form_screen.dart';
|
|||||||
import 'package:flux/features/operations/ui/operation_list_screen.dart';
|
import 'package:flux/features/operations/ui/operation_list_screen.dart';
|
||||||
import 'package:flux/features/settings/settings_screen.dart';
|
import 'package:flux/features/settings/settings_screen.dart';
|
||||||
import 'package:flux/features/settings/theme_settings_view.dart';
|
import 'package:flux/features/settings/theme_settings_view.dart';
|
||||||
|
import 'package:flux/features/tasks/blocs/task_form_cubit.dart';
|
||||||
|
import 'package:flux/features/tasks/blocs/task_list_cubit.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_model.dart';
|
||||||
|
import 'package:flux/features/tasks/ui/task_form_screen.dart';
|
||||||
|
import 'package:flux/features/tasks/ui/task_list_screen.dart';
|
||||||
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
|
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
|
||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
import 'package:flux/features/tickets/models/ticket_model.dart';
|
||||||
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
|
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
|
||||||
@@ -133,7 +140,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 +240,34 @@ 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) {
|
||||||
|
// 1. Recuperiamo lo stato della sessione per le dipendenze
|
||||||
|
final sessionState = context.read<SessionCubit>().state;
|
||||||
|
|
||||||
|
// Sicurezza: Se per qualche motivo non abbiamo l'azienda,
|
||||||
|
// qui potresti reindirizzare o gestire l'errore
|
||||||
|
final companyId = sessionState.company?.id;
|
||||||
|
if (companyId == null) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: Text("Errore: Azienda non trovata")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Iniettiamo il Cubit con tutto ciò che gli serve
|
||||||
|
return BlocProvider(
|
||||||
|
create: (context) => TaskListCubit(
|
||||||
|
currentCompanyId: companyId,
|
||||||
|
currentStoreId: sessionState
|
||||||
|
.currentStore
|
||||||
|
?.id, // Opzionale: filtra per negozio se l'utente è "dentro" uno store
|
||||||
|
),
|
||||||
|
child: const TaskListScreen(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -482,6 +526,39 @@ class AppRouter {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/tasks/form/:id',
|
||||||
|
name: Routes.taskForm,
|
||||||
|
builder: (context, state) {
|
||||||
|
final String pathId = state.pathParameters['id'] ?? 'new';
|
||||||
|
final TaskModel? task = state.extra as TaskModel?;
|
||||||
|
final String? realTaskId;
|
||||||
|
if (pathId == 'new') {
|
||||||
|
realTaskId = null;
|
||||||
|
} else if (task?.id != null) {
|
||||||
|
realTaskId = task!.id;
|
||||||
|
} else {
|
||||||
|
realTaskId = pathId;
|
||||||
|
}
|
||||||
|
|
||||||
|
final allStaffList = context.read<StaffCubit>().state.allStaff;
|
||||||
|
|
||||||
|
// Creiamo il BLoC "al volo" solo per questa schermata
|
||||||
|
return MultiBlocProvider(
|
||||||
|
providers: [
|
||||||
|
BlocProvider<TaskFormCubit>(
|
||||||
|
create: (context) => TaskFormCubit(
|
||||||
|
globalStaff: allStaffList,
|
||||||
|
initialTask: task,
|
||||||
|
initialTaskId: realTaskId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
child: TaskFormScreen(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ enum Bucket {
|
|||||||
|
|
||||||
class AttachmentsRepository {
|
class AttachmentsRepository {
|
||||||
final _supabase = Supabase.instance.client;
|
final _supabase = Supabase.instance.client;
|
||||||
static const String _tableName = Tables.attachments;
|
|
||||||
|
|
||||||
/// Scarica i byte di un file direttamente da Supabase Storage
|
/// Scarica i byte di un file direttamente da Supabase Storage
|
||||||
Future<Uint8List> downloadAttachmentBytes({
|
Future<Uint8List> downloadAttachmentBytes({
|
||||||
@@ -56,7 +55,7 @@ class AttachmentsRepository {
|
|||||||
final columnName = _getColumnNameForParent(parentType);
|
final columnName = _getColumnNameForParent(parentType);
|
||||||
|
|
||||||
return _supabase
|
return _supabase
|
||||||
.from(_tableName)
|
.from(Tables.attachments)
|
||||||
.stream(primaryKey: ['id'])
|
.stream(primaryKey: ['id'])
|
||||||
.eq(columnName, parentId)
|
.eq(columnName, parentId)
|
||||||
.map(
|
.map(
|
||||||
@@ -141,7 +140,7 @@ class AttachmentsRepository {
|
|||||||
insertData[columnName] = parentId;
|
insertData[columnName] = parentId;
|
||||||
|
|
||||||
// 6. Salviamo su Postgres
|
// 6. Salviamo su Postgres
|
||||||
await _supabase.from(_tableName).insert(insertData);
|
await _supabase.from(Tables.attachments).insert(insertData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception("Errore caricamento: $e");
|
throw Exception("Errore caricamento: $e");
|
||||||
}
|
}
|
||||||
@@ -179,12 +178,12 @@ class AttachmentsRepository {
|
|||||||
// A. Ci sono ancora altre entità che usano questo file!
|
// A. Ci sono ancora altre entità che usano questo file!
|
||||||
// Scolleghiamolo SOLO dal contesto attuale mettendo a NULL la sua colonna
|
// Scolleghiamolo SOLO dal contesto attuale mettendo a NULL la sua colonna
|
||||||
await _supabase
|
await _supabase
|
||||||
.from(_tableName)
|
.from(Tables.attachments)
|
||||||
.update({currentContextType.dbColumn: null})
|
.update({currentContextType.dbColumn: null})
|
||||||
.eq('id', file.id!);
|
.eq('id', file.id!);
|
||||||
} else {
|
} else {
|
||||||
// B. Nessuno usa più questo file! ELIMINAZIONE FISICA TOTALE.
|
// B. Nessuno usa più questo file! ELIMINAZIONE FISICA TOTALE.
|
||||||
await _supabase.from(_tableName).delete().eq('id', file.id!);
|
await _supabase.from(Tables.attachments).delete().eq('id', file.id!);
|
||||||
|
|
||||||
if (file.storagePath != null) {
|
if (file.storagePath != null) {
|
||||||
await _supabase.storage.from(bucket.value).remove([
|
await _supabase.storage.from(bucket.value).remove([
|
||||||
@@ -202,7 +201,7 @@ class AttachmentsRepository {
|
|||||||
Future<void> renameAttachment(String fileId, String newName) async {
|
Future<void> renameAttachment(String fileId, String newName) async {
|
||||||
try {
|
try {
|
||||||
await _supabase
|
await _supabase
|
||||||
.from(_tableName)
|
.from(Tables.attachments)
|
||||||
.update({'name': newName})
|
.update({'name': newName})
|
||||||
.eq('id', fileId);
|
.eq('id', fileId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -219,7 +218,7 @@ class AttachmentsRepository {
|
|||||||
try {
|
try {
|
||||||
// Facciamo un semplice UPDATE aggiungendo l'ID nella colonna giusta
|
// Facciamo un semplice UPDATE aggiungendo l'ID nella colonna giusta
|
||||||
await _supabase
|
await _supabase
|
||||||
.from(_tableName)
|
.from(Tables.attachments)
|
||||||
.update({targetType.dbColumn: targetId})
|
.update({targetType.dbColumn: targetId})
|
||||||
.eq('id', fileId);
|
.eq('id', fileId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -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}) 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ${state.errorMessage}",
|
||||||
|
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';
|
||||||
@@ -75,12 +76,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 +234,7 @@ 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
|
context.pushNamed(Routes.taskForm, pathParameters: {'id': 'new'});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -12,7 +12,22 @@ class StaffCubit extends Cubit<StaffState> {
|
|||||||
final StaffRepository _repository = GetIt.I.get<StaffRepository>();
|
final StaffRepository _repository = GetIt.I.get<StaffRepository>();
|
||||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
||||||
|
|
||||||
StaffCubit() : super(const StaffState());
|
StaffCubit() : super(const StaffState()) {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
emit(state.copyWith(status: StaffStatus.loading, error: null));
|
||||||
|
try {
|
||||||
|
final allStaff = await _repository.getStaffMembers(
|
||||||
|
_sessionCubit.state.company!.id!,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(state.copyWith(status: StaffStatus.success, allStaff: allStaff));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(status: StaffStatus.error, error: e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Carica tutto lo staff della compagnia
|
// Carica tutto lo staff della compagnia
|
||||||
Future<void> loadAllStaff() async {
|
Future<void> loadAllStaff() async {
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ class StaffRepository {
|
|||||||
Future<List<StaffMemberModel>> getStaffMembers(String companyId) async {
|
Future<List<StaffMemberModel>> getStaffMembers(String companyId) async {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from(Tables.staffMembers)
|
.from(Tables.staffMembers)
|
||||||
.select()
|
.select('''
|
||||||
|
*,
|
||||||
|
store_assignments:${Tables.staffInStores} (
|
||||||
|
${Tables.stores}(*)
|
||||||
|
)
|
||||||
|
''')
|
||||||
.eq('company_id', companyId)
|
.eq('company_id', companyId)
|
||||||
.order('name', ascending: true);
|
.order('name', ascending: true);
|
||||||
|
|
||||||
@@ -24,7 +29,12 @@ class StaffRepository {
|
|||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from(Tables.staffMembers)
|
.from(Tables.staffMembers)
|
||||||
.select()
|
.select('''
|
||||||
|
*,
|
||||||
|
store_assignments:${Tables.staffInStores} (
|
||||||
|
${Tables.stores}(*)
|
||||||
|
)
|
||||||
|
''')
|
||||||
.eq('id', staffId)
|
.eq('id', staffId)
|
||||||
.single();
|
.single();
|
||||||
return StaffMemberModel.fromMap(response);
|
return StaffMemberModel.fromMap(response);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
|
import 'package:flux/features/master_data/store/models/store_model.dart';
|
||||||
|
|
||||||
// L'Enum magico e blindato per il sistema
|
// L'Enum magico e blindato per il sistema
|
||||||
enum SystemRole {
|
enum SystemRole {
|
||||||
@@ -26,6 +28,8 @@ class StaffMemberModel extends Equatable {
|
|||||||
final SystemRole systemRole;
|
final SystemRole systemRole;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
final bool hasJoined;
|
final bool hasJoined;
|
||||||
|
final List<String> assignedStoreIds;
|
||||||
|
final List<StoreModel> assignedStores;
|
||||||
|
|
||||||
const StaffMemberModel({
|
const StaffMemberModel({
|
||||||
this.id,
|
this.id,
|
||||||
@@ -38,6 +42,8 @@ class StaffMemberModel extends Equatable {
|
|||||||
this.systemRole = SystemRole.user,
|
this.systemRole = SystemRole.user,
|
||||||
this.isActive = true,
|
this.isActive = true,
|
||||||
this.hasJoined = false,
|
this.hasJoined = false,
|
||||||
|
this.assignedStoreIds = const [],
|
||||||
|
this.assignedStores = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
StaffMemberModel copyWith({
|
StaffMemberModel copyWith({
|
||||||
@@ -52,6 +58,8 @@ class StaffMemberModel extends Equatable {
|
|||||||
SystemRole? systemRole,
|
SystemRole? systemRole,
|
||||||
bool? isActive,
|
bool? isActive,
|
||||||
bool? hasJoined,
|
bool? hasJoined,
|
||||||
|
List<String>? assignedStoreIds,
|
||||||
|
List<StoreModel>? assignedStores,
|
||||||
}) {
|
}) {
|
||||||
return StaffMemberModel(
|
return StaffMemberModel(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -64,6 +72,8 @@ class StaffMemberModel extends Equatable {
|
|||||||
systemRole: systemRole ?? this.systemRole,
|
systemRole: systemRole ?? this.systemRole,
|
||||||
isActive: isActive ?? this.isActive,
|
isActive: isActive ?? this.isActive,
|
||||||
hasJoined: hasJoined ?? this.hasJoined,
|
hasJoined: hasJoined ?? this.hasJoined,
|
||||||
|
assignedStoreIds: assignedStoreIds ?? this.assignedStoreIds,
|
||||||
|
assignedStores: assignedStores ?? this.assignedStores,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +89,24 @@ class StaffMemberModel extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
factory StaffMemberModel.fromMap(Map<String, dynamic> map) {
|
factory StaffMemberModel.fromMap(Map<String, dynamic> map) {
|
||||||
|
// 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota
|
||||||
|
final List<String> parsedAssignedStoreIds =
|
||||||
|
map['assigned_store_ids'] != null
|
||||||
|
? List<String>.from(map['assigned_store_ids'])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 2. Mappiamo il JOIN degli store, se presente
|
||||||
|
List<StoreModel> storeList = [];
|
||||||
|
|
||||||
|
// Gestione del JSON proveniente dal Join nidificato (es. task_assignments -> staff_members)
|
||||||
|
if (map['store_assignments'] != null) {
|
||||||
|
storeList = (map['store_assignments'] as List)
|
||||||
|
.map((a) => a[Tables.stores])
|
||||||
|
.where((s) => s != null)
|
||||||
|
.map((s) => StoreModel.fromMap(s))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
return StaffMemberModel(
|
return StaffMemberModel(
|
||||||
id: map['id'] as String?,
|
id: map['id'] as String?,
|
||||||
companyId: map['company_id'] ?? '',
|
companyId: map['company_id'] ?? '',
|
||||||
@@ -90,6 +118,8 @@ class StaffMemberModel extends Equatable {
|
|||||||
systemRole: SystemRole.fromString(map['system_role']),
|
systemRole: SystemRole.fromString(map['system_role']),
|
||||||
isActive: map['is_active'] ?? true,
|
isActive: map['is_active'] ?? true,
|
||||||
hasJoined: map['has_joined'] ?? false,
|
hasJoined: map['has_joined'] ?? false,
|
||||||
|
assignedStoreIds: parsedAssignedStoreIds,
|
||||||
|
assignedStores: storeList,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,5 +150,7 @@ class StaffMemberModel extends Equatable {
|
|||||||
systemRole,
|
systemRole,
|
||||||
isActive,
|
isActive,
|
||||||
hasJoined,
|
hasJoined,
|
||||||
|
assignedStoreIds,
|
||||||
|
assignedStores,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,11 @@ class DashboardNotesWidget extends StatelessWidget {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Vai al form di dettaglio passando l'ID o l'oggetto
|
// Vai al form di dettaglio passando l'ID o l'oggetto
|
||||||
context.push('/notes/edit/${note.id}');
|
context.pushNamed(
|
||||||
|
Routes.noteForm,
|
||||||
|
pathParameters: {'id': note.id!},
|
||||||
|
extra: note,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 140,
|
width: 140,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
import 'package:flux/core/utils/extensions.dart';
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||||
import 'package:flux/features/customers/models/customer_model.dart';
|
import 'package:flux/features/customers/models/customer_model.dart';
|
||||||
@@ -184,10 +185,11 @@ class OperationModel extends Equatable {
|
|||||||
// I campi relazionali nullabili restano rigorosamente null!
|
// I campi relazionali nullabili restano rigorosamente null!
|
||||||
providerId: map['provider_id'] as String?,
|
providerId: map['provider_id'] as String?,
|
||||||
// MAGIA ANTI-CRASH: Usiamo ?['chiave'] per non far esplodere i join vuoti
|
// MAGIA ANTI-CRASH: Usiamo ?['chiave'] per non far esplodere i join vuoti
|
||||||
providerDisplayName: (map['provider']?['name'] as String?)?.myFormat(),
|
providerDisplayName: (map[Tables.providers]?['name'] as String?)
|
||||||
|
?.myFormat(),
|
||||||
|
|
||||||
modelId: map['model_id'] as String?,
|
modelId: map['model_id'] as String?,
|
||||||
modelDisplayName: (map['model']?['name_with_brand'] as String?)
|
modelDisplayName: (map[Tables.models]?['name_with_brand'] as String?)
|
||||||
?.myFormat(),
|
?.myFormat(),
|
||||||
|
|
||||||
description: map['description'] as String?,
|
description: map['description'] as String?,
|
||||||
@@ -202,25 +204,26 @@ class OperationModel extends Equatable {
|
|||||||
storeId:
|
storeId:
|
||||||
map['store_id'] as String? ??
|
map['store_id'] as String? ??
|
||||||
'', // Questo è non-nullable nella tua classe
|
'', // Questo è non-nullable nella tua classe
|
||||||
storeDisplayName: (map['store']?['name'] as String?)?.myFormat(),
|
storeDisplayName: (map[Tables.stores]?['name'] as String?)?.myFormat(),
|
||||||
|
|
||||||
quantity: map['quantity'] is int
|
quantity: map['quantity'] is int
|
||||||
? map['quantity']
|
? map['quantity']
|
||||||
: int.tryParse(map['quantity']?.toString() ?? '1') ?? 1,
|
: int.tryParse(map['quantity']?.toString() ?? '1') ?? 1,
|
||||||
|
|
||||||
staffId: map['staff_id'] as String?,
|
staffId: map['staff_id'] as String?,
|
||||||
staffDisplayName: (map['staff_member']?['name'] as String?)?.myFormat(),
|
staffDisplayName: (map[Tables.staffMembers]?['name'] as String?)
|
||||||
|
?.myFormat(),
|
||||||
|
|
||||||
lastCampaignId: map['last_campaign_id'] as String?,
|
lastCampaignId: map['last_campaign_id'] as String?,
|
||||||
status: OperationStatus.fromString(map['status'] ?? 'draft'),
|
status: OperationStatus.fromString(map['status'] ?? 'draft'),
|
||||||
customerId: map['customer_id'] as String?,
|
customerId: map['customer_id'] as String?,
|
||||||
|
|
||||||
customer: map['customer'] != null
|
customer: map[Tables.customers] != null
|
||||||
? CustomerModel.fromMap(map['customer'] as Map<String, dynamic>)
|
? CustomerModel.fromMap(map[Tables.customers] as Map<String, dynamic>)
|
||||||
: null,
|
: null,
|
||||||
|
|
||||||
attachments:
|
attachments:
|
||||||
(map['attachment'] as List?)
|
(map[Tables.attachments] as List?)
|
||||||
?.map((x) => AttachmentModel.fromMap(x))
|
?.map((x) => AttachmentModel.fromMap(x))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
const [],
|
const [],
|
||||||
|
|||||||
211
lib/features/tasks/blocs/task_form_cubit.dart
Normal file
211
lib/features/tasks/blocs/task_form_cubit.dart
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||||
|
import 'package:flux/features/tasks/data/task_repository.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_model.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_status.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
part 'task_form_state.dart';
|
||||||
|
|
||||||
|
class TaskFormCubit extends Cubit<TaskFormState> {
|
||||||
|
final TaskRepository _taskRepository = GetIt.I.get<TaskRepository>();
|
||||||
|
final List<StaffMemberModel> _globalStaff;
|
||||||
|
final String currentCompanyId = GetIt.I<SessionCubit>().state.company!.id!;
|
||||||
|
final String? currentStoreId = GetIt.I<SessionCubit>().state.currentStore?.id;
|
||||||
|
|
||||||
|
TaskFormCubit({
|
||||||
|
required List<StaffMemberModel> 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<void> _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<String, List<StaffMemberModel>> 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<String>.from(state.selectedStaffIds);
|
||||||
|
if (currentList.contains(staffId)) {
|
||||||
|
currentList.remove(staffId);
|
||||||
|
} else {
|
||||||
|
currentList.add(staffId);
|
||||||
|
}
|
||||||
|
emit(state.copyWith(selectedStaffIds: currentList));
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleStoreSelection(String storeName, bool selectAll) {
|
||||||
|
// Recupera tutti i membri di quel gruppo
|
||||||
|
final staffInStore = state.groupedAvailableStaff[storeName] ?? [];
|
||||||
|
final idsInStore = staffInStore.map((s) => s.id!).toList();
|
||||||
|
|
||||||
|
final currentSelection = Set<String>.from(state.selectedStaffIds);
|
||||||
|
|
||||||
|
if (selectAll) {
|
||||||
|
currentSelection.addAll(idsInStore);
|
||||||
|
} else {
|
||||||
|
currentSelection.removeAll(idsInStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(state.copyWith(selectedStaffIds: currentSelection.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. AGGIORNAMENTO CAMPI STANDARD ---
|
||||||
|
void updateTitle(String title) => emit(state.copyWith(title: title));
|
||||||
|
|
||||||
|
void updateDescription(String desc) =>
|
||||||
|
emit(state.copyWith(description: desc));
|
||||||
|
|
||||||
|
void updateDueDate(DateTime? date) =>
|
||||||
|
emit(state.copyWith(dueDate: date, clearDueDate: date == null));
|
||||||
|
|
||||||
|
void updateLinkedTicket(String? ticketId) =>
|
||||||
|
emit(state.copyWith(linkedTicketId: ticketId));
|
||||||
|
|
||||||
|
// --- 5. LOGICA DEI REMINDER FINTI ---
|
||||||
|
void addMockReminder(String type, int minutes) {
|
||||||
|
final currentReminders = List<TaskReminder>.from(state.reminders);
|
||||||
|
currentReminders.add(TaskReminder(type: type, minutesBefore: minutes));
|
||||||
|
emit(state.copyWith(reminders: currentReminders));
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeMockReminder(int index) {
|
||||||
|
final currentReminders = List<TaskReminder>.from(state.reminders);
|
||||||
|
currentReminders.removeAt(index);
|
||||||
|
emit(state.copyWith(reminders: currentReminders));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 6. SALVATAGGIO FINALE ---
|
||||||
|
Future<void> saveTask({required String currentUserId}) async {
|
||||||
|
if (!state.isFormValid) return;
|
||||||
|
|
||||||
|
emit(state.copyWith(status: TaskFormStatus.submitting));
|
||||||
|
|
||||||
|
try {
|
||||||
|
final taskToSave = TaskModel(
|
||||||
|
id: state.id,
|
||||||
|
companyId: currentCompanyId,
|
||||||
|
storeId: state.isGlobal
|
||||||
|
? null
|
||||||
|
: currentStoreId, // La vera discriminante Globale/Store!
|
||||||
|
createdById: state.id == null
|
||||||
|
? currentUserId
|
||||||
|
: null, // Lo settiamo solo alla creazione
|
||||||
|
title: state.title.trim(),
|
||||||
|
description: state.description.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: state.description.trim(),
|
||||||
|
dueDate: state.dueDate,
|
||||||
|
status: state.taskStatus,
|
||||||
|
assignedToIds: state
|
||||||
|
.selectedStaffIds, // L'array che andrà a popolare la tabella di giunzione
|
||||||
|
);
|
||||||
|
|
||||||
|
if (state.id == null) {
|
||||||
|
await _taskRepository.createTask(taskToSave);
|
||||||
|
} else {
|
||||||
|
await _taskRepository.updateTask(taskToSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(state.copyWith(status: TaskFormStatus.success));
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: TaskFormStatus.failure,
|
||||||
|
errorMessage: 'Errore durante il salvataggio: $e',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
lib/features/tasks/blocs/task_form_state.dart
Normal file
109
lib/features/tasks/blocs/task_form_state.dart
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
part of 'task_form_cubit.dart';
|
||||||
|
|
||||||
|
enum TaskFormStatus { initial, loading, submitting, success, failure }
|
||||||
|
|
||||||
|
/// Placeholder finto per i futuri reminder (pg_cron)
|
||||||
|
class TaskReminder extends Equatable {
|
||||||
|
final String type; // es. 'email', 'push'
|
||||||
|
final int minutesBefore;
|
||||||
|
const TaskReminder({required this.type, required this.minutesBefore});
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [type, minutesBefore];
|
||||||
|
}
|
||||||
|
|
||||||
|
class TaskFormState extends Equatable {
|
||||||
|
final TaskFormStatus status;
|
||||||
|
final String? id; // Null se stiamo creando un nuovo task
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final DateTime? dueDate;
|
||||||
|
final TaskStatus taskStatus;
|
||||||
|
|
||||||
|
// --- SCOPING & ASSIGNMENTS ---
|
||||||
|
final bool isGlobal;
|
||||||
|
final List<String> selectedStaffIds;
|
||||||
|
final List<StaffMemberModel> availableStaff;
|
||||||
|
final Map<String, List<StaffMemberModel>> groupedAvailableStaff;
|
||||||
|
|
||||||
|
// --- FUTURI ANCORAGGI ---
|
||||||
|
final List<TaskReminder> reminders;
|
||||||
|
final String? linkedTicketId;
|
||||||
|
final String? linkedEmailId;
|
||||||
|
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const TaskFormState({
|
||||||
|
this.status = TaskFormStatus.initial,
|
||||||
|
this.id,
|
||||||
|
this.title = '',
|
||||||
|
this.description = '',
|
||||||
|
this.dueDate,
|
||||||
|
this.taskStatus = TaskStatus.open,
|
||||||
|
this.isGlobal = false,
|
||||||
|
this.selectedStaffIds = const [],
|
||||||
|
this.availableStaff = const [],
|
||||||
|
this.groupedAvailableStaff = const {},
|
||||||
|
this.reminders = const [],
|
||||||
|
this.linkedTicketId,
|
||||||
|
this.linkedEmailId,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
// MAGIA: Il form è valido solo se c'è un titolo e, opzionalmente, altre regole.
|
||||||
|
bool get isFormValid => title.trim().isNotEmpty;
|
||||||
|
|
||||||
|
TaskFormState copyWith({
|
||||||
|
TaskFormStatus? status,
|
||||||
|
String? id,
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
DateTime? dueDate,
|
||||||
|
bool clearDueDate = false, // Trucco Ninja per rimettere a null una data!
|
||||||
|
TaskStatus? taskStatus,
|
||||||
|
bool? isGlobal,
|
||||||
|
List<String>? selectedStaffIds,
|
||||||
|
List<StaffMemberModel>? availableStaff,
|
||||||
|
Map<String, List<StaffMemberModel>>? groupedAvailableStaff,
|
||||||
|
List<TaskReminder>? reminders,
|
||||||
|
String? linkedTicketId,
|
||||||
|
String? linkedEmailId,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return TaskFormState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
id: id ?? this.id,
|
||||||
|
title: title ?? this.title,
|
||||||
|
description: description ?? this.description,
|
||||||
|
dueDate: clearDueDate ? null : (dueDate ?? this.dueDate),
|
||||||
|
taskStatus: taskStatus ?? this.taskStatus,
|
||||||
|
isGlobal: isGlobal ?? this.isGlobal,
|
||||||
|
selectedStaffIds: selectedStaffIds ?? this.selectedStaffIds,
|
||||||
|
availableStaff: availableStaff ?? this.availableStaff,
|
||||||
|
groupedAvailableStaff:
|
||||||
|
groupedAvailableStaff ?? this.groupedAvailableStaff,
|
||||||
|
reminders: reminders ?? this.reminders,
|
||||||
|
linkedTicketId: linkedTicketId ?? this.linkedTicketId,
|
||||||
|
linkedEmailId: linkedEmailId ?? this.linkedEmailId,
|
||||||
|
errorMessage:
|
||||||
|
errorMessage, // Se copyWith è chiamato senza errore, lo pulisce in automatico
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
status,
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
dueDate,
|
||||||
|
taskStatus,
|
||||||
|
isGlobal,
|
||||||
|
selectedStaffIds,
|
||||||
|
availableStaff,
|
||||||
|
groupedAvailableStaff,
|
||||||
|
reminders,
|
||||||
|
linkedTicketId,
|
||||||
|
linkedEmailId,
|
||||||
|
errorMessage,
|
||||||
|
];
|
||||||
|
}
|
||||||
73
lib/features/tasks/blocs/task_list_cubit.dart
Normal file
73
lib/features/tasks/blocs/task_list_cubit.dart
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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:get_it/get_it.dart';
|
||||||
|
|
||||||
|
part 'task_list_state.dart';
|
||||||
|
|
||||||
|
class TaskListCubit extends Cubit<TaskListState> {
|
||||||
|
final TaskRepository _repository = GetIt.I.get<TaskRepository>();
|
||||||
|
final String currentCompanyId;
|
||||||
|
final String? currentStoreId;
|
||||||
|
|
||||||
|
// Il nostro abbonamento allo stream del repository
|
||||||
|
StreamSubscription<void>? _taskSubscription;
|
||||||
|
|
||||||
|
TaskListCubit({required this.currentCompanyId, this.currentStoreId})
|
||||||
|
: super(const TaskListState()) {
|
||||||
|
_initRealtime();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initRealtime() {
|
||||||
|
emit(state.copyWith(status: TaskListStatus.loading));
|
||||||
|
|
||||||
|
// Primo caricamento
|
||||||
|
_loadTasksSilently();
|
||||||
|
|
||||||
|
// Ci mettiamo in ascolto del campanello del Repository
|
||||||
|
_taskSubscription = _repository.watchCompanyTasks(currentCompanyId).listen((
|
||||||
|
_,
|
||||||
|
) {
|
||||||
|
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
|
||||||
|
_loadTasksSilently();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadTasks() async {
|
||||||
|
emit(state.copyWith(status: TaskListStatus.loading));
|
||||||
|
await _loadTasksSilently();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTasksSilently() async {
|
||||||
|
try {
|
||||||
|
final tasks = await _repository.getTasks(
|
||||||
|
companyId: currentCompanyId,
|
||||||
|
storeId: currentStoreId,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: TaskListStatus.success,
|
||||||
|
tasks: tasks,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: TaskListStatus.failure,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
// Stacchiamo l'abbonamento. Il controller.onCancel nel Repo farà il resto!
|
||||||
|
_taskSubscription?.cancel();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
30
lib/features/tasks/blocs/task_list_state.dart
Normal file
30
lib/features/tasks/blocs/task_list_state.dart
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [status, tasks, errorMessage];
|
||||||
|
}
|
||||||
211
lib/features/tasks/data/task_repository.dart
Normal file
211
lib/features/tasks/data/task_repository.dart
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
|
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;
|
||||||
|
|
||||||
|
// --- LOGICA REAL-TIME (Il Campanello) ---
|
||||||
|
Stream<void> watchCompanyTasks(String companyId) {
|
||||||
|
// Usiamo un broadcast nel caso più bloc volessero ascoltarlo in futuro
|
||||||
|
final controller = StreamController<void>.broadcast();
|
||||||
|
|
||||||
|
final channel = _supabase.channel('public:tasks_company_$companyId');
|
||||||
|
|
||||||
|
channel
|
||||||
|
.onPostgresChanges(
|
||||||
|
event: PostgresChangeEvent.all,
|
||||||
|
schema: 'public',
|
||||||
|
table: Tables.tasks,
|
||||||
|
filter: PostgresChangeFilter(
|
||||||
|
type: PostgresChangeFilterType.eq,
|
||||||
|
column: 'company_id',
|
||||||
|
value: companyId,
|
||||||
|
),
|
||||||
|
callback: (payload) {
|
||||||
|
if (!controller.isClosed) {
|
||||||
|
controller.add(
|
||||||
|
null,
|
||||||
|
); // Suoniamo il campanello! Nessun dato, solo il "ding"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
// Quando il Cubit smette di ascoltare, puliamo il canale Supabase in automatico
|
||||||
|
controller.onCancel = () {
|
||||||
|
channel.unsubscribe();
|
||||||
|
controller.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return controller.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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: Usa il join esplicito stile "Notes"
|
||||||
|
var filterBuilder = _supabase
|
||||||
|
.from(Tables.tasks)
|
||||||
|
.select('''
|
||||||
|
*,
|
||||||
|
task_assignments:${Tables.taskAssignments} (
|
||||||
|
${Tables.staffMembers} (*)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
.eq('company_id', companyId);
|
||||||
|
|
||||||
|
if (storeId != null) {
|
||||||
|
filterBuilder = filterBuilder.or(
|
||||||
|
'store_id.eq.$storeId,store_id.is.null',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staffId != null) {
|
||||||
|
// Grazie al trigger, hai l'array pronto per il filtro senza impazzire!
|
||||||
|
filterBuilder = filterBuilder.contains('assigned_to_ids', [staffId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statuses != null && statuses.isNotEmpty) {
|
||||||
|
final statusValues = statuses.map((s) => s.toValue).toList();
|
||||||
|
filterBuilder = filterBuilder.inFilter('status', statusValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. FASE TRASFORMAZIONI
|
||||||
|
var transformBuilder = filterBuilder
|
||||||
|
.order('due_date', ascending: true, nullsFirst: false)
|
||||||
|
.order('created_at', ascending: false, nullsFirst: false);
|
||||||
|
|
||||||
|
if (limit != null) {
|
||||||
|
transformBuilder = transformBuilder.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ESECUZIONE DELLA QUERY
|
||||||
|
final response = await transformBuilder;
|
||||||
|
|
||||||
|
// 4. PARSING DEI DATI
|
||||||
|
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(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!);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(Tables.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(Tables.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(Tables.tasks)
|
||||||
|
.select('''
|
||||||
|
*,
|
||||||
|
task_assignments:${Tables.taskAssignments} (
|
||||||
|
${Tables.staffMembers} (*)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
.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(Tables.taskAssignments).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(Tables.taskAssignments).insert(assignments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
lib/features/tasks/models/task_model.dart
Normal file
152
lib/features/tasks/models/task_model.dart
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
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> map) {
|
||||||
|
// 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota
|
||||||
|
final List<String> parsedAssignedToIds = map['assigned_to_ids'] != null
|
||||||
|
? List<String>.from(map['assigned_to_ids'])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 2. Mappiamo il JOIN dello staff, se presente
|
||||||
|
List<StaffMemberModel> staffList = [];
|
||||||
|
|
||||||
|
// Gestione del JSON proveniente dal Join nidificato (es. task_assignments -> staff_members)
|
||||||
|
if (map['task_assignments'] != null) {
|
||||||
|
staffList = (map['task_assignments'] as List)
|
||||||
|
.map((a) => a['staff_members'])
|
||||||
|
.where((s) => s != null)
|
||||||
|
.map((s) => StaffMemberModel.fromMap(s))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
// Gestione del JSON piatto (se mai lo userai in altre chiamate RPC o viste)
|
||||||
|
else if (map['assigned_to_staff'] != null) {
|
||||||
|
staffList = (map['assigned_to_staff'] as List)
|
||||||
|
.map((s) => StaffMemberModel.fromMap(s))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskModel(
|
||||||
|
id: map['id'] as String?,
|
||||||
|
companyId: map['company_id'] as String?,
|
||||||
|
title: map['title'] as String? ?? '',
|
||||||
|
description: map['description'] as String?,
|
||||||
|
assignedToIds: parsedAssignedToIds,
|
||||||
|
assignedToStaff: staffList,
|
||||||
|
createdById: map['created_by_id'] as String?,
|
||||||
|
dueDate: map['due_date'] != null
|
||||||
|
? DateTime.parse(map['due_date'] as String).toLocal()
|
||||||
|
: null,
|
||||||
|
status: TaskStatusExtension.fromString(map['status'] as String?),
|
||||||
|
createdAt: map['created_at'] != null
|
||||||
|
? DateTime.parse(map['created_at'] as String).toLocal()
|
||||||
|
: null,
|
||||||
|
storeId: map['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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
432
lib/features/tasks/ui/task_form_screen.dart
Normal file
432
lib/features/tasks/ui/task_form_screen.dart
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/features/tasks/blocs/task_form_cubit.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
class TaskFormScreen extends StatefulWidget {
|
||||||
|
const TaskFormScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TaskFormScreen> createState() => _TaskFormScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TaskFormScreenState extends State<TaskFormScreen> {
|
||||||
|
late final TextEditingController _titleController;
|
||||||
|
late final TextEditingController _descController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Leggiamo lo stato iniziale dal Cubit (che ha già i dati del task esistente)
|
||||||
|
final initialState = context.read<TaskFormCubit>().state;
|
||||||
|
|
||||||
|
_titleController = TextEditingController(text: initialState.title);
|
||||||
|
_descController = TextEditingController(text: initialState.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
_descController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocConsumer<TaskFormCubit, TaskFormState>(
|
||||||
|
listenWhen: (previous, current) => previous.status != current.status,
|
||||||
|
listener: (context, state) {
|
||||||
|
// GESTIONE DEEP LINK: Se eravamo in caricamento e ora siamo pronti, popoliamo i controller!
|
||||||
|
if (state.status == TaskFormStatus.initial) {
|
||||||
|
if (_titleController.text != state.title) {
|
||||||
|
_titleController.text = state.title;
|
||||||
|
}
|
||||||
|
if (_descController.text != state.description) {
|
||||||
|
_descController.text = state.description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.status == TaskFormStatus.success) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Task salvato con successo! 🎉')),
|
||||||
|
);
|
||||||
|
context.pop();
|
||||||
|
} else if (state.status == TaskFormStatus.failure) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(state.errorMessage ?? 'Errore di salvataggio'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
final cubit = context.read<TaskFormCubit>();
|
||||||
|
final isEditing = state.id != null;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(isEditing ? 'Modifica Task' : 'Nuovo Task'),
|
||||||
|
actions: [
|
||||||
|
if (state.status == TaskFormStatus.submitting)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: state.isFormValid
|
||||||
|
? () => cubit.saveTask(currentUserId: 'TODO_USER_ID')
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
label: const Text('Salva'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.orange,
|
||||||
|
disabledForegroundColor: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: state.status == TaskFormStatus.loading
|
||||||
|
// Se sta scaricando i dati dal Deep Link, mostriamo un bel loader centrato
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final isWideScreen = constraints.maxWidth > 800;
|
||||||
|
|
||||||
|
if (isWideScreen) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 6,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: _buildFormFields(context, state, cubit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
VerticalDivider(
|
||||||
|
color: Theme.of(context).dividerColor,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 4,
|
||||||
|
child: _buildStaffSelectorInline(
|
||||||
|
context,
|
||||||
|
state,
|
||||||
|
cubit,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildFormFields(context, state, cubit),
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
_showStaffBottomSheet(context, cubit),
|
||||||
|
icon: const Icon(Icons.group_add),
|
||||||
|
label: Text(
|
||||||
|
state.selectedStaffIds.isEmpty
|
||||||
|
? 'Assegna Staff'
|
||||||
|
: 'Assegnato a ${state.selectedStaffIds.length} persone',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- I CAMPI DEL FORM (Aggiornati con i Controller) ---
|
||||||
|
Widget _buildFormFields(
|
||||||
|
BuildContext context,
|
||||||
|
TaskFormState state,
|
||||||
|
TaskFormCubit cubit,
|
||||||
|
) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(
|
||||||
|
color: Theme.of(context).dividerColor.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SwitchListTile(
|
||||||
|
title: const Text(
|
||||||
|
'Task Globale Aziendale',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Visibile a tutta l\'azienda, non legato a un negozio specifico.',
|
||||||
|
),
|
||||||
|
value: state.isGlobal,
|
||||||
|
activeThumbColor: Colors.orange,
|
||||||
|
onChanged: (val) => cubit.toggleGlobalScope(val),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Addio initialValue, benvenuto controller!
|
||||||
|
TextFormField(
|
||||||
|
controller: _titleController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Titolo del Task*',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.title),
|
||||||
|
),
|
||||||
|
onChanged: cubit.updateTitle,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descController,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Descrizione (opzionale)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
onChanged: cubit.updateDescription,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// --- SCADENZA ---
|
||||||
|
ListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
side: BorderSide(color: Theme.of(context).dividerColor),
|
||||||
|
),
|
||||||
|
leading: const Icon(Icons.calendar_today, color: Colors.orange),
|
||||||
|
title: Text(
|
||||||
|
state.dueDate != null
|
||||||
|
? 'Scadenza: ${state.dueDate!.day}/${state.dueDate!.month}/${state.dueDate!.year}'
|
||||||
|
: 'Nessuna scadenza impostata',
|
||||||
|
),
|
||||||
|
trailing: state.dueDate != null
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => cubit.updateDueDate(null),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () async {
|
||||||
|
final date = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: state.dueDate ?? DateTime.now(),
|
||||||
|
firstDate: DateTime.now(),
|
||||||
|
lastDate: DateTime(2100),
|
||||||
|
);
|
||||||
|
if (date != null) cubit.updateDueDate(date);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. SELEZIONE STAFF INLINE (PER DESKTOP/WIDE)
|
||||||
|
// =========================================================================
|
||||||
|
Widget _buildStaffSelectorInline(
|
||||||
|
BuildContext context,
|
||||||
|
TaskFormState state,
|
||||||
|
TaskFormCubit cubit,
|
||||||
|
) {
|
||||||
|
return Container(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(24.0),
|
||||||
|
child: Text(
|
||||||
|
'Assegnazione Staff',
|
||||||
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
children: _buildGroupedStaffList(context, state, cubit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 3. BOTTOM SHEET SELEZIONE STAFF (PER MOBILE)
|
||||||
|
// =========================================================================
|
||||||
|
void _showStaffBottomSheet(BuildContext context, TaskFormCubit cubit) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (bottomSheetContext) {
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
expand: false,
|
||||||
|
initialChildSize: 0.7, // Occupa il 70% dello schermo in altezza
|
||||||
|
minChildSize: 0.5,
|
||||||
|
maxChildSize: 0.9,
|
||||||
|
builder: (_, controller) {
|
||||||
|
return BlocBuilder<TaskFormCubit, TaskFormState>(
|
||||||
|
bloc:
|
||||||
|
cubit, // Passiamo il cubit esistente per mantenere lo stato!
|
||||||
|
builder: (context, state) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Text(
|
||||||
|
'Assegna Staff',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
controller: controller,
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
children: _buildGroupedStaffList(context, state, cubit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 4. GENERATORE DELLA LISTA RAGGRUPPATA (RIUTILIZZABILE)
|
||||||
|
// =========================================================================
|
||||||
|
List<Widget> _buildGroupedStaffList(
|
||||||
|
BuildContext context,
|
||||||
|
TaskFormState state,
|
||||||
|
TaskFormCubit cubit,
|
||||||
|
) {
|
||||||
|
final widgets = <Widget>[];
|
||||||
|
|
||||||
|
if (state.groupedAvailableStaff.isEmpty) {
|
||||||
|
return [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(32.0),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Nessun membro dello staff trovato.',
|
||||||
|
style: TextStyle(fontStyle: FontStyle.italic),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iteriamo sulla mappa { "Nome Negozio" : [Lista Dipendenti] }
|
||||||
|
for (final entry in state.groupedAvailableStaff.entries) {
|
||||||
|
final storeName = entry.key;
|
||||||
|
final staffList = entry.value;
|
||||||
|
|
||||||
|
// Verifichiamo se TUTTI i membri di questo negozio sono selezionati
|
||||||
|
final allSelectedInStore = staffList.every(
|
||||||
|
(staff) => state.selectedStaffIds.contains(staff.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
widgets.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 24.0,
|
||||||
|
bottom: 8.0,
|
||||||
|
left: 16,
|
||||||
|
right: 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
storeName.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// IL MAGICO BOTTONE "SELEZIONA TUTTI" DEL NEGOZIO
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
cubit.toggleStoreSelection(storeName, !allSelectedInStore),
|
||||||
|
icon: Icon(
|
||||||
|
allSelectedInStore ? Icons.deselect : Icons.select_all,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
allSelectedInStore ? 'Deseleziona' : 'Seleziona Tutti',
|
||||||
|
),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
foregroundColor: allSelectedInStore
|
||||||
|
? Colors.grey
|
||||||
|
: Colors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Renderizziamo i dipendenti di questo negozio usando dei Wrap con FilterChip
|
||||||
|
widgets.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8.0,
|
||||||
|
runSpacing: 8.0,
|
||||||
|
children: staffList.map((staff) {
|
||||||
|
final isSelected = state.selectedStaffIds.contains(staff.id);
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(staff.name),
|
||||||
|
selected: isSelected,
|
||||||
|
selectedColor: Colors.orange.withValues(alpha: 0.2),
|
||||||
|
checkmarkColor: Colors.orange,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
onSelected: (_) => cubit.toggleStaffSelection(staff.id!),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
}
|
||||||
272
lib/features/tasks/ui/task_list_screen.dart
Normal file
272
lib/features/tasks/ui/task_list_screen.dart
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/features/tasks/blocs/task_list_cubit.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:flux/core/routes/routes.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_status.dart'; // Adegua al tuo path
|
||||||
|
import 'package:flux/features/tasks/models/task_model.dart';
|
||||||
|
|
||||||
|
class TaskListScreen extends StatelessWidget {
|
||||||
|
const TaskListScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Usiamo 3 tab per gli stati principali
|
||||||
|
return DefaultTabController(
|
||||||
|
length: 3,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Gestione Task'),
|
||||||
|
bottom: const TabBar(
|
||||||
|
indicatorColor: Colors.orange,
|
||||||
|
labelColor: Colors.orange,
|
||||||
|
tabs: [
|
||||||
|
Tab(text: 'Da Fare'),
|
||||||
|
Tab(text: 'In Corso'),
|
||||||
|
Tab(text: 'Completati'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: () => context.read<TaskListCubit>().loadTasks(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: BlocBuilder<TaskListCubit, TaskListState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.status == TaskListStatus.loading ||
|
||||||
|
state.status == TaskListStatus.initial) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status == TaskListStatus.failure) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 48,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(state.errorMessage ?? 'Errore sconosciuto'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () =>
|
||||||
|
context.read<TaskListCubit>().loadTasks(),
|
||||||
|
child: const Text('Riprova'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtriamo le 3 liste in memoria per ogni Tab
|
||||||
|
final todoTasks = state.tasks
|
||||||
|
.where((t) => t.status == TaskStatus.open)
|
||||||
|
.toList();
|
||||||
|
final inProgressTasks = state.tasks
|
||||||
|
.where((t) => t.status == TaskStatus.inProgress)
|
||||||
|
.toList();
|
||||||
|
final doneTasks = state.tasks
|
||||||
|
.where((t) => t.status == TaskStatus.completed)
|
||||||
|
.toList(); // Adegua in base ai tuoi enum
|
||||||
|
|
||||||
|
return TabBarView(
|
||||||
|
children: [
|
||||||
|
_buildTaskList(context, todoTasks, 'Nessun task da fare. 🎉'),
|
||||||
|
_buildTaskList(
|
||||||
|
context,
|
||||||
|
inProgressTasks,
|
||||||
|
'Nessun task in lavorazione.',
|
||||||
|
),
|
||||||
|
_buildTaskList(context, doneTasks, 'Nessun task completato.'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
onPressed: () =>
|
||||||
|
context.pushNamed(Routes.taskForm, pathParameters: {'id': 'new'}),
|
||||||
|
icon: const Icon(Icons.add, color: Colors.white),
|
||||||
|
label: const Text(
|
||||||
|
'Nuovo Task',
|
||||||
|
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WIDGET LISTA ---
|
||||||
|
Widget _buildTaskList(
|
||||||
|
BuildContext context,
|
||||||
|
List<TaskModel> tasks,
|
||||||
|
String emptyMessage,
|
||||||
|
) {
|
||||||
|
if (tasks.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
emptyMessage,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).textTheme.bodySmall?.color,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () => context.read<TaskListCubit>().loadTasks(),
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 16,
|
||||||
|
bottom: 80,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
), // Padding bottom per il FAB
|
||||||
|
itemCount: tasks.length,
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final task = tasks[index];
|
||||||
|
final isOverdue =
|
||||||
|
task.dueDate != null && task.dueDate!.isBefore(DateTime.now());
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(
|
||||||
|
color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () => context.pushNamed(
|
||||||
|
Routes.taskForm,
|
||||||
|
pathParameters: {'id': task.id!},
|
||||||
|
extra: task,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Riga 1: Badge Globale/Store + Data Scadenza
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: task.storeId == null
|
||||||
|
? Colors.purple.withValues(alpha: 0.1)
|
||||||
|
: Colors.blue.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
task.storeId == null ? 'GLOBALE' : 'STORE',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: task.storeId == null
|
||||||
|
? Colors.purple
|
||||||
|
: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (task.dueDate != null)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.access_time,
|
||||||
|
size: 14,
|
||||||
|
color: isOverdue ? Colors.red : Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${task.dueDate!.day}/${task.dueDate!.month}/${task.dueDate!.year}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: isOverdue
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isOverdue ? Colors.red : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Riga 2: Titolo
|
||||||
|
Text(
|
||||||
|
task.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Riga 3 (Opzionale): Descrizione breve
|
||||||
|
if (task.description != null &&
|
||||||
|
task.description!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
task.description!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Riga 4: Assegnatari
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.people_outline,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
(task.assignedToStaff.isEmpty)
|
||||||
|
? 'Nessun assegnatario'
|
||||||
|
: task.assignedToStaff
|
||||||
|
.map((s) => s.name)
|
||||||
|
.join(', '),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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