This commit is contained in:
2026-05-19 12:46:13 +02:00
parent 3f2f55d6c2
commit 3ecf617998
10 changed files with 313 additions and 16 deletions

View File

@@ -17,12 +17,12 @@ class LatestStoreOperationsBloc
status: LatestStoreOperationsStatus.initial,
),
) {
on<InitLastStoreOperationsEvent>((event, emit) async {
on<InitLatestStoreOperationsEvent>((event, emit) async {
emit(state.copyWith(status: LatestStoreOperationsStatus.loading));
try {
// 1. Creiamo uno stream "intermedio" che idrata i dati
final hydratedStream = _repository
.getLastStoreOperationsStream(storeId: event.storeId, limit: 5)
.getLatestStoreOperationsStream(storeId: event.storeId, limit: 10)
.asyncMap((List<OperationModel> rawOperations) async {
// Questo gira ad ogni "scatto" dello stream di Supabase
List<OperationModel> fullyHydratedOperations = [];

View File

@@ -7,10 +7,10 @@ sealed class LatestStoreOperationsEvent extends Equatable {
List<Object> get props => [];
}
class InitLastStoreOperationsEvent extends LatestStoreOperationsEvent {
class InitLatestStoreOperationsEvent extends LatestStoreOperationsEvent {
final String storeId;
const InitLastStoreOperationsEvent(this.storeId);
const InitLatestStoreOperationsEvent(this.storeId);
@override
List<Object> get props => [storeId];

View File

@@ -18,7 +18,7 @@ class LatestStoreOperationsCard extends StatelessWidget {
// 1. Creiamo il Bloc e facciamo partire subito la query
create: (context) =>
LatestStoreOperationsBloc()
..add(InitLastStoreOperationsEvent(currentStoreId ?? '')),
..add(InitLatestStoreOperationsEvent(currentStoreId ?? '')),
child: BlocListener<SessionCubit, SessionState>(
// 2. MAGIA: Se l'utente cambia negozio dalla barra in alto, riavviamo lo stream!
listenWhen: (previous, current) =>
@@ -26,7 +26,7 @@ class LatestStoreOperationsCard extends StatelessWidget {
listener: (context, state) {
if (state.currentStore?.id != null) {
context.read<LatestStoreOperationsBloc>().add(
InitLastStoreOperationsEvent(state.currentStore!.id!),
InitLatestStoreOperationsEvent(state.currentStore!.id!),
);
}
},

View File

@@ -0,0 +1,58 @@
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(),
),
);
}
});
// TODO: implement event handlers
}
}

View File

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,184 @@
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: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];
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: [
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,6 +6,7 @@ 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/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/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';
@@ -80,15 +81,7 @@ class HomeScreen extends StatelessWidget {
context: context,
),
LatestStoreOperationsCard(),
_buildDashboardWidget(
title: context.l10n.homeLatestOperationTickets,
icon: Icons.support_agent_outlined,
color: Colors.purple,
context: context,
onTap: () => context.pushNamed(
Routes.tickets,
), // <-- Aggiunto!
),
LatestStoreTicketsCard(),
]),
),
),

View File

@@ -82,7 +82,7 @@ class OperationsRepository {
}
}
Stream<List<OperationModel>> getLastStoreOperationsStream({
Stream<List<OperationModel>> getLatestStoreOperationsStream({
required String storeId,
required int limit,
}) {

View File

@@ -174,6 +174,22 @@ class TicketRepository {
});
}
Stream<List<TicketModel>> getLatestStoreTicketsStream({
required String storeId,
required int limit,
}) {
return _supabase
.from(_tableName)
.stream(primaryKey: ['id'])
.eq('store_id', storeId)
.order('created_at', ascending: false)
.limit(limit)
.map(
(listOfMaps) =>
listOfMaps.map((map) => TicketModel.fromMap(map)).toList(),
);
}
/// Recupera un ticket specifico CON TUTTE LE RELAZIONI espanse (Cliente e Modelli)
/// Questa è la vera magia di Supabase!
Future<TicketModel> getTicketById(String ticketId) async {