reworked operation (#12)

Reviewed-on: #12
Co-authored-by: Mark M2 Macbook <marco@catelli.it>
Co-committed-by: Mark M2 Macbook <marco@catelli.it>
This commit is contained in:
2026-05-04 15:36:42 +02:00
committed by brontomark
parent 9f57207a39
commit 94ad524bae
110 changed files with 5831 additions and 5306 deletions

View File

@@ -1 +1,16 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
# Escludiamo i file generati per le lingue, così il linter non ci entra proprio
- "lib/generated/**"
- "lib/l10n/*.dart"
- "**/*.g.dart" # Già che ci siamo escludiamo tutti i file generati (tipo quelli di JsonSerializable)
- "**/*.freezed.dart"
linter:
rules:
diagnostic_describe_all_properties: false
public_member_api_docs: false
# Ti consiglio di aggiungere anche questa se usi molto i file generati
avoid_relative_lib_imports: true

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
distributionUrl=https\://operations.gradle.org/distributions/gradle-8.14-all.zip

3
l10n.yaml Normal file
View File

@@ -0,0 +1,3 @@
arb-dir: lib/l10n
template-arb-file: app_it.arb
output-localization-file: app_localizations.dart

View File

@@ -24,7 +24,7 @@ class CoreRepository {
return CompanyModel.fromMap(response);
} catch (e) {
debugPrint('Errore recupero azienda: $e');
throw Exception('Errore recupero azienda: $e');
throw Exception('$e');
}
}
@@ -38,7 +38,7 @@ class CoreRepository {
if (response == null) return null;
return CompanyModel.fromMap(response);
} catch (e) {
debugPrint('Errore recupero azienda per ID: $e');
debugPrint('$e');
return null;
}
}
@@ -50,12 +50,12 @@ class CoreRepository {
.select()
.eq('company_id', companyId)
.eq('is_active', true) // Buona pratica
.order('nome'); // O come si chiama il campo nome
.order('name'); // O come si chiama il campo nome
return (response as List).map((s) => StoreModel.fromMap(s)).toList();
} catch (e) {
debugPrint('Errore recupero negozi: $e');
throw Exception('Errore recupero negozi: $e');
throw Exception('$e');
}
}
@@ -71,7 +71,7 @@ class CoreRepository {
return StaffMemberModel.fromMap(response);
} catch (e) {
debugPrint('Errore recupero profilo staff: $e');
throw Exception('Errore recupero profilo staff: $e');
throw Exception('$e');
}
}
@@ -87,7 +87,7 @@ class CoreRepository {
return CompanyModel.fromMap(response);
} catch (e) {
debugPrint('Creazione azienda fallita: $e');
throw Exception('Creazione azienda fallita: $e');
throw Exception('$e');
}
}
@@ -101,7 +101,7 @@ class CoreRepository {
return StoreModel.fromMap(response);
} catch (e) {
debugPrint('Creazione negozio fallita: $e');
throw Exception('Creazione negozio fallita: $e');
throw Exception('$e');
}
}
@@ -120,7 +120,7 @@ class CoreRepository {
return StaffMemberModel.fromMap(response);
} catch (e) {
debugPrint('Creazione profilo staff fallita: $e');
throw Exception('Creazione profilo staff fallita: $e');
throw Exception('$e');
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:go_router/go_router.dart';
class AppShell extends StatelessWidget {
@@ -43,21 +44,21 @@ class AppShell extends StatelessWidget {
onDestinationSelected: (index) =>
_onItemTapped(index, context),
labelType: NavigationRailLabelType.all,
destinations: const [
destinations: [
NavigationRailDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: Text('Dashboard'),
label: Text(context.l10n.commonDashboard),
),
NavigationRailDestination(
icon: Icon(Icons.folder_special_outlined),
selectedIcon: Icon(Icons.folder_special),
label: Text('Anagrafiche'),
label: Text(context.l10n.commonMasterData),
),
NavigationRailDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: Text('Impostazioni'),
label: Text(context.l10n.commonSettings),
),
],
),
@@ -73,21 +74,21 @@ class AppShell extends StatelessWidget {
: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: (index) => _onItemTapped(index, context),
destinations: const [
destinations: [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Dashboard',
label: context.l10n.commonDashboard,
),
NavigationDestination(
icon: Icon(Icons.folder_special_outlined),
selectedIcon: Icon(Icons.folder_special),
label: 'Anagrafiche',
label: context.l10n.commonMasterData,
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Impostazioni',
label: context.l10n.commonSettings,
),
],
),

View File

@@ -4,22 +4,31 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/data/core_repository.dart';
import 'package:flux/core/layout/app_shell.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/set_password_screen.dart';
import 'package:flux/features/auth/ui/auth_screen.dart';
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
import 'package:flux/features/customers/blocs/customers_cubit.dart';
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_mobile_upload_screen.dart';
import 'package:flux/features/customers/ui/customers_content.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';
import 'package:flux/features/master_data/products/ui/products_screen.dart';
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/master_data/providers/ui/providers_master_data_screen.dart';
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
import 'package:flux/features/master_data/store/ui/stores_screen.dart';
import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
import 'package:flux/features/onboarding/ui/onboarding_screen.dart';
import 'package:flux/features/services/blocs/service_files_bloc.dart';
import 'package:flux/features/services/models/service_model.dart';
import 'package:flux/features/services/ui/service_form_screen/service_form_screen.dart';
import 'package:flux/features/services/ui/service_form_screen/service_mobile_upload_screen.dart';
import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/operations/ui/operation_form_screen.dart';
import 'package:flux/features/operations/ui/operation_mobile_upload_screen.dart';
import 'package:flux/features/operations/ui/operations_screen.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
@@ -92,23 +101,24 @@ class AppRouter {
routes: [
GoRoute(
path: 'products', // Diventa /master-data/products
builder: (context, state) => const ProductsScreen(),
builder: (context, state) {
context.read<ProductsCubit>().refreshCubit();
return const ProductsScreen();
},
),
GoRoute(
path: 'staff', // Diventa /master-data/staff
builder: (context, state) =>
const Scaffold(body: Center(child: Text("Lista Staff"))),
builder: (context, state) => const StaffScreen(),
),
GoRoute(
path: 'stores', // Diventa /master-data/stores
builder: (context, state) =>
const Scaffold(body: Center(child: Text("Lista Negozi"))),
builder: (context, state) => const StoresScreen(),
),
GoRoute(
path: 'providers', // Diventa /master-data/providers
builder: (context, state) => const Scaffold(
body: Center(child: Text("Lista Fornitori")),
),
builder: (context, state) =>
const ProvidersMasterDataScreen(),
),
],
),
@@ -117,7 +127,7 @@ class AppRouter {
GoRoute(
path: '/settings',
builder: (context, state) => Scaffold(
appBar: AppBar(title: const Text("Impostazioni")),
appBar: AppBar(title: Text(context.l10n.commonSettings)),
body: Center(
child: ElevatedButton.icon(
onPressed: () => context.read<SessionCubit>().signOut(),
@@ -127,15 +137,19 @@ class AppRouter {
),
),
),
],
GoRoute(
path: '/operations',
builder: (context, state) => const OperationsScreen(),
),
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
GoRoute(
path: '/customers',
builder: (context, state) =>
const CustomersContent(), // O come si chiama il tuo widget della lista!
),
],
),
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
GoRoute(
path: '/customer/:id',
builder: (context, state) {
@@ -161,31 +175,58 @@ class AppRouter {
},
),
GoRoute(
path: '/service-form',
name: 'service-form',
path: '/operation-form',
name: 'operation-form',
builder: (context, state) {
final existingService = state.extra as ServiceModel?;
final serviceId = state.uri.queryParameters['serviceId'];
final existingOperation = state.extra as OperationModel?;
final operationId = state.uri.queryParameters['operationId'];
final currentStoreId = GetIt.I
.get<SessionCubit>()
.state
.currentStore!
.id!;
context.read<CustomersCubit>().loadCustomers();
context.read<ProvidersCubit>().loadActiveProvidersForStore(
currentStoreId,
);
context.read<ProductsCubit>().loadModels();
context.read<ProductsCubit>().loadBrands();
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
return BlocProvider(
create: (context) =>
ServiceFilesBloc(serviceId: serviceId ?? existingService?.id),
child: ServiceFormScreen(
serviceId: serviceId ?? existingService?.id,
existingService: existingService,
create: (context) => OperationFilesBloc(
operationId: operationId ?? existingOperation?.id,
),
child: OperationFormScreen(
operationId: operationId ?? existingOperation?.id,
existingOperation: existingOperation,
),
);
},
),
GoRoute(
path: '/service/:id/upload',
path: '/operation/:id/upload',
builder: (context, state) {
final serviceId = state.pathParameters['id']!;
final serviceName = state.uri.queryParameters['name'] ?? 'Pratica';
final operationId = state.pathParameters['id']!;
final operationName =
state.uri.queryParameters['name'] ?? 'Pratica';
final currentStoreId = GetIt.I
.get<SessionCubit>()
.state
.currentStore!
.id!;
context.read<CustomersCubit>().loadCustomers();
context.read<ProvidersCubit>().loadActiveProvidersForStore(
currentStoreId,
);
context.read<ProductsCubit>().loadModels();
context.read<ProductsCubit>().loadBrands();
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
return BlocProvider(
create: (context) => ServiceFilesBloc(serviceId: serviceId),
child: ServiceMobileUploadScreen(
serviceId: serviceId,
serviceName: serviceName,
create: (context) => OperationFilesBloc(operationId: operationId),
child: OperationMobileUploadScreen(
operationId: operationId,
operationName: operationName,
),
);
},

View File

@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
class AppMessage {
final String key;
final String? argument;
const AppMessage({required this.key, this.argument});
String translatedMessage(BuildContext context) {
switch (key) {
case 'authCubitCheckEmailToConfirmAccount':
return context.l10n.authCubitCheckEmailToConfirmAccount;
case 'authCubitResetPasswordEmailSentTo':
return context.l10n.authCubitResetPasswordEmailSentTo(argument!);
default:
return 'empty message';
}
}
}

View File

@@ -1,3 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flux/l10n/app_localizations.dart';
extension MyStringExtensions on String? {
// Gestiamo anche il nullable per sicurezza
String myFormat() {
@@ -40,3 +43,7 @@ extension MyStringExtensions on String? {
.join('.'); // Ritorna tutto tranne l'ultima parte
}
}
extension LocalizationExtension on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this)!;
}

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/utils/functions.dart';
class ImageViewerWidget extends StatelessWidget {
@@ -36,8 +37,8 @@ class ImageViewerWidget extends StatelessWidget {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return const Text(
"Errore caricamento immagine (Permessi negati?)",
return Text(
context.l10n.imageViewerWidgetErrorOpening,
style: TextStyle(color: Colors.red),
);
}

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/utils/functions.dart';
import 'package:pdfx/pdfx.dart';
import 'package:internet_file/internet_file.dart';
@@ -74,13 +75,13 @@ class _PdfViewerWidgetState extends State<PdfViewerWidget> {
if (_errorMessage != null) {
return Scaffold(
appBar: AppBar(leading: const CloseButton()),
body: Center(child: Text("Errore: $_errorMessage")),
body: Center(child: Text(context.l10n.commonError(_errorMessage!))),
);
}
return Scaffold(
appBar: AppBar(
title: const Text("Anteprima PDF"),
title: Text(context.l10n.pdfViewerAnteprimaPdf),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:qr_flutter/qr_flutter.dart';
class QrUploadDialog extends StatelessWidget {
@@ -84,7 +85,7 @@ class QrUploadDialog extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text("CHIUDI"),
child: Text(context.l10n.commonClose),
),
],
actionsAlignment: MainAxisAlignment.center,

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
@@ -25,9 +26,7 @@ class _SetPasswordScreenState extends State<SetPasswordScreen> {
final newPassword = _passwordCtrl.text.trim();
if (newPassword.length < 6) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("La password deve avere almeno 6 caratteri"),
),
SnackBar(content: Text(context.l10n.setPasswordScreenAtLeast6Chars)),
);
return;
}
@@ -43,23 +42,23 @@ class _SetPasswordScreenState extends State<SetPasswordScreen> {
// 2. Finito! Lo mandiamo alla home o facciamo ricaricare la sessione al SessionCubit
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Password impostata! Benvenuto a bordo 🚀"),
SnackBar(
content: Text(context.l10n.setPasswordScreenPasswordSetWelcome),
),
);
context.go('/'); // Rimandiamo al router principale
}
} on AuthException catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Errore Auth: ${e.message}")));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.authError(e.message))),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Errore: $e")));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.commonError(e.toString()))),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
@@ -70,7 +69,7 @@ class _SetPasswordScreenState extends State<SetPasswordScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Benvenuto in FLUX!"),
title: Text(context.l10n.setPasswordScreenWelcomeInFlux),
automaticallyImplyLeading:
false, // Non può tornare indietro, deve mettere la password!
),
@@ -82,21 +81,21 @@ class _SetPasswordScreenState extends State<SetPasswordScreen> {
children: [
const Icon(Icons.lock_reset, size: 80, color: Colors.blueAccent),
const SizedBox(height: 24),
const Text(
"Imposta la tua Password",
Text(
context.l10n.setPasswordScreenSetPassword,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
"Hai accettato l'invito. Scegli una password sicura per accedere in futuro.",
Text(
context.l10n.setPasswordInviteAcceptedChoosePassword,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 32),
FluxTextField(
controller: _passwordCtrl,
label: "Nuova Password",
label: context.l10n.commonNewPassword,
icon: Icons.lock,
isPassword: true,
),
@@ -108,8 +107,8 @@ class _SetPasswordScreenState extends State<SetPasswordScreen> {
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text(
"SALVA E INIZIA",
: Text(
context.l10n.setPasswordScreenSaveAndStart,
style: TextStyle(fontSize: 16),
),
),

View File

@@ -0,0 +1,23 @@
import 'dart:typed_data';
import 'package:supabase_flutter/supabase_flutter.dart';
class AttachmentsRepository {
final _supabase = Supabase.instance.client;
/// Scarica i byte di un file direttamente da Supabase Storage
Future<Uint8List> downloadAttachmentBytes(String storagePath) async {
try {
// ATTENZIONE: Sostituisci 'attachments' con il nome VERO del tuo bucket su Supabase!
// Se il tuo storagePath contiene già il nome del bucket all'inizio,
// assicurati di passargli solo il percorso interno.
final Uint8List bytes = await _supabase.storage
.from('attachments') // <--- NOME DEL TUO BUCKET
.download(storagePath);
return bytes;
} catch (e) {
throw Exception("Impossibile scaricare il documento dal cloud: $e");
}
}
}

View File

@@ -2,30 +2,49 @@ import 'dart:typed_data';
import 'package:equatable/equatable.dart';
class ServiceFileModel extends Equatable {
class AttachmentModel extends Equatable {
final String? id;
final DateTime? createdAt;
final String? customerId;
final String? operationId;
final String name;
final String extension;
final String storagePath;
final String serviceId;
final String? storagePath;
final int fileSize;
final Uint8List? localBytes;
final String companyId;
const ServiceFileModel({
const AttachmentModel({
this.id,
this.createdAt,
this.customerId,
this.operationId,
required this.name,
required this.extension,
required this.storagePath,
required this.serviceId,
this.storagePath,
required this.fileSize,
this.localBytes,
required this.companyId,
});
@override
List<Object?> get props => [
id,
createdAt,
customerId,
operationId,
name,
extension,
storagePath,
fileSize,
localBytes,
companyId,
];
bool get isLocal => localBytes != null;
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB)
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
String get sizeFormatted {
if (fileSize <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB"];
@@ -35,43 +54,45 @@ class ServiceFileModel extends Equatable {
return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}";
}
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
ServiceFileModel copyWith({
AttachmentModel copyWith({
String? id,
DateTime? createdAt,
String? customerId,
String? operationId,
String? name,
String? extension,
String? storagePath,
String? serviceId,
int? fileSize,
Uint8List? localBytes,
}) {
return ServiceFileModel(
String? companyId,
}) => AttachmentModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
customerId: customerId ?? this.customerId,
operationId: operationId ?? this.operationId,
name: name ?? this.name,
extension: extension ?? this.extension,
storagePath: storagePath ?? this.storagePath,
serviceId: serviceId ?? this.serviceId,
fileSize: fileSize ?? this.fileSize,
localBytes: localBytes ?? this.localBytes,
companyId: companyId ?? this.companyId,
);
}
factory ServiceFileModel.fromMap(Map<String, dynamic> map) {
return ServiceFileModel(
factory AttachmentModel.fromMap(Map<String, dynamic> map) {
return AttachmentModel(
id: map['id'] as String,
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
name: map['name'] ?? '',
extension: map['extension'] ?? '',
storagePath: map['storage_path'] ?? '',
serviceId: map['service_id']?.toString() ?? '',
customerId: map['customer_id'] as String?,
operationId: map['operation_id'] as String?,
name: map['name'] as String,
extension: map['extension'] as String,
storagePath: map['storage_path'] as String?,
fileSize: map['file_size'] is int
? map['file_size']
: int.tryParse(map['file_size']?.toString() ?? '0') ?? 0,
companyId: map['company_id'] as String,
);
}
@@ -81,20 +102,10 @@ class ServiceFileModel extends Equatable {
'name': name,
'extension': extension,
'storage_path': storagePath,
'service_id': serviceId,
'customer_id': customerId,
'operation_id': operationId,
'file_size': fileSize,
'company_id': companyId,
};
}
@override
List<Object?> get props => [
id,
createdAt,
name,
extension,
storagePath,
serviceId,
fileSize,
localBytes,
];
}

View File

@@ -0,0 +1,220 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flux/core/utils/functions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:pdfx/pdfx.dart';
import 'package:internet_file/internet_file.dart';
class AttachmentViewerScreen extends StatefulWidget {
final AttachmentModel attachment;
final Function(String newName)? onRename;
final VoidCallback? onDelete;
const AttachmentViewerScreen({
super.key,
required this.attachment,
this.onRename,
this.onDelete,
});
@override
State<AttachmentViewerScreen> createState() => _AttachmentViewerScreenState();
}
class _AttachmentViewerScreenState extends State<AttachmentViewerScreen> {
PdfControllerPinch? _pdfController;
bool _isLoading = true;
String? _errorMessage;
Uint8List? _fileBytes;
late String _fileName;
bool get isPdf => widget.attachment.extension.toLowerCase() == 'pdf';
@override
void initState() {
super.initState();
_fileName = widget.attachment.name;
_loadFile();
}
Future<void> _loadFile() async {
try {
// 1. Capiamo da dove prendere i dati
if (widget.attachment.localBytes != null) {
_fileBytes = widget.attachment.localBytes;
} else if (widget.attachment.storagePath != null &&
widget.attachment.storagePath!.isNotEmpty) {
final signedUrl = await getSignedUrl(widget.attachment.storagePath!);
_fileBytes = await InternetFile.get(signedUrl);
} else {
throw Exception("Nessun documento trovato o byte mancanti.");
}
// 2. Se è PDF, inizializziamo il controller
if (isPdf && _fileBytes != null) {
_pdfController = PdfControllerPinch(
document: PdfDocument.openData(_fileBytes!),
);
}
if (mounted) setState(() => _isLoading = false);
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = e.toString();
});
}
}
}
@override
void dispose() {
_pdfController?.dispose();
super.dispose();
}
void _showRenameDialog() {
final ctrl = TextEditingController(text: _fileName);
ctrl.selection = TextSelection(
baseOffset: 0,
extentOffset: ctrl.text.length,
);
final focusNode = FocusNode();
showDialog(
context: context,
builder: (context) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => focusNode.requestFocus(),
);
return AlertDialog(
title: const Text('Rinomina File'),
content: TextField(
controller: ctrl,
focusNode: focusNode,
decoration: InputDecoration(
labelText: 'Nuovo nome',
suffixText: '.${widget.attachment.extension}',
),
onSubmitted: (val) {
Navigator.pop(context);
if (val.trim().isNotEmpty && widget.onRename != null) {
setState(() {
_fileName = val.trim();
});
widget.onRename!(val.trim());
}
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annulla'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
if (ctrl.text.trim().isNotEmpty && widget.onRename != null) {
setState(() {
_fileName = ctrl.text.trim();
});
widget.onRename!(ctrl.text.trim());
}
},
child: const Text('Salva'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black87, // Sfondo scuro per i viewer è il top
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
actions: [
if (widget.onRename != null)
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Rinomina',
onPressed: _showRenameDialog,
),
if (widget.onDelete != null)
IconButton(
icon: const Icon(Icons.delete, color: Colors.redAccent),
tooltip: 'Elimina',
onPressed: () {
// Chiediamo conferma
showDialog(
context: context,
builder: (c) => AlertDialog(
title: const Text('Eliminare file?'),
content: const Text(
'Sei sicuro di voler eliminare questo allegato?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(c),
child: const Text('Annulla'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
onPressed: () {
Navigator.pop(c); // Chiude dialog
widget.onDelete!(); // Lancia eliminazione
Navigator.pop(context); // Chiude il viewer
},
child: const Text('Elimina'),
),
],
),
);
},
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_errorMessage != null) {
return Center(
child: Text(
'Errore: $_errorMessage',
style: const TextStyle(color: Colors.redAccent),
),
);
}
if (_fileBytes == null) {
return const Center(
child: Text(
'File non disponibile',
style: TextStyle(color: Colors.white),
),
);
}
if (isPdf && _pdfController != null) {
return PdfViewPinch(controller: _pdfController!);
} else {
return InteractiveViewer(
maxScale: 5.0,
child: Center(child: Image.memory(_fileBytes!)),
);
}
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
class QuickRenameDialog extends StatefulWidget {
final String suggestedName;
final Widget previewWidget; // Può essere Image.memory o un'icona PDF
const QuickRenameDialog({
super.key,
required this.suggestedName,
required this.previewWidget,
});
@override
State<QuickRenameDialog> createState() => _QuickRenameDialogState();
}
class _QuickRenameDialogState extends State<QuickRenameDialog> {
late TextEditingController _nameCtrl;
final FocusNode _focusNode = FocusNode();
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.suggestedName);
// MAGIA UX: Selezioniamo tutto il testo di default appena si apre!
_nameCtrl.selection = TextSelection(
baseOffset: 0,
extentOffset: widget.suggestedName.length,
);
// Richiediamo il focus appena il widget è costruito
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_nameCtrl.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Rinomina per Export'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Anteprima del documento (limitiamo l'altezza)
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(border: Border.all(color: Colors.grey)),
child: widget.previewWidget,
),
const SizedBox(height: 16),
TextField(
controller: _nameCtrl,
focusNode: _focusNode,
decoration: const InputDecoration(
labelText: 'Nome del file',
suffixText: '.pdf', // Facciamo capire che sarà un PDF
border: OutlineInputBorder(),
),
// MAGIA UX 2: Se preme invio sulla tastiera, salva e chiude!
onSubmitted: (value) => Navigator.of(context).pop(value),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(), // Ritorna null
child: const Text('Salta'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(_nameCtrl.text),
child: const Text('Esporta (Invio)'),
),
],
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/data/constants.dart';
import 'package:flux/core/utils/app_message.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
part 'auth_state.dart';
@@ -41,7 +42,9 @@ class AuthCubit extends Cubit<AuthState> {
emit(
state.copyWith(
status: AuthStatus.initial,
infoMessage: "Controlla la tua email per confermare l'account!",
infoMessage: AppMessage(
key: 'authCubitCheckEmailToConfirmAccount',
),
),
);
} else {
@@ -82,7 +85,10 @@ class AuthCubit extends Cubit<AuthState> {
emit(
state.copyWith(
status: AuthStatus.pwResetSent,
infoMessage: "Email per reset password inviata a $email!",
infoMessage: AppMessage(
key: 'authCubitResetPasswordEmailSentTo',
argument: email,
),
),
);
}

View File

@@ -6,7 +6,7 @@ class AuthState extends Equatable {
final AuthStatus status;
final bool isLoginMode;
final String? errorMessage;
final String? infoMessage;
final AppMessage? infoMessage;
const AuthState({
this.status = AuthStatus.initial,
@@ -19,7 +19,7 @@ class AuthState extends Equatable {
AuthStatus? status,
bool? isLoginMode,
String? errorMessage,
String? infoMessage,
AppMessage? infoMessage,
}) {
return AuthState(
status: status ?? this.status,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/flux_logo.dart';
import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/auth/bloc/auth_cubit.dart';
@@ -55,7 +56,7 @@ class _AuthScreenState extends State<AuthScreen> {
if (state.infoMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.infoMessage!),
content: Text(state.infoMessage!.translatedMessage(context)),
backgroundColor: Colors.blueAccent, // O context.accent
),
);
@@ -77,7 +78,9 @@ class _AuthScreenState extends State<AuthScreen> {
// --- TITOLO DINAMICO ---
Text(
state.isLoginMode ? 'BENTORNATO' : 'CREA ACCOUNT',
state.isLoginMode
? context.l10n.authScreenWelcomeBack
: context.l10n.authScreenCreateAccount,
style: TextStyle(
color: context.primaryText,
fontSize: 24,
@@ -88,8 +91,10 @@ class _AuthScreenState extends State<AuthScreen> {
const SizedBox(height: 8),
Text(
state.isLoginMode
? 'Accedi per gestire il tuo business'
: 'Inizia oggi a digitalizzare il tuo negozio',
? context.l10n.authScreenLoginToManageYourBusiness
: context
.l10n
.authScreenStartTodayToDigitalizeYourStore,
textAlign: TextAlign.center,
style: TextStyle(color: context.secondaryText),
),
@@ -97,7 +102,7 @@ class _AuthScreenState extends State<AuthScreen> {
// --- CAMPI INPUT ---
FluxTextField(
label: 'Email Aziendale',
label: context.l10n.authScreenBusinessEmail,
icon: Icons.email_outlined,
controller: _emailController,
keyboardType: TextInputType.emailAddress,
@@ -130,7 +135,9 @@ class _AuthScreenState extends State<AuthScreen> {
),
)
: Text(
state.isLoginMode ? 'ACCEDI' : 'REGISTRATI',
state.isLoginMode
? context.l10n.authScreenLogin
: context.l10n.authScreenSignUp,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
@@ -147,12 +154,15 @@ class _AuthScreenState extends State<AuthScreen> {
child: RichText(
text: TextSpan(
text: state.isLoginMode
? "Non hai un account? "
: "Hai già un account? ",
? context.l10n.authScreenDontHaveAccount
: context.l10n.authScreenAlreadyHaveAccount,
style: TextStyle(color: context.secondaryText),
children: [
TextSpan(
text: state.isLoginMode ? "Registrati" : "Accedi",
text: state.isLoginMode
? context.l10n.authScreenSignUp
: context.l10n.authScreenLogin,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
@@ -169,7 +179,7 @@ class _AuthScreenState extends State<AuthScreen> {
.read<AuthCubit>()
.requestPasswordReset(_emailController.text.trim()),
child: Text(
'Pw dimenticata/Invito scaduto?',
context.l10n.authScreenForgotPassword,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,

View File

@@ -22,5 +22,5 @@ class CompanyState extends Equatable {
}
@override
List<Object?> get props => [status, errorMessage];
List<Object?> get props => [status, errorMessage, company];
}

View File

@@ -17,7 +17,7 @@ class CompanyRepository {
} on PostgrestException catch (e) {
throw e.message;
} catch (e) {
throw 'Errore imprevisto durante la creazione dell\'azienda';
throw e.toString();
}
}

View File

@@ -45,14 +45,14 @@ class CompanyModel extends Equatable {
final String userId; // Nel DB è user_id (chiave esterna su auth.users)
// Dati Anagrafici e Fatturazione
final String ragioneSociale;
final String indirizzo;
final String cap;
final String citta;
final String provincia;
final String partitaIva;
final String codiceFiscale;
final String codiceUnivoco;
final String name;
final String address;
final String zipCode;
final String city;
final String province;
final String vatId;
final String fiscalCode;
final String sdi;
final String companyLogo;
// Stato Pagamenti (Ibride: manuale + Stripe)
@@ -70,14 +70,14 @@ class CompanyModel extends Equatable {
this.id,
this.createdAt,
required this.userId,
required this.ragioneSociale,
required this.indirizzo,
required this.cap,
required this.citta,
required this.provincia,
required this.partitaIva,
required this.codiceFiscale,
required this.codiceUnivoco,
required this.name,
required this.address,
required this.zipCode,
required this.city,
required this.province,
required this.vatId,
required this.fiscalCode,
required this.sdi,
this.companyLogo = '',
this.isPaid = false,
this.paymentExpiration,
@@ -92,14 +92,14 @@ class CompanyModel extends Equatable {
String? id,
DateTime? createdAt,
String? userId,
String? ragioneSociale,
String? indirizzo,
String? cap,
String? citta,
String? provincia,
String? partitaIva,
String? codiceFiscale,
String? codiceUnivoco,
String? name,
String? address,
String? zipCode,
String? city,
String? province,
String? vatId,
String? fiscalCode,
String? sdi,
String? companyLogo,
bool? isPaid,
DateTime? paymentExpiration,
@@ -113,14 +113,14 @@ class CompanyModel extends Equatable {
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
userId: userId ?? this.userId,
ragioneSociale: ragioneSociale ?? this.ragioneSociale,
indirizzo: indirizzo ?? this.indirizzo,
cap: cap ?? this.cap,
citta: citta ?? this.citta,
provincia: provincia ?? this.provincia,
partitaIva: partitaIva ?? this.partitaIva,
codiceFiscale: codiceFiscale ?? this.codiceFiscale,
codiceUnivoco: codiceUnivoco ?? this.codiceUnivoco,
name: name ?? this.name,
address: address ?? this.address,
zipCode: zipCode ?? this.zipCode,
city: city ?? this.city,
province: province ?? this.province,
vatId: vatId ?? this.vatId,
fiscalCode: fiscalCode ?? this.fiscalCode,
sdi: sdi ?? this.sdi,
companyLogo: companyLogo ?? this.companyLogo,
isPaid: isPaid ?? this.isPaid,
paymentExpiration: paymentExpiration ?? this.paymentExpiration,
@@ -137,14 +137,14 @@ class CompanyModel extends Equatable {
id: null,
createdAt: null,
userId: '',
ragioneSociale: '',
indirizzo: '',
cap: '',
citta: '',
provincia: '',
partitaIva: '',
codiceFiscale: '',
codiceUnivoco: '',
name: '',
address: '',
zipCode: '',
city: '',
province: '',
vatId: '',
fiscalCode: '',
sdi: '',
);
}
@@ -155,14 +155,14 @@ class CompanyModel extends Equatable {
? DateTime.tryParse(map['created_at'])
: null,
userId: map['user_id'] ?? '',
ragioneSociale: map['ragione_sociale'] ?? '',
indirizzo: map['indirizzo'] ?? '',
cap: map['cap'] ?? '',
citta: map['citta'] ?? '',
provincia: map['provincia'] ?? '',
partitaIva: map['partita_iva'] ?? '',
codiceFiscale: map['codice_fiscale'] ?? '',
codiceUnivoco: map['codice_univoco'] ?? '',
name: map['name'] ?? '',
address: map['address'] ?? '',
zipCode: map['zip_code'] ?? '',
city: map['city'] ?? '',
province: map['province'] ?? '',
vatId: map['vat_id'] ?? '',
fiscalCode: map['fiscal_code'] ?? '',
sdi: map['sdi'] ?? '',
companyLogo: map['company_logo'] ?? '',
isPaid: map['is_paid'] ?? false,
paymentExpiration: map['payment_expiration'] != null
@@ -185,14 +185,14 @@ class CompanyModel extends Equatable {
if (id != null) 'id': id,
// created_at è gestito dal DB di default, di solito non si passa nell'insert
'user_id': userId,
'ragione_sociale': ragioneSociale,
'indirizzo': indirizzo,
'cap': cap,
'citta': citta,
'provincia': provincia,
'partita_iva': partitaIva,
'codice_fiscale': codiceFiscale,
'codice_univoco': codiceUnivoco,
'name': name,
'address': address,
'zip_code': zipCode,
'city': city,
'province': province,
'vat_id': vatId,
'fiscal_code': fiscalCode,
'sdi': sdi,
'company_logo': companyLogo,
'is_paid': isPaid,
if (paymentExpiration != null)
@@ -213,14 +213,14 @@ class CompanyModel extends Equatable {
id,
createdAt,
userId,
ragioneSociale,
indirizzo,
cap,
citta,
provincia,
partitaIva,
codiceFiscale,
codiceUnivoco,
name,
address,
zipCode,
city,
province,
vatId,
fiscalCode,
sdi,
companyLogo,
isPaid,
paymentExpiration,
@@ -263,7 +263,7 @@ extension CompanyLimits on CompanyModel {
}
}
int get maxServicesPerMonth {
int get maxOperationsPerMonth {
switch (subscriptionTier) {
case SubscriptionTier.free:
return 50;

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/company/bloc/company_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/theme/theme.dart';
@@ -49,14 +50,14 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
final company = CompanyModel(
userId: userId,
ragioneSociale: _ragioneSocialeController.text.trim(),
indirizzo: _indirizzoController.text.trim(),
cap: _capController.text.trim(),
citta: _cittaController.text.trim(),
provincia: _provinciaController.text.trim(),
partitaIva: _pIvaController.text.trim(),
codiceFiscale: _cfController.text.trim(),
codiceUnivoco: _univocoController.text.trim().toUpperCase(),
name: _ragioneSocialeController.text.trim(),
address: _indirizzoController.text.trim(),
zipCode: _capController.text.trim(),
city: _cittaController.text.trim(),
province: _provinciaController.text.trim(),
vatId: _pIvaController.text.trim(),
fiscalCode: _cfController.text.trim(),
sdi: _univocoController.text.trim().toUpperCase(),
// Gli altri campi hanno i default nel modello
);
@@ -69,7 +70,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Configurazione Azienda'),
title: Text(context.l10n.createCompanyScreenCompanyConfiguration),
actions: [
IconButton(
icon: const Icon(Icons.logout_rounded),
@@ -98,7 +99,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
state.errorMessage ?? 'Errore durante il salvataggio',
state.errorMessage ?? context.l10n.commonSavingError,
),
backgroundColor: Colors.redAccent,
),
@@ -118,10 +119,12 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
const SizedBox(height: 32),
// --- SEZIONE 1: IDENTITÀ FISCALE ---
_SectionTitle(title: 'DATI FISCALI'),
_SectionTitle(
title: context.l10n.createCompanyScreenFiscalData,
),
const SizedBox(height: 16),
FluxTextField(
label: 'Ragione Sociale',
label: context.l10n.createCompanyScreenCompanyName,
icon: Icons.business,
controller: _ragioneSocialeController,
),
@@ -130,7 +133,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
children: [
Expanded(
child: FluxTextField(
label: 'Partita IVA',
label: context.l10n.createCompanyScreenVatId,
icon: Icons.numbers,
controller: _pIvaController,
),
@@ -138,7 +141,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
const SizedBox(width: 12),
Expanded(
child: FluxTextField(
label: 'Codice Fiscale',
label: context.l10n.createCompanyScreenFiscalCode,
icon: Icons.badge_outlined,
controller: _cfController,
),
@@ -147,7 +150,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
),
const SizedBox(height: 16),
FluxTextField(
label: 'Codice Univoco (SDI) / PEC',
label: context.l10n.createCompanyScreenSdiPec,
icon: Icons.send_and_archive_outlined,
controller: _univocoController,
),
@@ -155,10 +158,13 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
const SizedBox(height: 32),
// --- SEZIONE 2: SEDE LEGALE ---
_SectionTitle(title: 'SEDE LEGALE'),
_SectionTitle(
title:
context.l10n.createCompanyScreenCompanyLegalAddress,
),
const SizedBox(height: 16),
FluxTextField(
label: 'Indirizzo e n. civico',
label: context.l10n.commonAddress,
icon: Icons.home_work_outlined,
controller: _indirizzoController,
),
@@ -168,7 +174,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
Expanded(
flex: 2,
child: FluxTextField(
label: 'Città',
label: context.l10n.commonCity,
icon: Icons.location_city,
controller: _cittaController,
),
@@ -176,7 +182,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
const SizedBox(width: 12),
Expanded(
child: FluxTextField(
label: 'CAP',
label: context.l10n.commonZipCode,
icon: Icons.map_outlined,
controller: _capController,
),
@@ -184,7 +190,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
const SizedBox(width: 12),
Expanded(
child: FluxTextField(
label: 'Prov',
label: context.l10n.commonProvince,
icon: Icons.explore_outlined,
controller: _provinciaController,
),
@@ -232,7 +238,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
Icon(Icons.cloud_upload_outlined, color: context.accent, size: 32),
const SizedBox(height: 12),
Text(
'Carica Logo Aziendale',
context.l10n.createCompanyScreenUploadLogo,
style: TextStyle(
color: context.primaryText,
fontWeight: FontWeight.bold,
@@ -240,7 +246,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
),
const SizedBox(height: 4),
Text(
'Verrà usato per le tue stampe e ricevute',
context.l10n.createCompanyScreenWillBeUsedForReceipts,
textAlign: TextAlign.center,
style: TextStyle(color: context.secondaryText, fontSize: 12),
),
@@ -259,7 +265,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
: () => _onSave(),
child: state.status == CompanyStatus.loading
? const CircularProgressIndicator()
: const Text('SALVA AZIENDA'),
: Text(context.l10n.createCompanyScreenSaveCompany),
),
);
}
@@ -282,7 +288,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
),
const SizedBox(height: 24),
Text(
'Configura la tua Azienda',
context.l10n.createCompanyScreenSetupYourCompany,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: context.primaryText,
@@ -290,7 +296,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
),
const SizedBox(height: 12),
Text(
'FLUX ha bisogno dei tuoi dati fiscali per gestire correttamente le fatturazioni e le attivazioni dei tuoi negozi.',
context.l10n.createCompanyScreenFluxNeedsYourFiscalData,
style: TextStyle(
color: context.secondaryText,
fontSize: 15,

View File

@@ -4,8 +4,8 @@ import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:get_it/get_it.dart';
part 'customer_files_events.dart';
@@ -26,7 +26,7 @@ class CustomerFilesBloc extends Bloc<CustomerFilesEvent, CustomerFilesState> {
LoadCustomerFilesEvent event,
Emitter<CustomerFilesState> emit,
) async {
await emit.forEach<List<CustomerFileModel>>(
await emit.forEach<List<AttachmentModel>>(
_repository.getCustomerFilesStream(customerId),
onData: (customerFiles) => CustomerFilesState(
status: CustomerFilesStatus.success,
@@ -128,7 +128,7 @@ class CustomerFilesBloc extends Bloc<CustomerFilesEvent, CustomerFilesState> {
ToggleCustomerFileSelectionEvent event,
Emitter<CustomerFilesState> emit,
) {
List<CustomerFileModel> selectedFiles = List.from(state.selectedFiles);
List<AttachmentModel> selectedFiles = List.from(state.selectedFiles);
if (selectedFiles.contains(event.file)) {
selectedFiles.remove(event.file);
} else {

View File

@@ -25,6 +25,6 @@ class UploadMultipleCustomerFilesEvent extends CustomerFilesEvent {
class DeleteCustomerFilesEvent extends CustomerFilesEvent {}
class ToggleCustomerFileSelectionEvent extends CustomerFilesEvent {
final CustomerFileModel file;
final AttachmentModel file;
const ToggleCustomerFileSelectionEvent(this.file);
}

View File

@@ -12,8 +12,8 @@ class CustomerFilesState extends Equatable {
final CustomerFilesStatus status;
final String? error;
final List<CustomerFileModel> customerFiles;
final List<CustomerFileModel> selectedFiles;
final List<AttachmentModel> customerFiles;
final List<AttachmentModel> selectedFiles;
@override
List<Object?> get props => [status, error, customerFiles, selectedFiles];
@@ -21,8 +21,8 @@ class CustomerFilesState extends Equatable {
CustomerFilesState copyWith({
CustomerFilesStatus? status,
String? error,
List<CustomerFileModel>? customerFiles,
List<CustomerFileModel>? selectedFiles,
List<AttachmentModel>? customerFiles,
List<AttachmentModel>? selectedFiles,
}) {
return CustomerFilesState(
status: status ?? this.status,

View File

@@ -3,35 +3,34 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:get_it/get_it.dart';
part 'customer_state.dart';
part 'customers_state.dart';
class CustomerCubit extends Cubit<CustomerState> {
class CustomersCubit extends Cubit<CustomersState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
// Variabile per gestire il debounce della ricerca
Timer? _searchDebounce;
CustomerCubit() : super(const CustomerState());
CustomersCubit() : super(const CustomersState());
// --- LETTURA ---
Future<void> loadCustomers() async {
emit(state.copyWith(status: CustomerStatus.loading));
emit(state.copyWith(status: CustomersStatus.loading));
try {
final customers = await _repository.getCustomers(
_sessionCubit.state.company!.id!,
);
emit(
state.copyWith(status: CustomerStatus.success, customers: customers),
state.copyWith(status: CustomersStatus.success, customers: customers),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
status: CustomersStatus.failure,
errorMessage: e.toString(),
),
);
@@ -40,7 +39,7 @@ class CustomerCubit extends Cubit<CustomerState> {
// --- CREAZIONE ---
Future<void> createCustomer(CustomerModel customer) async {
emit(state.copyWith(status: CustomerStatus.loading));
emit(state.copyWith(status: CustomersStatus.loading));
try {
final newCustomer = await _repository.saveCustomer(customer);
@@ -50,7 +49,7 @@ class CustomerCubit extends Cubit<CustomerState> {
emit(
state.copyWith(
status: CustomerStatus.success,
status: CustomersStatus.success,
customers: updatedList,
lastCreatedCustomer: newCustomer,
),
@@ -58,7 +57,7 @@ class CustomerCubit extends Cubit<CustomerState> {
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
status: CustomersStatus.failure,
errorMessage: e.toString(),
),
);
@@ -67,7 +66,7 @@ class CustomerCubit extends Cubit<CustomerState> {
// --- AGGIORNAMENTO ---
Future<void> updateCustomer(CustomerModel customer) async {
emit(state.copyWith(status: CustomerStatus.loading));
emit(state.copyWith(status: CustomersStatus.loading));
try {
final updatedCustomer = await _repository.updateCustomer(customer);
@@ -80,7 +79,7 @@ class CustomerCubit extends Cubit<CustomerState> {
emit(
state.copyWith(
status: CustomerStatus.success,
status: CustomersStatus.success,
customers: updatedList,
lastCreatedCustomer:
updatedCustomer, // Utile se modifichi un cliente appena creato
@@ -89,7 +88,7 @@ class CustomerCubit extends Cubit<CustomerState> {
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
status: CustomersStatus.failure,
errorMessage: e.toString(),
),
);
@@ -116,12 +115,12 @@ class CustomerCubit extends Cubit<CustomerState> {
query,
);
emit(
state.copyWith(status: CustomerStatus.success, customers: results),
state.copyWith(status: CustomersStatus.success, customers: results),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
status: CustomersStatus.failure,
errorMessage: e.toString(),
),
);
@@ -135,8 +134,8 @@ class CustomerCubit extends Cubit<CustomerState> {
String? email,
}) async {
final newCustomer = CustomerModel(
nome: name,
telefono: phone ?? '',
name: name,
phoneNumber: phone ?? '',
email: email ?? '',
companyId: _sessionCubit.state.company!.id!,
note: '',

View File

@@ -1,6 +1,6 @@
part of 'customer_cubit.dart';
part of 'customers_cubit.dart';
enum CustomerStatus {
enum CustomersStatus {
initial,
loading,
filesLoading,
@@ -9,34 +9,30 @@ enum CustomerStatus {
failure,
}
class CustomerState extends Equatable {
final CustomerStatus status;
class CustomersState extends Equatable {
final CustomersStatus status;
final List<CustomerModel> customers;
final CustomerModel? lastCreatedCustomer;
final String? errorMessage;
final List<CustomerFileModel> customerFiles;
const CustomerState({
this.status = CustomerStatus.initial,
const CustomersState({
this.status = CustomersStatus.initial,
this.customers = const [],
this.lastCreatedCustomer,
this.errorMessage,
this.customerFiles = const [],
});
CustomerState copyWith({
CustomerStatus? status,
CustomersState copyWith({
CustomersStatus? status,
List<CustomerModel>? customers,
CustomerModel? lastCreatedCustomer,
String? errorMessage,
List<CustomerFileModel>? customerFiles,
}) {
return CustomerState(
return CustomersState(
status: status ?? this.status,
customers: customers ?? this.customers,
lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer,
errorMessage: errorMessage ?? this.errorMessage,
customerFiles: customerFiles ?? this.customerFiles,
);
}
@@ -46,6 +42,5 @@ class CustomerState extends Equatable {
customers,
lastCreatedCustomer,
errorMessage,
customerFiles,
];
}

View File

@@ -1,8 +1,7 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/customer_model.dart';
@@ -21,7 +20,7 @@ class CustomerRepository {
.single();
return CustomerModel.fromMap(response);
} catch (e) {
throw 'Errore durante il salvataggio del cliente: $e';
throw '$e';
}
}
@@ -35,7 +34,7 @@ class CustomerRepository {
.single();
return CustomerModel.fromMap(response);
} catch (e) {
throw 'Errore durante la modifica del cliente: $e';
throw '$e';
}
}
@@ -46,15 +45,15 @@ class CustomerRepository {
.from('customer')
.select('''
*,
customer_file(*)
attachment(*)
''')
.eq('company_id', companyId)
.eq('is_active', true)
.order('nome');
.order('name');
return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
} catch (e) {
throw 'Errore nel recupero clienti';
throw '$e';
}
}
@@ -68,7 +67,7 @@ class CustomerRepository {
.from('customer')
.select()
.eq('company_id', companyId)
.or('nome.ilike.%$query%,telefono.ilike.%$query%')
.or('name.ilike.%$query%,phone_number.ilike.%$query%')
.limit(10);
return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
@@ -78,36 +77,34 @@ class CustomerRepository {
}
/// Ascolta in tempo reale i file caricati per un cliente
Stream<List<CustomerFileModel>> getCustomerFilesStream(String customerId) {
Stream<List<AttachmentModel>> getCustomerFilesStream(String customerId) {
return _supabase
.from('customer_file')
.from('attachment')
.stream(primaryKey: ['id'])
.eq('customer_id', customerId)
.order('created_at', ascending: false)
.map(
(listOfMaps) =>
listOfMaps.map((map) => CustomerFileModel.fromMap(map)).toList(),
listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(),
);
}
/// Recupera i file di un cliente specifico
Future<List<CustomerFileModel>> getCustomerFiles(String customerId) async {
Future<List<AttachmentModel>> getCustomerFiles(String customerId) async {
try {
final response = await _supabase
.from('customer_file')
.from('attachment')
.select()
.eq('customer_id', customerId);
return (response as List)
.map((f) => CustomerFileModel.fromMap(f))
.toList();
return (response as List).map((f) => AttachmentModel.fromMap(f)).toList();
} catch (e) {
throw 'Errore recupero file: $e';
throw '$e';
}
}
/// Carica un file e salva il riferimento nel database
Future<CustomerFileModel> uploadAndRegisterFile({
Future<AttachmentModel> uploadAndRegisterFile({
required String customerId,
required PlatformFile pickedFile,
}) async {
@@ -118,7 +115,8 @@ class CustomerRepository {
final storagePath =
'$companyId/customers/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
final int fileSize = pickedFile.size;
final fileToSave = CustomerFileModel(
final fileToSave = AttachmentModel(
companyId: companyId,
customerId: customerId,
name: cleanFileName.fileNameWithoutExtension(),
extension: cleanFileName.fileExtension(),
@@ -131,7 +129,7 @@ class CustomerRepository {
try {
// Usiamo bytes invece del path per massima compatibilità
if (pickedFile.bytes == null && pickedFile.path == null) {
throw 'Impossibile leggere il contenuto del file';
throw 'File read error';
}
// Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes
@@ -146,54 +144,51 @@ class CustomerRepository {
}
final response = await _supabase
.from('customer_file')
.from('attachment')
.insert(fileToSave.toMap())
.select()
.single();
return CustomerFileModel.fromMap(response);
return AttachmentModel.fromMap(response);
} catch (e) {
throw 'Errore durante l\'upload: $e';
throw '$e';
}
}
Future<void> saveFileReference(CustomerFileModel file) async {
await _supabase.from('customer_file').upsert(file.toMap());
Future<void> saveFileReference(AttachmentModel file) async {
await _supabase.from('attachment').upsert(file.toMap());
}
/// Aggiorna la lista degli URL nel database
Future<void> updateCustomerDocuments(int id, List<String> urls) async {
await _supabase
.from('customer')
.update({'document_urls': urls})
.eq('id', id);
}
Future<void> deleteDocuments(List<CustomerFileModel> files) async {
Future<void> deleteDocuments(List<AttachmentModel> files) async {
if (files.isEmpty) return;
// 1. Prepariamo le liste di ID e di Percorsi
final List<String> idsToDelete = files.map((f) => f.id!).toList();
final List<String> storagePaths = files.map((f) => f.storagePath).toList();
final List<String> idsToDelete = [];
final List<String> storagePathsToDelete = [];
final List<String> idsToEdit = [];
for (var file in files) {
if (file.operationId == null) {
idsToDelete.add(file.id!);
storagePathsToDelete.add(file.storagePath!);
} else {
idsToEdit.add(file.id!);
}
}
try {
// 2. Cancellazione MASSIVA dal DB (una sola chiamata invece di un ciclo!)
// .in_ dice: "cancella tutti i record il cui ID è contenuto in questa lista"
await _supabase
.from('customer_file')
.delete()
.inFilter('id', idsToDelete);
if (idsToDelete.isNotEmpty) {
await _supabase.from('attachment').delete().inFilter('id', idsToDelete);
// 3. Cancellazione MASSIVA dallo Storage
await _supabase.storage.from('documents').remove(storagePaths);
debugPrint("Eliminati con successo ${files.length} file.");
await _supabase.storage.from('documents').remove(storagePathsToDelete);
}
if (idsToEdit.isNotEmpty) {
await _supabase
.from('attachment')
.update({'customer_id': null})
.inFilter('id', idsToEdit);
}
} on PostgrestException catch (e) {
debugPrint("Errore DB: ${e.message}");
throw 'Errore database: ${e.message}';
throw e.message;
} catch (e) {
debugPrint("Errore generico: $e");
throw 'Errore durante l\'eliminazione dei file: $e';
throw '$e';
}
}
}

View File

@@ -1,91 +0,0 @@
import 'package:equatable/equatable.dart';
class CustomerFileModel extends Equatable {
final String? id;
final String customerId; // Riferimento UUID
final String name;
final String storagePath;
final String extension;
final DateTime? createdAt;
final int fileSize;
const CustomerFileModel({
this.id,
required this.customerId,
required this.name,
required this.storagePath,
required this.extension,
this.createdAt,
required this.fileSize,
});
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB)
String get sizeFormatted {
if (fileSize <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB"];
var i = (fileSize.toString().length - 1) ~/ 3;
if (i >= suffixes.length) i = suffixes.length - 1;
double num = fileSize / (1 << (i * 10));
return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}";
}
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
CustomerFileModel copyWith({
String? id,
String? customerId,
String? name,
String? storagePath,
String? extension,
DateTime? createdAt,
int? fileSize,
}) {
return CustomerFileModel(
id: id ?? this.id,
customerId: customerId ?? this.customerId,
name: name ?? this.name,
storagePath: storagePath ?? this.storagePath,
extension: extension ?? this.extension,
createdAt: createdAt ?? this.createdAt,
fileSize: fileSize ?? this.fileSize,
);
}
factory CustomerFileModel.fromMap(Map<String, dynamic> map) {
return CustomerFileModel(
id: map['id'] as String,
customerId: map['customer_id'],
name: map['name'],
storagePath: map['storage_path'],
extension: map['extension'] ?? '',
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
fileSize: map['file_size'] is int
? map['file_size']
: int.tryParse(map['file_size']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'customer_id': customerId,
'name': name,
'storage_path': storagePath,
'extension': extension,
'file_size': fileSize,
};
}
@override
List<Object?> get props => [
id,
customerId,
name,
storagePath,
extension,
createdAt,
fileSize,
];
}

View File

@@ -1,74 +1,74 @@
import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
class CustomerModel extends Equatable {
final String? id; // Bigint in SQL
final DateTime? createdAt;
final String nome;
final String telefono;
final String name;
final String phoneNumber;
final String email;
final String note;
final DateTime? dataUltimoContatto;
final bool nonDisturbare;
final DateTime? lastContactDate;
final bool doNotDisturb;
final String companyId; // UUID
final bool isActive;
final List<CustomerFileModel> files;
final List<AttachmentModel> attachments;
const CustomerModel({
this.id,
this.createdAt,
required this.nome,
required this.telefono,
required this.name,
required this.phoneNumber,
required this.email,
required this.note,
this.dataUltimoContatto,
this.nonDisturbare = false,
this.lastContactDate,
this.doNotDisturb = false,
required this.companyId,
this.isActive = true,
this.files = const [],
this.attachments = const [],
});
@override
List<Object?> get props => [
id,
createdAt,
nome,
telefono,
name,
phoneNumber,
email,
note,
dataUltimoContatto,
nonDisturbare,
lastContactDate,
doNotDisturb,
companyId,
isActive,
files,
attachments,
];
CustomerModel copyWith({
String? id,
DateTime? createdAt,
String? nome,
String? telefono,
String? name,
String? phoneNumber,
String? email,
String? note,
DateTime? dataUltimoContatto,
bool? nonDisturbare,
DateTime? lastContactDate,
bool? doNotDisturb,
String? companyId,
bool? isActive,
List<CustomerFileModel>? files,
List<AttachmentModel>? attachments,
}) {
return CustomerModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
nome: nome ?? this.nome,
telefono: telefono ?? this.telefono,
name: name ?? this.name,
phoneNumber: phoneNumber ?? this.phoneNumber,
email: email ?? this.email,
note: note ?? this.note,
dataUltimoContatto: dataUltimoContatto ?? this.dataUltimoContatto,
nonDisturbare: nonDisturbare ?? this.nonDisturbare,
lastContactDate: lastContactDate ?? this.lastContactDate,
doNotDisturb: doNotDisturb ?? this.doNotDisturb,
companyId: companyId ?? this.companyId,
isActive: isActive ?? this.isActive,
files: files ?? this.files,
attachments: attachments ?? this.attachments,
);
}
@@ -78,19 +78,19 @@ class CustomerModel extends Equatable {
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
nome: (map['nome'] as String).myFormat(),
telefono: map['telefono'],
name: (map['name'] as String).myFormat(),
phoneNumber: map['phone_number'],
email: map['email'],
note: map['note'] ?? '',
dataUltimoContatto: map['data_ultimo_contatto'] != null
? DateTime.parse(map['data_ultimo_contatto'])
lastContactDate: map['last_contact_date'] != null
? DateTime.parse(map['last_contact_date'])
: null,
nonDisturbare: map['non_disturbare'] ?? false,
doNotDisturb: map['do_not_disturb'] ?? false,
companyId: map['company_id'] as String,
isActive: map['is_active'] ?? true,
files:
(map['customer_file'] as List?)
?.map((x) => CustomerFileModel.fromMap(x))
attachments:
(map['attachment'] as List?)
?.map((x) => AttachmentModel.fromMap(x))
.toList() ??
const [],
);
@@ -99,13 +99,13 @@ class CustomerModel extends Equatable {
Map<String, dynamic> toJson() {
return {
if (id != null) 'id': id,
'nome': nome.toLowerCase().trim(),
'telefono': telefono,
'name': name.toLowerCase().trim(),
'phone_number': phoneNumber,
'email': email.toLowerCase().trim(),
'note': note,
if (dataUltimoContatto != null)
'data_ultimo_contatto': dataUltimoContatto!.toIso8601String(),
'non_disturbare': nonDisturbare,
if (lastContactDate != null)
'last_contact_date': lastContactDate!.toIso8601String(),
'do_not_disturb': doNotDisturb,
'company_id': companyId,
'is_active': isActive,
};

View File

@@ -6,9 +6,9 @@ import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/widgets/image_viewer_widget.dart';
import 'package:flux/core/widgets/pdf_viewer_widget.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
class CustomerDetailScreen extends StatefulWidget {
final CustomerModel customer;
@@ -62,7 +62,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
backgroundColor: context.background,
appBar: AppBar(
title: Text(
widget.customer.nome,
widget.customer.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: context.background,
@@ -103,7 +103,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_infoTile(Icons.phone_android, "Telefono", widget.customer.telefono),
_infoTile(Icons.phone_android, "Telefono", widget.customer.phoneNumber),
_infoTile(
Icons.email_outlined,
"Email",
@@ -117,7 +117,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
: widget.customer.note,
),
const SizedBox(height: 20),
if (widget.customer.nonDisturbare)
if (widget.customer.doNotDisturb)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@@ -191,8 +191,8 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
context: context,
builder: (context) => QrUploadDialog(
deepLinkUrl:
'fluxapp://customer/${widget.customer.id}/upload?name=${Uri.encodeComponent(widget.customer.nome)}',
title: 'Scatta per ${widget.customer.nome}',
'fluxapp://customer/${widget.customer.id}/upload?name=${Uri.encodeComponent(widget.customer.name)}',
title: 'Scatta per ${widget.customer.name}',
),
);
},
@@ -262,12 +262,12 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
void _showDeleteConfirmationDialog({
required BuildContext context,
required List<CustomerFileModel> files,
required List<AttachmentModel> files,
}) {}
}
class _FileCard extends StatelessWidget {
final CustomerFileModel file;
final AttachmentModel file;
final CustomerFilesState state;
const _FileCard({required this.file, required this.state});
@@ -334,7 +334,7 @@ class _FileCard extends StatelessWidget {
}
}
void _handleDoubleClickOnFile(BuildContext context, CustomerFileModel file) {
void _handleDoubleClickOnFile(BuildContext context, AttachmentModel file) {
showDialog(
context: context,
barrierDismissible: true,

View File

@@ -30,15 +30,15 @@ class _CustomerFormState extends State<CustomerForm> {
void initState() {
super.initState();
// Se widget.customer è null, i campi saranno vuoti
_nomeController = TextEditingController(text: widget.customer?.nome ?? '');
_nomeController = TextEditingController(text: widget.customer?.name ?? '');
_telefonoController = TextEditingController(
text: widget.customer?.telefono ?? '',
text: widget.customer?.phoneNumber ?? '',
);
_emailController = TextEditingController(
text: widget.customer?.email ?? '',
);
_noteController = TextEditingController(text: widget.customer?.note ?? '');
_nonDisturbare = widget.customer?.nonDisturbare ?? false;
_nonDisturbare = widget.customer?.doNotDisturb ?? false;
}
@override
@@ -56,19 +56,19 @@ class _CustomerFormState extends State<CustomerForm> {
// o creandone uno da zero, preservando l'ID in caso di modifica.
final updatedCustomer =
widget.customer?.copyWith(
nome: _nomeController.text.trim(),
telefono: _telefonoController.text.trim(),
name: _nomeController.text.trim(),
phoneNumber: _telefonoController.text.trim(),
email: _emailController.text.trim(),
note: _noteController.text.trim(),
nonDisturbare: _nonDisturbare,
doNotDisturb: _nonDisturbare,
) ??
CustomerModel(
// Caso nuovo cliente
nome: _nomeController.text.trim(),
telefono: _telefonoController.text.trim(),
name: _nomeController.text.trim(),
phoneNumber: _telefonoController.text.trim(),
email: _emailController.text.trim(),
note: _noteController.text.trim(),
nonDisturbare: _nonDisturbare,
doNotDisturb: _nonDisturbare,
companyId: '', // Verrà iniettato dal Bloc o dal chiamante
);

View File

@@ -1,202 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/customers/blocs/customer_cubit.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
class CustomerSearchSheet extends StatefulWidget {
const CustomerSearchSheet({super.key});
@override
State<CustomerSearchSheet> createState() => _CustomerSearchSheetState();
}
class _CustomerSearchSheetState extends State<CustomerSearchSheet> {
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
context.read<CustomerCubit>().loadCustomers();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _onSearchChanged(String query) {
context.read<CustomerCubit>().searchCustomers(query);
}
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER ---
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Trova Cliente",
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
tooltip: "Chiudi",
),
],
),
const SizedBox(height: 16),
// --- BARRA DI RICERCA ---
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: "Cerca per nome, cognome o CF...",
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged("");
},
),
),
onChanged: _onSearchChanged,
),
const SizedBox(height: 16),
// --- TASTO NUOVO CLIENTE ---
SizedBox(
width: double.infinity,
child: IconButton(
icon: const Icon(Icons.person_add),
onPressed: () async {
final servicesCubit = context.read<ServicesCubit>();
// Apriamo la dialog passando la query attuale
final CustomerModel? nuovoCliente = await showDialog(
context: context,
builder: (context) => QuickCustomerDialog(
initialQuery: _searchController.text,
),
);
if (nuovoCliente != null) {
servicesCubit.updateField(
customerId: nuovoCliente.id,
customerDisplayName: nuovoCliente.nome,
);
setState(() {
_searchController.clear();
});
}
},
),
),
const SizedBox(height: 24),
// --- LISTA RISULTATI CON BLOC BUILDER ---
const Text(
"Risultati",
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey),
),
const SizedBox(height: 8),
Expanded(
// AGGANCIO AL CUBIT REALE
child: BlocBuilder<CustomerCubit, CustomerState>(
builder: (context, state) {
// 1. Stato di caricamento
if (state.status == CustomerStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
// 2. Nessun risultato trovato
if (state.customers.isEmpty) {
return const Center(
child: Text(
"Nessun cliente trovato.\nProva a cambiare i termini di ricerca.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
);
}
// 3. Mostriamo la lista vera
return ListView.separated(
itemCount: state.customers.length,
separatorBuilder: (context, index) =>
const Divider(height: 1),
itemBuilder: (context, index) {
final customer = state.customers[index];
// Assumo che il tuo CustomerModel abbia le proprietà name e surname.
// Adatta queste variabili al tuo modello reale!
final displayName = customer.nome.trim();
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimaryContainer,
// Mostra l'iniziale
child: Text(
displayName.isNotEmpty
? displayName[0].toUpperCase()
: "?",
),
),
title: Text(
displayName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(customer.email),
trailing: const Icon(
Icons.check_circle_outline,
color: Colors.grey,
),
onTap: () {
// Salviamo l'ID e il nome formattato nel form dei servizi
context.read<ServicesCubit>().updateField(
customerId: customer.id,
customerDisplayName: displayName,
);
// Chiudiamo la modale
Navigator.pop(context);
},
);
},
);
},
),
),
],
),
),
);
}
}

View File

@@ -2,7 +2,7 @@ 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/theme/theme.dart';
import 'package:flux/features/customers/blocs/customer_cubit.dart';
import 'package:flux/features/customers/blocs/customers_cubit.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/ui/customer_form.dart';
import 'package:go_router/go_router.dart';
@@ -26,14 +26,14 @@ class _CustomersContentState extends State<CustomersContent> {
void _loadInitialCustomers() {
final companyId = context.read<SessionCubit>().state.company?.id;
if (companyId != null) {
context.read<CustomerCubit>().loadCustomers();
context.read<CustomersCubit>().loadCustomers();
}
}
void _onSearch(String query) {
final companyId = context.read<SessionCubit>().state.company?.id;
if (companyId != null) {
context.read<CustomerCubit>().searchCustomers(query);
context.read<CustomersCubit>().searchCustomers(query);
}
}
@@ -86,9 +86,9 @@ class _CustomersContentState extends State<CustomersContent> {
// LISTA CLIENTI
Expanded(
child: BlocBuilder<CustomerCubit, CustomerState>(
child: BlocBuilder<CustomersCubit, CustomersState>(
builder: (context, state) {
if (state.status == CustomerStatus.loading &&
if (state.status == CustomersStatus.loading &&
state.customers.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
@@ -166,7 +166,7 @@ class _CustomerTile extends StatelessWidget {
radius: 24,
backgroundColor: context.accent.withValues(alpha: 0.1),
child: Text(
customer.nome.isNotEmpty ? customer.nome[0].toUpperCase() : '?',
customer.name.isNotEmpty ? customer.name[0].toUpperCase() : '?',
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
@@ -174,7 +174,7 @@ class _CustomerTile extends StatelessWidget {
),
),
title: Text(
customer.nome,
customer.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
subtitle: Padding(
@@ -184,7 +184,7 @@ class _CustomerTile extends StatelessWidget {
Icon(Icons.phone_android, size: 14, color: context.secondaryText),
const SizedBox(width: 4),
Text(
customer.telefono,
customer.phoneNumber,
style: TextStyle(color: context.secondaryText),
),
if (customer.email.isNotEmpty) ...[
@@ -196,11 +196,11 @@ class _CustomerTile extends StatelessWidget {
style: TextStyle(color: context.secondaryText),
),
],
if (customer.files.isNotEmpty) ...[
if (customer.attachments.isNotEmpty) ...[
Text(' - ', style: TextStyle(color: context.secondaryText)),
Icon(Icons.attach_file, size: 14, color: context.accent),
Text(
'${customer.files.length} doc',
'${customer.attachments.length} doc',
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
@@ -242,12 +242,12 @@ void openCustomerForm({
if (customer == null) {
// CASO NUOVO: Iniettiamo il companyId e inviamo l'evento create
context.read<CustomerCubit>().createCustomer(
context.read<CustomersCubit>().createCustomer(
customerFromForm.copyWith(companyId: companyId),
);
} else {
// CASO MODIFICA: L'ID e il companyId sono già nel modello
context.read<CustomerCubit>().updateCustomer(customerFromForm);
context.read<CustomersCubit>().updateCustomer(customerFromForm);
}
Navigator.pop(dialogContext);
},

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/customers/blocs/customer_cubit.dart';
import 'package:flux/features/customers/blocs/customers_cubit.dart';
class QuickCustomerDialog extends StatefulWidget {
final String initialQuery;
@@ -42,7 +42,9 @@ class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
setState(() => _isLoading = true);
// Chiamata al Cubit (aggiorna i parametri in base a come li hai definiti)
final newCustomer = await context.read<CustomerCubit>().quickCreateCustomer(
final newCustomer = await context
.read<CustomersCubit>()
.quickCreateCustomer(
name: _nameCtrl.text.trim(),
phone: _phoneCtrl.text.trim(),
// Aggiungi questi se li hai inseriti nel tuo CustomerCubit:

View File

@@ -0,0 +1,66 @@
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<InitLastStoreOperationsEvent>((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)
.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

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

View File

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

@@ -0,0 +1,189 @@
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/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:go_router/go_router.dart';
class LatestStoreOperationsCard extends StatelessWidget {
const LatestStoreOperationsCard({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(InitLastStoreOperationsEvent(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(
InitLastStoreOperationsEvent(state.currentStore!.id!),
);
}
},
child: _LatestOperationsCardContent(),
),
);
}
}
class _LatestOperationsCardContent 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: 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: TextButton(
onPressed: () => context.push('/operations'),
child: Text(
context.l10n.homeLatestOperations,
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<
LatestStoreOperationsBloc,
LatestStoreOperationsState
>(
builder: (context, state) {
if (state.status == LatestStoreOperationsStatus.loading ||
state.status == LatestStoreOperationsStatus.initial) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == LatestStoreOperationsStatus.failure) {
return Center(
child: Text(
"Errore di caricamento",
style: TextStyle(color: theme.colorScheme.error),
),
);
}
if (state.operations.isEmpty) {
return Center(
child: Text(
"Nessun servizio recente.",
style: TextStyle(
color: context.secondaryText,
fontStyle: FontStyle.italic,
),
),
);
}
return ListView.separated(
itemCount: state.operations.length,
separatorBuilder: (context, index) => Divider(
height: 1,
color: theme.dividerColor.withValues(alpha: 0.3),
),
itemBuilder: (context, index) {
final operation = state.operations[index];
return InkWell(
onTap: () => context.push(
'/operation-form',
extra: operation,
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
flex: 5,
child: Text(
operation.customerDisplayName ??
'Cliente sconosciuto',
style: TextStyle(
fontWeight: FontWeight.w700,
color: context.primaryText,
),
),
),
Expanded(
flex: 5,
child: Text(
operation.reference,
style: TextStyle(
fontWeight: FontWeight.w600,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
"${operation.createdAt?.day}/${operation.createdAt?.month}",
style: TextStyle(
color: context.secondaryText,
fontSize: 12,
),
),
],
),
),
);
},
);
},
),
),
],
),
),
);
}
}

View File

@@ -1,44 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flux/core/theme/theme.dart';
class DashboardActionCard extends StatelessWidget {
final String label;
final IconData icon;
final Color color;
final VoidCallback onTap;
const DashboardActionCard({
super.key,
required this.label,
required this.icon,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
// CAMBIA QUI: da Border.all a BorderSide
side: BorderSide(
color: context.accent.withValues(alpha: 0.1),
width: 1,
),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 32),
const SizedBox(height: 8),
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
),
);
}
}

View File

@@ -1,75 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/home/ui/dashboard_action_card.dart';
import 'package:flux/features/services/utils/service_actions.dart';
import 'package:go_router/go_router.dart';
class DashboardAdaptiveGrid extends StatelessWidget {
final bool isLargeScreen;
final Function(int)? onTabRequested;
const DashboardAdaptiveGrid({
super.key,
this.isLargeScreen = false,
this.onTabRequested,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Logica Colonne: Mobile 2, Tablet 3, Desktop 4+
int crossAxisCount = 2;
if (constraints.maxWidth > 1000) {
crossAxisCount = 5;
} else if (constraints.maxWidth > 700) {
crossAxisCount = 3;
}
return GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: crossAxisCount,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: isLargeScreen ? 1.3 : 1.5,
children: [
DashboardActionCard(
label: 'Nuova Op',
icon: Icons.add_task,
color: context.accent,
onTap: () => startNewService(context),
),
DashboardActionCard(
label: 'Clienti',
icon: Icons.people,
color: Colors.orange,
onTap: () => onTabRequested?.call(1),
),
DashboardActionCard(
label: 'Prodotti',
icon: Icons
.phone_android_outlined, // Icona "comoda" e professionale
color: context
.accent, // O un colore a tua scelta, magari Indigo o Blue
onTap: () => context.push(
'/products',
), // Apre la schermata sopra la Dashboard
),
DashboardActionCard(
label: 'Campagne',
icon: Icons.campaign,
color: Colors.purple,
onTap: () {},
),
DashboardActionCard(
label: 'Report',
icon: Icons.analytics,
color: Colors.teal,
onTap: () {},
),
],
);
},
);
}
}

View File

@@ -1,125 +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/theme/theme.dart';
import 'package:flux/features/home/ui/dashboard_adaptive_grid.dart';
class DashboardContent extends StatelessWidget {
final bool isLargeScreen;
final Function(int)? onTabRequested;
const DashboardContent({
super.key,
this.isLargeScreen = false,
this.onTabRequested,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<SessionCubit, SessionState>(
builder: (context, state) {
final store = state.currentStore;
final company = state.company;
return Scaffold(
backgroundColor: context.background,
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 100.0,
floating: false,
pinned: true,
elevation: 0,
backgroundColor: context.background,
flexibleSpace: FlexibleSpaceBar(
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
store?.nome ?? 'Dashboard',
style: TextStyle(
color: context.primaryText,
fontWeight: FontWeight.bold,
),
),
),
),
SliverToBoxAdapter(
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 1200),
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildWelcome(context, company?.ragioneSociale),
const SizedBox(height: 32),
const _SectionTitle(title: 'AZIONI RAPIDE'),
const SizedBox(height: 16),
DashboardAdaptiveGrid(
isLargeScreen: isLargeScreen,
onTabRequested: onTabRequested,
),
const SizedBox(height: 40),
const _SectionTitle(title: 'INFO PUNTO VENDITA'),
const SizedBox(height: 16),
_buildStoreCard(context, store),
],
),
),
),
),
],
),
);
},
);
}
Widget _buildWelcome(BuildContext context, String? name) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Benvenuto in',
style: TextStyle(color: context.secondaryText, fontSize: 16),
),
Text(
name ?? 'Azienda',
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
),
],
);
}
Widget _buildStoreCard(BuildContext context, dynamic store) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: context.accent.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: context.accent.withValues(alpha: 0.1)),
),
child: Row(
children: [
Icon(Icons.location_on, color: context.accent),
const SizedBox(width: 16),
Text('${store?.indirizzo}, ${store?.comune} (${store?.provincia})'),
],
),
);
}
}
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle({required this.title});
@override
Widget build(BuildContext context) => Text(
title,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
fontSize: 12,
letterSpacing: 1.2,
),
);
}

View File

@@ -2,6 +2,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/theme/theme.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_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:go_router/go_router.dart';
@@ -57,31 +59,27 @@ class HomeScreen extends StatelessWidget {
),
delegate: SliverChildListDelegate([
_buildDashboardWidget(
title: 'Contratti in Scadenza',
title: context.l10n.homeExpiringContracts,
icon: Icons.assignment_late_outlined,
color: Colors.orange,
context: context,
),
_buildDashboardWidget(
title: 'Sticky Notes',
title: context.l10n.commonStickyNotes,
icon: Icons.sticky_note_2_outlined,
color: Colors.yellow.shade700,
context: context,
),
_buildDashboardWidget(
title: 'I miei Task',
title: context.l10n.homeMyTasks,
icon: Icons.check_box_outlined,
color: Colors.green,
context: context,
),
LatestStoreOperationsCard(),
_buildDashboardWidget(
title: 'Ultimi Servizi',
icon: Icons.design_services_outlined,
color: Colors.blue,
context: context,
),
_buildDashboardWidget(
title: 'Ultime Assistenze',
title: context.l10n.homeLatestOperationTickets,
icon: Icons.support_agent_outlined,
color: Colors.purple,
context: context,
@@ -117,7 +115,7 @@ class HomeScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Bentornato, ${user!.name}! 👋",
context.l10n.homeWelcomeBack(user?.name ?? "Utente"),
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
@@ -151,7 +149,7 @@ class HomeScreen extends StatelessWidget {
Icon(Icons.storefront, size: 16, color: context.primary),
const SizedBox(width: 8),
Text(
currentStore?.nome ?? "Nessun negozio",
currentStore?.name ?? context.l10n.homeNoStoreFound,
style: TextStyle(
fontWeight: FontWeight.w600,
color: context.primary,
@@ -183,17 +181,17 @@ class HomeScreen extends StatelessWidget {
children: [
QuickActionButton(
icon: Icons.add,
label: "Servizio",
label: context.l10n.commonOperation,
color: Colors.blue,
onTap: () {
// Entriamo nel form! Nessun parametro extra = Nuovo Servizio
context.push('/service-form');
context.push('/operation-form');
},
),
const SizedBox(width: 12),
QuickActionButton(
icon: Icons.handyman,
label: "Assistenza",
label: context.l10n.homeNewOperationTicket,
color: Colors.redAccent,
onTap: () {
// TODO: Quando avrai la rotta per la nuova assistenza
@@ -203,7 +201,7 @@ class HomeScreen extends StatelessWidget {
const SizedBox(width: 12),
QuickActionButton(
icon: Icons.note_add,
label: "Nota",
label: context.l10n.commonNote,
color: Colors.amber,
onTap: () {
// TODO: Quando faremo il modale/pagina delle note
@@ -212,7 +210,7 @@ class HomeScreen extends StatelessWidget {
const SizedBox(width: 12),
QuickActionButton(
icon: Icons.task_alt,
label: "Task",
label: context.l10n.commonTask,
color: Colors.teal,
onTap: () {
// TODO: Quando faremo i task
@@ -280,7 +278,7 @@ class HomeScreen extends StatelessWidget {
const Spacer(),
Center(
child: Text(
"(Coming Soon)",
context.l10n.commonComingSoon,
style: TextStyle(
color: context.secondaryText.withValues(alpha: 0.7),
fontStyle: FontStyle.italic,
@@ -354,7 +352,7 @@ class HomeScreen extends StatelessWidget {
: theme.iconTheme.color,
),
title: Text(
store.nome,
store.name,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold

View File

@@ -9,19 +9,17 @@ import 'package:get_it/get_it.dart';
part 'product_state.dart';
class ProductCubit extends Cubit<ProductState> {
class ProductsCubit extends Cubit<ProductState> {
final ProductRepository _repository = GetIt.I<ProductRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
ProductCubit() : super(const ProductState());
ProductsCubit() : super(const ProductState());
// Caricamento iniziale dei Brand
Future<void> loadBrands() async {
emit(state.copyWith(status: ProductStatus.loading));
try {
final brands = await _repository.getBrands(
_sessionCubit.state.company!.id!,
);
final brands = await _repository.getBrands();
emit(state.copyWith(status: ProductStatus.success, brands: brands));
} catch (e) {
emit(
@@ -30,6 +28,27 @@ class ProductCubit extends Cubit<ProductState> {
}
}
Future<void> loadModels() async {
emit(state.copyWith(status: ProductStatus.loading));
try {
final models = await _repository.getModels();
emit(state.copyWith(status: ProductStatus.success, models: models));
} catch (e) {
emit(
state.copyWith(status: ProductStatus.error, errorMessage: e.toString()),
);
}
}
Future<void> refreshCubit() async {
if (state.selectedBrand != null) {
await selectBrand(state.selectedBrand);
} else {
emit(state.copyWith(status: ProductStatus.initial));
await loadBrands();
}
}
// Selezione Brand e caricamento Modelli
Future<void> selectBrand(BrandModel? brand) async {
if (brand == null) {

View File

@@ -1,3 +1,4 @@
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/brand_model.dart';
@@ -5,16 +6,17 @@ import '../models/model_model.dart';
class ProductRepository {
final SupabaseClient _supabase = GetIt.I<SupabaseClient>();
final String _companyId = GetIt.I<SessionCubit>().state.company!.id!;
// --- BRAND ---
/// Recupera tutti i brand dell'azienda
Future<List<BrandModel>> getBrands(String companyId) async {
Future<List<BrandModel>> getBrands() async {
try {
final response = await _supabase
.from('brand')
.select()
.eq('company_id', companyId)
.eq('company_id', _companyId)
.eq('is_active', true)
.order('name');
@@ -57,6 +59,19 @@ class ProductRepository {
}
}
Future<List<ModelModel>> getModels() async {
try {
final response = await _supabase
.from('model')
.select()
.eq('is_active', true)
.order('name');
return (response as List).map((m) => ModelModel.fromJson(m)).toList();
} catch (e) {
throw '$e';
}
}
/// Crea o aggiorna un modello
/// NOTA: name_with_brand verrà gestito dal trigger SQL che hai lanciato!
Future<ModelModel> upsertModel(ModelModel model) async {

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/core/utils/extensions.dart';
class BrandModel extends Equatable {
final String? id;

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/core/utils/extensions.dart';
class ModelModel extends Equatable {
final String? id;

View File

@@ -33,7 +33,7 @@ class BrandSelector extends StatelessWidget {
return DropdownMenuItem(value: brand, child: Text(brand.name));
}).toList(),
onChanged: (brand) =>
context.read<ProductCubit>().selectBrand(brand),
context.read<ProductsCubit>().selectBrand(brand),
),
),
const SizedBox(width: 16),

View File

@@ -64,7 +64,7 @@ class ModelsList extends StatelessWidget {
color: model.isActive ? context.accent : Colors.grey,
),
onPressed: () => context
.read<ProductCubit>()
.read<ProductsCubit>()
.toggleStatus('model', model.id!, model.isActive),
),
],

View File

@@ -40,7 +40,7 @@ void _submitBrand(
BrandModel? brand,
) {
if (controller.text.trim().isNotEmpty) {
context.read<ProductCubit>().saveBrand(controller.text, id: brand?.id);
context.read<ProductsCubit>().saveBrand(controller.text, id: brand?.id);
Navigator.pop(context);
}
}
@@ -81,7 +81,7 @@ void _submitModel(
ModelModel? model,
) {
if (controller.text.isNotEmpty) {
context.read<ProductCubit>().saveModel(controller.text, id: model?.id);
context.read<ProductsCubit>().saveModel(controller.text, id: model?.id);
Navigator.pop(context);
}
}

View File

@@ -12,7 +12,7 @@ class ProductsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Carichiamo i brand appena la pagina viene creata
context.read<ProductCubit>().loadBrands();
context.read<ProductsCubit>().loadBrands();
return Scaffold(
backgroundColor: context.background,
@@ -33,7 +33,7 @@ class ProductsScreen extends StatelessWidget {
),
),
),
body: BlocConsumer<ProductCubit, ProductState>(
body: BlocConsumer<ProductsCubit, ProductState>(
listener: (context, state) {
if (state.status == ProductStatus.error) {
ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -23,7 +23,7 @@ class _QuickProductDialogState extends State<QuickProductDialog> {
setState(() => _isLoading = true);
final newModel = await context.read<ProductCubit>().quickCreateProduct(
final newModel = await context.read<ProductsCubit>().quickCreateProduct(
brandName: _selectedBrandName.trim(),
modelName: _modelCtrl.text.trim(),
);

View File

@@ -51,7 +51,7 @@ class ProviderRepository {
)
''')
.eq('company_id', companyId)
.order('nome');
.order('name');
return (response as List).map((m) => ProviderModel.fromMap(m)).toList();
} catch (e) {

View File

@@ -3,28 +3,30 @@ import 'package:flux/features/master_data/store/models/store_model.dart';
class ProviderModel extends Equatable {
final String? id;
final String nome;
final bool telefoniaFissa;
final bool telefoniaMobile;
final bool energia;
final bool assicurazioni;
final bool intrattenimento;
final bool finanziamenti;
final bool altro;
final String name;
final bool landline;
final bool mobile;
final bool energy;
final bool insurance;
final bool entertainment;
final bool financing;
final bool telepass;
final bool other;
final bool isActive;
final String companyId;
final List<StoreModel> associatedStores;
const ProviderModel({
this.id,
required this.nome,
required this.telefoniaFissa,
required this.telefoniaMobile,
required this.energia,
required this.assicurazioni,
required this.intrattenimento,
required this.finanziamenti,
required this.altro,
required this.name,
required this.landline,
required this.mobile,
required this.energy,
required this.insurance,
required this.entertainment,
required this.financing,
required this.telepass,
required this.other,
required this.isActive,
required this.companyId,
this.associatedStores = const [],
@@ -44,14 +46,15 @@ class ProviderModel extends Equatable {
}
return ProviderModel(
id: map['id'],
nome: map['nome'],
telefoniaFissa: map['telefonia_fissa'] ?? false,
telefoniaMobile: map['telefonia_mobile'] ?? false,
energia: map['energia'] ?? false,
assicurazioni: map['assicurazioni'] ?? false,
intrattenimento: map['intrattenimento'] ?? false,
finanziamenti: map['finanziamenti'] ?? false,
altro: map['altro'] ?? false,
name: map['name'],
landline: map['landline'] ?? false,
mobile: map['mobile'] ?? false,
energy: map['energy'] ?? false,
insurance: map['insurance'] ?? false,
entertainment: map['entertainment'] ?? false,
financing: map['financing'] ?? false,
telepass: map['telepass'] ?? false,
other: map['other'] ?? false,
isActive: map['is_active'] ?? true,
companyId: map['company_id'],
associatedStores: stores,
@@ -60,14 +63,15 @@ class ProviderModel extends Equatable {
Map<String, dynamic> toMap() {
final map = {
'nome': nome,
'telefonia_fissa': telefoniaFissa,
'telefonia_mobile': telefoniaMobile,
'energia': energia,
'assicurazioni': assicurazioni,
'intrattenimento': intrattenimento,
'finanziamenti': finanziamenti,
'altro': altro,
'name': name,
'landline': landline,
'mobile': mobile,
'energy': energy,
'insurance': insurance,
'entertainment': entertainment,
'financing': financing,
'telepass': telepass,
'other': other,
'is_active': isActive,
'company_id': companyId,
};
@@ -82,14 +86,15 @@ class ProviderModel extends Equatable {
@override
List<Object?> get props => [
id,
nome,
telefoniaFissa,
telefoniaMobile,
energia,
assicurazioni,
intrattenimento,
finanziamenti,
altro,
name,
landline,
mobile,
energy,
insurance,
entertainment,
financing,
telepass,
other,
isActive,
companyId,
associatedStores,
@@ -97,28 +102,30 @@ class ProviderModel extends Equatable {
ProviderModel copyWith({
String? id,
String? nome,
bool? telefoniaFissa,
bool? telefoniaMobile,
bool? energia,
bool? assicurazioni,
bool? intrattenimento,
bool? finanziamenti,
bool? altro,
String? name,
bool? landline,
bool? mobile,
bool? energy,
bool? insurance,
bool? entertainment,
bool? financing,
bool? telepass,
bool? other,
bool? isActive,
String? companyId,
List<StoreModel>? associatedStores,
}) {
return ProviderModel(
id: id ?? this.id,
nome: nome ?? this.nome,
telefoniaFissa: telefoniaFissa ?? this.telefoniaFissa,
telefoniaMobile: telefoniaMobile ?? this.telefoniaMobile,
energia: energia ?? this.energia,
assicurazioni: assicurazioni ?? this.assicurazioni,
intrattenimento: intrattenimento ?? this.intrattenimento,
finanziamenti: finanziamenti ?? this.finanziamenti,
altro: altro ?? this.altro,
name: name ?? this.name,
landline: landline ?? this.landline,
mobile: mobile ?? this.mobile,
energy: energy ?? this.energy,
insurance: insurance ?? this.insurance,
entertainment: entertainment ?? this.entertainment,
financing: financing ?? this.financing,
telepass: telepass ?? this.telepass,
other: other ?? this.other,
isActive: isActive ?? this.isActive,
companyId: companyId ?? this.companyId,
associatedStores: associatedStores ?? this.associatedStores,

View File

@@ -15,13 +15,14 @@ class ProviderFormSheet extends StatefulWidget {
class _ProviderFormSheetState extends State<ProviderFormSheet> {
late TextEditingController _nameController;
late bool _telefoniaFissa;
late bool _telefoniaMobile;
late bool _energia;
late bool _assicurazioni;
late bool _intrattenimento;
late bool _finanziamenti;
late bool _altro;
late bool _landline;
late bool _mobile;
late bool _energy;
late bool _insurance;
late bool _entertainment;
late bool _financing;
late bool _telepass;
late bool _other;
late bool _isActive;
final List<String> _tempSelectedStoreIds =
[]; // Per gestire la selezione temporanea dei negozi
@@ -33,14 +34,15 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
for (final store in p?.associatedStores ?? []) {
_tempSelectedStoreIds.add(store.id!);
}
_nameController = TextEditingController(text: p?.nome ?? '');
_telefoniaFissa = p?.telefoniaFissa ?? false;
_telefoniaMobile = p?.telefoniaMobile ?? false;
_energia = p?.energia ?? false;
_assicurazioni = p?.assicurazioni ?? false;
_intrattenimento = p?.intrattenimento ?? false;
_finanziamenti = p?.finanziamenti ?? false;
_altro = p?.altro ?? false;
_nameController = TextEditingController(text: p?.name ?? '');
_landline = p?.landline ?? false;
_mobile = p?.mobile ?? false;
_energy = p?.energy ?? false;
_insurance = p?.insurance ?? false;
_entertainment = p?.entertainment ?? false;
_financing = p?.financing ?? false;
_telepass = p?.telepass ?? false;
_other = p?.other ?? false;
_isActive = p?.isActive ?? true;
}
@@ -57,14 +59,15 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
final cubit = context.read<ProvidersCubit>();
final provider = ProviderModel(
id: widget.initialProvider?.id, // Se nullo, Supabase farà insert
nome: _nameController.text.trim(),
telefoniaFissa: _telefoniaFissa,
telefoniaMobile: _telefoniaMobile,
energia: _energia,
assicurazioni: _assicurazioni,
intrattenimento: _intrattenimento,
finanziamenti: _finanziamenti,
altro: _altro,
name: _nameController.text.trim(),
landline: _landline,
mobile: _mobile,
energy: _energy,
insurance: _insurance,
entertainment: _entertainment,
financing: _financing,
telepass: _telepass,
other: _other,
isActive: _isActive,
companyId:
'', // Verrà ignorato dal toMap e gestito dal Cubit/SessionBloc se hai messo la logica lì
@@ -110,38 +113,43 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
),
_buildSwitch(
"Energia (Luce/Gas)",
_energia,
(v) => setState(() => _energia = v),
_energy,
(v) => setState(() => _energy = v),
),
_buildSwitch(
"Telefonia Fissa",
_telefoniaFissa,
(v) => setState(() => _telefoniaFissa = v),
_landline,
(v) => setState(() => _landline = v),
),
_buildSwitch(
"Telefonia Mobile",
_telefoniaMobile,
(v) => setState(() => _telefoniaMobile = v),
_mobile,
(v) => setState(() => _mobile = v),
),
_buildSwitch(
"Assicurazioni",
_assicurazioni,
(v) => setState(() => _assicurazioni = v),
_insurance,
(v) => setState(() => _insurance = v),
),
_buildSwitch(
"Intrattenimento",
_intrattenimento,
(v) => setState(() => _intrattenimento = v),
_entertainment,
(v) => setState(() => _entertainment = v),
),
_buildSwitch(
"Finanziamenti",
_finanziamenti,
(v) => setState(() => _finanziamenti = v),
_financing,
(v) => setState(() => _financing = v),
),
_buildSwitch(
"Telepass",
_telepass,
(v) => setState(() => _telepass = v),
),
_buildSwitch(
"Altro/Accessori",
_altro,
(v) => setState(() => _altro = v),
_other,
(v) => setState(() => _other = v),
),
const Divider(),
_buildSwitch(
@@ -164,7 +172,7 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
store.id,
);
return CheckboxListTile(
title: Text(store.nome),
title: Text(store.name),
value: isAssociated,
onChanged: (val) {
setState(() {

View File

@@ -93,7 +93,7 @@ class _ProvidersMasterDataScreenState extends State<ProvidersMasterDataScreen> {
),
),
title: Text(
provider.nome,
provider.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: _buildCardSubtitle(
@@ -141,12 +141,13 @@ class _ProvidersMasterDataScreenState extends State<ProvidersMasterDataScreen> {
return Wrap(
spacing: 4,
children: [
if (p.telefoniaFissa || p.telefoniaMobile)
_smallTag("📞 Tel", Colors.blue),
if (p.energia) _smallTag("⚡ Energy", Colors.orange),
if (p.assicurazioni) _smallTag("🛡️ Assic", Colors.teal),
if (p.intrattenimento) _smallTag("📺 Ent", Colors.red),
if (p.altro) _smallTag("📦 Altro", Colors.grey),
if (p.landline || p.mobile) _smallTag("📞 Tel", Colors.blue),
if (p.energy) _smallTag("⚡ Energy", Colors.orange),
if (p.insurance) _smallTag("🛡️ Assic", Colors.teal),
if (p.entertainment) _smallTag("📺 Ent", Colors.red),
if (p.financing) _smallTag("💰 Fin", Colors.purple),
if (p.telepass) _smallTag("🏎️ Telepass", Colors.yellow),
if (p.other) _smallTag("📦 Altro", Colors.grey),
],
);
}

View File

@@ -56,7 +56,7 @@ class StaffCubit extends Cubit<StaffState> {
state.staffByStore,
);
newMap[storeId] = staffInStore;
emit(state.copyWith(staffByStore: newMap));
emit(state.copyWith(staffByStore: newMap, storeStaff: staffInStore));
} catch (e) {
emit(state.copyWith(status: StaffStatus.error, error: e.toString()));
}

View File

@@ -7,6 +7,7 @@ class StaffState extends Equatable {
final List<StaffMemberModel> allStaff;
final Map<String, List<StoreModel>> storesByStaff;
final Map<String, List<StaffMemberModel>> staffByStore;
final List<StaffMemberModel> storeStaff;
final String? error;
const StaffState({
@@ -14,6 +15,7 @@ class StaffState extends Equatable {
this.allStaff = const [],
this.storesByStaff = const {},
this.staffByStore = const {},
this.storeStaff = const [],
this.error,
});
@@ -22,6 +24,7 @@ class StaffState extends Equatable {
List<StaffMemberModel>? allStaff,
Map<String, List<StoreModel>>? storesByStaff,
Map<String, List<StaffMemberModel>>? staffByStore,
List<StaffMemberModel>? storeStaff,
String? error,
}) {
return StaffState(
@@ -29,6 +32,7 @@ class StaffState extends Equatable {
allStaff: allStaff ?? this.allStaff,
storesByStaff: storesByStaff ?? this.storesByStaff,
staffByStore: staffByStore ?? this.staffByStore,
storeStaff: storeStaff ?? this.storeStaff,
error: error,
);
}
@@ -39,6 +43,7 @@ class StaffState extends Equatable {
allStaff,
storesByStaff,
staffByStore,
storeStaff,
error,
];
}

View File

@@ -126,7 +126,7 @@ class _StaffScreenState extends State<StaffScreen> {
initialValue: _selectedStoreId,
decoration: const InputDecoration(labelText: "Filtra per Negozio"),
items: state.stores
.map((s) => DropdownMenuItem(value: s.id, child: Text(s.nome)))
.map((s) => DropdownMenuItem(value: s.id, child: Text(s.name)))
.toList(),
onChanged: (id) {
setState(() => _selectedStoreId = id);
@@ -355,7 +355,7 @@ class _StaffScreenState extends State<StaffScreen> {
store.id,
);
return FilterChip(
label: Text(store.nome),
label: Text(store.name),
selected: isSelected,
onSelected: (selected) {
setModalState(() {

View File

@@ -98,7 +98,7 @@ class StoreRepository {
)
''')
.eq('company_id', companyId)
.order('nome');
.order('name');
return (response as List).map((m) => StoreModel.fromMap(m)).toList();
} catch (e) {

View File

@@ -4,30 +4,30 @@ import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
class StoreModel extends Equatable {
final String? id;
final String nome;
final String name;
final String companyId;
final bool isActive;
final bool isPaid;
final DateTime? paymentExpiration;
final String indirizzo;
final String cap;
final String comune;
final String provincia;
final String address;
final String zipCode;
final String city;
final String province;
final List<ProviderModel> associatedProviders; // Provider associati
final List<StaffMemberModel>
associatedStaffMembers; // Membri dello staff associati
const StoreModel({
this.id,
required this.nome,
required this.name,
required this.companyId,
this.isActive = true,
this.isPaid = false,
this.paymentExpiration,
required this.indirizzo,
required this.cap,
required this.comune,
required this.provincia,
required this.address,
required this.zipCode,
required this.city,
required this.province,
this.associatedProviders = const [],
this.associatedStaffMembers = const [],
});
@@ -36,15 +36,15 @@ class StoreModel extends Equatable {
@override
List<Object?> get props => [
id,
nome,
name,
companyId,
isActive,
isPaid,
paymentExpiration,
indirizzo,
cap,
comune,
provincia,
address,
zipCode,
city,
province,
associatedProviders,
associatedStaffMembers,
];
@@ -52,29 +52,29 @@ class StoreModel extends Equatable {
// Il mitico copyWith per creare nuove istanze modificando solo ciò che serve
StoreModel copyWith({
String? id,
String? nome,
String? name,
String? companyId,
bool? isActive,
bool? isPaid,
DateTime? paymentExpiration,
String? indirizzo,
String? cap,
String? comune,
String? provincia,
String? address,
String? zipCode,
String? city,
String? province,
List<ProviderModel>? associatedProviders,
List<StaffMemberModel>? associatedStaffMembers,
}) {
return StoreModel(
id: id ?? this.id,
nome: nome ?? this.nome,
name: name ?? this.name,
companyId: companyId ?? this.companyId,
isActive: isActive ?? this.isActive,
isPaid: isPaid ?? this.isPaid,
paymentExpiration: paymentExpiration ?? this.paymentExpiration,
indirizzo: indirizzo ?? this.indirizzo,
cap: cap ?? this.cap,
comune: comune ?? this.comune,
provincia: provincia ?? this.provincia,
address: address ?? this.address,
zipCode: zipCode ?? this.zipCode,
city: city ?? this.city,
province: province ?? this.province,
associatedProviders: associatedProviders ?? this.associatedProviders,
associatedStaffMembers:
associatedStaffMembers ?? this.associatedStaffMembers,
@@ -83,12 +83,12 @@ class StoreModel extends Equatable {
factory StoreModel.empty() {
return const StoreModel(
nome: '',
name: '',
companyId: '',
indirizzo: '',
cap: '',
comune: '',
provincia: '',
address: '',
zipCode: '',
city: '',
province: '',
);
}
@@ -118,17 +118,17 @@ class StoreModel extends Equatable {
}
return StoreModel(
id: map['id'] as String,
nome: map['nome'],
name: map['name'],
companyId: map['company_id'] as String,
isActive: map['is_active'] ?? true,
isPaid: map['is_paid'] ?? false,
paymentExpiration: map['payment_expiration'] != null
? DateTime.parse(map['payment_expiration'])
: null,
indirizzo: map['indirizzo'],
cap: map['cap'],
comune: map['comune'],
provincia: map['provincia'],
address: map['address'],
zipCode: map['zip_code'],
city: map['city'],
province: map['province'],
associatedProviders: providers,
associatedStaffMembers: staffMembers,
);
@@ -137,16 +137,16 @@ class StoreModel extends Equatable {
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'nome': nome,
'name': name,
'company_id': companyId,
'is_active': isActive,
'is_paid': isPaid,
if (paymentExpiration != null)
'payment_expiration': paymentExpiration!.toIso8601String(),
'indirizzo': indirizzo,
'cap': cap,
'comune': comune,
'provincia': provincia,
'address': address,
'zip_code': zipCode,
'city': city,
'province': province,
};
}
}

View File

@@ -37,14 +37,14 @@ class _CreateStoreScreenState extends State<CreateStoreScreen> {
final company = context.read<SessionCubit>().state.company;
if (company != null) {
setState(() {
_indirizzoController.text = company.indirizzo;
_capController.text = company.cap;
_indirizzoController.text = company.address;
_capController.text = company.zipCode;
_comuneController.text =
company.citta; // Nel DB company è 'citta', store è 'comune'
_provinciaController.text = company.provincia;
company.city; // Nel DB company è 'citta', store è 'comune'
_provinciaController.text = company.province;
// Suggeriamo anche un nome se vuoto
if (_nomeController.text.isEmpty) {
_nomeController.text = '${company.ragioneSociale} - Sede';
_nomeController.text = '${company.name} - Sede';
}
});
@@ -68,12 +68,12 @@ class _CreateStoreScreenState extends State<CreateStoreScreen> {
}
final store = StoreModel(
nome: _nomeController.text.trim(),
name: _nomeController.text.trim(),
companyId: company.id!,
indirizzo: _indirizzoController.text.trim(),
cap: _capController.text.trim(),
comune: _comuneController.text.trim(),
provincia: _provinciaController.text.trim().toUpperCase(),
address: _indirizzoController.text.trim(),
zipCode: _capController.text.trim(),
city: _comuneController.text.trim(),
province: _provinciaController.text.trim().toUpperCase(),
);
context.read<StoreCubit>().createStore(store);

View File

@@ -53,11 +53,11 @@ class _StoreCardState extends State<StoreCard> {
color: widget.store.isActive ? context.accent : Colors.grey,
),
title: Text(
widget.store.nome,
widget.store.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
"${widget.store.comune} (${widget.store.provincia}) - ${widget.store.indirizzo}",
"${widget.store.city} (${widget.store.province}) - ${widget.store.address}",
),
trailing: Switch(
value: widget.store.isActive,
@@ -129,7 +129,7 @@ class _StoreCardState extends State<StoreCard> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Personale di ${store.nome}",
"Personale di ${store.name}",
style: context.titleLarge,
),
const SizedBox(height: 16),
@@ -184,14 +184,14 @@ class _StoreCardState extends State<StoreCard> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("Providers di ${store.nome}", style: context.titleLarge),
Text("Providers di ${store.name}", style: context.titleLarge),
const SizedBox(height: 16),
...state.allProviders.map((provider) {
final isAssociated = _tempAssociatedProviders.any(
(p) => p.id == provider.id,
);
return CheckboxListTile(
title: Text(provider.nome),
title: Text(provider.name),
value: isAssociated,
onChanged: (selected) {
if (selected == true) {

View File

@@ -24,11 +24,11 @@ class _StoreFormState extends State<StoreForm> {
void initState() {
super.initState();
if (widget.store != null) {
nomeController.text = widget.store!.nome;
indirizzoController.text = widget.store!.indirizzo;
capController.text = widget.store!.cap;
comuneController.text = widget.store!.comune;
provinciaController.text = widget.store!.provincia;
nomeController.text = widget.store!.name;
indirizzoController.text = widget.store!.address;
capController.text = widget.store!.zipCode;
comuneController.text = widget.store!.city;
provinciaController.text = widget.store!.province;
}
}
@@ -124,11 +124,11 @@ class _StoreFormState extends State<StoreForm> {
id: widget
.store
?.id, // Se nullo, Supabase ne crea uno nuovo
nome: nomeController.text,
indirizzo: indirizzoController.text,
cap: capController.text,
comune: comuneController.text,
provincia: provinciaController.text,
name: nomeController.text,
address: indirizzoController.text,
zipCode: capController.text,
city: comuneController.text,
province: provinciaController.text,
companyId: context
.read<SessionCubit>()
.state

View File

@@ -25,7 +25,7 @@ class OnboardingCubit extends Cubit<OnboardingState> {
Future<void> saveCompany(String companyName) async {
emit(state.copyWith(isLoading: true));
final company = CompanyModel.empty().copyWith(
ragioneSociale: companyName,
name: companyName,
userId: GetIt.I<SupabaseClient>().auth.currentUser!.id,
subscriptionTier: SubscriptionTier.pro,
subscriptionStatus: SubscriptionStatus.trialing,

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; // <-- IMPORTANTE per i formatter
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/master_data/store/models/store_model.dart';
@@ -134,12 +135,12 @@ class _StoreOnboardingFormState extends State<StoreOnboardingForm> {
if (_formKey.currentState!.validate()) {
// MIRACOLO DELLA FACTORY EMPTY!
final newStore = StoreModel.empty().copyWith(
nome: _nameCtrl.text.trim(),
indirizzo: _addressCtrl.text.trim(),
comune: _cityCtrl.text.trim(),
cap: _zipCodeCtrl.text.trim(),
name: _nameCtrl.text.trim(),
address: _addressCtrl.text.trim(),
city: _cityCtrl.text.trim(),
zipCode: _zipCodeCtrl.text.trim(),
// Formattiamo in maiuscolo qui, al momento del salvataggio!
provincia: _provinceCtrl.text.trim().toUpperCase(),
province: _provinceCtrl.text.trim().toUpperCase(),
);
context.read<OnboardingCubit>().saveStore(newStore);
}

View File

@@ -0,0 +1,389 @@
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/operations/data/operations_repository.dart';
import 'package:get_it/get_it.dart';
import 'package:image_picker/image_picker.dart';
part 'operation_files_events.dart';
part 'operation_files_state.dart';
class OperationFilesBloc
extends Bloc<OperationFilesEvent, OperationFilesState> {
final _repository = GetIt.I.get<OperationsRepository>();
final String? operationId;
OperationFilesBloc({this.operationId})
: super(
OperationFilesState(
status: OperationFilesStatus.initial,
operationId: operationId,
),
) {
on<OperationsavedEvent>(_onOperationsaved);
on<LoadOperationFilesEvent>(_onLoadOperationFiles);
on<AddOperationFilesEvent>(_onAddOperationFiles);
on<UploadOperationFilesEvent>(_onUploadOperationFiles);
on<DeleteOperationFilesEvent>(_onDeleteOperationFiles);
on<ToggleOperationFileSelectionEvent>(_onToggleOperationFileSelection);
on<LinkFilesToCustomerEvent>(_onLinkFilesToCustomer);
on<RenameOperationFileEvent>(_onRenameOperationFile);
on<DeleteSpecificOperationFileEvent>(_onDeleteSpecificOperationFiles);
on<SelectAllOperationFilesEvent>(_onSelectAllOperationFiles);
on<ClearOperationFileSelectionEvent>(_onClearOperationFileSelection);
// Se il BLoC nasce con un ID, accendiamo subito lo stream!
if (operationId != null) {
add(LoadOperationFilesEvent(operationId: operationId));
}
}
FutureOr<void> _onOperationsaved(
OperationsavedEvent event,
Emitter<OperationFilesState> emit,
) async {
// 1. Aggiorniamo l'ID e mettiamo in loading
emit(
state.copyWith(
operationId: event.operationId,
status: OperationFilesStatus.uploading,
),
);
// 2. RECUPERO E UPLOAD DEI FILE "PARCHEGGIATI" (Pratica Nuova)
if (state.localFiles.isNotEmpty) {
try {
final List<Future<void>> uploadTasks = [];
for (var file in state.localFiles) {
// Ricreiamo il PlatformFile dal nostro AttachmentModel
// così il repository lo accetta senza fare storie!
final fakePlatformFile = PlatformFile(
name: '${file.name}.${file.extension}',
size: file.fileSize,
bytes: file.localBytes,
);
uploadTasks.add(
_repository.uploadAndRegisterOperationFile(
operationId: event.operationId, // L'ID APPENA NATO!
pickedFile: fakePlatformFile,
),
);
}
// Lanciamo tutti gli upload in parallelo
await Future.wait(uploadTasks);
} catch (e) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: "Errore upload post-salvataggio: $e",
),
);
return; // Ci fermiamo qui se esplode qualcosa
}
}
// 3. FINE DEI GIOCHI! Svuotiamo i locali, passiamo a success e accendiamo lo Stream
emit(state.copyWith(localFiles: [], status: OperationFilesStatus.success));
add(LoadOperationFilesEvent(operationId: event.operationId));
}
FutureOr<void> _onLoadOperationFiles(
LoadOperationFilesEvent event,
Emitter<OperationFilesState> emit,
) async {
final currentId = event.operationId ?? state.operationId;
if (currentId != null) {
emit(state.copyWith(status: OperationFilesStatus.loading));
await emit.forEach(
_repository.getOperationFilesStream(currentId),
onData: (List<AttachmentModel> data) => state.copyWith(
status: OperationFilesStatus.success,
remoteFiles: data,
),
onError: (error, stackTrace) => state.copyWith(
status: OperationFilesStatus.failure,
error: error.toString(),
),
);
}
}
void _onAddOperationFiles(
AddOperationFilesEvent event,
Emitter<OperationFilesState> emit,
) async {
final currentId = state.operationId;
// BIVIO 1: PRATICA NUOVA (Nessun ID - salvataggio locale)
if (currentId == null) {
final companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
final newLocalFiles = event.files.map((file) {
return AttachmentModel(
id: null,
companyId: companyId,
operationId: '', // Sarà riempito al salvataggio
name: file.name.fileNameWithoutExtension(),
extension: file.name.fileExtension(),
storagePath: '',
fileSize: file.size,
localBytes: file.bytes,
);
}).toList();
emit(
state.copyWith(
localFiles: [...state.localFiles, ...newLocalFiles],
status: OperationFilesStatus.success,
),
);
return;
}
// BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID - Upload immediato)
emit(state.copyWith(status: OperationFilesStatus.uploading));
try {
final List<Future<void>> uploadTasks = [];
for (var file in event.files) {
uploadTasks.add(
_repository.uploadAndRegisterOperationFile(
operationId: currentId,
pickedFile: file,
),
);
}
await Future.wait(uploadTasks);
emit(state.copyWith(status: OperationFilesStatus.success));
} catch (e) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: e.toString(),
),
);
}
}
FutureOr<void> _onUploadOperationFiles(
UploadOperationFilesEvent event,
Emitter<OperationFilesState> emit,
) async {
if ((event.pickedFiles == null || event.pickedFiles!.isEmpty) &&
(event.photos == null || event.photos!.isEmpty)) {
return;
}
if (state.operationId == null) return;
emit(state.copyWith(status: OperationFilesStatus.uploading));
try {
final List<Future<void>> uploadTasks = [];
// 1. Gestione Documenti normali (PlatformFile)
if (event.pickedFiles != null) {
for (var file in event.pickedFiles!) {
uploadTasks.add(
_repository.uploadAndRegisterOperationFile(
operationId: state.operationId!,
pickedFile: file,
),
);
}
}
// 2. Gestione Foto Fotocamera (XFile)
if (event.photos != null) {
for (var photo in event.photos!) {
// Leggiamo i byte asincronamente
final bytes = await photo.readAsBytes();
final fileSize = await photo.length();
// Lo travestiamo da PlatformFile per passarlo al Repository!
final fakePlatformFile = PlatformFile(
name: photo.name,
size: fileSize,
bytes: bytes,
path: photo.path,
);
uploadTasks.add(
_repository.uploadAndRegisterOperationFile(
operationId: state.operationId!,
pickedFile: fakePlatformFile,
),
);
}
}
// Esecuzione parallela di tutti i documenti e foto
await Future.wait(uploadTasks);
emit(state.copyWith(status: OperationFilesStatus.success));
} catch (e) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: e.toString(),
),
);
}
}
FutureOr<void> _onDeleteOperationFiles(
DeleteOperationFilesEvent event,
Emitter<OperationFilesState> emit,
) async {
emit(state.copyWith(status: OperationFilesStatus.loading));
try {
await _repository.deleteOperationFiles(state.selectedFiles);
emit(
state.copyWith(status: OperationFilesStatus.success, selectedFiles: []),
);
} catch (e) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: e.toString(),
),
);
}
}
FutureOr<void> _onToggleOperationFileSelection(
ToggleOperationFileSelectionEvent event,
Emitter<OperationFilesState> emit,
) {
final selectedFiles = List<AttachmentModel>.from(state.selectedFiles);
if (selectedFiles.contains(event.file)) {
selectedFiles.remove(event.file);
} else {
selectedFiles.add(event.file);
}
emit(state.copyWith(selectedFiles: selectedFiles));
}
void _onSelectAllOperationFiles(
SelectAllOperationFilesEvent event,
Emitter<OperationFilesState> emit,
) {
// Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati
emit(state.copyWith(selectedFiles: state.allFiles));
}
void _onClearOperationFileSelection(
ClearOperationFileSelectionEvent event,
Emitter<OperationFilesState> emit,
) {
// Svuotiamo brutalmente la lista
emit(state.copyWith(selectedFiles: []));
}
FutureOr<void> _onLinkFilesToCustomer(
LinkFilesToCustomerEvent event,
Emitter<OperationFilesState> emit,
) async {
if (state.selectedFiles.isEmpty) return;
// BIVIO 1: PRATICA NUOVA (Modalità Locale)
if (state.operationId == null) {
// Mappiamo i file locali: se sono tra quelli selezionati, iniettiamo il customerId
final updatedLocalFiles = state.localFiles.map((file) {
if (state.selectedFiles.contains(file)) {
return file.copyWith(customerId: event.customerId);
}
return file;
}).toList();
emit(
state.copyWith(
localFiles: updatedLocalFiles,
selectedFiles: [], // Svuotiamo la selezione dopo averli associati
status: OperationFilesStatus.success, // o un toast di feedback
),
);
return;
}
// BIVIO 2: PRATICA ESISTENTE (Modalità Remota su DB)
emit(state.copyWith(status: OperationFilesStatus.loading));
try {
final List<Future<void>> linkTasks = [];
for (var file in state.selectedFiles) {
linkTasks.add(
_repository.copyFileToCustomer(
file: file,
customerId: event.customerId,
),
);
}
await Future.wait(linkTasks);
// Svuotiamo la selezione.
// NON serve aggiornare la lista a mano, perché il DB si aggiorna
// e lo Stream di Supabase spingerà automaticamente in UI i file aggiornati!
emit(
state.copyWith(status: OperationFilesStatus.success, selectedFiles: []),
);
} catch (e) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: "Errore associazione: $e",
),
);
}
}
FutureOr<void> _onRenameOperationFile(
RenameOperationFileEvent event,
Emitter<OperationFilesState> emit,
) async {
// BIVIO 1: File Locale (Bozza)
if (event.file.localBytes != null) {
final updatedLocalFiles = state.localFiles.map((f) {
if (f == event.file) {
return f.copyWith(name: event.newName);
}
return f;
}).toList();
emit(state.copyWith(localFiles: updatedLocalFiles));
return;
}
// BIVIO 2: File Remoto (Salvato su DB)
emit(state.copyWith(status: OperationFilesStatus.loading));
try {
await _repository.renameAttachment(event.file.id!, event.newName);
emit(state.copyWith(status: OperationFilesStatus.success));
} catch (e) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: "Errore rinomina: $e",
),
);
}
}
FutureOr<void> _onDeleteSpecificOperationFiles(
DeleteSpecificOperationFileEvent event,
Emitter<OperationFilesState> emit,
) {
if (event.file.localBytes != null) {
final updatedLocalFiles = state.localFiles
.where((f) => f != event.file)
.toList();
emit(state.copyWith(localFiles: updatedLocalFiles));
}
}
}

View File

@@ -0,0 +1,81 @@
part of 'operation_files_bloc.dart';
abstract class OperationFilesEvent extends Equatable {
const OperationFilesEvent();
@override
List<Object?> get props => [];
}
class OperationsavedEvent extends OperationFilesEvent {
final String operationId;
const OperationsavedEvent(this.operationId);
@override
List<Object?> get props => [operationId];
}
class LoadOperationFilesEvent extends OperationFilesEvent {
final String? operationId;
final AttachmentModel? operation;
const LoadOperationFilesEvent({this.operationId, this.operation});
@override
List<Object?> get props => [operationId, operation];
}
class AddOperationFilesEvent extends OperationFilesEvent {
final List<PlatformFile> files;
const AddOperationFilesEvent(this.files);
@override
List<Object?> get props => [files];
}
class UploadOperationFilesEvent extends OperationFilesEvent {
final List<PlatformFile>? pickedFiles;
final List<XFile>? photos;
const UploadOperationFilesEvent({this.pickedFiles, this.photos});
@override
List<Object?> get props => [pickedFiles, photos];
}
class LinkFilesToCustomerEvent extends OperationFilesEvent {
final String customerId;
const LinkFilesToCustomerEvent({required this.customerId});
@override
List<Object?> get props => [customerId];
}
class DeleteOperationFilesEvent extends OperationFilesEvent {}
class ToggleOperationFileSelectionEvent extends OperationFilesEvent {
final AttachmentModel file;
const ToggleOperationFileSelectionEvent(this.file);
}
class RenameOperationFileEvent extends OperationFilesEvent {
final AttachmentModel file;
final String newName;
const RenameOperationFileEvent(this.file, this.newName);
@override
List<Object?> get props => [file, newName];
}
class DeleteSpecificOperationFileEvent extends OperationFilesEvent {
final AttachmentModel file;
const DeleteSpecificOperationFileEvent(this.file);
@override
List<Object?> get props => [file];
}
class SelectAllOperationFilesEvent extends OperationFilesEvent {}
class ClearOperationFileSelectionEvent extends OperationFilesEvent {}

View File

@@ -0,0 +1,52 @@
part of 'operation_files_bloc.dart';
enum OperationFilesStatus { initial, loading, uploading, success, failure }
class OperationFilesState extends Equatable {
const OperationFilesState({
this.operationId,
required this.status,
this.error,
this.localFiles = const [],
this.remoteFiles = const [],
this.selectedFiles = const [],
});
final String? operationId;
final OperationFilesStatus status;
final String? error;
final List<AttachmentModel> localFiles;
final List<AttachmentModel> remoteFiles;
final List<AttachmentModel> selectedFiles;
@override
List<Object?> get props => [
operationId,
status,
error,
localFiles,
remoteFiles,
selectedFiles,
];
List<AttachmentModel> get allFiles => [...remoteFiles, ...localFiles];
OperationFilesState copyWith({
String? operationId,
OperationFilesStatus? status,
String? error,
List<AttachmentModel>? localFiles,
List<AttachmentModel>? remoteFiles,
List<AttachmentModel>? selectedFiles,
}) {
return OperationFilesState(
operationId: operationId ?? this.operationId,
status: status ?? this.status,
error: error,
localFiles: localFiles ?? this.localFiles,
remoteFiles: remoteFiles ?? this.remoteFiles,
selectedFiles: selectedFiles ?? this.selectedFiles,
);
}
}

View File

@@ -0,0 +1,304 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.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';
import 'package:collection/collection.dart';
import 'package:uuid/uuid.dart';
part 'operations_state.dart';
class OperationsCubit extends Cubit<OperationsState> {
final OperationsRepository _repository = GetIt.I<OperationsRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
final Uuid _uuid = const Uuid(); // Generatore di UUID per il batch
OperationsCubit()
: super(const OperationsState(status: OperationsStatus.initial));
// --- CARICAMENTO E PAGINAZIONE ---
Future<void> loadOperations({bool refresh = false}) async {
if (state.status == OperationsStatus.loading) return;
if (!refresh && state.hasReachedMax) return;
emit(
state.copyWith(
status: OperationsStatus.loading,
errorMessage: null,
allOperations: refresh ? [] : state.allOperations,
hasReachedMax: refresh ? false : state.hasReachedMax,
),
);
try {
final currentOffset = refresh ? 0 : state.allOperations.length;
final companyId = _sessionCubit.state.company?.id;
if (companyId == null) {
throw Exception("Company ID non trovato nella sessione");
}
final newOperations = await _repository.fetchOperations(
companyId: companyId,
offset: currentOffset,
limit: 50,
searchTerm: state.query,
dateRange: state.dateRange,
);
final bool reachedMax = newOperations.length < 50;
emit(
state.copyWith(
status: OperationsStatus.ready,
allOperations: refresh
? newOperations
: [...state.allOperations, ...newOperations],
hasReachedMax: reachedMax,
),
);
} catch (e) {
emit(
state.copyWith(
status: OperationsStatus.failure,
errorMessage: "Errore nel caricamento operazioni: $e",
),
);
}
}
// --- GESTIONE FILTRI ---
void updateFilters({String? query, DateTimeRange? range}) {
emit(
state.copyWith(
query: query ?? state.query,
dateRange: range ?? state.dateRange,
),
);
loadOperations(refresh: true);
}
void clearFilters() {
emit(state.copyWith(query: '', dateRange: null));
loadOperations(refresh: true);
}
void initOperationForm({
OperationModel? existingOperation,
String? operationId,
String? staffId,
String? staffDisplayName,
}) async {
if (existingOperation != null) {
emit(
state.copyWith(
currentOperation: existingOperation,
status: OperationsStatus.ready,
),
);
} else if (operationId != null) {
OperationModel? operationModel = state.allOperations.firstWhereOrNull(
(s) => s.id == operationId,
);
operationModel ??= await _repository.fetchOperationById(operationId);
emit(
state.copyWith(
currentOperation: operationModel,
status: OperationsStatus.ready,
),
);
} else {
// NUOVA PRATICA: Creiamo un nuovo Batch UUID
emit(
state.copyWith(
currentOperation: OperationModel(
storeId: _sessionCubit.state.currentStore?.id ?? '',
reference: '',
createdAt: DateTime.now(),
companyId: _sessionCubit.state.company!.id!,
status: OperationStatus.draft,
batchUuid: _uuid.v4(), // <-- GENERIAMO IL BATCH UNIVOCO
),
status: OperationsStatus.ready,
),
);
}
}
/// MAGIA PURA: Prepara il form per inserire un altro servizio nella stessa pratica.
/// Mantiene il Cliente, il Batch e lo Store, ma svuota il resto.
void prepareNextOperationInBatch() {
if (state.currentOperation == null) return;
final current = state.currentOperation!;
emit(
state.copyWith(
status: OperationsStatus.ready,
currentOperation: OperationModel(
companyId: current.companyId,
storeId: current.storeId,
storeDisplayName: current.storeDisplayName,
batchUuid: current.batchUuid, // <-- MANTIENE IL COLLEGAMENTO
customerId: current.customerId, // <-- MANTIENE IL CLIENTE
customerDisplayName: current.customerDisplayName,
status: OperationStatus.draft,
createdAt: DateTime.now(),
),
),
);
}
// --- PERSISTENZA ---
Future<void> saveCurrentOperation({
required OperationStatus targetStatus,
bool shouldPop = true,
}) async {
if (state.currentOperation == null) return;
emit(state.copyWith(status: OperationsStatus.saving, errorMessage: null));
try {
final operationToSave = state.currentOperation!.copyWith(
status: targetStatus,
);
final updatedOperation = await _repository.saveFullOperation(
operation: operationToSave,
);
emit(
state.copyWith(
// Se non facciamo Pop (es. l'utente vuole aggiungere un altro servizio), non killiamo l'operazione corrente
status: shouldPop
? OperationsStatus.saved
: OperationsStatus.savedNoPop,
currentOperation: shouldPop ? null : updatedOperation,
),
);
// Ricarica in background per la dashboard
loadOperations(refresh: true);
} catch (e) {
emit(
state.copyWith(
status: OperationsStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// --- RECUPERO OPERAZIONI DELLO STESSO BATCH (Per UI di riepilogo) ---
/// Puoi usare questa funzione se nella UI vuoi mostrare "Hai inserito 3 servizi in questa pratica"
List<OperationModel> getOperationsInCurrentBatch() {
if (state.currentOperation == null) return [];
final currentBatch = state.currentOperation!.batchUuid;
// Filtriamo dalla lista caricata (o potresti fare una query diretta a Supabase se preferisci)
return state.allOperations
.where(
(op) =>
op.batchUuid == currentBatch &&
op.id != state.currentOperation!.id,
)
.toList();
}
// --- GESTIONE DELLO STATO DEL FORM IN TEMPO REALE ---
void updateOperationFields({
String? customerId,
String? customerDisplayName,
String? type,
String? providerId,
String? providerDisplayName,
String? subtype,
String? description,
DateTime? expirationDate,
int? quantity,
String? modelId,
String? modelDisplayName,
String? staffId,
String? staffDisplayName,
// Aggiungiamo questi flag per forzare la pulizia dei campi quando cambi tipo
bool clearProvider = false,
bool clearType = false,
bool clearSubtype = false,
bool clearDescription = false,
bool clearExpiration = false,
bool clearQuantity = false,
bool clearModel = false,
}) {
if (state.currentOperation == null) return;
final current = state.currentOperation!;
// Creiamo il modello aggiornato
// ATTENZIONE: adatta questa logica in base a come è scritto il tuo copyWith!
int? newQuantity;
if (clearQuantity) {
newQuantity = 1;
}
if (quantity != null && quantity <= 0) {
newQuantity = 0;
}
if (quantity != null && quantity > 0) {
newQuantity = quantity;
}
final updated = current.copyWith(
customerId: customerId,
customerDisplayName: customerDisplayName,
// Se clearProvider è true, forziamo una stringa vuota (o null se il tuo modello lo supporta)
providerId: clearProvider ? null : (providerId ?? current.providerId),
providerDisplayName: clearProvider
? null
: (providerDisplayName ?? current.providerDisplayName),
quantity: newQuantity,
type: clearType ? null : (type ?? current.type),
description: clearDescription
? null
: (description ?? current.description),
subtype: clearSubtype ? null : (subtype ?? current.subtype),
expirationDate: clearExpiration
? null
: (expirationDate ?? current.expirationDate),
modelId: clearModel ? null : (modelId ?? current.modelId),
modelDisplayName: clearModel
? null
: (modelDisplayName ?? current.modelDisplayName),
staffId: staffId ?? current.staffId,
staffDisplayName: staffDisplayName ?? current.staffDisplayName,
);
emit(state.copyWith(currentOperation: updated));
}
// Metodo di utilità per calcolare la data X mesi da oggi
DateTime _calculateMonths(int months) {
final now = DateTime.now();
return DateTime(now.year, now.month + months, now.day);
}
// Quando l'utente seleziona un tipo, impostiamo il default
void setTypeWithSmartDefault(String type) {
DateTime? defaultDate;
if (type == 'Energy') defaultDate = _calculateMonths(24);
if (type == 'Fin') defaultDate = _calculateMonths(30);
if (type == 'Entertainment') defaultDate = _calculateMonths(12);
updateOperationFields(
type: type,
expirationDate: defaultDate,
clearProvider: true,
clearSubtype: true,
clearModel: true,
clearQuantity: true,
);
}
}

View File

@@ -1,6 +1,6 @@
part of 'services_cubit.dart';
part of 'operations_cubit.dart';
enum ServicesStatus {
enum OperationsStatus {
initial,
loading,
ready,
@@ -11,20 +11,20 @@ enum ServicesStatus {
failure,
}
class ServicesState extends Equatable {
final ServicesStatus status;
final List<ServiceModel> allServices;
final ServiceModel? currentService; // La bozza che stiamo editando
class OperationsState extends Equatable {
final OperationsStatus status;
final List<OperationModel> allOperations;
final OperationModel? currentOperation; // La bozza che stiamo editando
final String? errorMessage;
final String query;
final DateTimeRange? dateRange;
final bool hasReachedMax;
final bool isSavingDraft;
const ServicesState({
const OperationsState({
required this.status,
this.allServices = const [],
this.currentService,
this.allOperations = const [],
this.currentOperation,
this.errorMessage,
this.query = '',
this.dateRange,
@@ -32,20 +32,20 @@ class ServicesState extends Equatable {
this.isSavingDraft = false,
});
ServicesState copyWith({
ServicesStatus? status,
List<ServiceModel>? allServices,
ServiceModel? currentService,
OperationsState copyWith({
OperationsStatus? status,
List<OperationModel>? allOperations,
OperationModel? currentOperation,
String? errorMessage,
String? query,
DateTimeRange? dateRange,
bool? hasReachedMax,
bool? isSavingDraft,
}) {
return ServicesState(
return OperationsState(
status: status ?? this.status,
allServices: allServices ?? this.allServices,
currentService: currentService ?? this.currentService,
allOperations: allOperations ?? this.allOperations,
currentOperation: currentOperation ?? this.currentOperation,
errorMessage: errorMessage,
query: query ?? this.query,
dateRange: dateRange ?? this.dateRange,
@@ -57,8 +57,8 @@ class ServicesState extends Equatable {
@override
List<Object?> get props => [
status,
allServices,
currentService,
allOperations,
currentOperation,
errorMessage,
query,
dateRange,

View File

@@ -0,0 +1,305 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/operation_model.dart';
class OperationsRepository {
final _supabase = Supabase.instance.client;
final companyId = GetIt.I.get<SessionCubit>().state.company!.id;
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
Future<OperationModel> fetchOperationById(String id) async {
try {
final response = await _supabase
.from('operation')
.select('''
*,
customer(name),
store(name),
staff_member(name),
provider(name),
model(name_with_brand),
attachment(*)
''')
.eq('id', id)
.single();
return OperationModel.fromMap(response);
} catch (e) {
throw Exception('Errore nel caricamento del servizio: $e');
}
}
// --- RECUPERO PAGINATO CON FILTRI E JOIN ---
Future<List<OperationModel>> fetchOperations({
required String companyId,
required int offset,
int limit = 50,
String? searchTerm,
DateTimeRange? dateRange,
}) async {
try {
var query = _supabase
.from('operation')
.select('''
*,
customer(name),
store(name),
provider(name),
model(name_with_brand),
staff_member(name),
attachment(*)
''')
.eq('company_id', companyId);
// Filtro Range Date
if (dateRange != null) {
query = query
.gte('created_at', dateRange.start.toIso8601String())
.lte('created_at', dateRange.end.toIso8601String());
}
if (searchTerm != null && searchTerm.isNotEmpty) {
// Filtra sui campi della tabella principale O su quelli della tabella joinata
query = query.or(
'reference.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.ilike.%$searchTerm%',
);
}
final response = await query
.order('created_at', ascending: false)
.range(offset, offset + limit - 1);
return (response as List)
.map((map) => OperationModel.fromMap(map))
.toList();
} catch (e) {
throw Exception('$e');
}
}
Stream<List<OperationModel>> getLastStoreOperationsStream({
required String storeId,
required int limit,
}) {
return _supabase
.from('operation')
.stream(primaryKey: ['id'])
.eq('store_id', storeId)
.order('created_at', ascending: false)
.limit(limit)
.map(
(listOfMaps) =>
listOfMaps.map((map) => OperationModel.fromMap(map)).toList(),
);
}
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
Future<OperationModel> saveFullOperation({
required OperationModel operation,
}) async {
try {
// 1. Salvataggio classico dell'operazione corrente
final response = await _supabase
.from('operation')
.upsert(operation.toMap())
.select(
'*, provider(*), model(*), store(*), staff_member(*), customer(*), attachment(*)',
)
.single();
final savedOperation = OperationModel.fromMap(response);
// 2. ALLINEAMENTO BATCH SEMPRE ATTIVO!
if (operation.batchUuid.isNotEmpty) {
await _supabase
.from('operation')
.update({'note': operation.note}) // Spalmiamo la nota attuale
.eq(
'batch_uuid',
operation.batchUuid,
); // Su tutte le pratiche di questo scontrino
}
return savedOperation;
} catch (e) {
throw Exception("Errore nel salvataggio dell'operazione: $e");
}
}
// --- ELIMINAZIONE ---
Future<void> deleteOperation(String id) async {
try {
await _supabase.from('operation').delete().eq('id', id);
} catch (e) {
throw Exception('$e');
}
}
// --- RECUPERO TIPI CONTENUTI PIÙ FREQUENTI PER AUTOCOMPLETE ---
Future<List<String>> fetchTopEntertainmentTypes(String companyId) async {
try {
// Cerchiamo i tipi più frequenti associati ai servizi di questa company
// Nota: dobbiamo passare attraverso la tabella 'operation' per filtrare per company_id
final response = await _supabase
.from('operation')
.select('description')
.eq('company_id', companyId)
.eq('type', 'Entertainment')
.limit(50); // Prendiamo un campione
// Logica rapida per contare le occorrenze e prendere i primi 5
final Map<String, int> counts = {};
for (var item in (response as List)) {
final description = item['description'] as String;
counts[description] = (counts[description] ?? 0) + 1;
}
var sortedKeys = counts.keys.toList()
..sort((a, b) => counts[b]!.compareTo(counts[a]!));
return sortedKeys.take(5).toList();
} catch (e) {
return [
"Netflix",
"DAZN",
"Disney+",
"Sky",
]; // Fallback se non c'è ancora storia
}
}
/// Ascolta in tempo reale i file caricati per una pratica
Stream<List<AttachmentModel>> getOperationFilesStream(String operationId) {
return _supabase
.from('attachment')
.stream(primaryKey: ['id'])
.eq('operation_id', operationId)
.order('created_at', ascending: false)
.map(
(listOfMaps) =>
listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(),
);
}
Future<AttachmentModel> uploadAndRegisterOperationFile({
required String operationId,
required PlatformFile pickedFile,
}) async {
final cleanFileName = pickedFile.name.replaceAll(
RegExp(r'[^a-zA-Z0-9\.\-]'),
'_',
);
final storagePath =
'$companyId/operations/$operationId/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
final int fileSize = pickedFile.size;
final fileToSave = AttachmentModel(
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
operationId: operationId,
name: cleanFileName.fileNameWithoutExtension(),
extension: cleanFileName.fileExtension(),
storagePath: storagePath,
fileSize: fileSize,
);
final String mimeType = fileToSave.extension.toLowerCase() == 'pdf'
? 'application/pdf'
: 'image/${fileToSave.extension}';
try {
// Usiamo bytes invece del path per massima compatibilità
if (pickedFile.bytes == null && pickedFile.path == null) {
throw 'Impossibile leggere il contenuto del file';
}
// Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes
if (pickedFile.bytes != null) {
await _supabase.storage
.from('documents')
.uploadBinary(
storagePath,
pickedFile.bytes!,
fileOptions: FileOptions(contentType: mimeType, upsert: true),
);
}
final response = await _supabase
.from('attachment')
.insert(fileToSave.toMap())
.select()
.single();
return AttachmentModel.fromMap(response);
} catch (e) {
throw 'Errore durante l\'upload: $e';
}
}
Future<void> copyFileToCustomer({
required AttachmentModel file,
required String customerId,
}) async {
await _supabase
.from('attachment')
.update({'customer_id': customerId})
.eq('id', file.id!);
}
Future<void> renameAttachment(String id, String newName) async {
try {
await _supabase.from('attachment').update({'name': newName}).eq('id', id);
} catch (e) {
throw '$e';
}
}
Future<void> deleteSpecificOperationFile(AttachmentModel file) async {
try {
if (file.customerId == null) {
await _supabase.from('attachment').delete().eq('id', file.id!);
await _supabase.storage.from('documents').remove([file.storagePath!]);
} else {
await _supabase
.from('attachment')
.update({'operation_id': null})
.eq('id', file.id!);
}
} catch (e) {
throw '$e';
}
}
Future<void> deleteOperationFiles(List<AttachmentModel> files) async {
if (files.isEmpty) return;
// 1. Prepariamo le liste di ID e di Percorsi
final List<String> idsToDelete = [];
final List<String> idsToEdit = [];
final List<String> storagePathsToDelete = [];
for (var file in files) {
if (file.customerId == null) {
idsToDelete.add(file.id!);
storagePathsToDelete.add(file.storagePath!);
} else {
idsToEdit.add(file.id!);
}
}
try {
if (idsToDelete.isNotEmpty) {
await _supabase.from('attachment').delete().inFilter('id', idsToDelete);
await _supabase.storage.from('documents').remove(storagePathsToDelete);
}
if (idsToEdit.isNotEmpty) {
await _supabase
.from('attachment')
.update({'operation_id': null})
.inFilter('id', idsToEdit);
}
} on PostgrestException catch (e) {
throw 'Errore database: ${e.message}';
} catch (e) {
throw 'Errore durante l\'eliminazione dei file: $e';
}
}
}

View File

@@ -0,0 +1,248 @@
import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
enum OperationStatus {
ok('ok'),
waitingforaction('waiting_for_action'),
waitingforsupport('waiting_for_support'),
waitingfordeployment('waiting_for_deployment'),
ko('ko'),
draft('draft'),
canceled('canceled');
static OperationStatus fromString(String value) {
final normalizedValue = value.replaceAll('_', '').toLowerCase();
return OperationStatus.values.firstWhere(
(e) => e.name.toLowerCase() == normalizedValue,
);
}
final String supabaseName;
const OperationStatus(this.supabaseName);
}
class OperationModel extends Equatable {
final String? id;
final DateTime? createdAt;
final String type;
final String? subtype;
final String? providerId;
final String? providerDisplayName;
final String? modelId;
final String? modelDisplayName;
final String? description;
final DateTime? expirationDate;
final String note;
final bool showInDashboard;
final String batchUuid;
final String companyId;
final String storeId;
final String? storeDisplayName;
final int quantity;
final String? staffId;
final String? staffDisplayName;
final String? lastCampaignId;
final OperationStatus status;
final String? customerId;
final String? customerDisplayName;
final String reference;
// ALLEGATI (Aggiunto)
final List<AttachmentModel> attachments;
const OperationModel({
this.id,
this.createdAt,
this.type = '',
this.subtype,
this.providerId,
this.providerDisplayName,
this.modelId,
this.modelDisplayName,
this.description,
this.expirationDate,
this.note = '',
this.showInDashboard = true,
this.batchUuid = '',
required this.companyId,
this.storeId = '',
this.storeDisplayName,
this.quantity = 1,
this.staffId,
this.staffDisplayName,
this.lastCampaignId,
this.status = OperationStatus.draft,
this.customerId,
this.customerDisplayName,
this.reference = '',
this.attachments = const [],
});
OperationModel copyWith({
String? id,
DateTime? createdAt,
String? type,
String? subtype,
String? providerId,
String? providerDisplayName,
String? modelId,
String? modelDisplayName,
String? description,
DateTime? expirationDate,
String? note,
bool? showInDashboard,
String? batchUuid,
String? companyId,
String? storeId,
String? storeDisplayName,
int? quantity,
String? staffId,
String? staffDisplayName,
String? lastCampaignId,
OperationStatus? status,
String? customerId,
String? customerDisplayName,
String? reference,
List<AttachmentModel>? attachments,
}) => OperationModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
type: type ?? this.type,
subtype: subtype ?? this.subtype,
providerId: providerId ?? this.providerId,
providerDisplayName: providerDisplayName ?? this.providerDisplayName,
modelId: modelId ?? this.modelId,
modelDisplayName: modelDisplayName ?? this.modelDisplayName,
description: description ?? this.description,
expirationDate: expirationDate ?? this.expirationDate,
note: note ?? this.note,
showInDashboard: showInDashboard ?? this.showInDashboard,
batchUuid: batchUuid ?? this.batchUuid,
companyId: companyId ?? this.companyId,
storeId: storeId ?? this.storeId,
storeDisplayName: storeDisplayName ?? this.storeDisplayName,
quantity: quantity ?? this.quantity,
staffId: staffId ?? this.staffId,
staffDisplayName: staffDisplayName ?? this.staffDisplayName,
lastCampaignId: lastCampaignId ?? this.lastCampaignId,
status: status ?? this.status,
customerId: customerId ?? this.customerId,
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
reference: reference ?? this.reference,
attachments: attachments ?? this.attachments,
);
@override
List<Object?> get props => [
id,
createdAt,
type,
subtype,
providerId,
providerDisplayName,
modelId,
modelDisplayName,
description,
expirationDate,
note,
showInDashboard,
batchUuid,
companyId,
storeId,
storeDisplayName,
quantity,
staffId,
staffDisplayName,
lastCampaignId,
status,
customerId,
customerDisplayName,
reference,
attachments,
];
factory OperationModel.empty({required String companyId}) {
return OperationModel(id: null, createdAt: null, companyId: companyId);
}
factory OperationModel.fromMap(Map<String, dynamic> map) {
return OperationModel(
id: map['id'] as String?,
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
type: map['type'] as String? ?? '',
subtype: map['sub_type'] as String?,
// I campi relazionali nullabili restano rigorosamente null!
providerId: map['provider_id'] as String?,
// MAGIA ANTI-CRASH: Usiamo ?['chiave'] per non far esplodere i join vuoti
providerDisplayName: (map['provider']?['name'] as String?)?.myFormat(),
modelId: map['model_id'] as String?,
modelDisplayName: (map['model']?['name_with_brand'] as String?)
?.myFormat(),
description: map['description'] as String?,
expirationDate: map['expiration_date'] != null
? DateTime.parse(map['expiration_date'])
: null,
note: map['note'] as String? ?? '',
showInDashboard: map['show_in_dashboard'] as bool? ?? true,
batchUuid: map['batch_uuid'] as String? ?? '',
companyId: map['company_id'] as String,
storeId:
map['store_id'] as String? ??
'', // Questo è non-nullable nella tua classe
storeDisplayName: (map['store']?['name'] as String?)?.myFormat(),
quantity: map['quantity'] is int
? map['quantity']
: int.tryParse(map['quantity']?.toString() ?? '1') ?? 1,
staffId: map['staff_id'] as String?,
staffDisplayName: (map['staff_member']?['name'] as String?)?.myFormat(),
lastCampaignId: map['last_campaign_id'] as String?,
status: OperationStatus.fromString(map['status'] ?? 'draft'),
customerId: map['customer_id'] as String?,
customerDisplayName: (map['customer']?['name'] as String?)?.myFormat(),
attachments:
(map['attachment'] as List?)
?.map((x) => AttachmentModel.fromMap(x))
.toList() ??
const [],
reference: map['reference'] as String? ?? '',
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'type': type,
'sub_type': subtype,
'provider_id': providerId,
'model_id': modelId,
'description': description,
if (expirationDate != null)
'expiration_date': expirationDate!.toIso8601String(),
'note': note,
'show_in_dashboard': showInDashboard,
'batch_uuid': batchUuid,
'company_id': companyId,
'store_id': storeId,
'quantity': quantity,
if (staffId != null) 'staff_id': staffId,
if (lastCampaignId != null) 'last_campaign_id': lastCampaignId,
'status': status.supabaseName,
if (customerId != null) 'customer_id': customerId,
'reference': reference,
};
}
}

View File

@@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
class ServiceActionCard extends StatelessWidget {
class OperationActionCard extends StatelessWidget {
final String title;
final IconData icon;
final VoidCallback onTap;
final Color color;
final int count;
const ServiceActionCard({
const OperationActionCard({
super.key,
required this.title,
required this.icon,

View File

@@ -0,0 +1,481 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/operations/ui/widgets/customer_section.dart';
import 'package:flux/features/operations/ui/widgets/details_section.dart';
import 'package:flux/features/operations/ui/widgets/operation_files_section.dart';
import 'package:flux/features/operations/ui/widgets/staff_section.dart';
import 'package:get_it/get_it.dart'; // ASSICURATI DEL PATH
// import 'package:flux/features/attachments/ui/operation_files_section.dart';
class OperationFormScreen extends StatefulWidget {
final String? operationId;
final OperationModel? existingOperation;
const OperationFormScreen({
super.key,
this.operationId,
this.existingOperation,
});
@override
State<OperationFormScreen> createState() => _OperationFormScreenState();
}
class _OperationFormScreenState extends State<OperationFormScreen> {
final _formKey = GlobalKey<FormState>();
final _referenceController = TextEditingController();
final _noteController = TextEditingController();
final _freeTextSubtypeController = TextEditingController();
final _freeTextDescriptionController = TextEditingController();
final List<String> _availableTypes = [
'AL',
'MNP',
'NIP',
'UNICA',
'TELEPASS',
'Energy',
'Fin',
'Entertainment',
'Custom',
];
bool _isInitialized = false;
@override
void initState() {
super.initState();
final cubit = context.read<OperationsCubit>();
final currentLoggedStaff = GetIt.I
.get<SessionCubit>()
.state
.currentStaffMember!;
// 1. Diciamo al Cubit di prepararsi
cubit.initOperationForm(
existingOperation: widget.existingOperation,
operationId: widget.operationId,
staffId: currentLoggedStaff.id,
staffDisplayName: currentLoggedStaff.name,
);
// 2. IL TRUCCO MAGICO:
// Se abbiamo passato existingOperation, il Cubit si è appena aggiornato.
// Lo stato è già pronto, quindi sincronizziamo i controller SUBITO!
if (cubit.state.currentOperation != null) {
_syncTextControllers(cubit.state.currentOperation!);
}
}
@override
void dispose() {
_referenceController.dispose();
_noteController.dispose();
_freeTextSubtypeController.dispose();
super.dispose();
}
void _syncTextControllers(OperationModel model) {
if (_referenceController.text.isEmpty && model.reference.isNotEmpty) {
_referenceController.text = model.reference;
}
if (_noteController.text.isEmpty && model.note.isNotEmpty) {
_noteController.text = model.note;
}
if (_freeTextSubtypeController.text.isEmpty &&
model.subtype != null &&
model.subtype!.isNotEmpty) {
_freeTextSubtypeController.text = model.subtype!;
}
if (_freeTextDescriptionController.text.isEmpty &&
model.description != null &&
model.description!.isNotEmpty) {
_freeTextDescriptionController.text = model.description!;
}
_isInitialized = true;
}
void _saveOperation({required bool keepAdding}) {
if (_formKey.currentState!.validate()) {
final cubit = context.read<OperationsCubit>();
final currentOperation = cubit.state.currentOperation!;
final operationToSave = currentOperation.copyWith(
reference: _referenceController.text,
note: _noteController.text,
subtype: ['Entertainment', 'Custom'].contains(currentOperation.type)
? _freeTextSubtypeController.text
: currentOperation.subtype,
description: ['Energy', 'Custom'].contains(currentOperation.type)
? _freeTextDescriptionController.text
: currentOperation.description,
);
cubit.initOperationForm(existingOperation: operationToSave);
cubit.saveCurrentOperation(
targetStatus: OperationStatus.ok,
shouldPop: !keepAdding,
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocConsumer<OperationsCubit, OperationsState>(
listenWhen: (previous, current) =>
previous.status != current.status ||
previous.currentOperation?.id != current.currentOperation?.id,
listener: (context, state) {
if (state.status == OperationsStatus.ready &&
state.currentOperation != null &&
!_isInitialized) {
_syncTextControllers(state.currentOperation!);
}
if (state.status == OperationsStatus.saved) {
Navigator.of(context).pop();
} else if (state.status == OperationsStatus.savedNoPop) {
context.read<OperationsCubit>().prepareNextOperationInBatch();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Servizio aggiunto! Inserisci il prossimo.'),
),
);
_freeTextSubtypeController.clear();
_freeTextDescriptionController.clear();
} else if (state.status == OperationsStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Errore'),
backgroundColor: theme.colorScheme.error,
),
);
}
},
builder: (context, state) {
if (!_isInitialized &&
(widget.operationId != null || widget.existingOperation != null) &&
state.status == OperationsStatus.loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: Text(
state.currentOperation?.id == null
? 'Nuova Pratica'
: 'Modifica Pratica',
),
),
body: Form(
key: _formKey,
child: LayoutBuilder(
builder: (context, constraints) {
final isUltraWide = constraints.maxWidth > 1400;
final isDesktop = constraints.maxWidth > 900;
if (isUltraWide) {
// --- LAYOUT 3 COLONNE (Schermi giganti) ---
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. FORM PRINCIPALE (40%)
Expanded(
flex: 4,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
// Attenzione: devi togliere la sezione file dal _buildMainFormContent!
child: _buildMainFormContent(
theme,
state,
showFiles: false,
),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
// 2. NOTE (30%)
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _buildNotesSection(isDesktop: true),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
// 3. FILE (30%)
Expanded(
flex: 3,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: OperationFilesSection(
currentOp: state.currentOperation!,
),
),
),
],
);
} else if (isDesktop) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 7,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: _buildMainFormContent(theme, state),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _buildNotesSection(isDesktop: true),
),
),
],
);
} else {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMainFormContent(theme, state),
const Divider(height: 32),
_buildNotesSection(isDesktop: false),
const SizedBox(height: 80),
],
),
);
}
},
),
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
flex: 1,
child: OutlinedButton(
onPressed: state.status == OperationsStatus.saving
? null
: () => _saveOperation(keepAdding: true),
child: const Text(
'Salva e Aggiungi Altro',
textAlign: TextAlign.center,
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: ElevatedButton(
onPressed: state.status == OperationsStatus.saving
? null
: () => _saveOperation(keepAdding: false),
child: state.status == OperationsStatus.saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text('Salva ed Esci'),
),
),
],
),
),
),
);
},
);
}
Widget _buildMainFormContent(
ThemeData theme,
OperationsState state, {
bool showFiles = true,
}) {
final currentOp = state.currentOperation;
final currentType = currentOp?.type ?? 'AL';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StaffSection(currentOp: currentOp),
const Divider(height: 50),
_buildSectionTitle('Cliente & Riferimento'),
CustomerSection(currentOp: currentOp),
const SizedBox(height: 16),
TextFormField(
controller: _referenceController,
decoration: const InputDecoration(
labelText: 'Riferimento (es. numero di telefono, targa...)',
prefixIcon: Icon(Icons.tag),
),
),
const Divider(height: 32),
_buildSectionTitle('Cosa stiamo facendo?'),
Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: _availableTypes.map((type) {
return ChoiceChip(
label: Text(type),
selected: currentType == type,
onSelected: (selected) {
if (selected) {
context.read<OperationsCubit>().setTypeWithSmartDefault(type);
}
},
);
}).toList(),
),
const Divider(height: 32),
_buildSectionTitle('Dettagli Servizio'),
DetailsSection(
currentOp: currentOp,
currentType: currentType,
freeTextSubtypeController: _freeTextSubtypeController,
freeTextDescriptionController: _freeTextDescriptionController,
durationQuickPicks: _buildDurationQuickPicks(currentOp),
),
// QUANTITÀ
Row(
children: [
const Text('Quantità: '),
IconButton(
icon: const Icon(Icons.remove),
onPressed: () {
final q = currentOp?.quantity ?? 1;
if (q > 1) {
context.read<OperationsCubit>().updateOperationFields(
quantity: q - 1,
);
}
},
),
Text(
'${currentOp?.quantity ?? 1}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
final q = currentOp?.quantity ?? 1;
context.read<OperationsCubit>().updateOperationFields(
quantity: q + 1,
);
},
),
],
),
const Divider(height: 32),
if (showFiles) ...[OperationFilesSection(currentOp: currentOp!)],
],
);
}
Widget _buildDurationQuickPicks(OperationModel? currentOp) {
final durations = [3, 6, 12, 24, 30, 36, 48];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Imposta durata rapida (mesi):",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: durations.map((months) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ActionChip(
label: Text("$months m"),
backgroundColor: Colors.blue.withValues(alpha: 0.05),
onPressed: () {
final now = DateTime.now();
context.read<OperationsCubit>().updateOperationFields(
expirationDate: DateTime(
now.year,
now.month + months,
now.day,
),
);
},
),
);
}).toList(),
),
),
],
);
}
Widget _buildNotesSection({required bool isDesktop}) {
final title = _buildSectionTitle('Note Interne');
final noteField = TextFormField(
controller: _noteController,
keyboardType: TextInputType.multiline,
minLines: isDesktop ? null : 5,
maxLines: null,
expands: isDesktop,
textAlignVertical: TextAlignVertical.top,
decoration: const InputDecoration(
hintText: 'Incolla qui seriali, ICCID, IBAN, indirizzi...',
alignLabelWithHint: true,
border: OutlineInputBorder(),
),
);
return isDesktop
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title,
const SizedBox(height: 8),
Expanded(child: noteField),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [title, const SizedBox(height: 8), noteField],
);
}
Widget _buildSectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
title,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
);
}
}

View File

@@ -1,26 +1,27 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flux/features/services/blocs/service_files_bloc.dart';
class ServiceMobileUploadScreen extends StatefulWidget {
final String serviceId;
final String serviceName;
class OperationMobileUploadScreen extends StatefulWidget {
final String operationId;
final String operationName;
const ServiceMobileUploadScreen({
const OperationMobileUploadScreen({
super.key,
required this.serviceId,
required this.serviceName,
required this.operationId,
required this.operationName,
});
@override
State<ServiceMobileUploadScreen> createState() =>
_ServiceMobileUploadScreenState();
State<OperationMobileUploadScreen> createState() =>
_OperationMobileUploadScreenState();
}
class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
class _OperationMobileUploadScreenState
extends State<OperationMobileUploadScreen> {
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
final List<PlatformFile> _stagedFiles = [];
@@ -35,10 +36,10 @@ class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
@override
Widget build(BuildContext context) {
return BlocListener<ServiceFilesBloc, ServiceFilesState>(
return BlocListener<OperationFilesBloc, OperationFilesState>(
listener: (context, state) {
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
if (state.status == ServiceFilesStatus.success && _isUploading) {
if (state.status == OperationFilesStatus.success && _isUploading) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Tutti i file caricati con successo! ✅"),
@@ -46,7 +47,7 @@ class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
);
Navigator.of(context).pop();
}
if (state.status == ServiceFilesStatus.failure) {
if (state.status == OperationFilesStatus.failure) {
setState(() => _isUploading = false);
ScaffoldMessenger.of(
context,
@@ -55,7 +56,7 @@ class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
},
child: Scaffold(
appBar: AppBar(
title: Text("Upload Pratica:\n${widget.serviceName}"),
title: Text("Upload Pratica:\n${widget.operationName}"),
automaticallyImplyLeading: !_isUploading,
),
body: Stack(
@@ -294,8 +295,8 @@ class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
// Diciamo al BLoC di caricare tutti i file.
// Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda)
final bloc = context.read<ServiceFilesBloc>();
bloc.add(UploadMultipleServiceFilesEvent(_stagedFiles));
final bloc = context.read<OperationFilesBloc>();
bloc.add(UploadOperationFilesEvent(pickedFiles: _stagedFiles));
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
}

View File

@@ -1,19 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/models/service_model.dart';
import 'package:flux/features/services/utils/service_actions.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:go_router/go_router.dart';
// Importa i tuoi modelli e cubit
class ServicesScreen extends StatefulWidget {
const ServicesScreen({super.key});
class OperationsScreen extends StatefulWidget {
const OperationsScreen({super.key});
@override
State<ServicesScreen> createState() => _ServicesScreenState();
State<OperationsScreen> createState() => _OperationsScreenState();
}
class _ServicesScreenState extends State<ServicesScreen> {
class _OperationsScreenState extends State<OperationsScreen> {
final ScrollController _scrollController = ScrollController();
@override
@@ -22,12 +21,12 @@ class _ServicesScreenState extends State<ServicesScreen> {
// Agganciamo il listener per la paginazione (Scroll Infinito)
_scrollController.addListener(_onScroll);
// Carichiamo i servizi iniziali
context.read<ServicesCubit>().loadServices();
context.read<OperationsCubit>().loadOperations();
}
void _onScroll() {
if (_isBottom) {
context.read<ServicesCubit>().loadServices();
context.read<OperationsCubit>().loadOperations();
}
}
@@ -60,16 +59,16 @@ class _ServicesScreenState extends State<ServicesScreen> {
),
],
),
body: BlocBuilder<ServicesCubit, ServicesState>(
body: BlocBuilder<OperationsCubit, OperationsState>(
builder: (context, state) {
// 1. Stato di caricamento iniziale
if (state.status == ServicesStatus.loading &&
state.allServices.isEmpty) {
if (state.status == OperationsStatus.loading &&
state.allOperations.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
// 2. Lista vuota
if (state.allServices.isEmpty) {
if (state.allOperations.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -77,9 +76,9 @@ class _ServicesScreenState extends State<ServicesScreen> {
const Text("Nessuna pratica trovata."),
const SizedBox(height: 10),
ElevatedButton(
onPressed: () => context.read<ServicesCubit>().loadServices(
refresh: true,
),
onPressed: () => context
.read<OperationsCubit>()
.loadOperations(refresh: true),
child: const Text("Riprova"),
),
],
@@ -90,15 +89,15 @@ class _ServicesScreenState extends State<ServicesScreen> {
// 3. La Lista (con Pull-to-refresh)
return RefreshIndicator(
onRefresh: () =>
context.read<ServicesCubit>().loadServices(refresh: true),
context.read<OperationsCubit>().loadOperations(refresh: true),
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.only(bottom: 80), // Spazio per il FAB
itemCount: state.hasReachedMax
? state.allServices.length
: state.allServices.length + 1,
? state.allOperations.length
: state.allOperations.length + 1,
itemBuilder: (context, index) {
if (index >= state.allServices.length) {
if (index >= state.allOperations.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
@@ -107,21 +106,21 @@ class _ServicesScreenState extends State<ServicesScreen> {
);
}
final service = state.allServices[index];
return _buildServiceCard(context, service);
final operation = state.allOperations[index];
return _buildOperationCard(context, operation);
},
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => startNewService(context),
onPressed: () => startNewOperation(context),
child: const Icon(Icons.add),
),
);
}
Widget _buildServiceCard(BuildContext context, ServiceModel service) {
Widget _buildOperationCard(BuildContext context, OperationModel operation) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
elevation: 2,
@@ -132,22 +131,13 @@ class _ServicesScreenState extends State<ServicesScreen> {
children: [
Expanded(
child: Text(
service.customerDisplayName ?? "Cliente sconosciuto",
operation.customerDisplayName ?? "Cliente sconosciuto",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
if (service.isBozza)
const Chip(
label: Text(
"BOZZA",
style: TextStyle(fontSize: 10, color: Colors.white),
),
backgroundColor: Colors.orange,
visualDensity: VisualDensity.compact,
),
],
),
subtitle: Column(
@@ -155,52 +145,56 @@ class _ServicesScreenState extends State<ServicesScreen> {
children: [
const SizedBox(height: 4),
Text(
"Pratica: ${service.number}${service.createdAt?.day}/${service.createdAt?.month}/${service.createdAt?.year}",
"Pratica: ${operation.reference}${operation.createdAt?.day}/${operation.createdAt?.month}/${operation.createdAt?.year}",
),
const SizedBox(height: 8),
// I nostri mini-chip per i servizi attivati
Wrap(
spacing: 6,
Row(
children: [
if (service.al > 0 || service.mnp > 0)
_miniBadge("📞 Tel", Colors.blue),
if (service.energyServices.isNotEmpty)
_miniBadge("⚡ Energy", Colors.green),
if (service.finServices.isNotEmpty)
_miniBadge("💰 Fin", Colors.purple),
if (service.entertainmentServices.isNotEmpty)
_miniBadge("📺 Ent", Colors.red),
Text(operation.type),
const SizedBox(width: 8),
_buildOperationStatus(operation.status),
],
),
],
),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.pushNamed(
'service-form',
extra: service, // <-- LA MAGIA È QUI: Passa l'oggetto intero!
'operation-form',
extra: operation, // <-- LA MAGIA È QUI: Passa l'oggetto intero!
// Teniamo anche il parametro URL per coerenza di routing
queryParameters: service.id != null ? {'serviceId': service.id!} : {},
queryParameters: operation.id != null
? {'operationId': operation.id!}
: {},
),
),
);
}
Widget _miniBadge(String text, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withValues(alpha: 0.5)),
),
child: Text(
text,
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
Widget _buildOperationStatus(OperationStatus status) {
Color color;
switch (status) {
case OperationStatus.canceled || OperationStatus.ko:
color = Colors.grey.shade800;
break;
case OperationStatus.waitingforaction || OperationStatus.draft:
color = Colors.orange;
break;
case OperationStatus.ok:
color = Colors.green;
break;
case OperationStatus.waitingfordeployment ||
OperationStatus.waitingforsupport:
color = Colors.blue;
break;
}
return Chip(
label: Text("BOZZA", style: TextStyle(fontSize: 10, color: Colors.white)),
backgroundColor: color,
visualDensity: VisualDensity.compact,
);
}
void startNewOperation(BuildContext context) {
context.pushNamed('operation-form');
}
}

View File

@@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/customers/blocs/customers_cubit.dart';
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
class CustomerSection extends StatelessWidget {
final OperationModel? currentOp;
const CustomerSection({super.key, required this.currentOp});
@override
Widget build(BuildContext context) {
final hasCustomer =
currentOp?.customerId != null && currentOp!.customerId!.isNotEmpty;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
'Cliente',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
InkWell(
onTap: () => _showCustomerModal(context), // Passiamo il context!
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.primary),
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.2),
),
child: Row(
children: [
const Icon(Icons.person),
const SizedBox(width: 12),
Expanded(
child: Text(
hasCustomer
? currentOp!.customerDisplayName!
: 'Seleziona Cliente *',
style: TextStyle(
fontWeight: hasCustomer
? FontWeight.bold
: FontWeight.normal,
color: hasCustomer ? null : Colors.grey,
),
),
),
const Icon(Icons.search),
],
),
),
),
],
);
}
// --- MODALE SELEZIONE CLIENTE ---
void _showCustomerModal(BuildContext context) {
String currentSearchQuery = '';
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.8,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
// Header
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Cliente',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
// Barra di Ricerca
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
autofocus: true,
decoration: InputDecoration(
hintText: 'Cerca per nome, telefono o email...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) {
currentSearchQuery = query;
context.read<CustomersCubit>().searchCustomers(query);
},
),
),
// Pulsante Nuovo Cliente
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
icon: const Icon(Icons.person_add),
label: const Text('Crea Nuovo Cliente'),
onPressed: () async {
final OperationsCubit operationsCubit = context
.read<OperationsCubit>();
// APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER
final newCustomer = await showDialog(
context: context,
builder: (dialogContext) {
return BlocProvider.value(
value: context.read<CustomersCubit>(),
child: QuickCustomerDialog(
initialQuery:
currentSearchQuery, // <-- Passiamo quello che ha digitato!
),
);
},
);
// Se l'ha creato davvero (e non ha premuto annulla)...
if (newCustomer != null) {
// 1. Aggiorniamo il form delle operazioni
operationsCubit.updateOperationFields(
customerId: newCustomer.id,
customerDisplayName: newCustomer.name,
);
// 2. Chiudiamo la BottomSheet dei clienti per tornare alla form!
if (context.mounted) {
Navigator.pop(modalContext);
}
}
},
),
),
const Divider(),
// Lista Clienti dal Bloc
Expanded(
child: BlocBuilder<CustomersCubit, CustomersState>(
builder: (context, state) {
if (state.status == CustomersStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.customers.isEmpty) {
return const Center(
child: Text(
'Nessun cliente trovato.',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
controller: scrollController,
itemCount: state.customers.length,
itemBuilder: (context, index) {
final customer = state.customers[index];
return ListTile(
leading: CircleAvatar(
child: Text(
customer.name.substring(0, 1).toUpperCase(),
),
),
title: Text(
customer.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
'${customer.phoneNumber}${customer.email}',
),
onTap: () {
// Aggiorniamo il form tramite il Cubit delle operazioni
context
.read<OperationsCubit>()
.updateOperationFields(
customerId: customer.id, // customer.id
customerDisplayName:
customer.name, // customer.name
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
}

View File

@@ -0,0 +1,423 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
import 'package:flux/features/master_data/products/ui/quick_product_dialog.dart';
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
class DetailsSection extends StatelessWidget {
final OperationModel? currentOp;
final String currentType;
final TextEditingController freeTextSubtypeController;
final TextEditingController freeTextDescriptionController;
final Widget durationQuickPicks;
const DetailsSection({
super.key,
required this.currentOp,
required this.currentType,
required this.freeTextSubtypeController,
required this.freeTextDescriptionController,
required this.durationQuickPicks,
});
bool _doesProviderMatchOperationType(dynamic provider, String operationType) {
if (operationType == 'Custom') return true;
switch (operationType) {
case 'AL':
case 'MNP':
return provider.mobile == true;
case 'NIP':
return provider.landline == true;
case 'UNICA':
return provider.landline == true || provider.mobile == true;
case 'Energy':
return provider.energy == true;
case 'Fin':
return provider.financing == true;
case 'Entertainment':
return provider.entertainment == true;
case 'TELEPASS':
return provider.telepass == true;
default:
return true;
}
}
void _showProviderModal(BuildContext context, String operationType) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.5,
minChildSize: 0.4,
maxChildSize: 0.8,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Gestore',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
const Divider(),
Expanded(
child: BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final allProviders = state.activeProviders;
final filteredProviders = allProviders
.where(
(p) => _doesProviderMatchOperationType(
p,
operationType,
),
)
.toList();
if (filteredProviders.isEmpty) {
return const Center(
child: Text(
'Nessun gestore compatibile con questo servizio.',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
controller: scrollController,
itemCount: filteredProviders.length,
itemBuilder: (context, index) {
final provider = filteredProviders[index];
return ListTile(
leading: const Icon(Icons.business),
title: Text(
provider.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
onTap: () {
context
.read<OperationsCubit>()
.updateOperationFields(
providerId: provider.id,
providerDisplayName: provider.name,
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
void _showModelModal(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Modello',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
decoration: InputDecoration(
hintText: 'Cerca modello (es. iPhone 15...)',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) =>
context.read<ProductsCubit>().searchModels(query),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
icon: const Icon(Icons.add),
label: const Text('Aggiungi Modello al Volo'),
onPressed: () async {
final operationsCubit = context.read<OperationsCubit>();
final existingBrands = context
.read<ProductsCubit>()
.state
.brands;
final newModel = await showDialog(
context: context,
builder: (dialogContext) {
return BlocProvider.value(
value: context.read<ProductsCubit>(),
child: QuickProductDialog(
existingBrands: existingBrands,
),
);
},
);
if (newModel != null) {
operationsCubit.updateOperationFields(
modelId: newModel.id,
modelDisplayName: newModel.nameWithBrand,
);
if (context.mounted) Navigator.pop(modalContext);
}
},
),
),
const Divider(),
Expanded(
child: BlocBuilder<ProductsCubit, ProductState>(
builder: (context, state) {
return ListView.builder(
controller: scrollController,
itemCount: state.models.length,
itemBuilder: (context, index) {
final deviceModel = state.models[index];
return ListTile(
leading: const Icon(Icons.devices),
title: Text(
deviceModel.nameWithBrand,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
onTap: () {
context
.read<OperationsCubit>()
.updateOperationFields(
modelId: deviceModel.id,
modelDisplayName: deviceModel.nameWithBrand,
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// PROVIDER (Mostrato quasi sempre)
ListTile(
title: const Text('Seleziona Gestore'),
subtitle: Text(
(currentOp?.providerDisplayName != null &&
currentOp!.providerDisplayName!.isNotEmpty)
? currentOp!.providerDisplayName!
: 'Nessun gestore selezionato',
style: TextStyle(
color:
(currentOp?.providerId == null ||
currentOp!.providerId!.isEmpty)
? Colors.grey
: null,
fontWeight:
(currentOp?.providerId == null ||
currentOp!.providerId!.isEmpty)
? FontWeight.normal
: FontWeight.bold,
),
),
trailing: const Icon(Icons.arrow_drop_down),
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
onTap: () => _showProviderModal(context, currentType),
),
const SizedBox(height: 16),
// 1. SCENARIO ENERGY (Dropdown Fisso)
if (currentType == 'Energy') ...[
DropdownButtonFormField<String>(
initialValue:
(currentOp?.subtype != null && currentOp!.subtype!.isNotEmpty)
? currentOp!.subtype
: null,
decoration: const InputDecoration(labelText: 'Dettaglio Fornitura'),
items: [
'Luce',
'Gas',
].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (val) {
if (val != null) {
context.read<OperationsCubit>().updateOperationFields(
subtype: val,
);
}
},
),
const SizedBox(height: 16),
TextFormField(
controller: freeTextDescriptionController,
decoration: InputDecoration(
labelText: currentType == 'Energy'
? 'Offerta scelta'
: 'Nome del servizio/offerta',
),
),
const SizedBox(height: 16),
],
// 2. SCENARIO FIN (Ricerca Modello/Prodotto)
if (currentType == 'Fin') ...[
ListTile(
title: const Text('Seleziona Dispositivo/Prodotto'),
subtitle: Text(
(currentOp?.modelDisplayName != null &&
currentOp!.modelDisplayName!.isNotEmpty)
? currentOp!.modelDisplayName!
: 'Nessun modello selezionato',
style: TextStyle(
color:
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
? Colors.grey
: null,
fontWeight:
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
? FontWeight.normal
: FontWeight.bold,
),
),
trailing: const Icon(Icons.arrow_drop_down),
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
onTap: () => _showModelModal(context),
),
const SizedBox(height: 16),
],
// 3. SCENARIO ENTERTAINMENT O CUSTOM (Testo libero)
if (currentType == 'Entertainment' || currentType == 'Custom') ...[
TextFormField(
controller: freeTextSubtypeController,
decoration: InputDecoration(
labelText: currentType == 'Entertainment'
? 'Piattaforma (es. Netflix, DAZN, Spotify...)'
: 'Specifica il servizio (es. Monopattino)',
),
),
const SizedBox(height: 16),
],
// SCADENZA (Reattivo per tipi complessi)
if ([
'Energy',
'Fin',
'Entertainment',
'Custom',
].contains(currentType)) ...[
const SizedBox(height: 8),
durationQuickPicks, // Passiamo i chips dall'esterno
const SizedBox(height: 16),
ListTile(
title: const Text('Data di Scadenza Effettiva'),
subtitle: Text(
currentOp?.expirationDate != null
? "${currentOp!.expirationDate!.day}/${currentOp!.expirationDate!.month}/${currentOp!.expirationDate!.year}"
: 'Nessuna scadenza impostata',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
trailing: const Icon(Icons.calendar_month, color: Colors.blue),
tileColor: Colors.blue.withValues(alpha: 0.05),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Colors.blue, width: 0.5),
),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate:
currentOp?.expirationDate ??
DateTime.now().add(const Duration(days: 365)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 3650)),
);
if (date != null && context.mounted) {
context.read<OperationsCubit>().updateOperationFields(
expirationDate: date,
);
}
},
),
const SizedBox(height: 16),
],
],
);
}
}

View File

@@ -0,0 +1,761 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flux/features/attachments/data/attachments_repository.dart';
import 'package:flux/features/attachments/ui/attachment_viewer_screen.dart';
import 'package:flux/features/attachments/ui/quick_rename_dialog.dart';
import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:pdf/pdf.dart' as p; // Se ti serve formattazione core
import 'package:pdfx/pdfx.dart' as px; // Isoliamo pdfx
class _ExportItem {
final Uint8List bytes;
final String sourceName;
final bool isMultiPage;
final int pageIndex;
_ExportItem({
required this.bytes,
required this.sourceName,
required this.isMultiPage,
required this.pageIndex,
});
}
class OperationFilesSection extends StatefulWidget {
final OperationModel currentOp;
const OperationFilesSection({super.key, required this.currentOp});
@override
State<OperationFilesSection> createState() => _OperationFilesSectionState();
}
class _OperationFilesSectionState extends State<OperationFilesSection> {
String? _exportDirectory;
@override
void initState() {
super.initState();
_loadExportDirectory();
}
// --- GESTIONE CARTELLA CITRIX (SOLO DESKTOP) ---
Future<void> _loadExportDirectory() async {
if (kIsWeb) return;
final prefs = await SharedPreferences.getInstance();
setState(() {
_exportDirectory = prefs.getString('citrix_export_path');
});
}
Future<void> _selectExportDirectory() async {
final String? selectedDirectory = await FilePicker.getDirectoryPath(
dialogTitle: 'Seleziona la cartella di esportazione per TIM/Citrix',
);
if (selectedDirectory != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('citrix_export_path', selectedDirectory);
setState(() {
_exportDirectory = selectedDirectory;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Cartella Export impostata: $selectedDirectory'),
),
);
}
}
}
// --- SELEZIONE FILE DAL PC/TELEFONO ---
Future<void> _pickFiles() async {
final result = await FilePicker.pickFiles(
allowMultiple: true,
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf'],
withData: true,
);
if (result != null && mounted) {
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
context.read<OperationFilesBloc>().add(
AddOperationFilesEvent(result.files),
);
}
}
// --- APERTURA VIEWER ---
void _openFile(AttachmentModel file) {
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
final operationFilesBloc = context.read<OperationFilesBloc>();
Navigator.push(
context,
MaterialPageRoute(
builder: (viewerContext) => BlocProvider.value(
value: operationFilesBloc,
child: AttachmentViewerScreen(
attachment: file,
onRename: (newName) {
// Spara l'evento al BLoC e lui farà il resto!
operationFilesBloc.add(RenameOperationFileEvent(file, newName));
},
onDelete: () {
operationFilesBloc.add(DeleteSpecificOperationFileEvent(file));
},
),
),
),
);
}
Future<void> _exportMergedPdf(List<AttachmentModel> selectedFiles) async {
if (!kIsWeb && (_exportDirectory == null || _exportDirectory!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Imposta prima la cartella Citrix!')),
);
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
// 1. "FLATTEN" DI TUTTO (Stessa magia di prima)
List<Uint8List> allPagesAsImages = [];
final repository = GetIt.I.get<AttachmentsRepository>();
for (var file in selectedFiles) {
Uint8List? fileBytes;
if (file.localBytes != null) {
fileBytes = file.localBytes;
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
fileBytes = await repository.downloadAttachmentBytes(
file.storagePath!,
);
}
if (fileBytes == null) continue;
if (file.extension == 'pdf') {
final document = await px.PdfDocument.openData(fileBytes);
for (int i = 1; i <= document.pagesCount; i++) {
final page = await document.getPage(i);
final pageImage = await page.render(
width: page.width * 2,
height: page.height * 2,
format: px.PdfPageImageFormat.jpeg,
);
if (pageImage != null) {
allPagesAsImages.add(pageImage.bytes);
}
await page.close();
}
await document.close();
} else {
// È un'immagine
allPagesAsImages.add(fileBytes);
}
}
if (mounted) Navigator.pop(context); // Togliamo il loading
// Se per qualche motivo la lista è vuota, usciamo
if (allPagesAsImages.isEmpty) return;
// 2. LOGICA DEL NOME SUGGERITO
String suggestedName;
if (selectedFiles.length == 1) {
// Se c'è un solo file (es. ho selezionato 3 foto ma poi ho deselezionato le altre)
suggestedName = selectedFiles.first.name;
} else {
// Se sono più file uniti
suggestedName = '${widget.currentOp.customerDisplayName}_Unito';
}
if (!mounted) return;
// 3. DIALOG DI CONFERMA (Mostriamo la PRIMA pagina come anteprima per fargli capire cos'è)
final finalName = await showDialog<String>(
context: context,
builder: (_) => QuickRenameDialog(
suggestedName: suggestedName,
previewWidget: Image.memory(
allPagesAsImages.first,
fit: BoxFit.contain,
),
),
);
if (finalName == null || finalName.isEmpty) return; // Ha annullato
// 4. CREAZIONE DEL PDF UNICO (IL MERGE VERO E PROPRIO)
final pdf = pw.Document();
// Cicliamo su tutte le immagini estratte e creiamo una pagina per ognuna
for (var imageBytes in allPagesAsImages) {
final pdfImage = pw.MemoryImage(imageBytes);
pdf.addPage(
pw.Page(
margin: pw.EdgeInsets.zero,
build: (pw.Context context) {
return pw.Center(child: pw.Image(pdfImage));
},
),
);
}
final mergedPdfBytes = await pdf.save();
// 5. SALVATAGGIO SUL DISCO
if (kIsWeb) {
// Trigger download web
} else {
final fileToSave = File('$_exportDirectory/$finalName.pdf');
await fileToSave.writeAsBytes(mergedPdfBytes);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('PDF Multi-pagina creato e salvato con successo!'),
),
);
}
} catch (e) {
if (mounted) {
// Se il loading è ancora aperto, lo chiudiamo
if (Navigator.canPop(context)) Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Errore durante l\'unione: $e')));
}
}
}
Future<void> _exportSplitPdfs(List<AttachmentModel> selectedFiles) async {
if (!kIsWeb && (_exportDirectory == null || _exportDirectory!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Imposta prima la cartella Citrix!')),
);
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
// 1. "FLATTEN" INTELLIGENTE (Ora usiamo la nostra classe _ExportItem)
List<_ExportItem> itemsToExport = [];
final repository = GetIt.I.get<AttachmentsRepository>();
for (var file in selectedFiles) {
Uint8List? fileBytes;
if (file.localBytes != null) {
fileBytes = file.localBytes;
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
fileBytes = await repository.downloadAttachmentBytes(
file.storagePath!,
);
}
if (fileBytes == null) continue;
// Recuperiamo il nome che l'utente ha (magari) già impostato
final baseName = file.name ?? 'Documento';
if (file.extension == 'pdf') {
final document = await px.PdfDocument.openData(fileBytes);
final isMulti =
document.pagesCount > 1; // Controlliamo se è multipagina!
for (int i = 1; i <= document.pagesCount; i++) {
final page = await document.getPage(i);
final pageImage = await page.render(
width: page.width * 2,
height: page.height * 2,
format: px.PdfPageImageFormat.jpeg,
);
if (pageImage != null) {
// Salviamo l'immagine CON il suo contesto storico
itemsToExport.add(
_ExportItem(
bytes: pageImage.bytes,
sourceName: baseName,
isMultiPage: isMulti,
pageIndex: i,
),
);
}
await page.close();
}
await document.close();
} else {
// SE È UN'IMMAGINE, la salviamo come singola pagina
itemsToExport.add(
_ExportItem(
bytes: fileBytes,
sourceName: baseName,
isMultiPage: false,
pageIndex: 1,
),
);
}
}
if (mounted) Navigator.pop(context);
// 2. IL CICLO UX
for (var item in itemsToExport) {
if (!mounted) return;
// LA TUA MAGIA UX SUI NOMI:
// Se è singolo (foto o PDF da 1 pag) -> Usa il nome originale nudo e crudo!
// Se è multipagina -> Usa il nome originale + il numero di pagina
String suggestedName = item.sourceName;
if (item.isMultiPage) {
suggestedName = '${item.sourceName}_Pag_${item.pageIndex}';
}
final finalName = await showDialog<String>(
context: context,
builder: (_) => QuickRenameDialog(
suggestedName: suggestedName,
previewWidget: Image.memory(item.bytes, fit: BoxFit.contain),
),
);
if (finalName == null || finalName.isEmpty) continue;
// CREAZIONE DEL PDF SINGOLO
final pdf = pw.Document();
final pdfImage = pw.MemoryImage(item.bytes); // Usiamo item.bytes!
pdf.addPage(
pw.Page(
margin: pw.EdgeInsets.zero,
build: (pw.Context context) {
return pw.Center(child: pw.Image(pdfImage));
},
),
);
final singlePdfBytes = await pdf.save();
if (kIsWeb) {
// Trigger download web
} else {
final fileToSave = File('$_exportDirectory/$finalName.pdf');
await fileToSave.writeAsBytes(singlePdfBytes);
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Esportazione completata con successo!'),
),
);
}
} catch (e) {
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Errore: $e')));
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// USIAMO IL TUO BLOC!
return BlocBuilder<OperationFilesBloc, OperationFilesState>(
builder: (context, state) {
final allFiles = state.allFiles;
final selectedFiles = state.selectedFiles;
final hasSelection = selectedFiles.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. SETTINGS CARTELLA (Solo visibile su Desktop)
if (!kIsWeb)
Card(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
elevation: 0,
margin: const EdgeInsets.only(bottom: 16),
child: ListTile(
leading: Icon(
Icons.folder_special,
color: theme.colorScheme.primary,
),
title: const Text(
'Cartella Export (Es. Citrix TIM)',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
_exportDirectory ??
'Nessuna cartella selezionata. Clicca per impostare.',
style: TextStyle(
color: _exportDirectory == null
? theme.colorScheme.error
: null,
),
),
trailing: const Icon(Icons.settings),
onTap: _selectExportDirectory,
),
),
// 2. ACTION BAR DINAMICA
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
// Bottone di Aggiunta
ElevatedButton.icon(
icon: const Icon(Icons.add_photo_alternate),
label: const Text('Aggiungi File'),
onPressed: state.status == OperationFilesStatus.uploading
? null
: _pickFiles,
),
const SizedBox(width: 12),
// NUOVO: SELEZIONA / DESELEZIONA TUTTO
if (allFiles.isNotEmpty) ...[
TextButton.icon(
icon: Icon(
selectedFiles.length == allFiles.length
? Icons.deselect
: Icons.select_all,
),
label: Text(
selectedFiles.length == allFiles.length
? 'Deseleziona Tutto'
: 'Seleziona Tutto',
),
onPressed: () {
if (selectedFiles.length == allFiles.length) {
context.read<OperationFilesBloc>().add(
ClearOperationFileSelectionEvent(),
);
} else {
context.read<OperationFilesBloc>().add(
SelectAllOperationFilesEvent(),
);
}
},
),
],
const SizedBox(width: 12),
// Loader di upload
if (state.status == OperationFilesStatus.uploading)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
const Spacer(),
// Azioni visibili SOLO se c'è una selezione!
if (hasSelection) ...[
// Bottone Elimina
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Elimina selezionati',
onPressed: () {
context.read<OperationFilesBloc>().add(
DeleteOperationFilesEvent(),
);
},
),
// Bottone Associa a Cliente
if (widget.currentOp.customerId != null &&
widget.currentOp.customerId!.isNotEmpty)
IconButton(
icon: const Icon(Icons.person_add, color: Colors.blue),
tooltip: 'Copia nei documenti del Cliente',
onPressed: () {
context.read<OperationFilesBloc>().add(
LinkFilesToCustomerEvent(
customerId: widget.currentOp.customerId!,
),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('File copiati nella scheda cliente!'),
),
);
},
),
// IL NUOVO BOTTONE ESPORTA CON MENU A TENDINA
PopupMenuButton<String>(
tooltip: 'Opzioni di esportazione',
position: PopupMenuPosition
.under, // Opzionale: fa aprire il menu sotto al bottone
onSelected: (value) {
if (value == 'merge') {
_exportMergedPdf(selectedFiles);
} else if (value == 'split') {
_exportSplitPdfs(selectedFiles);
}
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
const PopupMenuItem<String>(
value: 'merge',
child: ListTile(
leading: Icon(
Icons.merge_type,
color: Colors.blue,
),
title: Text('Unisci in un singolo PDF'),
),
),
const PopupMenuItem<String>(
value: 'split',
child: ListTile(
leading: Icon(
Icons.splitscreen,
color: Colors.orange,
),
title: Text(
'Dividi: un PDF per ogni pagina/foto',
),
),
),
],
// IL FIX È QUI SOTTO: AbsorbPointer + onPressed vuoto
child: AbsorbPointer(
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
),
icon: const Icon(Icons.picture_as_pdf),
label: Text('Esporta (${selectedFiles.length})'),
onPressed: () {}, // Manteniamo vivo il colore!
),
),
),
],
],
),
const SizedBox(height: 16),
// 3. GRIGLIA DEI FILE
if (allFiles.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
border: Border.all(
color: theme.dividerColor,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(8),
),
child: const Column(
children: [
Icon(Icons.upload_file, size: 48, color: Colors.grey),
SizedBox(height: 8),
Text(
'Nessun file allegato. Usa il pulsante per aggiungere documenti o foto.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
],
),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.8,
),
itemCount: allFiles.length,
itemBuilder: (context, index) {
final file = allFiles[index];
final isPdf = file.extension == 'pdf';
final isSelected = selectedFiles.contains(file);
final isLocal =
file.localBytes !=
null; // Per capire se è un file in bozza
return Stack(
children: [
// CARD DEL FILE
InkWell(
onTap: () => _openFile(file),
onLongPress: () {
// Selezione rapida con long press!
context.read<OperationFilesBloc>().add(
ToggleOperationFileSelectionEvent(file),
);
},
borderRadius: BorderRadius.circular(8),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: isSelected
? theme.colorScheme.primary
: theme.dividerColor,
width: isSelected ? 3 : 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Anteprima
Expanded(
child: Container(
decoration: BoxDecoration(
color: theme
.colorScheme
.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(8),
),
),
child: isPdf
? const Icon(
Icons.picture_as_pdf,
size: 48,
color: Colors.red,
)
: isLocal
? ClipRRect(
borderRadius:
const BorderRadius.vertical(
top: Radius.circular(8),
),
child: Image.memory(
file.localBytes!,
fit: BoxFit.cover,
),
)
: const Icon(
Icons.image,
size: 48,
color: Colors.blue,
), // Da remoto metterai il tuo NetworkImage se vuoi
),
),
// Nome File
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
file.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
textAlign: TextAlign.center,
),
),
],
),
),
),
// CHECKBOX DI SELEZIONE
Positioned(
top: 4,
right: 4,
child: InkWell(
onTap: () {
context.read<OperationFilesBloc>().add(
ToggleOperationFileSelectionEvent(file),
);
},
child: Container(
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: Colors.white.withValues(alpha: 0.8),
shape: BoxShape.circle,
border: Border.all(
color: theme.colorScheme.primary,
),
),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Icon(
isSelected ? Icons.check : Icons.circle,
size: 16,
color: isSelected
? Colors.white
: Colors.transparent,
),
),
),
),
),
// BADGE "IN ATTESA" (Se è locale ma la pratica è salvata)
if (isLocal)
Positioned(
top: 4,
left: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Bozza',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
},
),
],
);
},
);
}
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:get_it/get_it.dart';
// IMPORTA IL TUO CUBIT DELLO STAFF
// import 'package:flux/features/staff/blocs/staff_cubit.dart';
class StaffSection extends StatelessWidget {
final OperationModel? currentOp;
const StaffSection({super.key, required this.currentOp});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final selectedStaffId =
currentOp?.staffId ??
GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
'Operatore',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
BlocBuilder<StaffCubit, StaffState>(
builder: (context, state) {
// Dati finti per farti vedere la UI, piallali quando attacchi il BlocBuilder!
final staffMembers = state.storeStaff;
final currentLoggedStaffMember = GetIt.I
.get<SessionCubit>()
.state
.currentStaffMember;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: staffMembers.map((staff) {
final isSelected = staff.id == selectedStaffId;
return GestureDetector(
onTap: () {
// Aggiorniamo la form con un solo tap!
context.read<OperationsCubit>().updateOperationFields(
staffId: staff.id,
staffDisplayName: staff.name,
);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(right: 12.0),
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 10.0,
),
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: isSelected
? theme.colorScheme.primary
: theme.dividerColor,
width: 1.5,
),
boxShadow: isSelected
? [
BoxShadow(
color: theme.colorScheme.primary.withValues(
alpha: 0.3,
),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 12,
backgroundColor: isSelected
? Colors.white
: theme.colorScheme.primaryContainer,
child: Text(
staff.name.substring(0, 1).toUpperCase(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(width: 8),
Text(
staff == currentLoggedStaffMember
? 'Tu (${staff.name})'
: staff.name,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.w500,
color: isSelected
? Colors.white
: theme.colorScheme.onSurface,
),
),
],
),
),
);
}).toList(),
),
);
},
),
],
);
}
}

View File

@@ -1,232 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/features/services/data/services_repository.dart';
import 'package:flux/features/services/models/service_file_model.dart';
import 'package:flux/features/services/models/service_model.dart';
import 'package:get_it/get_it.dart';
part 'service_files_events.dart';
part 'service_files_state.dart';
class ServiceFilesBloc extends Bloc<ServiceFilesEvent, ServiceFilesState> {
final _repository = GetIt.I.get<ServicesRepository>();
final String? serviceId;
ServiceFilesBloc({this.serviceId})
: super(
ServiceFilesState(
status: ServiceFilesStatus.initial,
serviceId: serviceId,
),
) {
on<ServiceSavedEvent>(_onServiceSaved);
on<LoadServiceFilesEvent>(_onLoadServiceFiles);
on<AddServiceFilesEvent>(_onAddServiceFiles);
on<UploadServiceFilesEvent>(_onUploadServiceFiles);
on<UploadMultipleServiceFilesEvent>(_onUploadMultipleServiceFiles);
on<DeleteServiceFilesEvent>(_onDeleteServiceFiles);
on<ToggleServiceFileSelectionEvent>(_onToggleServiceFileSelection);
// Se il BLoC nasce con un ID, accendiamo subito lo stream!
if (serviceId != null) {
add(LoadServiceFilesEvent(serviceId: serviceId));
}
}
FutureOr<void> _onServiceSaved(
ServiceSavedEvent event,
Emitter<ServiceFilesState> emit,
) {
// 1. Aggiorniamo l'ID nello stato
// 2. PIALLIAMO i file locali: ormai sono partiti per Supabase!
// Così la UI si pulisce all'istante e aspetta quelli remoti.
emit(
state.copyWith(
serviceId: event.serviceId,
localFiles: [], // <-- LA MAGIA ANTI-DUPLICATI
),
);
// Lanciamo il caricamento
add(LoadServiceFilesEvent(serviceId: event.serviceId));
}
FutureOr<void> _onLoadServiceFiles(
LoadServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
// Usiamo l'ID dell'evento, e se non c'è usiamo quello dello stato
final currentId = event.serviceId ?? state.serviceId;
if (currentId != null) {
emit(state.copyWith(status: ServiceFilesStatus.loading));
await emit.forEach(
_repository.getServiceFilesStream(
currentId,
), // <-- Usiamo l'ID corretto!
onData: (data) => state.copyWith(
status: ServiceFilesStatus.success,
remoteFiles: data,
),
onError: (error, stackTrace) => state.copyWith(
status: ServiceFilesStatus.failure,
error: error.toString(),
),
);
}
}
void _onAddServiceFiles(
AddServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
final currentId = state.serviceId;
// BIVIO 1: PRATICA NUOVA (Nessun ID)
if (currentId == null) {
// Mettiamo i file nel "parcheggio" locale dello State
final newLocalFiles = event.files.map((file) {
return ServiceFileModel(
id: null,
serviceId: serviceId ?? '',
name: file.name.fileNameWithoutExtension(),
extension: file.name.fileExtension(),
storagePath: '',
fileSize: file.size,
localBytes: file.bytes,
);
}).toList();
final List<ServiceFileModel> updatedLocalFiles = [
...state.localFiles,
...newLocalFiles,
];
emit(
state.copyWith(
localFiles: updatedLocalFiles,
status: ServiceFilesStatus.success,
),
);
return;
}
// BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID)
emit(state.copyWith(status: ServiceFilesStatus.uploading));
try {
// Logica identica a quella che abbiamo fatto per i clienti
for (var file in event.files) {
await _repository.uploadAndRegisterServiceFile(
serviceId: serviceId!,
pickedFile: file,
);
}
emit(state.copyWith(status: ServiceFilesStatus.success));
} catch (e) {
emit(
state.copyWith(status: ServiceFilesStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onUploadServiceFiles(
UploadServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
if (event.pickedFiles == null && event.photos == null) return;
if (event.pickedFiles!.isEmpty && event.photos!.isEmpty) return;
// BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID
emit(state.copyWith(status: ServiceFilesStatus.uploading));
try {
// Logica identica a quella che abbiamo fatto per i clienti
if (event.pickedFiles != null && event.pickedFiles!.isNotEmpty) {
for (var file in event.pickedFiles!) {
await _repository.uploadAndRegisterServiceFile(
serviceId: state.serviceId!,
pickedFile: file,
);
}
}
emit(state.copyWith(status: ServiceFilesStatus.success));
} catch (e) {
emit(
state.copyWith(status: ServiceFilesStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onUploadMultipleServiceFiles(
UploadMultipleServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
if (event.files.isEmpty) {
emit(
state.copyWith(
status: ServiceFilesStatus.failure,
error: "Nessun file selezionato",
),
);
return;
}
emit(state.copyWith(status: ServiceFilesStatus.uploading, error: null));
try {
// 2. Creiamo una lista di "Promesse" (Futures) per il repository
final List<Future<void>> uploadTasks = [];
for (var file in event.files) {
// Aggiungiamo il task alla lista, ma NON usiamo await qui dentro!
uploadTasks.add(
_repository.uploadAndRegisterServiceFile(
serviceId: state.serviceId!,
pickedFile: file,
),
);
}
// 3. ESECUZIONE PARALLELA!
// Aspettiamo che tutti i file siano caricati contemporaneamente.
await Future.wait(uploadTasks);
// 4. GRAN FINALE: Tutto caricato, emettiamo il success!
emit(state.copyWith(status: ServiceFilesStatus.success));
} catch (e) {
// Se anche un solo file fallisce, catturiamo l'errore
emit(
state.copyWith(
status: ServiceFilesStatus.failure,
error: "Errore durante l'upload multiplo: $e",
),
);
}
}
FutureOr<void> _onDeleteServiceFiles(
DeleteServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
emit(state.copyWith(status: ServiceFilesStatus.loading));
try {
await _repository.deleteServiceFiles(state.selectedFiles);
emit(
state.copyWith(status: ServiceFilesStatus.success, selectedFiles: []),
);
} catch (e) {
emit(
state.copyWith(status: ServiceFilesStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onToggleServiceFileSelection(
ToggleServiceFileSelectionEvent event,
Emitter<ServiceFilesState> emit,
) {
List<ServiceFileModel> selectedFiles = List.from(state.selectedFiles);
if (selectedFiles.contains(event.file)) {
selectedFiles.remove(event.file);
} else {
selectedFiles.add(event.file);
}
emit(state.copyWith(selectedFiles: selectedFiles));
}
}

View File

@@ -1,56 +0,0 @@
part of 'service_files_bloc.dart';
abstract class ServiceFilesEvent extends Equatable {
const ServiceFilesEvent();
@override
List<Object?> get props => [];
}
class ServiceSavedEvent extends ServiceFilesEvent {
final String serviceId;
const ServiceSavedEvent(this.serviceId);
@override
List<Object?> get props => [serviceId];
}
class LoadServiceFilesEvent extends ServiceFilesEvent {
final String? serviceId;
final ServiceModel? service;
const LoadServiceFilesEvent({this.serviceId, this.service});
@override
List<Object?> get props => [serviceId, service];
}
class AddServiceFilesEvent extends ServiceFilesEvent {
final List<PlatformFile> files;
const AddServiceFilesEvent(this.files);
@override
List<Object?> get props => [files];
}
class UploadServiceFilesEvent extends ServiceFilesEvent {
final List<PlatformFile>? pickedFiles;
final List<File>? photos;
const UploadServiceFilesEvent({this.pickedFiles, this.photos});
@override
List<Object?> get props => [pickedFiles, photos];
}
class UploadMultipleServiceFilesEvent extends ServiceFilesEvent {
final List<PlatformFile> files;
const UploadMultipleServiceFilesEvent(this.files);
@override
List<Object?> get props => [files];
}
class DeleteServiceFilesEvent extends ServiceFilesEvent {}
class ToggleServiceFileSelectionEvent extends ServiceFilesEvent {
final ServiceFileModel file;
const ToggleServiceFileSelectionEvent(this.file);
}

View File

@@ -1,52 +0,0 @@
part of 'service_files_bloc.dart';
enum ServiceFilesStatus { initial, loading, uploading, success, failure }
class ServiceFilesState extends Equatable {
const ServiceFilesState({
this.serviceId,
required this.status,
this.error,
this.localFiles = const [],
this.remoteFiles = const [],
this.selectedFiles = const [],
});
final String? serviceId;
final ServiceFilesStatus status;
final String? error;
final List<ServiceFileModel> localFiles;
final List<ServiceFileModel> remoteFiles;
final List<ServiceFileModel> selectedFiles;
@override
List<Object?> get props => [
serviceId,
status,
error,
localFiles,
remoteFiles,
selectedFiles,
];
List<ServiceFileModel> get allFiles => [...remoteFiles, ...localFiles];
ServiceFilesState copyWith({
String? serviceId,
ServiceFilesStatus? status,
String? error,
List<ServiceFileModel>? localFiles,
List<ServiceFileModel>? remoteFiles,
List<ServiceFileModel>? selectedFiles,
}) {
return ServiceFilesState(
serviceId: serviceId ?? this.serviceId,
status: status ?? this.status,
error: error,
localFiles: localFiles ?? this.localFiles,
remoteFiles: remoteFiles ?? this.remoteFiles,
selectedFiles: selectedFiles ?? this.selectedFiles,
);
}
}

View File

@@ -1,348 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:file_picker/file_picker.dart';
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/utils/string_extensions.dart';
import 'package:flux/features/services/data/services_repository.dart';
import 'package:flux/features/services/models/energy_service_model.dart';
import 'package:flux/features/services/models/entertainment_service_model.dart';
import 'package:flux/features/services/models/fin_service_model.dart';
import 'package:flux/features/services/models/service_file_model.dart';
import 'package:flux/features/services/models/service_model.dart';
import 'package:get_it/get_it.dart';
import 'package:collection/collection.dart';
part 'services_state.dart';
class ServicesCubit extends Cubit<ServicesState> {
final ServicesRepository _repository = GetIt.I<ServicesRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
ServicesCubit() : super(const ServicesState(status: ServicesStatus.initial));
// --- CARICAMENTO E PAGINAZIONE ---
Future<void> loadServices({bool refresh = false}) async {
// Se stiamo già caricando, evitiamo chiamate doppie
if (state.status == ServicesStatus.loading) return;
// Se non è un refresh e abbiamo già raggiunto la fine dei dati, ci fermiamo
if (!refresh && state.hasReachedMax) return;
emit(
state.copyWith(
status: ServicesStatus.loading,
errorMessage: null,
// Se è un refresh, svuotiamo la lista attuale per mostrare lo shimmer/loading
allServices: refresh ? [] : state.allServices,
hasReachedMax: refresh ? false : state.hasReachedMax,
),
);
try {
final currentOffset = refresh ? 0 : state.allServices.length;
final companyId = _sessionCubit.state.company?.id;
if (companyId == null) {
throw Exception("Company ID non trovato nella sessione");
}
final newServices = await _repository.fetchServices(
companyId: companyId,
offset: currentOffset,
limit: 50,
searchTerm: state.query,
dateRange: state.dateRange,
);
// Se ricevi meno record del limite, significa che non ce ne sono altri sul DB
final bool reachedMax = newServices.length < 50;
emit(
state.copyWith(
status: ServicesStatus.ready,
allServices: refresh
? newServices
: [...state.allServices, ...newServices],
hasReachedMax: reachedMax,
),
);
} catch (e) {
emit(
state.copyWith(
status: ServicesStatus.failure,
errorMessage: "Errore nel caricamento servizi: $e",
),
);
}
}
// --- GESTIONE FILTRI ---
/// Aggiorna i parametri di ricerca e ricarica da zero
void updateFilters({String? query, DateTimeRange? range}) {
emit(
state.copyWith(
query: query ?? state.query,
dateRange: range ?? state.dateRange,
),
);
loadServices(refresh: true);
}
/// Pulisce tutti i filtri
void clearFilters() {
emit(state.copyWith(query: '', dateRange: null));
loadServices(refresh: true);
}
// --- GESTIONE BOZZA (DRAFT) ---
/// Inizializza un nuovo servizio o ne carica uno esistente per la modifica
void initServiceForm({
ServiceModel? existingService,
String? serviceId,
}) async {
if (existingService != null) {
emit(
state.copyWith(
currentService: existingService,
status: ServicesStatus.ready,
),
);
} else if (serviceId != null) {
ServiceModel? serviceModel = state.allServices.firstWhereOrNull(
(s) => s.id == serviceId,
);
serviceModel ??= await _repository.fetchServiceById(serviceId);
emit(
state.copyWith(
currentService: serviceModel,
status: ServicesStatus.ready,
),
);
} else {
// Crea un template vuoto con lo store di default (se disponibile)
emit(
state.copyWith(
currentService: ServiceModel(
storeId: _sessionCubit.state.currentStore?.id ?? '',
number: '', // Sarà compilato dall'utente
createdAt: DateTime.now(),
companyId: _sessionCubit.state.company!.id!,
),
status: ServicesStatus.ready,
),
);
}
}
/// Metodo generico per aggiornare i campi base (AL, MNP, Note, ecc.)
void updateField({
int? al,
int? mnp,
int? nip,
int? unica,
int? telepass,
String? note,
String? number,
bool? isBozza,
bool? resultOk,
String? customerId,
String? customerDisplayName,
}) {
if (state.currentService == null) return;
final updated = state.currentService!.copyWith(
al: al,
mnp: mnp,
nip: nip,
unica: unica,
telepass: telepass,
note: note,
number: number,
isBozza: isBozza,
resultOk: resultOk,
customerId: customerId,
customerDisplayName: customerDisplayName,
);
emit(state.copyWith(currentService: updated));
}
// --- GESTIONE MODULI COMPLESSI ---
void updateEnergyServices(List<EnergyServiceModel> energyList) {
emit(
state.copyWith(
currentService: state.currentService?.copyWith(
energyServices: energyList,
),
),
);
}
void updateFinServices(List<FinServiceModel> finList) {
emit(
state.copyWith(
currentService: state.currentService?.copyWith(finServices: finList),
),
);
}
void updateEntertainmentServices(List<EntertainmentServiceModel> entList) {
emit(
state.copyWith(
currentService: state.currentService?.copyWith(
entertainmentServices: entList,
),
),
);
}
// --- PERSISTENZA ---
Future<void> saveCurrentService({
required bool isBozza,
bool shouldPop = true,
List<ServiceFileModel>? files,
}) async {
if (state.currentService == null) return;
emit(state.copyWith(status: ServicesStatus.saving, errorMessage: null));
try {
// 1. Aggiorniamo il flag bozza in base a quale pulsante ha premuto l'utente
final serviceToSave = state.currentService!.copyWith(
isBozza: isBozza,
files: files,
);
// 2. Salvataggio corazzato
final updatedService = await _repository.saveFullService(serviceToSave);
// 3. Reset e ricaricamento
emit(
state.copyWith(
status: shouldPop ? ServicesStatus.saved : ServicesStatus.savedNoPop,
currentService: shouldPop ? null : updatedService,
),
);
await loadServices(refresh: true);
} catch (e) {
emit(
state.copyWith(
status: ServicesStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// --- GESTIONE ALLEGATI LOCALI ---
void addAttachments(List<PlatformFile> files) {
final newAttachments = files.map((file) {
return ServiceFileModel(
id: null, // Meglio null se non è su DB
serviceId: state.currentService?.id ?? '',
name: file.name.fileNameWithoutExtension(),
extension: file.name.fileExtension(),
storagePath: '',
fileSize: file.size,
localBytes: file.bytes,
createdAt: DateTime.now(),
);
}).toList();
// Creiamo una nuova lista pulita
final List<ServiceFileModel> updatedList = [
...(state.currentService?.files ?? []),
...newAttachments,
];
// Emettiamo lo stato assicurandoci che il ServiceModel venga clonato
if (state.currentService != null) {
emit(
state.copyWith(
currentService: state.currentService!.copyWith(files: updatedList),
),
);
}
}
void removeAttachment(int index) {
if (state.currentService == null) return;
final updatedList = List<ServiceFileModel>.from(
state.currentService!.files,
);
updatedList.removeAt(index);
emit(
state.copyWith(
currentService: state.currentService?.copyWith(files: updatedList),
),
);
}
void saveAndCopyFileToCustomer(List<ServiceFileModel> selectedFiles) async {
final currentService = state.currentService;
// 1. Check di sicurezza: se non c'è il cliente, non sappiamo dove copiare
if (currentService == null || currentService.customerId == null) {
emit(
state.copyWith(
status: ServicesStatus.failure,
errorMessage:
"Impossibile copiare: nessun cliente associato alla pratica.",
),
);
return;
}
emit(state.copyWith(status: ServicesStatus.loading));
try {
// 2. SALVATAGGIO CORAZZATO
// Chiamiamo il repo e otteniamo la pratica con TUTTI i file ora dotati di ID e storagePath
final updatedService = await _repository.saveFullService(currentService);
// 3. COPIA RELAZIONALE
// Per ogni file che l'utente ha selezionato nella UI, cerchiamo la sua versione
// "ufficiale" (quella con lo storagePath) nel modello appena tornato dal DB.
for (var selectedFile in selectedFiles) {
// Cerchiamo il match nel modello aggiornato
final persistedFile = updatedService.files.firstWhere(
(f) =>
f.name == selectedFile.name &&
f.extension == selectedFile.extension,
orElse: () => throw Exception(
"File ${selectedFile.name} non trovato dopo il salvataggio.",
),
);
// Creiamo il link nel database del cliente
await _repository.copyFileToCustomer(
file: persistedFile,
customerId: currentService.customerId!,
);
}
// 4. AGGIORNAMENTO STATO
// Aggiorniamo il Cubit con il servizio salvato così la UI mostra i file come "Remoti"
emit(
state.copyWith(
status: ServicesStatus.success,
currentService: updatedService,
),
);
} catch (e) {
emit(
state.copyWith(
status: ServicesStatus.failure,
errorMessage: "Errore durante il salvataggio e copia: $e",
),
);
}
}
}

View File

@@ -1,359 +0,0 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:flux/features/services/models/service_file_model.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/service_model.dart';
class ServicesRepository {
final _supabase = Supabase.instance.client;
final companyId = GetIt.I.get<SessionCubit>().state.company!.id;
final CustomerRepository _customerRepository = GetIt.I<CustomerRepository>();
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
Future<ServiceModel> fetchServiceById(String id) async {
try {
final response = await _supabase
.from('service')
.select('''
*,
customer(nome),
energy_service(*),
fin_service(*),
entertainment_service(*),
service_file(*)
''')
.eq('id', id)
.single();
return ServiceModel.fromMap(response);
} catch (e) {
throw Exception('Errore nel caricamento del servizio: $e');
}
}
// --- RECUPERO PAGINATO CON FILTRI E JOIN ---
Future<List<ServiceModel>> fetchServices({
required String companyId,
required int offset,
int limit = 50,
String? searchTerm,
DateTimeRange? dateRange,
}) async {
try {
// Nota: 'customer(name, surname)' serve per il display name nella card
var query = _supabase
.from('service')
.select('''
*,
customer(nome),
energy_service(*),
fin_service(*),
entertainment_service(*),
service_file(*)
''')
.eq('company_id', companyId);
// Filtro Range Date
if (dateRange != null) {
query = query
.gte('created_at', dateRange.start.toIso8601String())
.lte('created_at', dateRange.end.toIso8601String());
}
if (searchTerm != null && searchTerm.isNotEmpty) {
// Filtra sui campi della tabella principale O su quelli della tabella joinata
query = query.or(
'number.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.nome.ilike.%$searchTerm%',
);
}
final response = await query
.order('created_at', ascending: false)
.range(offset, offset + limit - 1);
return (response as List)
.map((map) => ServiceModel.fromMap(map))
.toList();
} catch (e) {
throw Exception('Errore nel caricamento servizi: $e');
}
}
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
Future<ServiceModel> saveFullService(ServiceModel service) async {
try {
// 1. Upsert del record principale
final serviceData = await _supabase
.from('service')
.upsert(service.toMap())
.select()
.single();
final String newId = serviceData['id'];
// 2. MODIFICA: Pulizia atomica dei figli
// Se stiamo modificando (id != null), resettiamo le tabelle collegate
if (service.id != null) {
await Future.wait([
_supabase.from('energy_service').delete().eq('service_id', newId),
_supabase.from('fin_service').delete().eq('service_id', newId),
_supabase
.from('entertainment_service')
.delete()
.eq('service_id', newId),
// Aggiungi qui eventuali altre tabelle pivot o file
]);
}
// 3. Inserimento dei moduli in parallelo per velocità
final List<Future> insertTasks = [];
if (service.energyServices.isNotEmpty) {
insertTasks.add(
_supabase
.from('energy_service')
.insert(
service.energyServices
.map((item) => item.copyWith(serviceId: newId).toMap())
.toList(),
),
);
}
if (service.finServices.isNotEmpty) {
insertTasks.add(
_supabase
.from('fin_service')
.insert(
service.finServices
.map((item) => item.copyWith(serviceId: newId).toMap())
.toList(),
),
);
}
if (service.entertainmentServices.isNotEmpty) {
insertTasks.add(
_supabase
.from('entertainment_service')
.insert(
service.entertainmentServices
.map((item) => item.copyWith(serviceId: newId).toMap())
.toList(),
),
);
}
if (insertTasks.isNotEmpty) {
await Future.wait(insertTasks);
}
// 4. UPLOAD DEI FILE LOCALI (Nuovi)
// Filtriamo solo i file che non hanno ancora un ID (quindi sono locali)
final localFilesToUpload = service.files
.where((f) => f.id == null)
.toList();
if (localFilesToUpload.isNotEmpty) {
final List<Future> uploadTasks = [];
for (var file in localFilesToUpload) {
final storagePath =
'$companyId/services/$newId/${DateTime.now().millisecondsSinceEpoch}_${file.name}.${file.extension}';
final String mimeType = file.extension.toLowerCase() == 'pdf'
? 'application/pdf'
: 'image/${file.extension}';
final fileToSave = file.copyWith(
serviceId: newId,
storagePath: storagePath,
);
// Creiamo una funzione asincrona per caricare file e scrivere nel DB
Future<void> uploadAndLink() async {
// A. Upload nel Bucket Storage (usiamo i bytes così funziona anche su Web!)
await _supabase.storage
.from('documents')
.uploadBinary(
storagePath,
fileToSave.localBytes!,
fileOptions: FileOptions(contentType: mimeType, upsert: true),
);
// B. Inserimento riga nel DB relazionale
await _supabase.from('service_file').insert(fileToSave.toMap());
}
uploadTasks.add(uploadAndLink());
}
// Eseguiamo tutti gli upload in parallelo per la massima velocità
await Future.wait(uploadTasks);
}
// 5. GRAN FINALE: RECUPERO DEL MODELLO COMPLETO E AGGIORNATO
// Interroghiamo Supabase per farci restituire la pratica con TUTTI gli ID generati
// (inclusi quelli della tabella service_file appena inseriti)
final updatedServiceData = await _supabase
.from('service')
.select('''
*,
energy_service(*),
fin_service(*),
entertainment_service(*),
service_file(*)
''')
.eq('id', newId)
.single();
return ServiceModel.fromMap(updatedServiceData);
} catch (e) {
// Qui potresti aggiungere una logica di "rollback manuale" se necessario
throw Exception('Errore durante il salvataggio corazzato: $e');
}
}
// --- ELIMINAZIONE ---
Future<void> deleteService(String id) async {
try {
await _supabase.from('service').delete().eq('id', id);
} catch (e) {
throw Exception('Errore durante l\'eliminazione: $e');
}
}
// --- RECUPERO TIPI CONTENUTI PIÙ FREQUENTI PER AUTOCOMPLETE ---
Future<List<String>> fetchTopEntertainmentTypes(String companyId) async {
try {
// Cerchiamo i tipi più frequenti associati ai servizi di questa company
// Nota: dobbiamo passare attraverso la tabella 'service' per filtrare per company_id
final response = await _supabase
.from('entertainment_service')
.select('type, service!inner(store!inner(company_id))')
.eq('service.store.company_id', companyId)
.limit(100); // Prendiamo un campione
// Logica rapida per contare le occorrenze e prendere i primi 5
final Map<String, int> counts = {};
for (var item in (response as List)) {
final type = item['type'] as String;
counts[type] = (counts[type] ?? 0) + 1;
}
var sortedKeys = counts.keys.toList()
..sort((a, b) => counts[b]!.compareTo(counts[a]!));
return sortedKeys.take(5).toList();
} catch (e) {
return [
"Netflix",
"DAZN",
"Disney+",
"Sky",
]; // Fallback se non c'è ancora storia
}
}
/// Ascolta in tempo reale i file caricati per una pratica
Stream<List<ServiceFileModel>> getServiceFilesStream(String serviceId) {
return _supabase
.from('service_file')
.stream(primaryKey: ['id'])
.eq('service_id', serviceId)
.order('created_at', ascending: false)
.map(
(listOfMaps) =>
listOfMaps.map((map) => ServiceFileModel.fromMap(map)).toList(),
);
}
Future<ServiceFileModel> uploadAndRegisterServiceFile({
required String serviceId,
required PlatformFile pickedFile,
}) async {
final cleanFileName = pickedFile.name.replaceAll(
RegExp(r'[^a-zA-Z0-9\.\-]'),
'_',
);
final storagePath =
'$companyId/services/$serviceId/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
final int fileSize = pickedFile.size;
final fileToSave = ServiceFileModel(
serviceId: serviceId,
name: cleanFileName.fileNameWithoutExtension(),
extension: cleanFileName.fileExtension(),
storagePath: storagePath,
fileSize: fileSize,
);
final String mimeType = fileToSave.extension.toLowerCase() == 'pdf'
? 'application/pdf'
: 'image/${fileToSave.extension}';
try {
// Usiamo bytes invece del path per massima compatibilità
if (pickedFile.bytes == null && pickedFile.path == null) {
throw 'Impossibile leggere il contenuto del file';
}
// Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes
if (pickedFile.bytes != null) {
await _supabase.storage
.from('documents')
.uploadBinary(
storagePath,
pickedFile.bytes!,
fileOptions: FileOptions(contentType: mimeType, upsert: true),
);
}
final response = await _supabase
.from('service_file')
.insert(fileToSave.toMap())
.select()
.single();
return ServiceFileModel.fromMap(response);
} catch (e) {
throw 'Errore durante l\'upload: $e';
}
}
Future<void> copyFileToCustomer({
required ServiceFileModel file,
required String customerId,
}) async {
CustomerFileModel fileToCopy = CustomerFileModel(
customerId: customerId,
name: file.name,
storagePath: file.storagePath,
extension: file.extension,
fileSize: file.fileSize,
);
await _customerRepository.saveFileReference(fileToCopy);
}
Future<void> deleteServiceFiles(List<ServiceFileModel> files) async {
if (files.isEmpty) return;
// 1. Prepariamo le liste di ID e di Percorsi
final List<String> idsToDelete = files.map((f) => f.id!).toList();
final List<String> storagePaths = files.map((f) => f.storagePath).toList();
try {
await _supabase.from('service_file').delete().inFilter('id', idsToDelete);
await _supabase.storage.from('documents').remove(storagePaths);
debugPrint("Eliminati con successo ${files.length} file.");
} on PostgrestException catch (e) {
debugPrint("Errore DB: ${e.message}");
throw 'Errore database: ${e.message}';
} catch (e) {
debugPrint("Errore generico: $e");
throw 'Errore durante l\'eliminazione dei file: $e';
}
}
}

View File

@@ -1,72 +0,0 @@
import 'package:equatable/equatable.dart';
enum EnergyType { luce, gas } // Mappa il tuo public.energy_type
class EnergyServiceModel extends Equatable {
final String? id;
final DateTime? createdAt;
final EnergyType type;
final DateTime expiration;
final String providerId;
final String? serviceId;
const EnergyServiceModel({
this.id,
this.createdAt,
required this.type,
required this.expiration,
required this.providerId,
this.serviceId,
});
EnergyServiceModel copyWith({
String? id,
DateTime? createdAt,
EnergyType? type,
DateTime? expiration,
String? providerId,
String? serviceId,
}) {
return EnergyServiceModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
type: type ?? this.type,
expiration: expiration ?? this.expiration,
providerId: providerId ?? this.providerId,
serviceId: serviceId ?? this.serviceId,
);
}
@override
List<Object?> get props => [
id,
createdAt,
type,
expiration,
providerId,
serviceId,
];
factory EnergyServiceModel.fromMap(Map<String, dynamic> map) {
return EnergyServiceModel(
id: map['id'],
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
type: map['type'] == 'gas' ? EnergyType.gas : EnergyType.luce,
expiration: DateTime.parse(map['expiration']),
providerId: map['provider_id'],
serviceId: map['service_id'],
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'type': type.name, // .name trasforma l'enum in 'luce' o 'gas'
'expiration': expiration.toIso8601String(),
'provider_id': providerId,
'service_id': serviceId,
};
}
}

View File

@@ -1,77 +0,0 @@
import 'package:equatable/equatable.dart';
class EntertainmentServiceModel extends Equatable {
final String? id;
final DateTime? createdAt;
final String type; // es. Sky, DAZN, ecc.
final bool constrained; // Vincolato?
final DateTime constrainExpiration;
final String? serviceId;
final String? providerId;
const EntertainmentServiceModel({
this.id,
this.createdAt,
required this.type,
required this.constrained,
required this.constrainExpiration,
this.serviceId,
this.providerId,
});
EntertainmentServiceModel copyWith({
String? id,
DateTime? createdAt,
String? type,
bool? constrained,
DateTime? constrainExpiration,
String? serviceId,
String? providerId,
}) {
return EntertainmentServiceModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
type: type ?? this.type,
constrained: constrained ?? this.constrained,
constrainExpiration: constrainExpiration ?? this.constrainExpiration,
serviceId: serviceId ?? this.serviceId,
providerId: providerId ?? this.providerId,
);
}
@override
List<Object?> get props => [
id,
createdAt,
type,
constrained,
constrainExpiration,
serviceId,
providerId,
];
factory EntertainmentServiceModel.fromMap(Map<String, dynamic> map) {
return EntertainmentServiceModel(
id: map['id'],
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
type: map['type'],
constrained: map['constrained'] ?? false,
constrainExpiration: DateTime.parse(map['constrain_expiration']),
serviceId: map['service_id'],
providerId: map['provider_id'],
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'type': type,
'constrained': constrained,
'constrain_expiration': constrainExpiration.toIso8601String(),
'service_id': serviceId,
'provider_id': providerId,
};
}
}

View File

@@ -1,63 +0,0 @@
import 'package:equatable/equatable.dart';
class FinServiceModel extends Equatable {
final String? id;
final DateTime? createdAt;
final DateTime expiration;
final String? serviceId;
final String? modelId; // FK verso model (es. iPhone, Samsung, ecc.)
final String? providerId;
const FinServiceModel({
this.id,
this.createdAt,
required this.expiration,
this.serviceId,
this.modelId,
this.providerId,
});
FinServiceModel copyWith({
String? id,
DateTime? createdAt,
DateTime? expiration,
String? serviceId,
String? modelId,
String? providerId,
}) {
return FinServiceModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
expiration: expiration ?? this.expiration,
serviceId: serviceId ?? this.serviceId,
modelId: modelId ?? this.modelId,
providerId: providerId ?? this.providerId,
);
}
@override
List<Object?> get props => [id, createdAt, expiration, serviceId, modelId];
factory FinServiceModel.fromMap(Map<String, dynamic> map) {
return FinServiceModel(
id: map['id'],
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
expiration: DateTime.parse(map['expiration']),
serviceId: map['service_id'],
modelId: map['model_id'],
providerId: map['provider_id'],
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'expiration': expiration.toIso8601String(),
'service_id': serviceId,
'model_id': modelId,
'provider_id': providerId,
};
}
}

View File

@@ -1,200 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/features/services/models/energy_service_model.dart';
import 'package:flux/features/services/models/entertainment_service_model.dart';
import 'package:flux/features/services/models/fin_service_model.dart';
import 'package:flux/features/services/models/service_file_model.dart'; // <-- Aggiunto Import
class ServiceModel extends Equatable {
final String? id;
final DateTime? createdAt;
final String storeId;
final String? employeeId;
final String? customerId;
final String number;
final bool isBozza;
final String note;
final bool resultOk;
final String? customerDisplayName;
final String companyId;
// Telefonia
final int al;
final int mnp;
final int nip;
final int unica;
final int telepass;
// Moduli (Liste)
final List<EnergyServiceModel> energyServices;
final List<FinServiceModel> finServices;
final List<EntertainmentServiceModel> entertainmentServices;
// ALLEGATI (Aggiunto)
final List<ServiceFileModel> files;
const ServiceModel({
this.id,
this.createdAt,
required this.storeId,
this.employeeId,
this.customerId,
required this.number,
this.isBozza = true,
this.note = '',
this.resultOk = true,
this.al = 0,
this.mnp = 0,
this.nip = 0,
this.unica = 0,
this.telepass = 0,
this.energyServices = const [],
this.finServices = const [],
this.entertainmentServices = const [],
this.files = const [], // <-- Aggiunto default vuoto
this.customerDisplayName,
required this.companyId,
});
ServiceModel copyWith({
String? id,
DateTime? createdAt,
String? storeId,
String? employeeId,
String? customerId,
String? number,
bool? isBozza,
String? note,
bool? resultOk,
int? al,
int? mnp,
int? nip,
int? unica,
int? telepass,
List<EnergyServiceModel>? energyServices,
List<FinServiceModel>? finServices,
List<EntertainmentServiceModel>? entertainmentServices,
List<ServiceFileModel>? files, // <-- Aggiunto
String? customerDisplayName,
String? companyId,
}) {
return ServiceModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
storeId: storeId ?? this.storeId,
employeeId: employeeId ?? this.employeeId,
customerId: customerId ?? this.customerId,
number: number ?? this.number,
isBozza: isBozza ?? this.isBozza,
note: note ?? this.note,
resultOk: resultOk ?? this.resultOk,
al: al ?? this.al,
mnp: mnp ?? this.mnp,
nip: nip ?? this.nip,
unica: unica ?? this.unica,
telepass: telepass ?? this.telepass,
energyServices: energyServices ?? this.energyServices,
finServices: finServices ?? this.finServices,
entertainmentServices:
entertainmentServices ?? this.entertainmentServices,
files: files ?? this.files, // <-- Aggiunto
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
companyId: companyId ?? this.companyId,
);
}
@override
List<Object?> get props => [
id,
createdAt,
storeId,
employeeId,
customerId,
number,
isBozza,
note,
resultOk,
al,
mnp,
nip,
unica,
telepass,
energyServices,
finServices,
entertainmentServices,
files, // <-- Aggiunto
customerDisplayName,
companyId,
];
factory ServiceModel.fromMap(Map<String, dynamic> map) {
return ServiceModel(
id: map['id'].toString(),
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: DateTime.now(),
storeId: map['store_id'] ?? '',
employeeId: map['employee_id']?.toString(),
customerId: map['customer_id']?.toString(),
number: map['number']?.toString() ?? '',
isBozza: map['bozza'] ?? true,
note: map['note'] ?? '',
resultOk: map['result_ok'] ?? true,
al: map['al'] ?? 0,
mnp: map['mnp'] ?? 0,
nip: map['nip'] ?? 0,
unica: map['unica'] ?? 0,
telepass: map['telepass'] ?? 0,
// Estrazione sicura liste collegate
energyServices:
(map['energy_service'] as List?)
?.map((x) => EnergyServiceModel.fromMap(x))
.toList() ??
const [],
finServices:
(map['fin_service'] as List?)
?.map((x) => FinServiceModel.fromMap(x))
.toList() ??
const [],
entertainmentServices:
(map['entertainment_service'] as List?)
?.map((x) => EntertainmentServiceModel.fromMap(x))
.toList() ??
const [],
// I FILE! (Assicurati che la foreign key su Supabase usi esattamente questo nome)
files:
(map['service_file'] as List?)
?.map((x) => ServiceFileModel.fromMap(x))
.toList() ??
const [],
// Display name del cliente con fallback
customerDisplayName: map['customer'] != null
? "${map['customer']['nome'] ?? ''}".myFormat()
: "Cliente non assegnato",
companyId: map['company_id'] as String,
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'store_id': storeId,
'employee_id': employeeId,
'customer_id': customerId,
'number': number,
'bozza': isBozza,
'note': note,
'result_ok': resultOk,
'al': al,
'mnp': mnp,
'nip': nip,
'unica': unica,
'telepass': telepass,
'company_id': companyId,
// Le liste non le mettiamo qui perché vanno in tabelle diverse!
};
}
}

View File

@@ -1,85 +0,0 @@
import 'package:flutter/material.dart';
class ActionCard extends StatelessWidget {
final String label;
final int count;
final IconData icon;
final Color color;
final VoidCallback onTap;
const ActionCard({
super.key,
required this.label,
required this.count,
required this.icon,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final isActive = count > 0;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 110, // Larghezza fissa per avere una griglia ordinata
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
decoration: BoxDecoration(
color: isActive
? color.withValues(alpha: 0.15)
: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isActive ? color : Colors.grey.withValues(alpha: 0.3),
width: isActive ? 2 : 1,
),
boxShadow: isActive
? [
BoxShadow(
color: color.withValues(alpha: 0.2),
blurRadius: 8,
spreadRadius: 1,
),
]
: [],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: isActive ? color : Colors.grey, size: 28),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
color: isActive ? color : Colors.grey.shade700,
),
textAlign: TextAlign.center,
),
if (isActive) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: Text(
count.toString(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
],
),
),
);
}
}

View File

@@ -1,384 +0,0 @@
import 'package:file_picker/file_picker.dart';
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/widgets/image_viewer_widget.dart';
import 'package:flux/core/widgets/pdf_viewer_widget.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/features/services/blocs/service_files_bloc.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/models/service_file_model.dart';
class AttachmentsSection extends StatelessWidget {
const AttachmentsSection({super.key});
Future<void> _pickFiles(BuildContext context) async {
// Usiamo withData: true fondamentale per avere i bytes e caricare su Supabase Storage
FilePickerResult? result = await FilePicker.pickFiles(
allowMultiple: true,
type: FileType.custom,
allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],
withData: true,
);
if (result != null && context.mounted) {
context.read<ServiceFilesBloc>().add(AddServiceFilesEvent(result.files));
}
}
@override
Widget build(BuildContext context) {
ServiceFilesBloc serviceFilesBloc = BlocProvider.of<ServiceFilesBloc>(
context,
);
return BlocListener<ServicesCubit, ServicesState>(
listenWhen: (previous, current) =>
previous.currentService?.id == null &&
current.currentService?.id != null,
listener: (context, state) {
// FIGASSA! La pratica è stata salvata e ora ha un ID.
// Diciamo al Bloc dei file di agganciarsi al database.
final newId = state.currentService!.id!;
context.read<ServiceFilesBloc>().add(ServiceSavedEvent(newId));
},
child: BlocBuilder<ServiceFilesBloc, ServiceFilesState>(
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER SEZIONE ---
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"DOCUMENTI ALLEGATI",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
letterSpacing: 1.2,
),
),
Row(
children: [
OutlinedButton.icon(
icon: const Icon(Icons.attach_file),
label: const Text("Aggiungi File"),
onPressed: () => _pickFiles(context),
),
if (!context
.read<SessionCubit>()
.state
.isMobileDevice) ...[
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: () => _handleGenerateQr(context),
icon: const Icon(Icons.qr_code),
label: const Text("GENERA QR"),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
foregroundColor: Theme.of(
context,
).colorScheme.primary,
elevation: 0,
),
),
],
],
),
],
),
const SizedBox(height: 12),
// --- LISTA VUOTA ---
if (state.allFiles.isEmpty) // Furbata: usiamo allFiles.isEmpty!
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey.shade300,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(8),
color: Colors.grey.shade50,
),
child: const Text(
"Nessun documento allegato alla bozza.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
// --- LISTA PIENA ---
else ...[
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: state.allFiles.length,
itemBuilder: (context, index) {
final file = state.allFiles[index];
final sizeMb = (file.fileSize / (1024 * 1024))
.toStringAsFixed(2);
final isPdf = file.extension.toLowerCase() == 'pdf';
final isSelected = state.selectedFiles.contains(file);
return GestureDetector(
onTap: () => serviceFilesBloc.add(
ToggleServiceFileSelectionEvent(file),
),
onDoubleTap: () => _handleDoubleClick(context, file),
child: Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 0,
// UX Fina: cambiamo colore del bordo se selezionato
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
),
// UX Fina: Sfondo leggermente colorato se selezionato
color: isSelected
? Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.05)
: Theme.of(context).colorScheme.surface,
child: ListTile(
leading: Icon(
isSelected
? Icons.check_box
: Icons.check_box_outline_blank,
color: Theme.of(context).colorScheme.primary,
size: 32,
),
title: Text(
file.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
file.isLocal ? "$sizeMb MB • (Nuovo)" : " MB",
),
trailing: Icon(
isPdf ? Icons.picture_as_pdf : Icons.image,
color: isPdf ? Colors.red : Colors.blue,
size: 32,
),
),
),
);
},
),
// --- PANNELLO AZIONI CONTESTUALI (LA MAGIA) ---
// Appare SOLO se c'è almeno un file selezionato
if (state.selectedFiles.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Row(
children: [
// Contatore
Text(
"${state.selectedFiles.length} file selezionati",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const Spacer(),
// Bottone Elimina
TextButton.icon(
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
icon: const Icon(Icons.delete_outline),
label: const Text("Elimina"),
onPressed: () {
// Qui lancerai l'evento per eliminare i file selezionati!
// Es: serviceFilesBloc.add(DeleteSelectedFilesEvent());
},
),
const SizedBox(width: 8),
// Bottone Copia
ElevatedButton.icon(
icon: const Icon(Icons.copy),
label: const Text("Copia in Cliente"),
onPressed: () => saveAndCopyFilesToCustomer(
context,
state.selectedFiles,
),
),
],
),
),
),
],
],
);
},
),
);
}
Future<void> _handleGenerateQr(BuildContext context) async {
final cubit = context.read<ServicesCubit>();
var currentService = cubit.state.currentService;
// 1. CATTURIAMO IL BLOC MENTRE SIAMO ANCORA NELLA PAGINA
final serviceFilesBloc = context.read<ServiceFilesBloc>();
// 2. SE LA PRATICA E' NUOVA (Manca l'ID)
if (currentService == null || currentService.id == null) {
// NIENTE BlocListener qui! Solo un semplice Dialog di conferma
final bool? confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Salvataggio Necessario"),
content: const Text(
"Per generare il QR Code e caricare file dal telefono, la pratica deve essere prima salvata in BOZZA.\n\nVuoi salvare ora?",
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text("Salva in Bozza"),
),
],
),
);
if (confirm != true) return; // Utente ha annullato
// Salviamo forzatamente in bozza
await cubit.saveCurrentService(
isBozza: true,
shouldPop: false,
files: serviceFilesBloc.state.localFiles,
);
// Recuperiamo il servizio aggiornato con l'ID!
currentService = cubit.state.currentService;
if (currentService?.id == null) return;
}
// 3. MOSTRIAMO IL QR CODE (Con il Ponte e l'Auto-Chiusura!)
if (context.mounted) {
final nomePratica = "Pratica ${currentService?.customerDisplayName ?? ''}"
.trim();
showDialog(
context: context,
builder: (dialogContext) => BlocProvider.value(
// INIETTIAMO IL BLOC NEL CONTESTO DEL DIALOG ALIENO
value: serviceFilesBloc,
// ORA METTIAMO L'AUTO-CHIUSURA SUL QR CODE!
child: BlocListener<ServiceFilesBloc, ServiceFilesState>(
listener: (context, state) {
// Se arrivano file remoti e lo stato è success, chiudiamo il QR!
// (Nota: usiamo dialogContext per assicurarci di chiudere il popup giusto)
if (state.status == ServiceFilesStatus.success &&
state.remoteFiles.isNotEmpty) {
Navigator.of(dialogContext).pop();
}
},
child: QrUploadDialog(
deepLinkUrl:
'fluxapp:///service/${currentService!.id}/upload?name=${Uri.encodeComponent(nomePratica)}',
title: 'Scatta per\n$nomePratica',
),
),
),
);
}
}
// --- LOGICA DI COPIA AL CLIENTE ---
void saveAndCopyFilesToCustomer(
BuildContext context,
List<ServiceFileModel> files,
) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Copia nei documenti Cliente"),
content: const Text(
"Vuoi copiare i file selezionati nell'anagrafica del cliente? \n\n"
"Attenzione: per procedere, la pratica attuale verrà prima salvata in stato BOZZA.",
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
// 1. Diciamo al Cubit di salvare in Bozza e fare la copia
context.read<ServicesCubit>().saveAndCopyFileToCustomer(files);
},
child: const Text("Salva e Copia"),
),
],
),
);
}
// --- LOGICA DI VISUALIZZAZIONE OVERLAY ---
void _handleDoubleClick(BuildContext context, ServiceFileModel file) {
showDialog(
context: context,
barrierDismissible: true,
builder: (ctx) => Dialog(
insetPadding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: double.infinity,
height: MediaQuery.of(context).size.height * 0.8,
child: file.isPdf
? PdfViewerWidget(
storagePath: file.storagePath.isNotEmpty
? file.storagePath
: null,
bytes: file.localBytes,
)
: ImageViewerWidget(
storagePath: file.storagePath.isNotEmpty
? file.storagePath
: null,
bytes: file.localBytes,
),
),
),
),
);
}
}

View File

@@ -1,96 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flux/features/customers/ui/customer_search_sheet.dart';
import 'package:flux/features/services/models/service_model.dart';
class CustomerSection extends StatelessWidget {
final ServiceModel service;
const CustomerSection({super.key, required this.service});
void _openCustomerSearch(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (modalContext) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(modalContext).viewInsets.bottom,
),
// La modale di ricerca
child: const CustomerSearchSheet(),
);
},
);
}
@override
Widget build(BuildContext context) {
// Niente BlocBuilder qui! Leggiamo solo la variabile 'service'
final hasCustomer = service.customerId != null;
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.person,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
"Dati Cliente",
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 16),
if (!hasCustomer)
Center(
child: ElevatedButton.icon(
onPressed: () => _openCustomerSearch(context),
icon: const Icon(Icons.search),
label: const Text("Seleziona o Crea Cliente"),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
)
else
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
service.customerDisplayName ?? "Cliente Selezionato",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
TextButton.icon(
onPressed: () => _openCustomerSearch(context),
icon: const Icon(Icons.edit, size: 18),
label: const Text("Cambia"),
),
],
),
],
),
),
);
}
}

View File

@@ -1,417 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/master_data/providers/models/provider_model.dart';
import 'package:flux/features/services/models/energy_service_model.dart'; // Assicurati degli import
class EnergyServiceDialog extends StatefulWidget {
final List<EnergyServiceModel> initialServices;
final String
currentStoreId; // Ci serve per sapere per quale negozio caricare i gestori
const EnergyServiceDialog({
super.key,
required this.initialServices,
required this.currentStoreId,
});
@override
State<EnergyServiceDialog> createState() => _EnergyServiceDialogState();
}
class _EnergyServiceDialogState extends State<EnergyServiceDialog> {
// Lista temporanea per non "sporcare" il cubit finché non si preme Conferma
late List<EnergyServiceModel> _tempList;
bool _isAddingNew = false;
@override
void initState() {
super.initState();
_tempList = List.from(widget.initialServices);
// Al caricamento della modale, chiediamo al Cubit di recuperare i gestori veri!
context.read<ProvidersCubit>().loadActiveProvidersForStore(
widget.currentStoreId,
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
Icon(Icons.bolt, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text(_isAddingNew ? "Nuovo Contratto" : "Servizi Energia"),
],
),
content: AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: SizedBox(
width: double.maxFinite,
// Cambia vista in base al flag
child: _isAddingNew
? _EnergyForm(
onSave: (newService) {
setState(() {
_tempList.add(newService);
_isAddingNew = false; // Torna alla lista
});
},
onCancel: () {
setState(() => _isAddingNew = false);
},
)
: _EnergyList(
services: _tempList,
onDelete: (index) {
setState(() => _tempList.removeAt(index));
},
onAddTap: () {
setState(() => _isAddingNew = true); // Passa al form
},
activeProviders: [
// Passiamo i provider attivi filtrati per tipo Energia
...context
.read<ProvidersCubit>()
.state
.activeProviders
.where((p) => p.energia == true),
],
),
),
),
actions: [
if (!_isAddingNew) ...[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, _tempList),
child: const Text("Conferma Tutti"),
),
],
],
);
}
}
// ==========================================
// VISTA 1: LA LISTA DEI CONTRATTI
// ==========================================
class _EnergyList extends StatelessWidget {
final List<EnergyServiceModel> services;
final List<ProviderModel>
activeProviders; // <--- NUOVO: La lista vera dal Cubit
final Function(int) onDelete;
final VoidCallback onAddTap;
const _EnergyList({
required this.services,
required this.activeProviders, // <--- Richiesto
required this.onDelete,
required this.onAddTap,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (services.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Text(
"Nessun contratto energia inserito.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
else
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: services.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final s = services[index];
final isLuce = s.type == EnergyType.luce;
// LA MAGIA: Troviamo il nome partendo dall'ID salvato nel servizio
final providerIndex = activeProviders.indexWhere(
(p) => p.id == s.providerId,
);
final providerName = providerIndex >= 0
? (activeProviders[providerIndex].nome)
: 'Gestore Rimosso/Sconosciuto';
// Formattazione data pulita (es. 04/09/2025)
final day = s.expiration.day.toString().padLeft(2, '0');
final month = s.expiration.month.toString().padLeft(2, '0');
final formattedDate = "$day/$month/${s.expiration.year}";
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: isLuce
? Colors.orange.shade100
: Colors.blue.shade100,
child: Icon(
isLuce
? Icons.lightbulb_outline
: Icons.local_fire_department,
color: isLuce ? Colors.orange : Colors.blue,
),
),
title: Text(
providerName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text("Scadenza: $formattedDate"),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => onDelete(index),
),
);
},
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: onAddTap,
icon: const Icon(Icons.add),
label: const Text("Aggiungi Contratto"),
),
],
);
}
}
// ==========================================
// VISTA 2: IL FORM DI INSERIMENTO
// ==========================================
class _EnergyForm extends StatefulWidget {
final Function(EnergyServiceModel) onSave;
final VoidCallback onCancel;
const _EnergyForm({required this.onSave, required this.onCancel});
@override
State<_EnergyForm> createState() => _EnergyFormState();
}
class _EnergyFormState extends State<_EnergyForm> {
EnergyType _selectedType = EnergyType.luce;
String? _selectedProviderId;
DateTime? _selectedExpiration;
int? _selectedMonthsPreset;
void _applyPreset(int? months) {
if (months == null) return;
setState(() {
_selectedMonthsPreset = months;
// Calcoliamo la data: oggi + X mesi
final now = DateTime.now();
_selectedExpiration = DateTime(now.year, now.month + months, now.day);
});
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now().add(
const Duration(days: 365),
), // Default 1 anno
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
);
if (picked != null) {
setState(() => _selectedExpiration = picked);
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 1. Tipo (Luce o Gas) - Segmented Button stile M3
SegmentedButton<EnergyType>(
segments: const [
ButtonSegment(
value: EnergyType.luce,
label: Text("Luce"),
icon: Icon(Icons.lightbulb_outline),
),
ButtonSegment(
value: EnergyType.gas,
label: Text("Gas"),
icon: Icon(Icons.local_fire_department),
),
],
selected: {_selectedType},
onSelectionChanged: (Set<EnergyType> newSelection) {
setState(() => _selectedType = newSelection.first);
},
),
const SizedBox(height: 20),
// 2. SCADENZA INTELLIGENTE (La parte PRO)
const Text(
"Scadenza Contratto",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 8),
SegmentedButton<int?>(
showSelectedIcon: false, // Per un look più pulito
segments: const [
ButtonSegment(value: 12, label: Text("12m")),
ButtonSegment(value: 24, label: Text("24m")),
ButtonSegment(value: 36, label: Text("36m")),
ButtonSegment(
value: null,
label: Icon(Icons.calendar_month, size: 20),
),
],
selected: {_selectedMonthsPreset},
onSelectionChanged: (Set<int?> newSelection) {
final val = newSelection.first;
if (val == null) {
_pickDate(); // Se clicca l'icona calendario, apre il picker
} else {
_applyPreset(val); // Altrimenti applica 12, 24 o 36
}
},
),
const SizedBox(height: 12),
// Visualizzazione della data calcolata (o scelta)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _selectedExpiration != null
? Theme.of(context).colorScheme.primary
: Colors.transparent,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.event,
size: 18,
color: _selectedExpiration != null
? Theme.of(context).colorScheme.primary
: Colors.grey,
),
const SizedBox(width: 8),
Text(
_selectedExpiration != null
? "Scade il: ${_selectedExpiration!.day.toString().padLeft(2, '0')}/${_selectedExpiration!.month.toString().padLeft(2, '0')}/${_selectedExpiration!.year}"
: "Seleziona una scadenza",
style: TextStyle(
fontWeight: FontWeight.bold,
color: _selectedExpiration != null
? Theme.of(context).colorScheme.onSurface
: Colors.grey,
),
),
],
),
),
const SizedBox(height: 20),
// 2. Provider Dropdown
BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(
child: LinearProgressIndicator(),
); // Mostra una barretta di caricamento
}
if (state.activeProviders.isEmpty) {
return const Text(
"Nessun gestore associato a questo negozio.",
style: TextStyle(color: Colors.red),
);
}
// Filtra solo i provider di tipo Energia (Se hai una categoria nel modello)
// Se non hai una categoria nel ProviderModel, puoi rimuovere il .where
final energyProviders = state.activeProviders;
return DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: "Gestore / Provider",
border: OutlineInputBorder(),
),
initialValue: _selectedProviderId,
items: energyProviders.map((p) {
return DropdownMenuItem(value: p.id, child: Text(p.nome));
}).toList(),
onChanged: (val) => setState(() => _selectedProviderId = val),
);
},
),
const SizedBox(height: 16),
// 3. Scadenza (DatePicker integrato in un TextField)
TextFormField(
readOnly: true,
onTap: _pickDate,
decoration: InputDecoration(
labelText: "Data Scadenza",
border: const OutlineInputBorder(),
suffixIcon: const Icon(Icons.calendar_month),
),
// Mostra la data se selezionata, altrimenti vuoto
controller: TextEditingController(
text: _selectedExpiration != null
? "${_selectedExpiration!.day}/${_selectedExpiration!.month}/${_selectedExpiration!.year}"
: "",
),
),
const SizedBox(height: 24),
// 4. Pulsanti Interni al Form
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: widget.onCancel,
child: const Text("Indietro"),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed:
(_selectedProviderId == null || _selectedExpiration == null)
? null // Disabilitato se mancano dati obbligatori
: () {
final newService = EnergyServiceModel(
type: _selectedType,
expiration: _selectedExpiration!,
providerId: _selectedProviderId!,
);
widget.onSave(newService);
},
child: const Text("Salva Contratto"),
),
],
),
],
);
}
}

View File

@@ -1,393 +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/features/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/master_data/providers/models/provider_model.dart';
import 'package:flux/features/services/data/services_repository.dart';
import 'package:flux/features/services/models/entertainment_service_model.dart';
import 'package:get_it/get_it.dart';
class EntertainmentServiceDialog extends StatefulWidget {
final List<EntertainmentServiceModel> initialServices;
final String currentStoreId;
const EntertainmentServiceDialog({
super.key,
required this.initialServices,
required this.currentStoreId,
});
@override
State<EntertainmentServiceDialog> createState() =>
_EntertainmentServiceDialogState();
}
class _EntertainmentServiceDialogState
extends State<EntertainmentServiceDialog> {
late List<EntertainmentServiceModel> _tempList;
bool _isAddingNew = false;
@override
void initState() {
super.initState();
_tempList = List.from(widget.initialServices);
// Carichiamo i provider attivi per lo store corrente
context.read<ProvidersCubit>().loadActiveProvidersForStore(
widget.currentStoreId,
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
Icon(
Icons.movie_filter_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(_isAddingNew ? "Nuovo Servizio" : "Servizi Intrattenimento"),
],
),
content: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: _isAddingNew
? _EntertainmentForm(
// Il form che abbiamo creato prima
onSave: (newService) => setState(() {
_tempList.add(newService);
_isAddingNew = false;
}),
onCancel: () => setState(() => _isAddingNew = false),
)
: BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
// Passiamo allProviders per garantire la visione dello storico
return _EntertainmentList(
services: _tempList,
allProviders: state.allProviders,
onDelete: (index) =>
setState(() => _tempList.removeAt(index)),
onAddTap: () => setState(() => _isAddingNew = true),
);
},
),
),
),
actions: !_isAddingNew
? [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, _tempList),
child: const Text("Conferma Tutti"),
),
]
: null, // I pulsanti del form sono interni al form stesso
);
}
}
class _EntertainmentList extends StatelessWidget {
final List<EntertainmentServiceModel> services;
final List<ProviderModel> allProviders;
final Function(int) onDelete;
final VoidCallback onAddTap;
const _EntertainmentList({
required this.services,
required this.allProviders,
required this.onDelete,
required this.onAddTap,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (services.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Text(
"Nessun servizio intrattenimento.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
else
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: services.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final s = services[index];
final providerName = allProviders
.firstWhere(
(p) => p.id == s.providerId,
orElse: () => ProviderModel(
id: '',
nome: 'Fornitore Storico',
companyId: '',
isActive: false,
energia: false,
telefoniaFissa: false,
telefoniaMobile: false,
assicurazioni: false,
finanziamenti: false,
altro: false,
intrattenimento: false,
),
)
.nome;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: Colors.purple.shade100,
child: const Icon(
Icons.movie_creation_outlined,
color: Colors.purple,
),
),
title: Text(
"${s.type}$providerName",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
s.constrained
? "Vincolo fino al: ${s.constrainExpiration.day}/${s.constrainExpiration.month}/${s.constrainExpiration.year}"
: "Senza vincoli",
style: TextStyle(
color: s.constrained
? Colors.red.shade700
: Colors.green.shade700,
),
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => onDelete(index),
),
);
},
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: onAddTap,
icon: const Icon(Icons.add),
label: const Text("Aggiungi Servizio"),
),
],
);
}
}
// ---ENTERTAINMENT FORM (MODALE)---
class _EntertainmentForm extends StatefulWidget {
final Function(EntertainmentServiceModel) onSave;
final VoidCallback onCancel;
const _EntertainmentForm({required this.onSave, required this.onCancel});
@override
State<_EntertainmentForm> createState() => _EntertainmentFormState();
}
class _EntertainmentFormState extends State<_EntertainmentForm> {
String? _selectedProviderId;
final TextEditingController _typeController = TextEditingController();
bool _isConstrained = false;
DateTime _expirationDate = DateTime.now().add(
const Duration(days: 365),
); // Default 12 mesi
// Preset rapidi per il vincolo (es: 12, 24 mesi)
int? _selectedPresetMonths;
void _applyPreset(int months) {
setState(() {
_selectedPresetMonths = months;
_isConstrained = true;
final now = DateTime.now();
_expirationDate = DateTime(now.year, now.month + months, now.day);
});
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _expirationDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
);
if (picked != null) {
setState(() {
_expirationDate = picked;
_selectedPresetMonths = null;
_isConstrained = true;
});
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 1. GESTORE (Filtro intrattenimento)
BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
final filtered = state.activeProviders
.where((p) => p.intrattenimento)
.toList();
return DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: "Fornitore (es: Sky, TIM)",
border: OutlineInputBorder(),
),
items: filtered
.map(
(p) => DropdownMenuItem(value: p.id, child: Text(p.nome)),
)
.toList(),
onChanged: (val) => setState(() => _selectedProviderId = val),
);
},
),
const SizedBox(height: 16),
// 2. TIPO SERVIZIO (TextField con suggerimenti rapidi sotto)
TextFormField(
controller: _typeController,
decoration: const InputDecoration(
labelText: "Servizio",
hintText: "es: Netflix, DAZN, Disney+",
border: OutlineInputBorder(),
),
onChanged: (val) => setState(() {}),
),
const SizedBox(height: 8),
// Suggerimenti rapidi (Chip)
FutureBuilder<List<String>>(
future: GetIt.I<ServicesRepository>().fetchTopEntertainmentTypes(
GetIt.I<SessionCubit>().state.company!.id!,
),
builder: (context, snapshot) {
final suggestions = snapshot.data ?? ["Netflix", "DAZN", "Sky"];
return Wrap(
spacing: 8,
children: suggestions.map((s) {
return ActionChip(
label: Text(s, style: const TextStyle(fontSize: 12)),
onPressed: () => setState(() => _typeController.text = s),
);
}).toList(),
);
},
),
const SizedBox(height: 16),
// 3. VINCOLO CONTRATTUALE
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Vincolo di permanenza",
style: TextStyle(fontWeight: FontWeight.bold),
),
Switch(
value: _isConstrained,
onChanged: (val) => setState(() {
_isConstrained = val;
if (!val) _selectedPresetMonths = null;
}),
),
],
),
if (_isConstrained) ...[
const SizedBox(height: 8),
SegmentedButton<int?>(
segments: const [
ButtonSegment(value: 12, label: Text("12m")),
ButtonSegment(value: 24, label: Text("24m")),
ButtonSegment(
value: null,
label: Icon(Icons.calendar_month, size: 20),
),
],
selected: {_selectedPresetMonths},
onSelectionChanged: (val) {
if (val.first == null) {
_pickDate();
} else {
_applyPreset(val.first!);
}
},
),
const SizedBox(height: 12),
// Box data scadenza vincolo
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.event_busy, size: 18, color: Colors.redAccent),
const SizedBox(width: 8),
Text(
"Scadenza vincolo: ${_expirationDate.day.toString().padLeft(2, '0')}/${_expirationDate.month.toString().padLeft(2, '0')}/${_expirationDate.year}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
],
const SizedBox(height: 24),
// PULSANTI
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: widget.onCancel,
child: const Text("Annulla"),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed:
(_selectedProviderId == null || _typeController.text.isEmpty)
? null
: () => widget.onSave(
EntertainmentServiceModel(
providerId: _selectedProviderId!,
type: _typeController.text,
constrained: _isConstrained,
constrainExpiration: _expirationDate,
),
),
child: const Text("Aggiungi"),
),
],
),
],
);
}
}

View File

@@ -1,479 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
import 'package:flux/features/master_data/products/models/model_model.dart';
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/services/models/fin_service_model.dart';
import 'package:flux/features/master_data/providers/models/provider_model.dart';
// ===========================================================================
// DIALOG PRINCIPALE
// ===========================================================================
class FinanceServiceDialog extends StatefulWidget {
final List<FinServiceModel> initialServices;
final String currentStoreId;
final ProductCubit productCubit;
const FinanceServiceDialog({
super.key,
required this.initialServices,
required this.currentStoreId,
required this.productCubit,
});
@override
State<FinanceServiceDialog> createState() => _FinanceServiceDialogState();
}
class _FinanceServiceDialogState extends State<FinanceServiceDialog> {
late List<FinServiceModel> _tempList;
bool _isAddingNew = false;
@override
void initState() {
super.initState();
_tempList = List.from(widget.initialServices);
// Carichiamo i dati necessari dai Cubit
context.read<ProvidersCubit>().loadActiveProvidersForStore(
widget.currentStoreId,
);
context.read<ProductCubit>().loadBrands();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: widget.productCubit,
child: AlertDialog(
title: Row(
children: [
Icon(
Icons.payments_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(_isAddingNew ? "Dettagli Finanziamento" : "Finanziamenti"),
],
),
content: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: _isAddingNew
? _FinanceForm(
onSave: (newFin) => setState(() {
_tempList.add(newFin);
_isAddingNew = false;
}),
onCancel: () => setState(() => _isAddingNew = false),
)
: BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, provState) {
return BlocBuilder<ProductCubit, ProductState>(
builder: (context, prodState) {
return _FinanceList(
services: _tempList,
allProviders:
provState.allProviders, // Per vedere lo storico
allModels: prodState.models,
onDelete: (index) =>
setState(() => _tempList.removeAt(index)),
onAddTap: () => setState(() => _isAddingNew = true),
);
},
);
},
),
),
),
actions: !_isAddingNew
? [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, _tempList),
child: const Text("Conferma"),
),
]
: null,
),
);
}
}
// ===========================================================================
// VISTA LISTA (STORICA)
// ===========================================================================
class _FinanceList extends StatelessWidget {
final List<FinServiceModel> services;
final List<ProviderModel> allProviders;
final List<ModelModel> allModels;
final Function(int) onDelete;
final VoidCallback onAddTap;
const _FinanceList({
required this.services,
required this.allProviders,
required this.allModels,
required this.onDelete,
required this.onAddTap,
});
@override
Widget build(BuildContext context) {
if (services.isEmpty) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Text(
"Nessun finanziamento inserito.",
style: TextStyle(color: Colors.grey),
),
),
OutlinedButton.icon(
onPressed: onAddTap,
icon: const Icon(Icons.add),
label: const Text("Aggiungi primo"),
),
],
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: services.length,
separatorBuilder: (_, _) => const Divider(),
itemBuilder: (context, index) {
final s = services[index];
// Cerchiamo il nome del provider in TUTTI quelli caricati (storico)
final providerName = allProviders
.firstWhere(
(p) => p.id == s.providerId,
orElse: () => ProviderModel(
id: '',
nome: 'Operatore Storico',
companyId: '',
isActive: false,
energia: false,
telefoniaFissa: false,
telefoniaMobile: false,
assicurazioni: false,
altro: false,
intrattenimento: false,
finanziamenti: false,
),
)
.nome;
// Cerchiamo il nome del modello
final modelName = allModels
.firstWhere(
(m) => m.id == s.modelId,
orElse: () => ModelModel(
id: '',
name: 'Prodotto',
nameWithBrand: 'Prodotto Storico',
brandId: '',
),
)
.nameWithBrand;
final dateStr =
"${s.expiration.day.toString().padLeft(2, '0')}/${s.expiration.month.toString().padLeft(2, '0')}/${s.expiration.year}";
return ListTile(
title: Text(
modelName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text("$providerName • Scade: $dateStr"),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => onDelete(index),
),
);
},
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: onAddTap,
icon: const Icon(Icons.add),
label: const Text("Aggiungi altro"),
),
],
);
}
}
// ===========================================================================
// FORM CON OMNI-SEARCH
// ===========================================================================
class _FinanceForm extends StatefulWidget {
final Function(FinServiceModel) onSave;
final VoidCallback onCancel;
const _FinanceForm({required this.onSave, required this.onCancel});
@override
State<_FinanceForm> createState() => _FinanceFormState();
}
class _FinanceFormState extends State<_FinanceForm> {
String? _selectedProviderId;
ModelModel? _selectedModel;
int _selectedMonths = 30; // Default richiesto
Timer? _debounce;
final TextEditingController _searchController = TextEditingController();
late DateTime _selectedExpirationDate;
@override
void initState() {
super.initState();
final now = DateTime.now();
_selectedExpirationDate = DateTime(
now.year,
now.month + _selectedMonths,
now.day,
); // Inizialmente 30 mesi dalla data attuale
}
void _onSearchChanged(String query) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
context.read<ProductCubit>().searchModels(query);
});
}
// Funzione per aggiornare la data quando si clicca sui segmenti 24, 30, 48
void _updateExpirationByMonths(int months) {
setState(() {
_selectedMonths = months;
final now = DateTime.now();
// Calcolo preciso: aggiungiamo i mesi alla data attuale
_selectedExpirationDate = DateTime(now.year, now.month + months, now.day);
});
}
// Funzione per il picker manuale
Future<void> _selectManualDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedExpirationDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(
const Duration(days: 365 * 10),
), // Fino a 10 anni
);
if (picked != null && picked != _selectedExpirationDate) {
setState(() {
_selectedExpirationDate = picked;
_selectedMonths = 0; // Resettiamo i segmenti perché è una data custom
});
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 1. SCELTA ISTITUTO (Solo attivi)
BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
final finProviders = state.activeProviders
.where((p) => p.finanziamenti)
.toList(); // Già filtrati dal caricamento della dialog
return DropdownButtonFormField<String>(
initialValue: _selectedProviderId,
decoration: const InputDecoration(
labelText: "Gestore",
border: OutlineInputBorder(),
),
items: finProviders
.map(
(p) => DropdownMenuItem(value: p.id, child: Text(p.nome)),
)
.toList(),
onChanged: (val) => setState(() => _selectedProviderId = val),
);
},
),
const SizedBox(height: 16),
// 2. RICERCA MODELLO
if (_selectedModel == null) ...[
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: "Cerca modello (es: iPhone...)",
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () => _showQuickCreate(context),
),
),
onChanged: (val) {
_onSearchChanged(val);
},
),
const SizedBox(height: 8),
_buildSearchSuggestions(),
] else
Card(
color: Theme.of(context).colorScheme.secondaryContainer,
child: ListTile(
leading: const Icon(Icons.phone_android),
title: Text(
_selectedModel!.nameWithBrand,
style: const TextStyle(fontWeight: FontWeight.bold),
),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () => setState(() => _selectedModel = null),
),
),
),
const SizedBox(height: 16),
// 3. DURATA PRESET
const Text(
"Durata Rate",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 24, label: Text("24m")),
ButtonSegment(value: 30, label: Text("30m")),
ButtonSegment(value: 48, label: Text("48m")),
],
selected: {_selectedMonths},
onSelectionChanged: (val) => _updateExpirationByMonths(val.first),
),
const SizedBox(height: 16),
// RIEPILOGO DATA E PICKER MANUALE (Stile Energia)
const Text(
"Scadenza Finanziamento",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 4),
InkWell(
onTap: _selectManualDate,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(
Icons.calendar_today,
size: 18,
color: Colors.blue,
),
const SizedBox(width: 12),
Text(
"${_selectedExpirationDate.day.toString().padLeft(2, '0')}/${_selectedExpirationDate.month.toString().padLeft(2, '0')}/${_selectedExpirationDate.year}",
style: const TextStyle(fontSize: 16),
),
],
),
const Icon(Icons.edit, size: 18, color: Colors.grey),
],
),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: widget.onCancel,
child: const Text("Indietro"),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: (_selectedProviderId == null || _selectedModel == null)
? null
: () {
final now = DateTime.now();
widget.onSave(
FinServiceModel(
providerId: _selectedProviderId!,
modelId: _selectedModel!.id!,
expiration: DateTime(
now.year,
now.month + _selectedMonths,
now.day,
),
),
);
},
child: const Text("Salva"),
),
],
),
],
);
}
Widget _buildSearchSuggestions() {
return BlocBuilder<ProductCubit, ProductState>(
builder: (context, state) {
final query = _searchController.text.toLowerCase();
if (query.isEmpty) return const SizedBox.shrink();
final filtered = state.models
.where((m) => m.nameWithBrand.toLowerCase().contains(query))
.take(3)
.toList();
return Column(
children: filtered
.map(
(m) => ListTile(
title: Text(m.nameWithBrand),
onTap: () => setState(() => _selectedModel = m),
dense: true,
),
)
.toList(),
);
},
);
}
void _showQuickCreate(BuildContext context) {
// Implementazione rapida dialog creazione Brand/Modello come discusso prima
}
}

View File

@@ -1,111 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/models/service_model.dart';
class GeneralInfoSection extends StatelessWidget {
final ServiceModel service;
const GeneralInfoSection({super.key, required this.service});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
"Info Generali",
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 16),
// Numero di Riferimento / Telefono
TextFormField(
initialValue: service.number,
keyboardType: TextInputType
.phone, // Fa aprire il tastierino numerico su mobile
decoration: const InputDecoration(
labelText: "Numero di Telefono / Riferimento",
hintText: "Es. 3331234567",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
),
onChanged: (val) {
context.read<ServicesCubit>().updateField(number: val);
},
),
const SizedBox(height: 16),
// I due Switch affiancati (Bozza e A buon fine)
Row(
children: [
Expanded(
child: SwitchListTile(
title: const Text("Bozza"),
subtitle: const Text(
"Pratica in lavorazione",
style: TextStyle(fontSize: 12),
),
value: service.isBozza,
activeThumbColor: Colors.orange,
contentPadding: EdgeInsets.zero,
onChanged: (val) {
context.read<ServicesCubit>().updateField(isBozza: val);
},
),
),
const SizedBox(width: 16),
Expanded(
child: SwitchListTile(
title: const Text("A buon fine"),
subtitle: const Text(
"Esito positivo",
style: TextStyle(fontSize: 12),
),
value: service.resultOk,
activeThumbColor: Colors.green,
contentPadding: EdgeInsets.zero,
onChanged: (val) {
context.read<ServicesCubit>().updateField(resultOk: val);
},
),
),
],
),
const SizedBox(height: 16),
// Campo Note
TextFormField(
initialValue: service.note,
maxLines: 4,
minLines: 2,
decoration: const InputDecoration(
labelText: "Note Operazione",
hintText:
"Scrivi qui eventuali dettagli o richieste del cliente...",
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
onChanged: (val) {
context.read<ServicesCubit>().updateField(note: val);
},
),
],
),
),
);
}
}

View File

@@ -1,158 +0,0 @@
import 'dart:async'; // Necessario per il Timer
import 'package:flutter/material.dart';
Future<void> updateCountDialog(
BuildContext context,
String title,
int currentValue,
Function(int) onSave,
) async {
int tempValue =
currentValue; // Variabile locale per gestire il conteggio nella dialog
final result = await showDialog<int>(
context: context,
builder: (context) => AlertDialog(
title: Text("Imposta $title"),
content: QuickCounter(
initialValue: tempValue,
onChanged: (val) => tempValue =
val, // Aggiorna il valore locale quando il counter cambia
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, tempValue),
child: const Text("Conferma"),
),
],
),
);
if (result != null) {
onSave(result);
}
}
// --- Widget Interno Specifico per il Counter Veloce ---
class QuickCounter extends StatefulWidget {
final int initialValue;
final ValueChanged<int>
onChanged; // Callback per notificare il padre dei cambiamenti
const QuickCounter({
super.key,
required this.initialValue,
required this.onChanged,
});
@override
State<QuickCounter> createState() => _QuickCounterState();
}
class _QuickCounterState extends State<QuickCounter> {
late int _value;
Timer? _longPressTimer; // Il timer per l'auto-incremento
@override
void initState() {
super.initState();
_value = widget.initialValue;
}
@override
void dispose() {
_longPressTimer
?.cancel(); // IMPORTANTE: Annulla sempre il timer alla distruzione
super.dispose();
}
// Logica comune per incremento/decremento singolo o rapido
void _update(int delta) {
setState(() {
_value += delta;
if (_value < 0) _value = 0; // Impedisci numeri negativi
});
widget.onChanged(_value); // Notifica il padre
}
// Gestione dell'inizio della pressione prolungata
void _startLongPress(int delta) {
_update(delta); // Esegui subito il primo aggiornamento al tocco iniziale
_longPressTimer = Timer.periodic(const Duration(milliseconds: 100), (
timer,
) {
_update(delta); // Aggiorna velocemente finché la pressione continua
});
}
// Gestione della fine della pressione prolungata
void _stopLongPress() {
_longPressTimer?.cancel();
}
@override
Widget build(BuildContext context) {
final canDecrement = _value > 0;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// --- Pulsante MENO ---
GestureDetector(
onLongPressStart: canDecrement ? (_) => _startLongPress(-1) : null,
onLongPressEnd: (_) => _stopLongPress(),
onLongPressCancel: () => _stopLongPress(),
onTap: canDecrement ? () => _update(-1) : null,
child: Opacity(
// Visivamente disabilitato se < 0
opacity: canDecrement ? 1.0 : 0.4,
child: const ActionButton(icon: Icons.remove, color: Colors.red),
),
),
// --- Valore Centrale ---
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
_value.toString(),
style: const TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
),
),
// --- Pulsante PIU' ---
GestureDetector(
onLongPressStart: (_) => _startLongPress(1),
onLongPressEnd: (_) => _stopLongPress(),
onLongPressCancel: () => _stopLongPress(),
onTap: () => _update(1),
child: const ActionButton(icon: Icons.add, color: Colors.green),
),
],
);
}
}
// Piccolo widget di utilità per l'aspetto del pulsante
class ActionButton extends StatelessWidget {
final IconData icon;
final Color color;
const ActionButton({super.key, required this.icon, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
shape: BoxShape.circle,
border: Border.all(color: color, width: 2),
),
child: Icon(icon, color: color, size: 30),
);
}
}

View File

@@ -1,182 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/models/service_model.dart';
import 'package:flux/features/services/ui/service_form_screen/attachment_section.dart';
import 'package:flux/features/services/ui/service_form_screen/customer_section.dart';
import 'package:flux/features/services/ui/service_form_screen/general_info_section.dart';
import 'package:flux/features/services/ui/service_form_screen/services_grid.dart';
class ServiceFormScreen extends StatefulWidget {
final String? serviceId;
final ServiceModel? existingService; // <-- AGGIUNTO
const ServiceFormScreen({
super.key,
this.serviceId,
this.existingService, // <-- AGGIUNTO
});
@override
State<ServiceFormScreen> createState() => _ServiceFormScreenState();
}
class _ServiceFormScreenState extends State<ServiceFormScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
// Diamo in pasto al Cubit tutto quello che abbiamo!
context.read<ServicesCubit>().initServiceForm(
existingService: widget.existingService,
serviceId: widget.serviceId,
);
});
}
void _performSave(BuildContext context, {required bool isBozza}) {
FocusScope.of(context).unfocus();
context.read<ServicesCubit>().saveCurrentService(isBozza: isBozza);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<ServicesCubit, ServicesState>(
listener: (context, state) {
if (state.status == ServicesStatus.saved) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Pratica salvata con successo!"),
backgroundColor: Colors.green,
),
);
Navigator.pop(context);
}
if (state.status == ServicesStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Errore: ${state.errorMessage ?? ''}"),
backgroundColor: Colors.red,
),
);
}
if (state.status == ServicesStatus.savedNoPop) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Pratica salvata con successo!"),
backgroundColor: Colors.green,
),
);
}
},
builder: (context, state) {
final service = state.currentService;
final isSaving = state.status == ServicesStatus.saving;
final isEditMode = widget.serviceId != null;
return Scaffold(
appBar: AppBar(
title: Text(isEditMode ? "Modifica Pratica" : "Nuova Pratica"),
actions: [
if (isSaving)
const Padding(
padding: EdgeInsets.only(right: 20.0),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
else if (service != null) ...[
IconButton(
icon: const Icon(Icons.edit_note),
tooltip: "Salva come Bozza",
onPressed: () => _performSave(context, isBozza: true),
),
IconButton(
icon: const Icon(
Icons.check_circle_outline,
color: Colors.green,
),
tooltip: "Conferma Pratica",
onPressed: () => _performSave(context, isBozza: false),
),
const SizedBox(width: 8),
],
],
),
body: (service == null)
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomerSection(service: service),
const SizedBox(height: 24),
GeneralInfoSection(service: service),
const SizedBox(height: 24),
ServicesGrid(service: service),
const SizedBox(height: 32),
AttachmentsSection(),
const SizedBox(height: 32),
_buildBottomActionButtons(context, isSaving: isSaving),
const SizedBox(height: 32),
],
),
),
);
},
);
}
Widget _buildBottomActionButtons(
BuildContext context, {
required bool isSaving,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Expanded(
flex: 1,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
icon: const Icon(Icons.edit_note),
label: const Text("Salva in Bozza"),
onPressed: isSaving
? null
: () => _performSave(context, isBozza: true),
),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green.shade600,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
icon: const Icon(Icons.check_circle_outline),
label: const Text(
"CONFERMA PRATICA",
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1),
),
onPressed: isSaving
? null
: () => _performSave(context, isBozza: false),
),
),
],
);
}
}

Some files were not shown because too many files have changed in this diff Show More