refactor dashboard store ticket list

This commit is contained in:
2026-05-30 16:26:59 +02:00
parent f31ff19a74
commit 6394e5a2cd
12 changed files with 408 additions and 404 deletions

View File

@@ -18,6 +18,9 @@ import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/ui/customer_detail_screen.dart';
import 'package:flux/features/customers/ui/customer_form_screen.dart';
import 'package:flux/features/customers/ui/customers_list_screen.dart';
import 'package:flux/features/home/dashboard_store_operation_list/bloc/dashboard_store_operation_list_cubit.dart';
import 'package:flux/features/home/dashboard_store_ticket_list/blocs/dashboard_store_ticket_list_cubit.dart';
import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart';
import 'package:flux/features/home/ui/home_screen.dart';
import 'package:flux/features/master_data/master_data_hub_content.dart';
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
@@ -142,7 +145,29 @@ class AppRouter {
path: '/',
name: Routes.home,
builder: (context, state) {
return const HomeScreen();
return MultiBlocProvider(
providers: [
BlocProvider<DashboardStoreOperationListCubit>(
create: (context) => DashboardStoreOperationListCubit(
companyId: sessionCubit.state.company?.id,
storeId: sessionCubit.state.currentStore?.id,
),
),
BlocProvider<DashboardTaskListCubit>(
create: (context) => DashboardTaskListCubit(
companyId: sessionCubit.state.company?.id,
staffId: sessionCubit.state.currentStaffMember?.id,
),
),
BlocProvider<DashboardStoreTicketListCubit>(
create: (context) => DashboardStoreTicketListCubit(
companyId: sessionCubit.state.company?.id,
storeId: sessionCubit.state.currentStore?.id,
),
),
],
child: const HomeScreen(),
);
},
),

View File

@@ -0,0 +1,79 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/tickets/data/ticket_repository.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:get_it/get_it.dart';
part 'dashboard_store_ticket_list_state.dart';
class DashboardStoreTicketListCubit
extends Cubit<DashboardStoreTicketListState> {
final TicketRepository _repository = GetIt.I.get<TicketRepository>();
final String? companyId;
final String? storeId;
StreamSubscription<void>? _subscription;
DashboardStoreTicketListCubit({
required this.companyId,
required this.storeId,
}) : super(
const DashboardStoreTicketListState(
status: DashboardStoreTicketListStatus.initial,
),
);
void stopListening() {
_subscription?.cancel();
_subscription = null;
}
void startListening() {
stopListening();
emit(state.copyWith(status: DashboardStoreTicketListStatus.loading));
// Primo caricamento
_loadTicketsSilently();
// Inizio ascolto campanello
try {
_subscription = _repository
.getLatestStoreTicketsStream(storeId: storeId!, limit: 10)
.listen((_) {
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
_loadTicketsSilently();
});
} on Exception catch (e) {
debugPrint(e.toString());
}
}
Future<void> _loadTicketsSilently() async {
try {
final tickets = await _repository.fetchTickets(
companyId: companyId!,
storeId: storeId,
limit: 10,
offset: 0,
);
emit(
state.copyWith(
status: DashboardStoreTicketListStatus.success,
tickets: tickets,
errorMessage: null,
),
);
} catch (e) {
emit(
state.copyWith(
status: DashboardStoreTicketListStatus.failure,
errorMessage: e.toString(),
),
);
}
}
}

View File

@@ -0,0 +1,30 @@
part of 'dashboard_store_ticket_list_cubit.dart';
enum DashboardStoreTicketListStatus { initial, loading, success, failure }
class DashboardStoreTicketListState extends Equatable {
final DashboardStoreTicketListStatus status;
final List<TicketModel> tickets;
final String? errorMessage;
const DashboardStoreTicketListState({
this.status = DashboardStoreTicketListStatus.initial,
this.tickets = const [],
this.errorMessage,
});
DashboardStoreTicketListState copyWith({
DashboardStoreTicketListStatus? status,
List<TicketModel>? tickets,
String? errorMessage,
}) {
return DashboardStoreTicketListState(
status: status ?? this.status,
tickets: tickets ?? this.tickets,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [status, tickets, errorMessage];
}

View File

@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/home/dashboard_store_ticket_list/blocs/dashboard_store_ticket_list_cubit.dart';
import 'package:flux/features/tickets/models/ticket_status_extension.dart';
import 'package:go_router/go_router.dart';
class DashboardStoreTicketListCard extends StatelessWidget {
const DashboardStoreTicketListCard({super.key});
@override
Widget build(BuildContext context) {
return _DashboardStoreTicketListCardContent();
}
}
class _DashboardStoreTicketListCardContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const color = Colors.blue;
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.tickets),
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.design_services_outlined,
color: color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
"Ticket recenti",
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<
DashboardStoreTicketListCubit,
DashboardStoreTicketListState
>(
builder: (context, state) {
if (state.status ==
DashboardStoreTicketListStatus.loading ||
state.status ==
DashboardStoreTicketListStatus.initial) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state.status ==
DashboardStoreTicketListStatus.failure) {
return Center(
child: Text(
"Errore di caricamento",
style: TextStyle(color: theme.colorScheme.error),
),
);
}
if (state.tickets.isEmpty) {
return Center(
child: Text(
"Nessun ticket recente.",
style: TextStyle(
color: context.secondaryText,
fontStyle: FontStyle.italic,
),
),
);
}
return ListView.separated(
itemCount: state.tickets.length,
separatorBuilder: (context, index) => Divider(
height: 1,
color: theme.dividerColor.withValues(alpha: 0.3),
),
itemBuilder: (context, index) {
final ticket = state.tickets[index];
final statusColor = ticket.ticketStatus.color;
return InkWell(
onTap: () => context.pushNamed(
Routes.ticketForm,
extra: (createdBy: null, ticket: ticket),
pathParameters: {'id': ticket.id!},
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Container(
width: 8,
height:
30, // Un'altezza fissa per farlo comparire!
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(
4,
), // Angoli smussati per stile
),
),
const SizedBox(width: 4),
Expanded(
flex: 5,
child: Text(
ticket.customer?.name ??
'Cliente sconosciuto',
style: TextStyle(
fontWeight: FontWeight.w700,
color: context.primaryText,
),
),
),
Expanded(
flex: 5,
child: Text(
ticket.targetModelName ??
'Modello sconosciuto',
style: TextStyle(
fontWeight: FontWeight.w600,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
"${ticket.createdAt?.day}/${ticket.createdAt?.month}",
style: TextStyle(
color: context.secondaryText,
fontSize: 12,
),
),
],
),
),
);
},
);
},
),
),
],
),
),
),
);
}
}

View File

@@ -20,6 +20,8 @@ class DashboardTaskListCubit extends Cubit<DashboardTaskListState> {
: super(const DashboardTaskListState());
void startListening() {
stopListening();
emit(state.copyWith(status: DashboardTaskListStatus.loading));
// Primo caricamento

View File

@@ -1,10 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:flux/features/tasks/models/task_status.dart';
@@ -13,24 +11,7 @@ class DashboardTasksCard extends StatelessWidget {
@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 = GetIt.I
.get<SessionCubit>()
.state
.currentStaffMember
?.id;
final companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
if (currentStaffId == null) {
return const SizedBox.shrink(); // Sicurezza se lo stato non è pronto
}
return BlocProvider(
create: (context) =>
DashboardTaskListCubit(staffId: currentStaffId, companyId: companyId),
child: const _DashboardTasksCardContent(),
);
return _DashboardTasksCardContent();
}
}

View File

@@ -1,57 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/features/tickets/data/ticket_repository.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:get_it/get_it.dart';
part 'latest_store_tickets_events.dart';
part 'latest_store_tickets_state.dart';
class LatestStoreTicketsBloc
extends Bloc<LatestStoreTicketsEvent, LatestStoreTicketsState> {
final _repository = GetIt.I.get<TicketRepository>();
LatestStoreTicketsBloc()
: super(
const LatestStoreTicketsState(status: LatestStoreTicketsStatus.initial),
) {
on<InitLatestStoreTicketsEvent>((event, emit) async {
emit(state.copyWith(status: LatestStoreTicketsStatus.loading));
try {
final hydratedStream = _repository
.getLatestStoreTicketsStream(storeId: event.storeId, limit: 10)
.asyncMap((List<TicketModel> rawTickets) async {
List<TicketModel> fullyHydratedTickets = [];
for (TicketModel ticket in rawTickets) {
TicketModel fullTicket = await _repository.getTicketById(
ticket.id!,
);
fullyHydratedTickets.add(fullTicket);
}
return fullyHydratedTickets;
});
await emit.forEach(
hydratedStream,
onData: (List<TicketModel> fullyHydratedTickets) {
return state.copyWith(
tickets: fullyHydratedTickets,
status: LatestStoreTicketsStatus.success,
);
},
onError: (error, stackTrace) => state.copyWith(
status: LatestStoreTicketsStatus.failure,
error: error.toString(),
),
);
} catch (e) {
emit(
state.copyWith(
status: LatestStoreTicketsStatus.failure,
error: e.toString(),
),
);
}
});
}
}

View File

@@ -1,17 +0,0 @@
part of 'latest_store_tickets_bloc.dart';
abstract class LatestStoreTicketsEvent extends Equatable {
const LatestStoreTicketsEvent();
@override
List<Object> get props => [];
}
class InitLatestStoreTicketsEvent extends LatestStoreTicketsEvent {
final String storeId;
const InitLatestStoreTicketsEvent(this.storeId);
@override
List<Object> get props => [storeId];
}

View File

@@ -1,29 +0,0 @@
part of 'latest_store_tickets_bloc.dart';
enum LatestStoreTicketsStatus { initial, loading, success, failure }
class LatestStoreTicketsState extends Equatable {
final LatestStoreTicketsStatus status;
final String? error;
final List<TicketModel> tickets;
const LatestStoreTicketsState({
required this.status,
this.error,
this.tickets = const [],
});
@override
List<Object?> get props => [status, error, tickets];
LatestStoreTicketsState copyWith({
LatestStoreTicketsStatus? status,
String? error,
List<TicketModel>? tickets,
}) {
return LatestStoreTicketsState(
status: status ?? this.status,
error: error,
tickets: tickets ?? this.tickets,
);
}
}

View File

@@ -1,198 +0,0 @@
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/latest_store_tickets/blocs/latest_store_tickets_bloc.dart';
import 'package:flux/features/tickets/models/ticket_status_extension.dart';
import 'package:go_router/go_router.dart';
class LatestStoreTicketsCard extends StatelessWidget {
const LatestStoreTicketsCard({super.key});
@override
Widget build(BuildContext context) {
final currentStoreId = context.read<SessionCubit>().state.currentStore?.id;
return BlocProvider(
// 1. Creiamo il Bloc e facciamo partire subito la query
create: (context) =>
LatestStoreTicketsBloc()
..add(InitLatestStoreTicketsEvent(currentStoreId ?? '')),
child: BlocListener<SessionCubit, SessionState>(
// 2. MAGIA: Se l'utente cambia negozio dalla barra in alto, riavviamo lo stream!
listenWhen: (previous, current) =>
previous.currentStore?.id != current.currentStore?.id,
listener: (context, state) {
if (state.currentStore?.id != null) {
context.read<LatestStoreTicketsBloc>().add(
InitLatestStoreTicketsEvent(state.currentStore!.id!),
);
}
},
child: _LatestStoreTicketsCardContent(),
),
);
}
}
class _LatestStoreTicketsCardContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const color = Colors.blue;
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.tickets),
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.design_services_outlined,
color: color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
"Ticket recenti",
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<LatestStoreTicketsBloc, LatestStoreTicketsState>(
builder: (context, state) {
if (state.status == LatestStoreTicketsStatus.loading ||
state.status == LatestStoreTicketsStatus.initial) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == LatestStoreTicketsStatus.failure) {
return Center(
child: Text(
"Errore di caricamento",
style: TextStyle(color: theme.colorScheme.error),
),
);
}
if (state.tickets.isEmpty) {
return Center(
child: Text(
"Nessun ticket recente.",
style: TextStyle(
color: context.secondaryText,
fontStyle: FontStyle.italic,
),
),
);
}
return ListView.separated(
itemCount: state.tickets.length,
separatorBuilder: (context, index) => Divider(
height: 1,
color: theme.dividerColor.withValues(alpha: 0.3),
),
itemBuilder: (context, index) {
final ticket = state.tickets[index];
final statusColor = ticket.ticketStatus.color;
return InkWell(
onTap: () => context.pushNamed(
Routes.ticketForm,
extra: (createdBy: null, ticket: ticket),
pathParameters: {'id': ticket.id!},
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 8,
height:
30, // Un'altezza fissa per farlo comparire!
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(
4,
), // Angoli smussati per stile
),
),
const SizedBox(width: 4),
Expanded(
flex: 5,
child: Text(
ticket.customer?.name ??
'Cliente sconosciuto',
style: TextStyle(
fontWeight: FontWeight.w700,
color: context.primaryText,
),
),
),
Expanded(
flex: 5,
child: Text(
ticket.targetModelName ??
'Modello sconosciuto',
style: TextStyle(
fontWeight: FontWeight.w600,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
"${ticket.createdAt?.day}/${ticket.createdAt?.month}",
style: TextStyle(
color: context.secondaryText,
fontSize: 12,
),
),
],
),
),
);
},
);
},
),
),
],
),
),
),
);
}
}

View File

@@ -6,10 +6,11 @@ import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/staff_selector_modal.dart';
import 'package:flux/features/home/dashboard_store_operation_list/bloc/dashboard_store_operation_list_cubit.dart';
import 'package:flux/features/home/dashboard_store_ticket_list/blocs/dashboard_store_ticket_list_cubit.dart';
import 'package:flux/features/home/dashboard_store_ticket_list/ui/dashboard_store_ticket_list_card.dart';
import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart';
import 'package:flux/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart';
import 'package:flux/features/home/dashboard_store_operation_list/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/ui/quick_actions_widget.dart';
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
@@ -38,22 +39,31 @@ class _HomeScreenState extends State<HomeScreen> {
onPause: () {
// L'utente ha messo l'app in background (es. per rispondere a un messaggio su WhatsApp)
// Chiudiamo i rubinetti per non sprecare risorse e prevenire crash
context.read<DashboardStoreOperationListCubit>().stopListening();
context.read<DashboardTaskListCubit>().stopListening();
_stopListeners();
debugPrint('App in background: Stream sospesi.');
},
onResume: () {
// L'utente è tornato sull'app!
// Riappriamo i rubinetti, Supabase ricreerà una connessione fresca
context.read<DashboardStoreOperationListCubit>().startListening();
context.read<DashboardTaskListCubit>().startListening();
_startListeners();
debugPrint('App in foreground: Stream riattivati.');
},
);
// Facciamo partire gli stream la primissima volta che la schermata si carica
_startListeners();
}
void _stopListeners() {
context.read<DashboardStoreOperationListCubit>().stopListening();
context.read<DashboardTaskListCubit>().stopListening();
context.read<DashboardStoreTicketListCubit>().stopListening();
}
void _startListeners() {
context.read<DashboardStoreOperationListCubit>().startListening();
context.read<DashboardTaskListCubit>().startListening();
context.read<DashboardStoreTicketListCubit>().startListening();
}
@override
@@ -66,24 +76,8 @@ class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final sessionCubit = GetIt.I.get<SessionCubit>();
return MultiBlocProvider(
providers: [
BlocProvider<DashboardStoreOperationListCubit>(
create: (context) => DashboardStoreOperationListCubit(
companyId: sessionCubit.state.company?.id,
storeId: sessionCubit.state.currentStore?.id,
),
),
BlocProvider<DashboardTaskListCubit>(
create: (context) => DashboardTaskListCubit(
companyId: sessionCubit.state.company?.id,
staffId: sessionCubit.state.currentStaffMember?.id,
),
),
],
child: Scaffold(
return Scaffold(
backgroundColor: theme.colorScheme.surface,
body: SafeArea(
child: Column(
@@ -127,7 +121,7 @@ class _HomeScreenState extends State<HomeScreen> {
),
delegate: SliverChildListDelegate([
DashboardStoreOperationListCard(),
LatestStoreTicketsCard(),
DashboardStoreTicketListCard(),
_buildDashboardWidget(
title: context.l10n.homeExpiringContracts,
icon: Icons.assignment_late_outlined,
@@ -148,7 +142,6 @@ class _HomeScreenState extends State<HomeScreen> {
],
),
),
),
);
}

View File

@@ -75,7 +75,9 @@ class TicketRepository {
}
// --- RECUPERO PAGINATO CON FILTRI E JOIN DEI TICKET DI TUTTA L'AZIENDA ---
Future<List<TicketModel>> fetchCompanyTickets({
Future<List<TicketModel>> fetchTickets({
required String? companyId,
String? storeId,
required int offset,
int limit = 50,
String? searchTerm,
@@ -96,7 +98,7 @@ class TicketRepository {
target_model:${Tables.models}!ticket_model_id_1_fkey (*),
source_model:${Tables.models}!ticket_model_id_2_fkey (*)
''')
.eq('company_id', GetIt.I.get<SessionCubit>().state.company!.id!);
.eq('company_id', companyId!);
// Filtro Range Date
if (dateRange != null) {
@@ -105,6 +107,10 @@ class TicketRepository {
.lte('created_at', dateRange.end.toIso8601String());
}
if (storeId != null) {
query = query.or('store_id.eq.$storeId,store_id.is.null');
}
if (ticketStatusFilter != null) {
query = query.eq('status', ticketStatusFilter.value);
}