refactor dashboard operation list e task list with applifecycle

This commit is contained in:
2026-05-30 15:19:22 +02:00
parent 064179a753
commit f31ff19a74
9 changed files with 282 additions and 228 deletions

View File

@@ -0,0 +1,82 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/operations/data/operations_repository.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:get_it/get_it.dart';
part 'dashboard_store_operation_list_state.dart';
class DashboardStoreOperationListCubit
extends Cubit<DashboardStoreOperationListState> {
final OperationsRepository _repository = GetIt.I.get<OperationsRepository>();
final String? companyId;
final String? storeId;
StreamSubscription<void>? _operationsSubscription;
DashboardStoreOperationListCubit({
required this.companyId,
required this.storeId,
}) : super(
const DashboardStoreOperationListState(
status: DashboardStoreOperationListStatus.initial,
),
);
void startListening() {
emit(state.copyWith(status: DashboardStoreOperationListStatus.loading));
stopListening();
// Primo caricamento
_loadOperationsSilently();
// Inizio ascolto campanello
try {
_operationsSubscription = _repository
.watchStoreOperations(storeId: storeId!, limit: 10)
.listen((_) {
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
_loadOperationsSilently();
});
} on Exception catch (e) {
debugPrint(e.toString());
}
}
void stopListening() {
_operationsSubscription?.cancel();
_operationsSubscription = null;
}
void _loadOperationsSilently() async {
try {
final operations = await _repository.fetchOperations(
companyId: companyId!,
storeId: storeId!,
limit: 10,
offset: 0,
);
emit(
state.copyWith(
status: DashboardStoreOperationListStatus.success,
operations: operations,
error: null,
),
);
} catch (e) {
emit(
state.copyWith(
status: DashboardStoreOperationListStatus.failure,
error: e.toString(),
),
);
}
}
@override
Future<void> close() {
stopListening();
return super.close();
}
}

View File

@@ -0,0 +1,30 @@
part of 'dashboard_store_operation_list_cubit.dart';
enum DashboardStoreOperationListStatus { initial, loading, success, failure }
class DashboardStoreOperationListState extends Equatable {
final DashboardStoreOperationListStatus status;
final String? error;
final List<OperationModel> operations;
const DashboardStoreOperationListState({
required this.status,
this.error,
this.operations = const [],
});
@override
List<Object?> get props => [status, error, operations];
DashboardStoreOperationListState copyWith({
DashboardStoreOperationListStatus? status,
String? error,
List<OperationModel>? operations,
}) {
return DashboardStoreOperationListState(
status: status ?? this.status,
error: error,
operations: operations ?? this.operations,
);
}
}

View File

@@ -1,38 +1,17 @@
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/core/utils/extensions.dart';
import 'package:flux/features/home/latest_store_operations/bloc/latest_store_operations_bloc.dart';
import 'package:flux/features/home/dashboard_store_operation_list/bloc/dashboard_store_operation_list_cubit.dart';
import 'package:go_router/go_router.dart';
class LatestStoreOperationsCard extends StatelessWidget {
const LatestStoreOperationsCard({super.key});
class DashboardStoreOperationListCard extends StatelessWidget {
const DashboardStoreOperationListCard({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) =>
LatestStoreOperationsBloc()
..add(InitLatestStoreOperationsEvent(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<LatestStoreOperationsBloc>().add(
InitLatestStoreOperationsEvent(state.currentStore!.id!),
);
}
},
child: _LatestOperationsCardContent(),
),
);
return _LatestOperationsCardContent();
}
}
@@ -91,21 +70,21 @@ class _LatestOperationsCardContent extends StatelessWidget {
Expanded(
child:
BlocBuilder<
LatestStoreOperationsBloc,
LatestStoreOperationsState
DashboardStoreOperationListCubit,
DashboardStoreOperationListState
>(
builder: (context, state) {
if (state.status ==
LatestStoreOperationsStatus.loading ||
DashboardStoreOperationListStatus.loading ||
state.status ==
LatestStoreOperationsStatus.initial) {
DashboardStoreOperationListStatus.initial) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state.status ==
LatestStoreOperationsStatus.failure) {
DashboardStoreOperationListStatus.failure) {
return Center(
child: Text(
"Errore di caricamento",

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.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';
@@ -11,37 +12,41 @@ part 'dashboard_task_list_state.dart';
class DashboardTaskListCubit extends Cubit<DashboardTaskListState> {
final TasksRepository _repository = GetIt.I.get<TasksRepository>();
final String staffId;
final String companyId;
StreamSubscription<void>? _taskSubscription;
final String? staffId;
final String? companyId;
StreamSubscription<void>? _tasksSubscription;
DashboardTaskListCubit({required this.staffId, required this.companyId})
: super(const DashboardTaskListState()) {
_initRealtime();
}
: super(const DashboardTaskListState());
void _initRealtime() {
void startListening() {
emit(state.copyWith(status: DashboardTaskListStatus.loading));
// Primo caricamento
_loadTasksSilently();
// Inizio ascolto campanello
_taskSubscription = _repository.watchCompanyTasks(companyId).listen((_) {
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
_loadTasksSilently();
});
try {
_tasksSubscription = _repository.watchCompanyTasks(companyId!).listen((
_,
) {
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
_loadTasksSilently();
});
} on Exception catch (e) {
debugPrint(e.toString());
}
}
Future<void> loadTasks() async {
emit(state.copyWith(status: DashboardTaskListStatus.loading));
await _loadTasksSilently();
void stopListening() {
_tasksSubscription?.cancel();
_tasksSubscription = null;
}
Future<void> _loadTasksSilently() async {
try {
final tasks = await _repository.getTasks(
companyId: companyId,
companyId: companyId!,
staffId: staffId,
statuses: [TaskStatus.open, TaskStatus.inProgress],
limit: 10,
@@ -66,8 +71,7 @@ class DashboardTaskListCubit extends Cubit<DashboardTaskListState> {
@override
Future<void> close() {
// Stacchiamo l'abbonamento. Il controller.onCancel nel Repo farà il resto!
_taskSubscription?.cancel();
stopListening();
return super.close();
}
}

View File

@@ -1,66 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/features/operations/data/operations_repository.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:get_it/get_it.dart';
part '../../latest_store_operations/bloc/latest_store_operations_events.dart';
part '../../latest_store_operations/bloc/latest_store_operations_state.dart';
class LatestStoreOperationsBloc
extends Bloc<LatestStoreOperationsEvent, LatestStoreOperationsState> {
final _repository = GetIt.I.get<OperationsRepository>();
LatestStoreOperationsBloc()
: super(
const LatestStoreOperationsState(
status: LatestStoreOperationsStatus.initial,
),
) {
on<InitLatestStoreOperationsEvent>((event, emit) async {
emit(state.copyWith(status: LatestStoreOperationsStatus.loading));
try {
// 1. Creiamo uno stream "intermedio" che idrata i dati
final hydratedStream = _repository
.getLatestStoreOperationsStream(storeId: event.storeId, limit: 10)
.asyncMap((List<OperationModel> rawOperations) async {
// Questo gira ad ogni "scatto" dello stream di Supabase
List<OperationModel> fullyHydratedOperations = [];
for (OperationModel operation in rawOperations) {
// Peschiamo i dati completi (incluso il cliente)
OperationModel fullOperation = await _repository
.fetchOperationById(operation.id!);
fullyHydratedOperations.add(fullOperation);
}
// Passiamo la lista completa allo step successivo
return fullyHydratedOperations;
});
// 2. Ora passiamo lo stream idratato all'emit.forEach
await emit.forEach(
hydratedStream, // Usiamo lo stream modificato!
onData: (List<OperationModel> fullyHydratedOperations) {
// Qui ora è tutto sincrono e bellissimo
return state.copyWith(
operations: fullyHydratedOperations,
status: LatestStoreOperationsStatus.success,
);
},
onError: (error, stackTrace) => state.copyWith(
status: LatestStoreOperationsStatus.failure,
error: error.toString(),
),
);
} catch (e) {
emit(
state.copyWith(
status: LatestStoreOperationsStatus.failure,
error: e.toString(),
),
);
}
});
}
}

View File

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

View File

@@ -1,30 +0,0 @@
part of 'latest_store_operations_bloc.dart';
enum LatestStoreOperationsStatus { initial, loading, success, failure }
class LatestStoreOperationsState extends Equatable {
final LatestStoreOperationsStatus status;
final String? error;
final List<OperationModel> operations;
const LatestStoreOperationsState({
required this.status,
this.error,
this.operations = const [],
});
@override
List<Object?> get props => [status, error, operations];
LatestStoreOperationsState copyWith({
LatestStoreOperationsStatus? status,
String? error,
List<OperationModel>? operations,
}) {
return LatestStoreOperationsState(
status: status ?? this.status,
error: error,
operations: operations ?? this.operations,
);
}
}

View File

@@ -5,8 +5,10 @@ import 'package:flux/core/routes/routes.dart';
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_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/latest_store_operations/ui/latest_store_operations_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';
@@ -17,85 +19,140 @@ import 'package:flux/features/notes/ui/dashboard_notes_widget.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
class HomeScreen extends StatelessWidget {
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
late final AppLifecycleListener _lifecycleListener;
@override
void initState() {
super.initState();
// Inizializziamo il sensore del ciclo di vita
_lifecycleListener = AppLifecycleListener(
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();
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();
debugPrint('App in foreground: Stream riattivati.');
},
);
// Facciamo partire gli stream la primissima volta che la schermata si carica
context.read<DashboardStoreOperationListCubit>().startListening();
context.read<DashboardTaskListCubit>().startListening();
}
@override
void dispose() {
// Pulizia fondamentale
_lifecycleListener.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final sessionCubit = GetIt.I.get<SessionCubit>();
return Scaffold(
backgroundColor: theme.colorScheme.surface,
body: SafeArea(
child: Column(
children: [
// ==========================================
// 1. HEADER FISSO (Non scrolla mai)
// ==========================================
Container(
padding: const EdgeInsets.all(24.0),
// Un leggero colore di sfondo aiuta a staccare l'header quando il contenuto ci passa sotto
color: theme.colorScheme.surface,
child: _buildHeader(context, theme),
),
// ==========================================
// 2. CORPO DELLA DASHBOARD (Scrollabile)
// ==========================================
Expanded(
child: CustomScrollView(
slivers: [
// --- QUICK ACTIONS: AZIONI RAPIDE ---
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: _buildQuickActions(context),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
// --- I WIDGET DELLA DASHBOARD (Responsive Grid) ---
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
sliver: SliverGrid(
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 500,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.3,
),
delegate: SliverChildListDelegate([
LatestStoreOperationsCard(),
LatestStoreTicketsCard(),
_buildDashboardWidget(
title: context.l10n.homeExpiringContracts,
icon: Icons.assignment_late_outlined,
color: Colors.orange,
context: context,
),
DashboardNotesWidget(),
DashboardTasksCard(),
]),
),
),
// Spazio finale per non far attaccare l'ultima card al fondo
const SliverToBoxAdapter(child: SizedBox(height: 40)),
],
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(
backgroundColor: theme.colorScheme.surface,
body: SafeArea(
child: Column(
children: [
// ==========================================
// 1. HEADER FISSO (Non scrolla mai)
// ==========================================
Container(
padding: const EdgeInsets.all(24.0),
// Un leggero colore di sfondo aiuta a staccare l'header quando il contenuto ci passa sotto
color: theme.colorScheme.surface,
child: _buildHeader(context, theme),
),
),
],
// ==========================================
// 2. CORPO DELLA DASHBOARD (Scrollabile)
// ==========================================
Expanded(
child: CustomScrollView(
slivers: [
// --- QUICK ACTIONS: AZIONI RAPIDE ---
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: _buildQuickActions(context),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
// --- I WIDGET DELLA DASHBOARD (Responsive Grid) ---
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
sliver: SliverGrid(
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 500,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.3,
),
delegate: SliverChildListDelegate([
DashboardStoreOperationListCard(),
LatestStoreTicketsCard(),
_buildDashboardWidget(
title: context.l10n.homeExpiringContracts,
icon: Icons.assignment_late_outlined,
color: Colors.orange,
context: context,
),
DashboardNotesWidget(),
DashboardTasksCard(),
]),
),
),
// Spazio finale per non far attaccare l'ultima card al fondo
const SliverToBoxAdapter(child: SizedBox(height: 40)),
],
),
),
],
),
),
),
);
}
// ==========================================
// WIDGET BUILDERS
// ==========================================
Widget _buildHeader(BuildContext context, ThemeData theme) {
final user = context.watch<SessionCubit>().state.currentStaffMember;
final currentStore = context.watch<SessionCubit>().state.currentStore;