31 Commits

Author SHA1 Message Date
c610d68b9c added singleUserMode and removed StaffSection from forms 2026-05-13 15:41:35 +02:00
efb82b0d4a uff 2026-05-13 12:41:07 +02:00
216fd85888 a 2026-05-12 12:36:50 +02:00
2aab70aec5 sistemati ticket 2026-05-12 11:14:48 +02:00
57061da20d a 2026-05-11 20:44:17 +02:00
cbc5387097 w 2026-05-11 18:35:53 +02:00
e52dbee835 mmm 2026-05-11 18:19:48 +02:00
1dee51a7cd prova con metodo pdf vecchio programma assistenza 2026-05-11 17:13:57 +02:00
a76180497e boh 2026-05-11 11:44:14 +02:00
5c86483563 ticket labels e ticket receipt 2026-05-10 14:09:57 +02:00
385c3da0a5 named router with constants to prevent silent bugs 2026-05-09 20:42:42 +02:00
5f39d5b1ad change routes with names 2026-05-09 19:32:40 +02:00
1081609530 fix router 2026-05-09 17:30:51 +02:00
901f63841f d 2026-05-09 16:00:40 +02:00
27a262b54a changed image upload screen from mobile upload screen 2026-05-09 16:00:06 +02:00
a81515e4d8 dialog qr si chiude quando upload finito 2026-05-09 11:43:54 +02:00
73c5751677 Refactor QrUploadDialog to integrate BlocListener for attachment state management 2026-05-09 11:30:02 +02:00
0171ee6141 Refactor camera handling to remove image quality setting and streamline processing logic 2026-05-09 11:24:08 +02:00
1ee2758756 Improve camera image processing with overlay and error handling 2026-05-09 11:07:38 +02:00
fbb21dd8a4 f 2026-05-09 11:03:12 +02:00
45d49b38f7 tolto upsert nel upload documenti (cozzava con rls) 2026-05-09 10:54:49 +02:00
91a7663681 f 2026-05-09 10:20:53 +02:00
302bec114f fix 2026-05-09 10:08:29 +02:00
65aa3c7de8 fix mobile upload 2026-05-09 09:50:20 +02:00
c6ef798b22 dfa 2026-05-08 18:51:28 +02:00
42a9506f02 j 2026-05-08 12:28:14 +02:00
9793ba8348 a 2026-05-07 19:29:39 +02:00
fbf18acf05 df 2026-05-07 18:37:25 +02:00
5c1f9c0ebc upload ticket files 2026-05-07 18:08:45 +02:00
4cc1c9d157 df
Some checks failed
Deploy to Cloudflare Pages / build-and-deploy (push) Has been cancelled
2026-05-07 16:32:26 +02:00
7d03d0dea5 feat-tickets (#14)
Some checks failed
Deploy to Cloudflare Pages / build-and-deploy (push) Has been cancelled
Reviewed-on: #14
Co-authored-by: mark-cachy <marco@catelli.it>
Co-committed-by: mark-cachy <marco@catelli.it>
2026-05-07 16:28:01 +02:00
97 changed files with 8340 additions and 4815 deletions

2
.gitignore vendored
View File

@@ -6,7 +6,7 @@
*.env
.DS_Store
.atom/
.build/
.build/*
.buildlog/
.history
.svn/

4
.wrangler/cache/pages.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"account_id": "6badf20faeef39fa5c99283f46f07508",
"project_name": "flux"
}

6
.wrangler/cache/wrangler-account.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"account": {
"id": "6badf20faeef39fa5c99283f46f07508",
"name": "Marco@catelli.it's Account"
}
}

View File

@@ -1,8 +1,5 @@
plugins {
id("com.android.application")
// START: FlutterFire Configuration
id("com.google.gms.google-services")
// END: FlutterFire Configuration
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")

View File

@@ -1,86 +0,0 @@
{
"project_info": {
"project_number": "872447580790",
"project_id": "assistenza-catelli",
"storage_bucket": "assistenza-catelli.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:872447580790:android:193235afcc2920ce5d9d57",
"android_client_info": {
"package_name": "com.catelli.scans2"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBSxpdLDlPnN0xjejlX_5JL19BDeSzKOr8"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:872447580790:android:9c6172d77b1d2cae5d9d57",
"android_client_info": {
"package_name": "com.catellisrl.assistenza"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBSxpdLDlPnN0xjejlX_5JL19BDeSzKOr8"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:872447580790:android:425d21710d7682005d9d57",
"android_client_info": {
"package_name": "com.catellisrl.catelli_energy_comparator"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBSxpdLDlPnN0xjejlX_5JL19BDeSzKOr8"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:872447580790:android:a1d8d57960451f935d9d57",
"android_client_info": {
"package_name": "com.catellisrl.flux"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBSxpdLDlPnN0xjejlX_5JL19BDeSzKOr8"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -24,11 +24,11 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter android:label="flux_deep_link">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="fluxapp" />
<data android:scheme="flux" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.

View File

@@ -20,9 +20,6 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
// START: FlutterFire Configuration
id("com.google.gms.google-services") version("4.3.15") apply false
// END: FlutterFire Configuration
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"flutter":{"platforms":{"android":{"default":{"projectId":"assistenza-catelli","appId":"1:872447580790:android:a1d8d57960451f935d9d57","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"assistenza-catelli","configurations":{"android":"1:872447580790:android:a1d8d57960451f935d9d57","ios":"1:872447580790:ios:a87d56c718aa61e05d9d57","macos":"1:872447580790:ios:a87d56c718aa61e05d9d57","web":"1:872447580790:web:10745e7f9afb447d5d9d57","windows":"1:872447580790:web:3b1623eda6abdac75d9d57"}}}}}}

View File

@@ -143,6 +143,10 @@ class SessionCubit extends Cubit<SessionState> {
}
}
void updateCurrentCompany(CompanyModel newCompany) {
emit(state.copyWith(company: newCompany));
}
// --- FUNZIONE EXTRA: CAMBIO NEGOZIO DALLA DASHBOARD ---
Future<void> changeStore(StoreModel newStore) async {
if (newStore.id != null) {
@@ -160,4 +164,8 @@ class SessionCubit extends Cubit<SessionState> {
void setIsMobileDevice(bool isMobile) {
emit(state.copyWith(isMobileDevice: isMobile));
}
void setIsSingleUserMode(bool isSingleUser) {
emit(state.copyWith(isSingleUserMode: isSingleUser));
}
}

View File

@@ -25,6 +25,7 @@ class SessionState extends Equatable {
final StaffMemberModel? currentStaffMember;
final OnboardingStep onboardingStep;
final bool isMobileDevice;
final bool isSingleUserMode;
const SessionState({
this.status = SessionStatus.initial,
@@ -34,6 +35,7 @@ class SessionState extends Equatable {
this.currentStaffMember,
this.onboardingStep = OnboardingStep.none,
this.isMobileDevice = false,
this.isSingleUserMode = false,
});
/// Metodo per creare una copia dello stato modificando solo i campi necessari
@@ -45,6 +47,7 @@ class SessionState extends Equatable {
StaffMemberModel? currentStaffMember,
OnboardingStep? onboardingStep,
bool? isMobileDevice,
bool? isSingleUserMode,
}) {
return SessionState(
status: status ?? this.status,
@@ -54,6 +57,7 @@ class SessionState extends Equatable {
currentStaffMember: currentStaffMember ?? this.currentStaffMember,
onboardingStep: onboardingStep ?? this.onboardingStep,
isMobileDevice: isMobileDevice ?? this.isMobileDevice,
isSingleUserMode: isSingleUserMode ?? this.isSingleUserMode,
);
}
@@ -66,6 +70,7 @@ class SessionState extends Equatable {
currentStaffMember,
onboardingStep,
isMobileDevice,
isSingleUserMode,
];
// Helper rapidi per la UI

View File

@@ -4,14 +4,17 @@ 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/routes/routes.dart';
import 'package:flux/core/widgets/image_upload/blocs/image_upload_cubit.dart';
import 'package:flux/core/widgets/image_upload/ui/image_upload_screen.dart';
import 'package:flux/core/widgets/set_password_screen.dart';
import 'package:flux/core/widgets/image_upload/ui/upload_success_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/company/bloc/company_settings_cubit.dart';
import 'package:flux/features/company/ui/company_settings_screen.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';
@@ -20,23 +23,27 @@ 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/models/staff_member_model.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/operations/blocs/operation_files_bloc.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
import 'package:flux/features/operations/blocs/operation_list_cubit.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:flux/features/operations/ui/operation_list_screen.dart';
import 'package:flux/features/settings/settings_screen.dart';
import 'package:flux/features/settings/theme_settings_view.dart';
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
import 'package:flux/features/tickets/ui/ticket_list_screen.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
// Nota: Dovrai creare questi placeholder o file per non avere errori di compilazione
// import 'package:flux/features/master_data/master_data_hub_screen.dart';
// import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
// import 'package:flux/features/master_data/store/ui/stores_screen.dart';
class AppRouter {
static GoRouter createRouter(SessionCubit sessionCubit) {
return GoRouter(
@@ -48,18 +55,35 @@ class AppRouter {
final isGoingToOnboarding = state.matchedLocation == '/onboarding';
final isGoingToSetPassword = state.matchedLocation == '/set-password';
// 1. LA PASSATOIA VIP (DEVE ESSERE IN CIMA)
// Usiamo state.uri.path perché state.matchedLocation a volte fa i capricci coi deep link iniziali
final isPublicRoute = state.uri.path.startsWith('/upload');
if (isPublicRoute) {
// Ritorna null esplicitamente per dire al router "Rimani qui e non fare altri controlli"
return null;
}
// 2. CONTROLLO INIZIALE
// Se la sessione sta ancora caricando la primissima volta (es. splash screen logico)
if (sessionState.status == SessionStatus.initial) return null;
// 3. UTENTE NON LOGGATO (Ma ci arriva solo se non è su /upload)
if (sessionState.status == SessionStatus.unauthenticated) {
// Se sta già andando alle uniche altre pagine pubbliche, lascialo andare
if (isGoingToLogin || isGoingToSetPassword) return null;
// Altrimenti bloccalo e mandalo al login
return '/login';
}
// 4. UTENTE LOGGATO MA DEVE COMPLETARE L'ONBOARDING
if (sessionState.status == SessionStatus.onboardingRequired) {
return isGoingToOnboarding ? null : '/onboarding';
}
// 5. UTENTE PERFETTAMENTE LOGGATO E OPERATIVO
if (sessionState.status == SessionStatus.authenticated) {
// Se per sbaglio cerca di tornare al login o all'onboarding, ributtalo in dashboard
if (isGoingToLogin || isGoingToOnboarding) return '/';
return null;
}
@@ -70,14 +94,17 @@ class AppRouter {
// --- ROTTE DI SERVIZIO (FUORI DALLA SHELL) ---
GoRoute(
path: '/login',
name: Routes.login,
builder: (context, state) => const AuthScreen(),
),
GoRoute(
path: '/set-password',
name: Routes.setPassword,
builder: (context, state) => const SetPasswordScreen(),
),
GoRoute(
path: '/onboarding',
name: Routes.onboarding,
builder: (context, state) => BlocProvider(
create: (context) => OnboardingCubit(
GetIt.I.get<SessionCubit>(),
@@ -92,15 +119,21 @@ class AppRouter {
builder: (context, state, child) => AppShell(child: child),
routes: [
// 1. DASHBOARD
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
GoRoute(
path: '/',
name: Routes.home,
builder: (context, state) => const HomeScreen(),
),
// 2. HUB ANAGRAFICHE E SOTTO-ROTTE
GoRoute(
path: '/master-data',
name: Routes.masterData,
builder: (context, state) => const MasterDataHubScreen(),
routes: [
GoRoute(
path: 'products', // Diventa /master-data/products
name: Routes.products,
builder: (context, state) {
context.read<ProductsCubit>().refreshCubit();
@@ -108,15 +141,26 @@ class AppRouter {
},
),
GoRoute(
path: 'staff', // Diventa /master-data/staff
path: 'company-settings',
name: Routes.companySettings,
builder: (context, state) => BlocProvider(
create: (context) => CompanySettingsCubit(),
child: const CompanySettingsScreen(),
),
),
GoRoute(
path: 'staff',
name: Routes.staff, // Diventa /master-data/staff
builder: (context, state) => const StaffScreen(),
),
GoRoute(
path: 'stores', // Diventa /master-data/stores
path: Routes.stores,
name: 'stores', // Diventa /master-data/stores
builder: (context, state) => const StoresScreen(),
),
GoRoute(
path: 'providers', // Diventa /master-data/providers
path: 'providers',
name: Routes.providers, // Diventa /master-data/providers
builder: (context, state) =>
const ProvidersMasterDataScreen(),
),
@@ -126,90 +170,135 @@ class AppRouter {
// 3. IMPOSTAZIONI
GoRoute(
path: '/settings',
builder: (context, state) => Scaffold(
appBar: AppBar(title: Text(context.l10n.commonSettings)),
body: Center(
child: ElevatedButton.icon(
onPressed: () => context.read<SessionCubit>().signOut(),
icon: const Icon(Icons.logout),
label: const Text("Esci da FLUX"),
),
name: Routes.settings,
builder: (context, state) => const SettingsScreen(),
routes: [
GoRoute(
path: 'themeSettings',
name: Routes.themeSettings,
builder: (context, state) => const ThemeSettingsView(),
),
),
],
),
GoRoute(
path: '/operations',
builder: (context, state) => const OperationsScreen(),
name: Routes.operations,
builder: (context, state) => BlocProvider(
create: (context) => OperationListCubit(),
child: const OperationListScreen(),
),
),
GoRoute(
path: '/customers',
name: Routes.customers,
builder: (context, state) =>
const CustomersContent(), // O come si chiama il tuo widget della lista!
),
GoRoute(
path: '/tickets',
name: Routes.tickets,
builder: (context, state) => BlocProvider(
create: (context) => TicketListCubit(),
child: const TicketListScreen(),
),
),
],
),
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
GoRoute(
path: '/customer/:id',
// Il path sarà es. /tickets/form/123 oppure /tickets/form/new
path: '/tickets/form/:id',
name: Routes.ticketForm,
builder: (context, state) {
// 1. Leggiamo l'ID dall'URL
final String pathId = state.pathParameters['id'] ?? 'new';
// 2. CAST DA NINJA (Aggiungi i punti interrogativi!)
final record =
state.extra
as ({StaffMemberModel? createdBy, TicketModel? ticket})?;
// 3. LOGICA SOBRIA
final String? realTicketId;
if (pathId == 'new') {
realTicketId = null;
} else if (record?.ticket?.id != null) {
// <-- Parentesi TONDE per la condizione, GRAFFE per il blocco!
realTicketId = record!.ticket!.id;
} else {
realTicketId = pathId;
}
context.read<CustomersCubit>().loadCustomers();
context.read<ProductsCubit>().loadModels();
context.read<ProductsCubit>().loadBrands();
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => AttachmentsBloc(
parentType: AttachmentParentType.ticket,
parentId: realTicketId,
),
),
BlocProvider(
create: (context) => TicketFormCubit(
// Passiamo il creatore e l'eventuale ticket esistente presi dal Record!
createdBy: record?.createdBy,
existingTicket: record?.ticket,
),
),
],
child: TicketFormScreen(
ticketId: realTicketId,
existingTicket: record?.ticket,
),
);
},
),
GoRoute(
path: '/upload-success',
name: Routes.uploadSuccess,
builder: (context, state) => const UploadSuccessScreen(),
),
GoRoute(
path: '/customer/form/:id',
name: 'customer-form',
builder: (context, state) {
final customer = state.extra as CustomerModel;
return BlocProvider(
create: (context) => CustomerFilesBloc(customer.id!),
create: (context) => AttachmentsBloc(
parentType: AttachmentParentType.customer,
parentId: customer.id,
),
child: CustomerDetailScreen(customer: customer),
);
},
),
GoRoute(
path: '/customer/:id/upload',
builder: (context, state) {
final customerId = state.pathParameters['id']!;
final customerName = state.uri.queryParameters['name'] ?? 'Cliente';
return BlocProvider(
create: (context) => CustomerFilesBloc(customerId),
child: CustomerMobileUploadScreen(
customerId: customerId,
customerName: customerName,
),
);
},
),
GoRoute(
path: '/operation-form',
name: 'operation-form',
builder: (context, state) {
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) => OperationFilesBloc(
operationId: operationId ?? existingOperation?.id,
),
child: OperationFormScreen(
operationId: operationId ?? existingOperation?.id,
existingOperation: existingOperation,
),
);
},
),
GoRoute(
path: '/operation/:id/upload',
path: '/operations/form/:id',
name: Routes.operationForm,
builder: (context, state) {
final operationId = state.pathParameters['id']!;
final operationName =
state.uri.queryParameters['name'] ?? 'Pratica';
final String pathId = state.pathParameters['id'] ?? 'new';
final record =
state.extra
as ({
StaffMemberModel? createdBy,
OperationModel? operation,
})?;
final String? realOperationId;
if (pathId == 'new') {
realOperationId = null;
} else if (record?.operation?.id != null) {
realOperationId = record!.operation!.id;
} else {
realOperationId = pathId;
}
final currentStoreId = GetIt.I
.get<SessionCubit>()
.state
@@ -221,12 +310,57 @@ class AppRouter {
);
context.read<ProductsCubit>().loadModels();
context.read<ProductsCubit>().loadBrands();
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
return BlocProvider(
create: (context) => OperationFilesBloc(operationId: operationId),
child: OperationMobileUploadScreen(
operationId: operationId,
operationName: operationName,
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => AttachmentsBloc(
parentId: realOperationId,
parentType: AttachmentParentType.operation,
),
),
BlocProvider(
create: (context) => OperationFormCubit(
createdBy: record?.createdBy,
existingOperation: record?.operation,
),
),
],
child: OperationFormScreen(
operationId: realOperationId,
existingOperation: record?.operation,
),
);
},
),
GoRoute(
path: '/upload/:type/:id',
name: Routes.upload,
builder: (context, state) {
final typeString = state.pathParameters['type']!;
final id = state.pathParameters['id']!;
final companyId = state.uri.queryParameters['companyId']!;
// Trasformiamo la stringa dell'URL nel nostro amato Enum!
final parentType = AttachmentParentType.values.firstWhere(
(e) => e.name == typeString,
orElse: () =>
AttachmentParentType.ticket, // Fallback di sicurezza
);
// Creiamo il BLoC "al volo" solo per questa schermata
return MultiBlocProvider(
providers: [
BlocProvider<AttachmentsBloc>(
create: (context) =>
AttachmentsBloc(parentId: id, parentType: parentType),
),
BlocProvider(create: (context) => ImageUploadCubit()),
],
child: ImageUploadScreen(
title: 'Caricamento Rapido',
companyId: companyId,
),
);
},

View File

@@ -0,0 +1,22 @@
class Routes {
static const String login = 'login';
static const String setPassword = 'set-password';
static const String onboarding = 'onboarding';
static const String home = 'home';
static const String masterData = 'master-data';
static const String products = 'products';
static const String companySettings = 'company-settings';
static const String staff = 'staff';
static const String stores = 'stores';
static const String providers = 'providers';
static const String settings = 'settings';
static const String themeSettings = 'themeSettings';
static const String operations = 'operations';
static const String customers = 'customers';
static const String tickets = 'tickets';
static const String ticketForm = 'ticket-form';
static const String operationForm = 'operation-form';
static const String uploadSuccess = 'upload-success';
static const String customerForm = 'customer-form';
static const String upload = 'upload';
}

View File

@@ -0,0 +1,60 @@
import 'package:equatable/equatable.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
part 'image_upload_state.dart';
class ImageUploadCubit extends Cubit<ImageUploadState> {
ImageUploadCubit() : super(const ImageUploadState());
void setStatus(ImageUploadStatus status) {
emit(state.copyWith(status: status));
}
void setError(String? message) {
emit(
state.copyWith(status: ImageUploadStatus.failure, errorMessage: message),
);
}
void addFiles(List<PlatformFile> files) {
List<PlatformFile> newFiles = List.from(state.stagedFiles);
newFiles.addAll(files);
emit(
state.copyWith(status: ImageUploadStatus.success, stagedFiles: newFiles),
);
}
void removeFile(PlatformFile file) {
List<PlatformFile> newFiles = List.from(state.stagedFiles);
newFiles.remove(file);
emit(
state.copyWith(status: ImageUploadStatus.success, stagedFiles: newFiles),
);
}
Future<void> addPhoto(XFile photo) async {
final List<PlatformFile> files = List.from(state.stagedFiles);
files.add(PlatformFile(name: photo.name, size: 0));
emit(
state.copyWith(
status: ImageUploadStatus.addingPicture,
stagedFiles: files,
),
);
final List<PlatformFile> newFiles = List.from(files);
newFiles.removeLast();
final PlatformFile loadedFile = PlatformFile(
name: photo.name,
size: await photo.length(),
bytes: await photo.readAsBytes(),
path: photo.path,
);
newFiles.add(loadedFile);
emit(
state.copyWith(status: ImageUploadStatus.success, stagedFiles: newFiles),
);
}
}

View File

@@ -0,0 +1,29 @@
part of 'image_upload_cubit.dart';
enum ImageUploadStatus { initial, addingPicture, uploading, success, failure }
class ImageUploadState extends Equatable {
final ImageUploadStatus status;
final String? errorMessage;
final List<PlatformFile> stagedFiles;
const ImageUploadState({
this.status = ImageUploadStatus.initial,
this.errorMessage,
this.stagedFiles = const [],
});
ImageUploadState copyWith({
ImageUploadStatus? status,
String? errorMessage,
List<PlatformFile>? stagedFiles,
}) {
return ImageUploadState(
status: status ?? this.status,
errorMessage: errorMessage,
stagedFiles: stagedFiles ?? this.stagedFiles,
);
}
@override
List<Object?> get props => [status, errorMessage, stagedFiles];
}

View File

@@ -0,0 +1,306 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/image_upload/blocs/image_upload_cubit.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
class ImageUploadScreen extends StatelessWidget {
final String title;
final String companyId;
const ImageUploadScreen({
super.key,
required this.title,
required this.companyId,
});
bool _isImage(String path) {
return ['jpg', 'jpeg', 'png', 'webp'].contains(path.fileExtension());
}
@override
Widget build(BuildContext context) {
return BlocBuilder<ImageUploadCubit, ImageUploadState>(
builder: (context, state) {
return BlocListener<AttachmentsBloc, AttachmentsState>(
listener: (context, attachmentState) {
if (attachmentState.status == AttachmentsStatus.success &&
state.status == ImageUploadStatus.uploading) {
if (Navigator.of(context).canPop()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("File caricati con successo! ✅"),
),
);
Navigator.of(context).pop();
} else {
context.go('/upload-success');
}
}
if (attachmentState.status == AttachmentsStatus.failure) {
context.read<ImageUploadCubit>().setError(attachmentState.error);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Errore: ${state.errorMessage}")),
);
}
},
child: Scaffold(
appBar: AppBar(
title: Text('Upload: $title'),
automaticallyImplyLeading:
state.status != ImageUploadStatus.uploading,
),
body: Stack(
children: [
Column(
children: [
// --- SEZIONE PULSANTI (Fotocamera / Galleria) ---
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed:
state.status == ImageUploadStatus.uploading
? null
: () => _handleCamera(context),
icon: const Icon(Icons.camera_alt),
label: const Text('SCATTA'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 16,
),
),
),
),
Expanded(
child: OutlinedButton.icon(
onPressed:
state.status == ImageUploadStatus.uploading
? null
: () => _handleFilePicker(context),
icon: const Icon(Icons.folder),
label: const Text("GALLERIA"),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 16,
),
),
),
),
],
),
),
const Divider(),
// --- SEZIONE ANTEPRIME (La GridView Magica) ---
Expanded(
child: state.stagedFiles.isEmpty
? const Center(
child: Text(
"Nessun file selezionato.\nScatta una foto o scegli dalla galleria.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
3, // 3 colonne stile galleria
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: state.stagedFiles.length,
itemBuilder: (context, index) {
final file = state.stagedFiles[index];
final isImg = _isImage(file.name);
if (file.bytes == null) {
return Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: const Center(
child: CircularProgressIndicator(
color: Colors.blue,
),
),
),
);
}
return Stack(
clipBehavior: Clip.none,
children: [
// L'ANTEPRIMA
Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: isImg
? (file.bytes != null
// Se abbiamo i bytes (es. scatto da fotocamera) usiamo quelli (a prova di Web!)
? Image.memory(
file.bytes!,
fit: BoxFit.cover,
)
// Altrimenti andiamo di file fisico
: const Center(
child:
CircularProgressIndicator(
color: Colors.blue,
),
))
: const Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Icons.picture_as_pdf,
color: Colors.red,
size: 36,
),
SizedBox(height: 4),
Text(
"PDF",
style: TextStyle(
fontSize: 10,
fontWeight:
FontWeight.bold,
),
),
],
),
),
),
// IL PULSANTE CESTINO (In alto a destra)
Positioned(
top: -8,
right: -8,
child: GestureDetector(
onTap: () => context
.read<ImageUploadCubit>()
.removeFile(file),
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.white,
size: 16,
),
),
),
),
],
);
},
),
),
// --- SEZIONE INVIA E CHIUDI ---
SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
// Il pulsante si accende SOLO se ci sono file nel carrello
onPressed:
state.stagedFiles.isEmpty ||
state.status == ImageUploadStatus.uploading
? null
: () => _submitAllFiles(context),
icon: const Icon(Icons.cloud_upload),
label: Text(
"INVIA ${state.stagedFiles.length} FILE E CHIUDI",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimary,
),
),
),
),
),
],
),
],
),
),
);
},
);
}
// --- LOGICA FOTOCAMERA E LIBRERIA ---
Future<void> _handleCamera(BuildContext context) async {
final ImageUploadCubit imageUploadCubit = context.read<ImageUploadCubit>();
final picker = ImagePicker();
final photo = await picker.pickImage(source: ImageSource.camera);
if (photo != null) {
imageUploadCubit.addPhoto(photo);
}
}
Future<void> _handleFilePicker(BuildContext context) async {
final ImageUploadCubit imageUploadCubit = context.read<ImageUploadCubit>();
final result = await FilePicker.pickFiles(
allowMultiple: true,
withData: true,
);
if (result != null) {
imageUploadCubit.addFiles(result.files);
}
}
// --- LOGICA DI INVIO AL BLoC ---
void _submitAllFiles(BuildContext context) {
final ImageUploadCubit imageUploadCubit = context.read<ImageUploadCubit>();
imageUploadCubit.setStatus(ImageUploadStatus.uploading);
// Lanciamo l'evento del nostro nuovo AttachmentsBloc Agnostico!
context.read<AttachmentsBloc>().add(
UploadAttachmentsEvent(
pickedFiles: imageUploadCubit.state.stagedFiles,
companyId: companyId,
),
);
}
}

View File

@@ -1,32 +1,32 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
class CustomerMobileUploadScreen extends StatefulWidget {
final String customerId;
final String customerName;
class OldSharedUploadScreen extends StatefulWidget {
final String title;
final String companyId;
const CustomerMobileUploadScreen({
const OldSharedUploadScreen({
super.key,
required this.customerId,
required this.customerName,
required this.title,
required this.companyId,
});
@override
State<CustomerMobileUploadScreen> createState() =>
_CustomerMobileUploadScreenState();
State<OldSharedUploadScreen> createState() => _OldSharedUploadScreenState();
}
class _CustomerMobileUploadScreenState
extends State<CustomerMobileUploadScreen> {
class _OldSharedUploadScreenState extends State<OldSharedUploadScreen> {
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
final List<PlatformFile> _stagedFiles = [];
// 2. STATO DI CARICAMENTO GLOBALE
bool _isUploading = false;
bool _isProcessingLocal = false;
// Funzione magica per capire se è un'immagine o un PDF dall'estensione
bool _isImage(String path) {
@@ -36,18 +36,25 @@ class _CustomerMobileUploadScreenState
@override
Widget build(BuildContext context) {
return BlocListener<CustomerFilesBloc, CustomerFilesState>(
return BlocListener<AttachmentsBloc, AttachmentsState>(
listener: (context, state) {
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
if (state.status == CustomerFilesStatus.success && _isUploading) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Tutti i file caricati con successo! ✅"),
),
);
Navigator.of(context).pop();
if (state.status == AttachmentsStatus.success && _isUploading) {
// CONTROLLO MAGICO: C'è una pagina dietro di noi?
if (Navigator.of(context).canPop()) {
// Modalità "App Nativa": siamo entrati dal tasto "Aggiungi"
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("File caricati con successo! ✅")),
);
Navigator.of(context).pop();
} else {
// Modalità "Web/QR Code": Navighiamo alla pagina di successo!
// Assicurati di aver importato go_router in questo file
context.go('/upload-success');
}
}
if (state.status == CustomerFilesStatus.failure) {
if (state.status == AttachmentsStatus.failure) {
setState(() => _isUploading = false);
ScaffoldMessenger.of(
context,
@@ -56,8 +63,8 @@ class _CustomerMobileUploadScreenState
},
child: Scaffold(
appBar: AppBar(
title: Text("Upload: ${widget.customerName}"),
// Togliamo la freccia indietro se stiamo caricando per evitare disastri
title: Text("Upload: ${widget.title}"),
// Togliamo la freccia indietro se stiamo caricando per evitare macelli
automaticallyImplyLeading: !_isUploading,
),
body: Stack(
@@ -110,8 +117,7 @@ class _CustomerMobileUploadScreenState
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
3, // 3 colonne come la galleria dell'iPhone
crossAxisCount: 3, // 3 colonne stile galleria
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
@@ -137,10 +143,17 @@ class _CustomerMobileUploadScreenState
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: isImg
? Image.file(
File(file.path!),
fit: BoxFit.cover,
)
? (file.bytes != null
// Se abbiamo i bytes (es. scatto da fotocamera) usiamo quelli (a prova di Web!)
? Image.memory(
file.bytes!,
fit: BoxFit.cover,
)
// Altrimenti andiamo di file fisico
: Image.file(
File(file.path!),
fit: BoxFit.cover,
))
: const Column(
mainAxisAlignment:
MainAxisAlignment.center,
@@ -228,11 +241,11 @@ class _CustomerMobileUploadScreenState
],
),
// --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) ---
if (_isUploading)
// --- OVERLAY DI CARICAMENTO ---
if (_isUploading || _isProcessingLocal)
Container(
color: Colors.black.withValues(alpha: 0.5),
child: const Center(
child: Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(24.0),
@@ -242,7 +255,9 @@ class _CustomerMobileUploadScreenState
CircularProgressIndicator(),
SizedBox(height: 16),
Text(
"Caricamento in corso...",
_isUploading
? "Invio in corso..."
: "Elaborazione foto...",
style: TextStyle(fontWeight: FontWeight.bold),
),
],
@@ -259,30 +274,38 @@ class _CustomerMobileUploadScreenState
// --- LOGICA FOTOCAMERA E LIBRERIA ---
Future<void> _handleCamera() async {
final picker = ImagePicker();
final photo = await picker.pickImage(
source: ImageSource.camera,
imageQuality: 80,
);
if (photo != null) {
final photoBytes = await photo.readAsBytes(); // Sicuro anche per Web!
final photoSize = await photo.length();
setState(() => _isProcessingLocal = true);
await Future.delayed(const Duration(milliseconds: 100));
final platformFile = PlatformFile(
name: photo.name,
size: photoSize,
path: photo.path,
bytes: photoBytes, // I bytes ci salvano la vita su Supabase!
);
setState(() {
_stagedFiles.add(platformFile); // Unifichiamo tutto in un dart:io File
});
try {
final picker = ImagePicker();
final photo = await picker.pickImage(source: ImageSource.camera);
if (photo != null) {
final photoBytes = await photo.readAsBytes();
final photoSize = await photo.length();
final platformFile = PlatformFile(
name: photo.name,
size: photoSize,
path: photo.path,
bytes: photoBytes,
);
setState(() {
_stagedFiles.add(platformFile);
});
}
} finally {
setState(() => _isProcessingLocal = false);
}
}
Future<void> _handleFilePicker() async {
// allowMultiple: true permette di pescare 5 foto dalla galleria in un colpo solo!
final result = await FilePicker.pickFiles(allowMultiple: true);
final result = await FilePicker.pickFiles(
allowMultiple: true,
withData: true,
);
if (result != null) {
setState(() {
_stagedFiles.addAll(result.files);
@@ -294,11 +317,12 @@ class _CustomerMobileUploadScreenState
void _submitAllFiles() {
setState(() => _isUploading = true);
// 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<CustomerFilesBloc>();
bloc.add(UploadMultipleCustomerFilesEvent(_stagedFiles));
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
// Lanciamo l'evento del nostro nuovo AttachmentsBloc Agnostico!
context.read<AttachmentsBloc>().add(
UploadAttachmentsEvent(
pickedFiles: _stagedFiles,
companyId: widget.companyId,
),
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
class UploadSuccessScreen extends StatelessWidget {
const UploadSuccessScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.green.shade50,
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.green.withValues(alpha: 0.3),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: const Icon(Icons.check, size: 80, color: Colors.white),
),
const SizedBox(height: 32),
const Text(
"Upload Completato!",
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
const SizedBox(height: 16),
const Text(
"I file sono stati caricati con successo sulla pratica.\nPuoi chiudere questa pagina o finestra del browser.",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.black54),
),
],
),
),
),
);
}
}

View File

@@ -1,5 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:qr_flutter/qr_flutter.dart';
class QrUploadDialog extends StatelessWidget {
@@ -17,78 +19,84 @@ class QrUploadDialog extends StatelessWidget {
// Usiamo i colori del tema per renderlo coerente col tuo design
final theme = Theme.of(context);
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: theme.colorScheme.surface,
title: Column(
children: [
Icon(
Icons.qr_code_scanner,
size: 48,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
content: SizedBox(
height: 400,
width: 350,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, // Fondamentale per i dialog
children: [
const Text(
"Inquadra questo codice con la fotocamera del tuo telefono per scattare e caricare i documenti direttamente qui.",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 24),
// IL CUORE DELLA MAGIA
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors
.white, // Lo sfondo bianco salva la vita sui temi scuri
borderRadius: BorderRadius.circular(16),
return BlocListener<AttachmentsBloc, AttachmentsState>(
listener: (context, state) {
Navigator.of(context).pop();
},
listenWhen: (previous, current) =>
previous.allFiles.length < current.allFiles.length,
child: AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: theme.colorScheme.surface,
title: Column(
children: [
Icon(
Icons.qr_code_scanner,
size: 48,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
content: SizedBox(
height: 400,
width: 350,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, // Fondamentale per i dialog
children: [
const Text(
"Inquadra questo codice con la fotocamera del tuo telefono per scattare e caricare i documenti direttamente qui.",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
child: QrImageView(
data: deepLinkUrl,
version: QrVersions.auto,
size: 200.0,
//Opzionale: puoi metterci il logo di FLUX in mezzo!
embeddedImage: const AssetImage('assets/images/logo.png'),
embeddedImageStyle: const QrEmbeddedImageStyle(
size: Size(40, 40),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors
.white, // Lo sfondo bianco salva la vita sui temi scuri
borderRadius: BorderRadius.circular(16),
),
child: QrImageView(
data: deepLinkUrl,
version: QrVersions.auto,
size: 200.0,
//Opzionale: puoi metterci il logo di FLUX in mezzo!
embeddedImage: const AssetImage('assets/images/logo.png'),
embeddedImageStyle: const QrEmbeddedImageStyle(
size: Size(40, 40),
),
),
),
),
const SizedBox(height: 16),
Text(
"In attesa di file...",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
const SizedBox(height: 16),
Text(
"In attesa di file...",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(height: 8),
const LinearProgressIndicator(), // Per far capire che è "in ascolto"
],
const SizedBox(height: 8),
const LinearProgressIndicator(), // Per far capire che è "in ascolto"
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.commonClose),
),
],
actionsAlignment: MainAxisAlignment.center,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.commonClose),
),
],
actionsAlignment: MainAxisAlignment.center,
);
}
}

View File

@@ -4,16 +4,16 @@ 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/core/blocs/session/session_cubit.dart';
import 'package:flux/core/widgets/qr_upload_dialog.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:flux/features/attachments/blocs/attachments_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 {
@@ -30,16 +30,26 @@ class _ExportItem {
});
}
class OperationFilesSection extends StatefulWidget {
final OperationModel currentOp;
class SharedAttachmentsSection extends StatefulWidget {
final String? parentId;
final String titleForUpload;
final AttachmentParentType parentType;
final Future<String?> Function()? onGenerateIdForQr;
const OperationFilesSection({super.key, required this.currentOp});
const SharedAttachmentsSection({
super.key,
this.parentId,
this.titleForUpload = 'Cliente_sconosciuto',
required this.parentType,
this.onGenerateIdForQr,
});
@override
State<OperationFilesSection> createState() => _OperationFilesSectionState();
State<SharedAttachmentsSection> createState() =>
_SharedAttachmentsSectionState();
}
class _OperationFilesSectionState extends State<OperationFilesSection> {
class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
String? _exportDirectory;
@override
@@ -59,7 +69,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
Future<void> _selectExportDirectory() async {
final String? selectedDirectory = await FilePicker.getDirectoryPath(
dialogTitle: 'Seleziona la cartella di esportazione per TIM/Citrix',
dialogTitle: 'Seleziona la cartella di esportazione',
);
if (selectedDirectory != null) {
@@ -89,16 +99,14 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
if (result != null && mounted) {
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
context.read<OperationFilesBloc>().add(
AddOperationFilesEvent(result.files),
);
context.read<AttachmentsBloc>().add(AddAttachmentsEvent(result.files));
}
}
// --- APERTURA VIEWER ---
void _openFile(AttachmentModel file) {
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
final operationFilesBloc = context.read<OperationFilesBloc>();
final operationFilesBloc = context.read<AttachmentsBloc>();
Navigator.push(
context,
MaterialPageRoute(
@@ -108,10 +116,10 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
attachment: file,
onRename: (newName) {
// Spara l'evento al BLoC e lui farà il resto!
operationFilesBloc.add(RenameOperationFileEvent(file, newName));
operationFilesBloc.add(RenameAttachmentEvent(file, newName));
},
onDelete: () {
operationFilesBloc.add(DeleteSpecificOperationFileEvent(file));
operationFilesBloc.add(DeleteSpecificAttachmentEvent(file));
},
),
),
@@ -184,7 +192,8 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
suggestedName = selectedFiles.first.name;
} else {
// Se sono più file uniti
suggestedName = '${widget.currentOp.customerDisplayName}_Unito';
suggestedName = '${widget.titleForUpload}_Unito';
}
if (!mounted) return;
@@ -281,7 +290,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
if (fileBytes == null) continue;
// Recuperiamo il nome che l'utente ha (magari) già impostato
final baseName = file.name ?? 'Documento';
final baseName = file.name;
if (file.extension == 'pdf') {
final document = await px.PdfDocument.openData(fileBytes);
@@ -392,8 +401,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
// USIAMO IL TUO BLOC!
return BlocBuilder<OperationFilesBloc, OperationFilesState>(
return BlocBuilder<AttachmentsBloc, AttachmentsState>(
builder: (context, state) {
final allFiles = state.allFiles;
final selectedFiles = state.selectedFiles;
@@ -416,7 +424,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
color: theme.colorScheme.primary,
),
title: const Text(
'Cartella Export (Es. Citrix TIM)',
'Cartella Export (Es. TIM AttachmentRepository)',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
@@ -443,9 +451,77 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
ElevatedButton.icon(
icon: const Icon(Icons.add_photo_alternate),
label: const Text('Aggiungi File'),
onPressed: state.status == OperationFilesStatus.uploading
onPressed: state.status == AttachmentsStatus.uploading
? null
: _pickFiles,
/* : () {
final bloc = context.read<AttachmentsBloc>();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => BlocProvider.value(
value: bloc,
child: SharedMobileUploadScreen(
title: widget.titleForUpload,
),
),
),
);
}, */
),
const SizedBox(width: 8),
Tooltip(
message: 'Carica foto con lo smartphone',
child: IconButton(
icon: const Icon(Icons.qr_code_scanner),
color: theme.colorScheme.primary, // Sempre colorato!
onPressed: () async {
String? targetId = state.parentId;
// SE L'ID NON C'È, CHIAMIAMO IL SALVATAGGIO IN BACKGROUND!
if (targetId == null) {
if (widget.onGenerateIdForQr != null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Salvataggio rapido scheda in corso... ⏳',
),
duration: Duration(seconds: 1),
),
);
// Aspettiamo che il TicketFormCubit faccia il suo lavoro
targetId = await widget.onGenerateIdForQr!();
}
// Se fallisce (es. validazione form non passata), ci fermiamo
if (targetId == null) return;
}
// GENERAZIONE DEL DEEP LINK AGNOSTICO
final companyId = GetIt.I
.get<SessionCubit>()
.state
.company!
.id!;
final deepLink =
'https://flux.catelli.it/upload/${state.parentType.name}/$targetId?companyId=$companyId';
if (context.mounted) {
final attachmentBloc = context.read<AttachmentsBloc>();
showDialog(
context: context,
builder: (_) => BlocProvider.value(
value: attachmentBloc,
child: QrUploadDialog(
deepLinkUrl: deepLink,
title: 'Carica File: ${widget.titleForUpload}',
),
),
);
}
},
),
),
const SizedBox(width: 12),
@@ -464,12 +540,12 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
),
onPressed: () {
if (selectedFiles.length == allFiles.length) {
context.read<OperationFilesBloc>().add(
ClearOperationFileSelectionEvent(),
context.read<AttachmentsBloc>().add(
ClearAttachmentSelectionEvent(),
);
} else {
context.read<OperationFilesBloc>().add(
SelectAllOperationFilesEvent(),
context.read<AttachmentsBloc>().add(
SelectAllAttachmentsEvent(),
);
}
},
@@ -478,7 +554,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
const SizedBox(width: 12),
// Loader di upload
if (state.status == OperationFilesStatus.uploading)
if (state.status == AttachmentsStatus.uploading)
const SizedBox(
width: 24,
height: 24,
@@ -494,21 +570,21 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Elimina selezionati',
onPressed: () {
context.read<OperationFilesBloc>().add(
DeleteOperationFilesEvent(),
context.read<AttachmentsBloc>().add(
DeleteAttachmentsEvent(),
);
},
),
// Bottone Associa a Cliente
if (widget.currentOp.customerId != null &&
widget.currentOp.customerId!.isNotEmpty)
if (widget.parentId != null && widget.parentId != '')
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!,
context.read<AttachmentsBloc>().add(
LinkAttachmentsToEntityEvent(
targetId: widget.parentId!,
targetType: AttachmentParentType.customer,
),
);
ScaffoldMessenger.of(context).showSnackBar(
@@ -622,8 +698,8 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
onTap: () => _openFile(file),
onLongPress: () {
// Selezione rapida con long press!
context.read<OperationFilesBloc>().add(
ToggleOperationFileSelectionEvent(file),
context.read<AttachmentsBloc>().add(
ToggleAttachmentSelectionEvent(file),
);
},
borderRadius: BorderRadius.circular(8),
@@ -697,8 +773,8 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
right: 4,
child: InkWell(
onTap: () {
context.read<OperationFilesBloc>().add(
ToggleOperationFileSelectionEvent(file),
context.read<AttachmentsBloc>().add(
ToggleAttachmentSelectionEvent(file),
);
},
child: Container(

View File

@@ -1,18 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/routes/routes.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/quick_customer_dialog.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
class CustomerSection extends StatelessWidget {
final OperationModel? currentOp;
const CustomerSection({super.key, required this.currentOp});
class SharedCustomerSection extends StatelessWidget {
final CustomerModel? customer;
final ValueChanged<CustomerModel> onCustomerSelected;
const SharedCustomerSection({
super.key,
this.customer,
required this.onCustomerSelected,
});
@override
Widget build(BuildContext context) {
final hasCustomer =
currentOp?.customerId != null && currentOp!.customerId!.isNotEmpty;
final hasCustomer = customer != null && customer!.id!.isNotEmpty;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -41,9 +50,7 @@ class CustomerSection extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
hasCustomer
? currentOp!.customerDisplayName!
: 'Seleziona Cliente *',
hasCustomer ? customer!.name : 'Seleziona Cliente *',
style: TextStyle(
fontWeight: hasCustomer
? FontWeight.bold
@@ -53,10 +60,145 @@ class CustomerSection extends StatelessWidget {
),
),
const Icon(Icons.search),
if (hasCustomer) ...[
const SizedBox(width: 12),
IconButton(
onPressed: () => context.pushNamed(
Routes.customerForm,
pathParameters: {'id': customer!.id!},
extra: customer,
),
icon: const Icon(Icons.edit),
),
],
],
),
),
),
if (hasCustomer &&
(customer!.phoneNumber.isNotEmpty ||
customer!.email.isNotEmpty)) ...[
const SizedBox(height: 12), // Un po' più di respiro dal box sopra
// Mettiamo i contatti in un Container con un po' di stile per farli sembrare una "Contact Card" integrata
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.05), // Sfondo leggerissimo
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.withValues(alpha: 0.2)),
),
child: Column(
children: [
// --- RIGA TELEFONO ---
if (customer!.phoneNumber.isNotEmpty)
Row(
children: [
// Usiamo i pulsanti "Small" per non occupare troppo spazio verticale
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () => launchUrl(
Uri.parse('https://wa.me/39${customer!.phoneNumber}'),
),
icon: const FaIcon(
FontAwesomeIcons.whatsapp,
color: Colors.green,
size: 20,
),
tooltip: 'Invia WhatsApp',
),
const SizedBox(width: 8),
Expanded(
// Expanded evita l'overflow se il numero è assurdamente lungo
child: SelectableText(
customer!.phoneNumber,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
Clipboard.setData(
ClipboardData(text: customer!.phoneNumber),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Telefono copiato!'),
duration: Duration(seconds: 2),
),
);
},
icon: const Icon(
Icons.copy,
size: 18,
color: Colors.grey,
),
tooltip: 'Copia',
),
],
),
// Sezione divisoria se ci sono entrambi
if (customer!.phoneNumber.isNotEmpty &&
customer!.email.isNotEmpty)
const Divider(height: 8, thickness: 0.5),
// --- RIGA EMAIL ---
if (customer!.email.isNotEmpty)
Row(
children: [
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () => launchUrl(
Uri.parse('mailto:${customer!.email}'),
), // Rimosso il // dopo mailto:, è più sicuro
icon: const FaIcon(
FontAwesomeIcons.envelope,
color: Colors.blue,
size: 20,
),
tooltip: 'Invia Email',
),
const SizedBox(width: 8),
Expanded(
// L'Expanded è vitale per le email che possono essere lunghissime
child: SelectableText(
customer!.email,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
Clipboard.setData(
ClipboardData(text: customer!.email),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Email copiata!'),
duration: Duration(seconds: 2),
),
);
},
icon: const Icon(
Icons.copy,
size: 18,
color: Colors.grey,
),
tooltip: 'Copia',
),
],
),
],
),
),
],
],
);
}
@@ -125,9 +267,6 @@ class CustomerSection extends StatelessWidget {
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,
@@ -145,10 +284,7 @@ class CustomerSection extends StatelessWidget {
// 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,
);
onCustomerSelected(newCustomer);
// 2. Chiudiamo la BottomSheet dei clienti per tornare alla form!
if (context.mounted) {
@@ -196,14 +332,7 @@ class CustomerSection extends StatelessWidget {
'${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
);
onCustomerSelected(customer);
Navigator.pop(modalContext);
},
);

View File

@@ -0,0 +1,170 @@
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';
class SharedModelSection extends StatelessWidget {
final String? modelId;
final String? modelName;
final String label;
final Color? backgroundColor;
final Color? borderColor;
// Usiamo una callback che passa direttamente ID e Nome
// così non dobbiamo preoccuparci di importare la classe esatta del modello ovunque
final void Function(String id, String name) onModelSelected;
const SharedModelSection({
super.key,
required this.modelId,
required this.modelName,
required this.onModelSelected,
this.label = 'Seleziona Modello',
this.backgroundColor,
this.borderColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasModel = modelId != null && modelId!.isNotEmpty;
return ListTile(
tileColor: backgroundColor,
title: Text(label),
subtitle: Text(
hasModel ? modelName! : 'Nessun modello selezionato',
style: TextStyle(
color: hasModel ? null : Colors.grey,
fontWeight: hasModel ? FontWeight.bold : FontWeight.normal,
),
),
trailing: const Icon(Icons.arrow_drop_down),
shape: RoundedRectangleBorder(
side: BorderSide(color: borderColor ?? theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
onTap: () => _showModelModal(context),
);
}
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 {
// Leggiamo i brand dal Cubit per passarli alla dialog
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) {
// CHIAMIAMO LA CALLBACK!
onModelSelected(newModel.id, 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: () {
// CHIAMIAMO LA CALLBACK!
onModelSelected(
deviceModel.id!,
deviceModel.nameWithBrand,
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
}

View File

@@ -0,0 +1,242 @@
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_upload/ui/image_upload_screen.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:get_it/get_it.dart';
class SharedFilesSection extends StatelessWidget {
final String titleNameForUpload;
// LA NOSTRA CALLBACK MAGICA
final Future<String?> Function()? onGenerateIdForQr;
const SharedFilesSection({
super.key,
required this.titleNameForUpload,
this.onGenerateIdForQr,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Allegati e Foto',
style: TextStyle(fontWeight: FontWeight.bold),
),
BlocBuilder<AttachmentsBloc, AttachmentsState>(
builder: (context, state) {
return Row(
children: [
// --- IL TASTO QR CODE (Ora sempre attivo!) ---
Tooltip(
message: 'Carica foto con lo smartphone',
child: IconButton(
icon: const Icon(Icons.qr_code_scanner),
color: theme.colorScheme.primary, // Sempre colorato!
onPressed: () async {
String? targetId = state.parentId;
// SE L'ID NON C'È, CHIAMIAMO IL SALVATAGGIO IN BACKGROUND!
if (targetId == null) {
if (onGenerateIdForQr != null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Salvataggio rapido scheda in corso... ⏳',
),
duration: Duration(seconds: 1),
),
);
// Aspettiamo che il TicketFormCubit faccia il suo lavoro
targetId = await onGenerateIdForQr!();
}
// Se fallisce (es. validazione form non passata), ci fermiamo
if (targetId == null) return;
}
// GENERAZIONE DEL DEEP LINK AGNOSTICO
final companyId = GetIt.I
.get<SessionCubit>()
.state
.company!
.id!;
final deepLink =
'https://flux.catelli.it/upload/${state.parentType.name}/$targetId?companyId=$companyId';
if (context.mounted) {
showDialog(
context: context,
builder: (_) => QrUploadDialog(
deepLinkUrl: deepLink,
title: 'Carica File: $titleNameForUpload',
),
);
}
},
),
),
const SizedBox(width: 8),
// --- IL TASTO AGGIUNGI CLASSICO (da PC) ---
TextButton.icon(
icon: const Icon(Icons.add_a_photo),
label: const Text('Aggiungi'),
onPressed: () {
final bloc = context.read<AttachmentsBloc>();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => BlocProvider.value(
value: bloc,
child: ImageUploadScreen(
title: titleNameForUpload,
companyId: GetIt.I
.get<SessionCubit>()
.state
.company!
.id!,
),
),
),
);
},
),
],
);
},
),
],
),
const SizedBox(height: 8),
// --- LA VETRINA DEI FILE (Identica a prima) ---
BlocBuilder<AttachmentsBloc, AttachmentsState>(
builder: (context, state) {
final files = state.allFiles;
if (state.status == AttachmentsStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (files.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border.all(
color: theme.dividerColor,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(12),
),
child: const Column(
children: [
Icon(
Icons.image_not_supported_outlined,
color: Colors.grey,
size: 32,
),
SizedBox(height: 8),
Text(
'Nessun file allegato',
style: TextStyle(color: Colors.grey),
),
],
),
);
}
return Wrap(
spacing: 12,
runSpacing: 12,
children: files.map((file) {
final isImage = [
'jpg',
'jpeg',
'png',
'webp',
].contains(file.extension.toLowerCase());
return Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.dividerColor),
),
child: Stack(
children: [
Center(
child: isImage
? const Icon(
Icons.image,
color: Colors.blue,
size: 40,
)
: const Icon(
Icons.picture_as_pdf,
color: Colors.red,
size: 40,
),
),
if (file.id == null)
Positioned(
bottom: 4,
left: 4,
right: 4,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Da salvare',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
),
),
Positioned(
top: -8,
right: -8,
child: IconButton(
icon: const Icon(
Icons.cancel,
color: Colors.redAccent,
size: 20,
),
onPressed: () {
context.read<AttachmentsBloc>().add(
DeleteSpecificAttachmentEvent(file),
);
},
),
),
],
),
);
}).toList(),
);
},
),
],
);
}
}

View File

@@ -2,23 +2,29 @@ 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:flux/features/master_data/staff/models/staff_member_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;
final String? label;
final String? staffId;
final String? staffName;
final ValueChanged<StaffMemberModel> onStaffSelected;
const StaffSection({super.key, required this.currentOp});
const StaffSection({
super.key,
required this.onStaffSelected,
this.label,
this.staffId,
this.staffName,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Se staffId è nullo, proviamo a preselezionare l'utente loggato
final selectedStaffId =
currentOp?.staffId ??
GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
staffId ?? GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -26,7 +32,8 @@ class StaffSection extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
'Operatore',
label ??
'Operatore', // <-- FIX: Ora usa l'etichetta passata dal form!
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -34,8 +41,28 @@ class StaffSection extends StatelessWidget {
),
BlocBuilder<StaffCubit, StaffState>(
builder: (context, state) {
// Dati finti per farti vedere la UI, piallali quando attacchi il BlocBuilder!
// FIX: Aggiunto un controllo se sta caricando
if (state.status == StaffStatus.loading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final staffMembers = state.storeStaff;
// FIX: Feedback visivo se la lista è vuota
if (staffMembers.isEmpty) {
return const Text(
'Nessun operatore caricato. Controlla il Cubit!',
style: TextStyle(color: Colors.red),
);
}
final currentLoggedStaffMember = GetIt.I
.get<SessionCubit>()
.state
@@ -49,11 +76,7 @@ class StaffSection extends StatelessWidget {
return GestureDetector(
onTap: () {
// Aggiorniamo la form con un solo tap!
context.read<OperationsCubit>().updateOperationFields(
staffId: staff.id,
staffDisplayName: staff.name,
);
onStaffSelected(staff);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),

View File

@@ -0,0 +1,147 @@
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/master_data/staff/models/staff_member_model.dart';
// import 'package:flutter_bloc/flutter_bloc.dart';
// Importa il tuo StaffModel
/// Funzione helper globale per lanciare la modale ovunque ti trovi con 1 riga di codice
Future<dynamic> showStaffSelectorModal(BuildContext context) async {
return showModalBottomSheet(
context: context,
isScrollControlled:
true, // Permette alla modale di essere più alta se serve
backgroundColor: Colors.transparent,
builder: (context) => const StaffSelectorModal(),
);
}
class StaffSelectorModal extends StatelessWidget {
const StaffSelectorModal({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
padding: const EdgeInsets.all(24),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min, // Occupa solo lo spazio necessario
children: [
// --- Maniglietta superiore (UX standard dei BottomSheet) ---
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: theme.dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
// --- Titolo ---
const Text(
'Chi sei?',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Seleziona il tuo profilo per continuare',
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 32),
BlocBuilder<StaffCubit, StaffState>(
builder: (context, state) {
if (state.status == StaffStatus.loading) {
return const CircularProgressIndicator();
}
final staffList = state.storeStaff;
return _buildStaffGrid(context, staffList);
},
),
const SizedBox(height: 16),
// --- Tasto Annulla ---
TextButton(
onPressed: () => Navigator.of(context).pop(), // Restituisce null
child: const Text('Annulla'),
),
],
),
),
);
}
Widget _buildStaffGrid(
BuildContext context,
List<StaffMemberModel> staffList,
) {
return Wrap(
spacing: 16,
runSpacing: 16,
alignment: WrapAlignment.center,
children: staffList.map((staff) {
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
// Quando l'utente tappa il suo nome, la modale si chiude
// e restituisce il modello (o l'ID) alla schermata precedente!
Navigator.of(context).pop(staff);
},
child: Container(
width: 100, // Pulsanti larghi e comodi
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).dividerColor),
),
child: Column(
children: [
CircleAvatar(
radius: 30,
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
child: Text(
staff.name.substring(0, 1).toUpperCase(),
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
Text(
staff.name,
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}).toList(),
);
}
}
Future<StaffMemberModel?> getStaffMember(BuildContext context) async {
final sessionState = context.read<SessionCubit>().state;
if (sessionState.isSingleUserMode) {
// Dispositivo personale: non rompiamo le palle. Usiamo l'utente loggato.
return sessionState.currentStaffMember;
} else {
// Dispositivo Condiviso (Kiosk Mode): Chiediamo chi è!
return await showStaffSelectorModal(context);
}
}

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/data/attachments_repository.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:get_it/get_it.dart';
import 'package:image_picker/image_picker.dart';
part 'attachments_events.dart';
part 'attachments_state.dart';
class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
final _repository = GetIt.I.get<AttachmentsRepository>();
final String? companyId = GetIt.I.get<SessionCubit>().state.company?.id;
AttachmentsBloc({String? parentId, required AttachmentParentType parentType})
: super(
AttachmentsState(
status: AttachmentsStatus.initial,
parentId: parentId,
parentType: parentType,
),
) {
on<ParentEntitySavedEvent>(_onParentEntitySaved);
on<LoadAttachmentsEvent>(_onLoadAttachments);
on<AddAttachmentsEvent>(_onAddAttachments);
on<UploadAttachmentsEvent>(_onUploadAttachments);
on<DeleteAttachmentsEvent>(_onDeleteAttachments);
on<ToggleAttachmentSelectionEvent>(_onToggleAttachmentSelection);
on<LinkAttachmentsToEntityEvent>(_onLinkAttachmentsToEntity);
on<RenameAttachmentEvent>(_onRenameAttachment);
on<DeleteSpecificAttachmentEvent>(_onDeleteSpecificAttachment);
on<SelectAllAttachmentsEvent>(_onSelectAllAttachments);
on<ClearAttachmentSelectionEvent>(_onClearAttachmentSelection);
// Se il BLoC nasce già con un ID, carichiamo i file
if (parentId != null && companyId != null) {
add(LoadAttachmentsEvent(parentId: parentId));
}
}
FutureOr<void> _onParentEntitySaved(
ParentEntitySavedEvent event,
Emitter<AttachmentsState> emit,
) async {
emit(
state.copyWith(
parentId: event.newParentId,
status: AttachmentsStatus.uploading,
),
);
if (state.localFiles.isNotEmpty) {
try {
final List<Future<void>> uploadTasks = state.localFiles.map((file) {
final fakePlatformFile = PlatformFile(
name: '${file.name}.${file.extension}',
size: file.fileSize,
bytes: file.localBytes,
);
// Chiamiamo il metodo generico passando il parentId e il TYPE
return _repository.uploadAndRegisterFile(
parentId: event.newParentId,
parentType: state.parentType,
pickedFile: fakePlatformFile,
companyId: companyId!,
);
}).toList();
await Future.wait(uploadTasks);
} catch (e) {
emit(
state.copyWith(
status: AttachmentsStatus.failure,
error: "Errore upload post-salvataggio: $e",
),
);
return;
}
}
emit(state.copyWith(localFiles: [], status: AttachmentsStatus.ready));
add(LoadAttachmentsEvent(parentId: event.newParentId));
}
FutureOr<void> _onLoadAttachments(
LoadAttachmentsEvent event,
Emitter<AttachmentsState> emit,
) async {
final currentId = event.parentId ?? state.parentId;
if (currentId != null) {
emit(state.copyWith(status: AttachmentsStatus.loading));
await emit.forEach(
_repository.getFilesStream(
currentId,
state.parentType,
), // Passiamo il tipo!
onData: (List<AttachmentModel> data) =>
state.copyWith(status: AttachmentsStatus.ready, remoteFiles: data),
onError: (error, stackTrace) => state.copyWith(
status: AttachmentsStatus.failure,
error: error.toString(),
),
);
}
}
void _onAddAttachments(
AddAttachmentsEvent event,
Emitter<AttachmentsState> emit,
) async {
final currentId = state.parentId;
// BIVIO 1: PRATICA NUOVA (Salvataggio locale)
if (currentId == null) {
final newLocalFiles = event.files.map((file) {
// Assegniamo i campi dinamicamente in base al parentType!
return AttachmentModel(
id: null,
companyId: companyId!,
operationId: state.parentType == AttachmentParentType.operation
? ''
: null,
ticketId: state.parentType == AttachmentParentType.ticket ? '' : null,
customerId: state.parentType == AttachmentParentType.customer
? ''
: null,
name: file.name.fileNameWithoutExtension(),
extension: file.name.fileExtension(),
storagePath: '',
fileSize: file.size,
localBytes: file.bytes,
);
}).toList();
emit(
state.copyWith(
localFiles: [...state.localFiles, ...newLocalFiles],
status: AttachmentsStatus.ready,
),
);
return;
}
// BIVIO 2: PRATICA ESISTENTE (Upload immediato)
emit(state.copyWith(status: AttachmentsStatus.uploading));
try {
final List<Future<void>> uploadTasks = event.files.map((file) {
return _repository.uploadAndRegisterFile(
parentId: currentId,
parentType: state.parentType,
pickedFile: file,
companyId: companyId!,
);
}).toList();
await Future.wait(uploadTasks);
emit(state.copyWith(status: AttachmentsStatus.ready));
} catch (e) {
emit(
state.copyWith(status: AttachmentsStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onUploadAttachments(
UploadAttachmentsEvent event,
Emitter<AttachmentsState> emit,
) async {
if ((event.pickedFiles == null || event.pickedFiles!.isEmpty) &&
(event.photos == null || event.photos!.isEmpty)) {
return;
}
if (state.parentId == null) return;
emit(state.copyWith(status: AttachmentsStatus.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.uploadAndRegisterFile(
parentId: state.parentId!,
parentType: state.parentType,
pickedFile: file,
companyId: event.companyId,
),
);
}
}
// 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.uploadAndRegisterFile(
parentId: state.parentId!,
parentType: state.parentType,
pickedFile: fakePlatformFile,
companyId: event.companyId,
),
);
}
}
// Esecuzione parallela di tutti i documenti e foto
await Future.wait(uploadTasks);
emit(state.copyWith(status: AttachmentsStatus.success));
} catch (e) {
emit(
state.copyWith(status: AttachmentsStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onDeleteAttachments(
DeleteAttachmentsEvent event,
Emitter<AttachmentsState> emit,
) async {
emit(state.copyWith(status: AttachmentsStatus.loading));
try {
await _repository.deleteFiles(
files: state.selectedFiles,
currentContextType: state.parentType,
);
emit(state.copyWith(status: AttachmentsStatus.ready, selectedFiles: []));
} catch (e) {
emit(
state.copyWith(status: AttachmentsStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onToggleAttachmentSelection(
ToggleAttachmentSelectionEvent event,
Emitter<AttachmentsState> 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 _onSelectAllAttachments(
SelectAllAttachmentsEvent event,
Emitter<AttachmentsState> emit,
) {
// Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati
emit(state.copyWith(selectedFiles: state.allFiles));
}
void _onClearAttachmentSelection(
ClearAttachmentSelectionEvent event,
Emitter<AttachmentsState> emit,
) {
// Svuotiamo brutalmente la lista
emit(state.copyWith(selectedFiles: []));
}
FutureOr<void> _onLinkAttachmentsToEntity(
LinkAttachmentsToEntityEvent event,
Emitter<AttachmentsState> emit,
) async {
if (state.selectedFiles.isEmpty) return;
// BIVIO 1: PRATICA/TICKET NON ANCORA SALVATA (Modalità Locale)
if (state.parentId == null) {
final updatedLocalFiles = state.localFiles.map((file) {
if (state.selectedFiles.contains(file)) {
// Assegniamo dinamicamente l'ID in base all'entità scelta
switch (event.targetType) {
case AttachmentParentType.customer:
return file.copyWith(customerId: event.targetId);
case AttachmentParentType.ticket:
return file.copyWith(ticketId: event.targetId);
case AttachmentParentType.operation:
return file.copyWith(operationId: event.targetId);
}
}
return file;
}).toList();
emit(
state.copyWith(
localFiles: updatedLocalFiles,
selectedFiles: [], // Svuotiamo la selezione
status: AttachmentsStatus.ready,
),
);
return;
}
// BIVIO 2: PRATICA/TICKET ESISTENTE (Modalità Remota su DB)
emit(state.copyWith(status: AttachmentsStatus.loading));
try {
final List<Future<void>> linkTasks = [];
for (var file in state.selectedFiles) {
if (file.id != null) {
linkTasks.add(
_repository.linkFileToEntity(
fileId: file.id!,
targetType: event.targetType,
targetId: event.targetId,
),
);
}
}
await Future.wait(linkTasks);
// Lo stream aggiornerà automaticamente la UI
emit(state.copyWith(status: AttachmentsStatus.ready, selectedFiles: []));
} catch (e) {
emit(
state.copyWith(
status: AttachmentsStatus.failure,
error: "Errore durante il collegamento: $e",
),
);
}
}
FutureOr<void> _onRenameAttachment(
RenameAttachmentEvent event,
Emitter<AttachmentsState> 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: AttachmentsStatus.loading));
try {
await _repository.renameAttachment(event.file.id!, event.newName);
emit(state.copyWith(status: AttachmentsStatus.ready));
} catch (e) {
emit(
state.copyWith(
status: AttachmentsStatus.failure,
error: "Errore rinomina: $e",
),
);
}
}
FutureOr<void> _onDeleteSpecificAttachment(
DeleteSpecificAttachmentEvent event,
Emitter<AttachmentsState> 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,73 @@
part of 'attachments_bloc.dart';
abstract class AttachmentsEvent extends Equatable {
const AttachmentsEvent();
@override
List<Object?> get props => [];
}
/// Chiamato quando l'entità "padre" (es. il Ticket) viene salvata per la prima volta
class ParentEntitySavedEvent extends AttachmentsEvent {
final String newParentId;
const ParentEntitySavedEvent(this.newParentId);
@override
List<Object?> get props => [newParentId];
}
class LoadAttachmentsEvent extends AttachmentsEvent {
final String? parentId;
const LoadAttachmentsEvent({this.parentId});
}
class AddAttachmentsEvent extends AttachmentsEvent {
final List<PlatformFile> files;
const AddAttachmentsEvent(this.files);
}
class UploadAttachmentsEvent extends AttachmentsEvent {
final List<PlatformFile>? pickedFiles;
final List<XFile>? photos;
final String companyId;
const UploadAttachmentsEvent({
this.pickedFiles,
this.photos,
required this.companyId,
});
}
class DeleteAttachmentsEvent extends AttachmentsEvent {}
class ToggleAttachmentSelectionEvent extends AttachmentsEvent {
final AttachmentModel file;
const ToggleAttachmentSelectionEvent(this.file);
}
class SelectAllAttachmentsEvent extends AttachmentsEvent {}
class ClearAttachmentSelectionEvent extends AttachmentsEvent {}
class LinkAttachmentsToEntityEvent extends AttachmentsEvent {
final AttachmentParentType targetType;
final String targetId;
const LinkAttachmentsToEntityEvent({
required this.targetType,
required this.targetId,
});
@override
List<Object?> get props => [targetType, targetId];
}
class RenameAttachmentEvent extends AttachmentsEvent {
final AttachmentModel file;
final String newName;
const RenameAttachmentEvent(this.file, this.newName);
}
class DeleteSpecificAttachmentEvent extends AttachmentsEvent {
final AttachmentModel file;
const DeleteSpecificAttachmentEvent(this.file);
}

View File

@@ -1,10 +1,29 @@
part of 'operation_files_bloc.dart';
part of 'attachments_bloc.dart';
enum OperationFilesStatus { initial, loading, uploading, success, failure }
enum AttachmentsStatus { initial, loading, ready, uploading, success, failure }
class OperationFilesState extends Equatable {
const OperationFilesState({
this.operationId,
enum AttachmentParentType {
operation('operation_id'),
ticket('ticket_id'),
customer('customer_id');
final String dbColumn;
const AttachmentParentType(this.dbColumn);
}
class AttachmentsState extends Equatable {
final String? parentId;
final AttachmentParentType parentType;
final AttachmentsStatus status;
final String? error;
final List<AttachmentModel> localFiles;
final List<AttachmentModel> remoteFiles;
final List<AttachmentModel> selectedFiles;
const AttachmentsState({
this.parentId,
required this.parentType,
required this.status,
this.error,
this.localFiles = const [],
@@ -12,17 +31,10 @@ class OperationFilesState extends Equatable {
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,
parentId,
parentType,
status,
error,
localFiles,
@@ -32,16 +44,18 @@ class OperationFilesState extends Equatable {
List<AttachmentModel> get allFiles => [...remoteFiles, ...localFiles];
OperationFilesState copyWith({
String? operationId,
OperationFilesStatus? status,
AttachmentsState copyWith({
String? parentId,
AttachmentParentType? parentType,
AttachmentsStatus? status,
String? error,
List<AttachmentModel>? localFiles,
List<AttachmentModel>? remoteFiles,
List<AttachmentModel>? selectedFiles,
}) {
return OperationFilesState(
operationId: operationId ?? this.operationId,
return AttachmentsState(
parentId: parentId ?? this.parentId,
parentType: parentType ?? this.parentType,
status: status ?? this.status,
error: error,
localFiles: localFiles ?? this.localFiles,

View File

@@ -1,23 +1,199 @@
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
class AttachmentsRepository {
final _supabase = Supabase.instance.client;
static const String _bucketName = 'documents';
static const String _tableName =
'attachment'; // Cambia col vero nome della tua tabella se diverso!
/// 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
.from(_bucketName)
.download(storagePath);
return bytes;
} catch (e) {
throw Exception("Impossibile scaricare il documento dal cloud: $e");
}
}
/// RESTITUISCE IL NOME DELLA COLONNA DB IN BASE AL TIPO
String _getColumnNameForParent(AttachmentParentType parentType) {
switch (parentType) {
case AttachmentParentType.operation:
return 'operation_id';
case AttachmentParentType.ticket:
return 'ticket_id';
case AttachmentParentType.customer:
return 'customer_id';
}
}
/// RECUPERA I FILE IN TEMPO REALE
Stream<List<AttachmentModel>> getFilesStream(
String parentId,
AttachmentParentType parentType,
) {
final columnName = _getColumnNameForParent(parentType);
return _supabase
.from(_tableName)
.stream(primaryKey: ['id'])
.eq(columnName, parentId)
.map(
(listOfMaps) =>
listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(),
);
}
/// CARICA IL FILE NELLO STORAGE E LO REGISTRA NEL DB
Future<void> uploadAndRegisterFile({
required String parentId,
required AttachmentParentType parentType,
required PlatformFile pickedFile,
required String companyId,
}) async {
try {
if (pickedFile.bytes == null) {
throw Exception(
"I bytes del file sono vuoti! Ricarica la pagina senza cache.",
);
}
final extension = pickedFile.extension ?? pickedFile.name.split('.').last;
final cleanName = pickedFile.name
.replaceAll(RegExp(r'[^\w\s\.-]'), '')
.replaceAll(' ', '_');
// Creiamo un path ordinato: idAzienda/tipoEntita/idEntita/timestamp_nomefile
final timestamp = DateTime.now().millisecondsSinceEpoch;
final storagePath =
'$companyId/${parentType.name}/$parentId/${timestamp}_$cleanName';
// 1. Upload su Supabase Storage
await _supabase.storage
.from(_bucketName)
.uploadBinary(
storagePath,
pickedFile.bytes!,
fileOptions: FileOptions(contentType: _guessContentType(extension)),
);
// 2. Creiamo la mappa per il DB dinamicamente
final Map<String, dynamic> insertData = {
'company_id': companyId,
'name': pickedFile.name.replaceAll('.$extension', ''),
'extension': extension,
'file_size': pickedFile.size,
'storage_path': storagePath,
};
// Inseriamo l'ID nella colonna giusta!
final columnName = _getColumnNameForParent(parentType);
insertData[columnName] = parentId;
// 3. Salviamo su Postgres
await _supabase.from(_tableName).insert(insertData);
} catch (e) {
throw Exception("Errore caricamento: $e");
}
}
/// ELIMINA IL FILE (Scollegamento intelligente)
Future<void> deleteFiles({
required List<AttachmentModel> files,
required AttachmentParentType currentContextType,
}) async {
if (files.isEmpty) return;
try {
for (var file in files) {
if (file.id == null) continue;
// 1. Capiamo quali collegamenti ha questo file attualmente
final currentLinks = {
AttachmentParentType.operation: file.operationId,
AttachmentParentType.ticket: file.ticketId,
AttachmentParentType.customer: file.customerId,
};
// 2. Simuliamo la rimozione del collegamento per il contesto attuale
currentLinks[currentContextType] = null;
// 3. Controlliamo se rimangono altri ID valorizzati
final hasOtherActiveLinks = currentLinks.values.any(
(id) => id != null && id.isNotEmpty,
);
if (hasOtherActiveLinks) {
// A. Ci sono ancora altre entità che usano questo file!
// Scolleghiamolo SOLO dal contesto attuale mettendo a NULL la sua colonna
await _supabase
.from(_tableName)
.update({currentContextType.dbColumn: null})
.eq('id', file.id!);
} else {
// B. Nessuno usa più questo file! ELIMINAZIONE FISICA TOTALE.
await _supabase.from(_tableName).delete().eq('id', file.id!);
if (file.storagePath != null) {
await _supabase.storage.from(_bucketName).remove([
file.storagePath!,
]);
}
}
}
} catch (e) {
throw Exception("Errore nell'eliminazione dei file: $e");
}
}
/// RINOMINA UN FILE (Solo nel DB, non cambiamo il file fisico)
Future<void> renameAttachment(String fileId, String newName) async {
try {
await _supabase
.from(_tableName)
.update({'name': newName})
.eq('id', fileId);
} catch (e) {
throw Exception("Errore nella rinomina del file: $e");
}
}
/// ASSOCIA UN FILE A UN'ALTRA ENTITÀ (Modifica il record esistente)
Future<void> linkFileToEntity({
required String fileId,
required AttachmentParentType targetType,
required String targetId,
}) async {
try {
// Facciamo un semplice UPDATE aggiungendo l'ID nella colonna giusta
await _supabase
.from(_tableName)
.update({targetType.dbColumn: targetId})
.eq('id', fileId);
} catch (e) {
throw Exception("Errore nel collegamento del file: $e");
}
}
// Helper per indovinare il content-type base
String _guessContentType(String extension) {
switch (extension.toLowerCase()) {
case 'pdf':
return 'application/pdf';
case 'png':
return 'image/png';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
default:
return 'application/octet-stream';
}
}
}

View File

@@ -7,6 +7,7 @@ class AttachmentModel extends Equatable {
final DateTime? createdAt;
final String? customerId;
final String? operationId;
final String? ticketId;
final String name;
final String extension;
final String? storagePath;
@@ -19,6 +20,7 @@ class AttachmentModel extends Equatable {
this.createdAt,
this.customerId,
this.operationId,
this.ticketId,
required this.name,
required this.extension,
this.storagePath,
@@ -33,6 +35,7 @@ class AttachmentModel extends Equatable {
createdAt,
customerId,
operationId,
ticketId,
name,
extension,
storagePath,
@@ -59,6 +62,7 @@ class AttachmentModel extends Equatable {
DateTime? createdAt,
String? customerId,
String? operationId,
String? ticketId,
String? name,
String? extension,
String? storagePath,
@@ -70,6 +74,7 @@ class AttachmentModel extends Equatable {
createdAt: createdAt ?? this.createdAt,
customerId: customerId ?? this.customerId,
operationId: operationId ?? this.operationId,
ticketId: ticketId ?? this.ticketId,
name: name ?? this.name,
extension: extension ?? this.extension,
storagePath: storagePath ?? this.storagePath,
@@ -86,6 +91,7 @@ class AttachmentModel extends Equatable {
: null,
customerId: map['customer_id'] as String?,
operationId: map['operation_id'] as String?,
ticketId: map['ticket_id'] as String?,
name: map['name'] as String,
extension: map['extension'] as String,
storagePath: map['storage_path'] as String?,
@@ -104,6 +110,7 @@ class AttachmentModel extends Equatable {
'storage_path': storagePath,
'customer_id': customerId,
'operation_id': operationId,
'ticket_id': ticketId,
'file_size': fileSize,
'company_id': companyId,
};

View File

@@ -1,33 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/features/company/data/company_repository.dart';
import 'package:flux/features/company/models/company_model.dart';
import 'package:get_it/get_it.dart';
part 'company_events.dart';
part 'company_state.dart';
class CompanyBloc extends Bloc<CompanyEvent, CompanyState> {
final CompanyRepository _repository = GetIt.I<CompanyRepository>();
CompanyBloc() : super(const CompanyState(status: CompanyStatus.initial)) {
on<CreateCompanyRequested>((event, emit) async {
emit(const CompanyState(status: CompanyStatus.loading));
try {
final createdCompany = await _repository.createCompany(event.company);
emit(
state.copyWith(
status: CompanyStatus.success,
company: createdCompany,
),
);
} catch (e) {
emit(
state.copyWith(
status: CompanyStatus.failure,
errorMessage: e.toString(),
),
);
}
});
}
}

View File

@@ -1,19 +0,0 @@
part of 'company_bloc.dart';
// lib/blocs/company/company_event.dart
abstract class CompanyEvent extends Equatable {
const CompanyEvent();
@override
List<Object?> get props => [];
}
class CreateCompanyRequested extends CompanyEvent {
final CompanyModel company;
const CreateCompanyRequested({required this.company});
@override
List<Object?> get props => [company];
}

View File

@@ -0,0 +1,131 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; // Per kIsWeb
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/company/data/company_repository.dart';
import 'package:flux/features/company/models/company_model.dart';
import 'package:get_it/get_it.dart';
part 'company_settings_state.dart';
class CompanySettingsCubit extends Cubit<CompanySettingsState> {
final CompanyRepository _repository = GetIt.I<CompanyRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
CompanySettingsCubit() : super(const CompanySettingsState());
void initSettings() {
final currentCompany = _sessionCubit.state.company;
if (currentCompany != null) {
emit(
state.copyWith(
company: currentCompany,
status: CompanySettingsStatus.ready,
),
);
}
}
void updateFields({
String? name,
String? vatId, // Modificato da vatNumber a vatId
String? fiscalCode, // Aggiunto
String? sdi, // Aggiunto
String? address,
String? city,
String? province, // Aggiunto
String? zipCode,
String? phone,
String? email,
String? ticketDisclaimer,
LabelFormat? labelFormat,
double? labelWidth,
double? labelHeight,
bool? isVertical,
}) {
if (state.company == null) return;
final updated = state.company!.copyWith(
name: name ?? state.company!.name,
vatId: vatId ?? state.company!.vatId,
fiscalCode: fiscalCode ?? state.company!.fiscalCode,
sdi: sdi ?? state.company!.sdi,
address: address ?? state.company!.address,
city: city ?? state.company!.city,
province: province ?? state.company!.province,
zipCode: zipCode ?? state.company!.zipCode,
phone: phone ?? state.company!.phone,
email: email ?? state.company!.email,
ticketDisclaimer: ticketDisclaimer ?? state.company!.ticketDisclaimer,
labelFormat: labelFormat ?? state.company!.labelFormat,
labelWidth: labelWidth ?? state.company!.labelWidth,
labelHeight: labelHeight ?? state.company!.labelHeight,
isLabelVertical: isVertical ?? state.company!.isLabelVertical,
);
emit(state.copyWith(company: updated));
}
Future<void> saveSettings() async {
if (state.company == null) return;
emit(
state.copyWith(status: CompanySettingsStatus.saving, errorMessage: null),
);
try {
// 1. Salva i dati su Supabase
final updatedCompany = await _repository.updateCompany(state.company!);
// 2. Aggiorna la sessione globale per riflettere i cambiamenti in tutta l'app
_sessionCubit.updateCurrentCompany(updatedCompany);
emit(
state.copyWith(
status: CompanySettingsStatus.success,
company: updatedCompany,
),
);
} catch (e) {
emit(
state.copyWith(
status: CompanySettingsStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// Metodo per gestire l'upload del logo
Future<void> uploadLogo(Uint8List bytes, String fileName) async {
if (state.company == null) return;
emit(state.copyWith(status: CompanySettingsStatus.uploadingLogo));
try {
// Usa il tuo repository per caricare il file nel bucket 'company_logos'
// Il file può essere Uint8List (se sei su Web) o File (se sei su Mobile/Desktop)
final publicUrl = await _repository.uploadCompanyLogo(
companyId: state.company!.id!,
fileBytes: bytes,
fileName: fileName,
);
final updatedCompany = state.company!.copyWith(logoUrl: publicUrl);
emit(
state.copyWith(
company: updatedCompany,
status: CompanySettingsStatus.ready,
),
);
// Chiamiamo il salvataggio per rendere definitivo l'URL nel record della compagnia
await saveSettings();
} catch (e) {
emit(
state.copyWith(
status: CompanySettingsStatus.failure,
errorMessage: "Errore caricamento logo: $e",
),
);
}
}
}

View File

@@ -0,0 +1,37 @@
part of 'company_settings_cubit.dart';
class CompanySettingsState extends Equatable {
final CompanySettingsStatus status;
final CompanyModel? company;
final String? errorMessage;
const CompanySettingsState({
this.status = CompanySettingsStatus.initial,
this.company,
this.errorMessage,
});
CompanySettingsState copyWith({
CompanySettingsStatus? status,
CompanyModel? company,
String? errorMessage,
}) {
return CompanySettingsState(
status: status ?? this.status,
company: company ?? this.company,
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => [status, company, errorMessage];
}
enum CompanySettingsStatus {
initial,
ready,
saving,
uploadingLogo,
success,
failure,
}

View File

@@ -1,26 +0,0 @@
part of 'company_bloc.dart';
enum CompanyStatus { initial, loading, success, failure }
class CompanyState extends Equatable {
final CompanyStatus status;
final String? errorMessage;
final CompanyModel? company;
const CompanyState({required this.status, this.errorMessage, this.company});
CompanyState copyWith({
CompanyStatus? status,
String? errorMessage,
CompanyModel? company,
}) {
return CompanyState(
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
company: company ?? this.company,
);
}
@override
List<Object?> get props => [status, errorMessage, company];
}

View File

@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/company_model.dart';
@@ -21,6 +23,62 @@ class CompanyRepository {
}
}
Future<CompanyModel> updateCompany(CompanyModel company) async {
try {
final response = await _supabase
.from('company')
.update(company.toMap())
.eq('id', company.id!)
.select()
.single();
return CompanyModel.fromMap(response);
} on PostgrestException catch (e) {
throw e.message;
} catch (e) {
throw e.toString();
}
}
Future<String> uploadCompanyLogo({
required String companyId,
required Uint8List fileBytes,
required String fileName,
}) async {
try {
// 1. Prepariamo il path.
// Organizziamo per companyId e aggiungiamo un timestamp per evitare cache del browser
// quando l'utente cambia logo più volte.
final extension = fileName.split('.').last;
final timestamp = DateTime.now().millisecondsSinceEpoch;
final filePath = '$companyId/logo_$timestamp.$extension';
// 2. Caricamento fisico dei bytes
// Usiamo uploadBinary che è perfetto per Uint8List
await _supabase.storage
.from('company_logos')
.uploadBinary(
filePath,
fileBytes,
fileOptions: const FileOptions(
cacheControl: '3600',
upsert:
true, // Se esiste già un file con lo stesso nome, lo sovrascrive
),
);
// 3. Otteniamo l'URL pubblico.
// Nota: il bucket 'company_logos' deve essere impostato come PUBLIC su Supabase
final String publicUrl = _supabase.storage
.from('company_logos')
.getPublicUrl(filePath);
return publicUrl;
} catch (e) {
throw Exception("Errore durante l'upload del logo: $e");
}
}
Future<CompanyModel?> getCompany() async {
try {
final userId = _supabase.auth.currentUser?.id;

View File

@@ -35,6 +35,21 @@ enum SubscriptionStatus {
}
}
enum LabelFormat {
none,
small_62x29,
medium_54x101,
large_102x152,
custom;
static LabelFormat fromString(String? value) {
return LabelFormat.values.firstWhere(
(e) => e.name == value,
orElse: () => LabelFormat.none,
);
}
}
// ===================================================================
// IL MODELLO ESATTO
// ===================================================================
@@ -53,8 +68,14 @@ class CompanyModel extends Equatable {
final String vatId;
final String fiscalCode;
final String sdi;
final String companyLogo;
final String? phone;
final String? email;
final String? logoUrl;
final String? ticketDisclaimer;
final LabelFormat labelFormat;
final double? labelWidth;
final double? labelHeight;
final bool isLabelVertical;
// Stato Pagamenti (Ibride: manuale + Stripe)
final bool isPaid;
final DateTime? paymentExpiration;
@@ -78,7 +99,14 @@ class CompanyModel extends Equatable {
required this.vatId,
required this.fiscalCode,
required this.sdi,
this.companyLogo = '',
this.phone,
this.email,
this.logoUrl,
this.ticketDisclaimer,
this.labelFormat = LabelFormat.none,
this.labelWidth,
this.labelHeight,
this.isLabelVertical = false,
this.isPaid = false,
this.paymentExpiration,
this.subscriptionTier = SubscriptionTier.free,
@@ -100,7 +128,14 @@ class CompanyModel extends Equatable {
String? vatId,
String? fiscalCode,
String? sdi,
String? companyLogo,
String? logoUrl,
String? ticketDisclaimer,
LabelFormat? labelFormat,
double? labelWidth,
double? labelHeight,
bool? isLabelVertical,
String? phone,
String? email,
bool? isPaid,
DateTime? paymentExpiration,
SubscriptionTier? subscriptionTier,
@@ -121,7 +156,14 @@ class CompanyModel extends Equatable {
vatId: vatId ?? this.vatId,
fiscalCode: fiscalCode ?? this.fiscalCode,
sdi: sdi ?? this.sdi,
companyLogo: companyLogo ?? this.companyLogo,
logoUrl: logoUrl ?? this.logoUrl,
phone: phone ?? this.phone,
email: email ?? this.email,
ticketDisclaimer: ticketDisclaimer ?? this.ticketDisclaimer,
labelFormat: labelFormat ?? this.labelFormat,
labelWidth: labelWidth ?? this.labelWidth,
labelHeight: labelHeight ?? this.labelHeight,
isLabelVertical: isLabelVertical ?? this.isLabelVertical,
isPaid: isPaid ?? this.isPaid,
paymentExpiration: paymentExpiration ?? this.paymentExpiration,
subscriptionTier: subscriptionTier ?? this.subscriptionTier,
@@ -163,7 +205,18 @@ class CompanyModel extends Equatable {
vatId: map['vat_id'] ?? '',
fiscalCode: map['fiscal_code'] ?? '',
sdi: map['sdi'] ?? '',
companyLogo: map['company_logo'] ?? '',
logoUrl: map['logo_url'],
phone: map['phone'] ?? '',
email: map['email'] ?? '',
ticketDisclaimer: map['ticket_disclaimer'],
labelFormat: LabelFormat.fromString(map['label_format']),
labelWidth: map['label_width'] != null
? (map['label_width'] as num).toDouble()
: null,
labelHeight: map['label_height'] != null
? (map['label_height'] as num).toDouble()
: null,
isLabelVertical: map['is_label_vertical'] ?? false,
isPaid: map['is_paid'] ?? false,
paymentExpiration: map['payment_expiration'] != null
? DateTime.tryParse(map['payment_expiration'])
@@ -193,7 +246,14 @@ class CompanyModel extends Equatable {
'vat_id': vatId,
'fiscal_code': fiscalCode,
'sdi': sdi,
'company_logo': companyLogo,
'logo_url': logoUrl,
'phone': phone,
'email': email,
'ticket_disclaimer': ticketDisclaimer,
'label_format': labelFormat.name,
'label_width': labelWidth,
'label_height': labelHeight,
'is_label_vertical': isLabelVertical,
'is_paid': isPaid,
if (paymentExpiration != null)
'payment_expiration': paymentExpiration!.toIso8601String(),
@@ -221,7 +281,14 @@ class CompanyModel extends Equatable {
vatId,
fiscalCode,
sdi,
companyLogo,
logoUrl,
phone,
email,
ticketDisclaimer,
labelFormat,
labelWidth,
labelHeight,
isLabelVertical,
isPaid,
paymentExpiration,
subscriptionTier,

View File

@@ -0,0 +1,452 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/company/bloc/company_settings_cubit.dart';
import 'package:flux/features/company/models/company_model.dart';
import 'package:flux/features/settings/document_sequence/blocs/document_sequence_cubit.dart';
import 'package:flux/features/settings/document_sequence/ui/document_sequence_section.dart';
import 'package:image_picker/image_picker.dart';
class CompanySettingsScreen extends StatefulWidget {
const CompanySettingsScreen({super.key});
@override
State<CompanySettingsScreen> createState() => _CompanySettingsScreenState();
}
class _CompanySettingsScreenState extends State<CompanySettingsScreen> {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _vatCtrl = TextEditingController();
final _fiscalCodeCtrl = TextEditingController(); // Nuovo
final _sdiCtrl = TextEditingController(); // Nuovo
final _addressCtrl = TextEditingController();
final _cityCtrl = TextEditingController();
final _provinceCtrl = TextEditingController(); // Nuovo
final _zipCtrl = TextEditingController();
final _phoneCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _disclaimerCtrl = TextEditingController();
bool _isInitialized = false;
@override
void initState() {
super.initState();
final cubit = context.read<CompanySettingsCubit>();
cubit.initSettings();
if (cubit.state.status == CompanySettingsStatus.ready &&
cubit.state.company != null) {
_syncControllers(cubit.state.company!);
}
}
@override
void dispose() {
_nameCtrl.dispose();
_vatCtrl.dispose();
_fiscalCodeCtrl.dispose(); // Nuovo
_sdiCtrl.dispose(); // Nuovo
_addressCtrl.dispose();
_cityCtrl.dispose();
_provinceCtrl.dispose(); // Nuovo
_zipCtrl.dispose();
_phoneCtrl.dispose();
_emailCtrl.dispose();
_disclaimerCtrl.dispose();
super.dispose();
}
void _syncControllers(CompanyModel company) {
if (_nameCtrl.text.isEmpty) _nameCtrl.text = company.name;
if (_vatCtrl.text.isEmpty) _vatCtrl.text = company.vatId;
if (_fiscalCodeCtrl.text.isEmpty) {
_fiscalCodeCtrl.text = company.fiscalCode; // Nuovo
}
if (_sdiCtrl.text.isEmpty) _sdiCtrl.text = company.sdi; // Nuovo
if (_provinceCtrl.text.isEmpty) {
_provinceCtrl.text = company.province; // Nuovo
}
if (_addressCtrl.text.isEmpty) _addressCtrl.text = company.address;
if (_cityCtrl.text.isEmpty) _cityCtrl.text = company.city;
if (_zipCtrl.text.isEmpty) _zipCtrl.text = company.zipCode;
if (_phoneCtrl.text.isEmpty) _phoneCtrl.text = company.phone ?? '';
if (_emailCtrl.text.isEmpty) _emailCtrl.text = company.email ?? '';
_isInitialized = true;
if (_disclaimerCtrl.text.isEmpty) {
_disclaimerCtrl.text = company.ticketDisclaimer ?? '';
}
}
void _flushToCubit() {
context.read<CompanySettingsCubit>().updateFields(
name: _nameCtrl.text,
vatId: _vatCtrl.text,
fiscalCode: _fiscalCodeCtrl.text, // Nuovo
sdi: _sdiCtrl.text, // Nuovo
province: _provinceCtrl.text,
address: _addressCtrl.text,
city: _cityCtrl.text,
zipCode: _zipCtrl.text,
phone: _phoneCtrl.text,
email: _emailCtrl.text,
ticketDisclaimer: _disclaimerCtrl.text,
);
}
Future<void> _pickAndUploadLogo() async {
final picker = ImagePicker();
final companySettingsCubit = context.read<CompanySettingsCubit>();
final pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null && mounted) {
// Passiamo i bytes per compatibilità totale con Flutter Web
final bytes = await pickedFile.readAsBytes();
companySettingsCubit.uploadLogo(bytes, pickedFile.name);
}
}
void _onLabelFormatChanged(LabelFormat selectedFormat) {
double? w;
double? h;
switch (selectedFormat) {
case LabelFormat.small_62x29:
w = 62.0;
h = 29.0;
break;
case LabelFormat.medium_54x101:
w = 54.0;
h = 101.0;
break;
case LabelFormat.large_102x152:
w = 102.0;
h = 152.0;
break;
case LabelFormat.custom:
case LabelFormat.none:
// Lasciamo i valori null o quelli vecchi
break;
}
context.read<CompanySettingsCubit>().updateFields(
labelFormat: selectedFormat,
labelWidth: w,
labelHeight: h,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Impostazioni Azienda')),
body: BlocConsumer<CompanySettingsCubit, CompanySettingsState>(
listener: (context, state) {
if (state.status == CompanySettingsStatus.ready && !_isInitialized) {
_syncControllers(state.company!);
}
if (state.status == CompanySettingsStatus.success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Impostazioni salvate con successo!'),
backgroundColor: Colors.green,
),
);
}
if (state.status == CompanySettingsStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Errore'),
backgroundColor: Colors.red,
),
);
}
},
builder: (context, state) {
if (state.company == null) {
return const Center(child: CircularProgressIndicator());
}
final company = state.company!;
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
// --- SEZIONE LOGO ---
Center(
child: Stack(
alignment: Alignment.bottomRight,
children: [
Container(
height: 120,
width: 120,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: theme.dividerColor),
image: company.logoUrl != null
? DecorationImage(
image: NetworkImage(company.logoUrl!),
fit: BoxFit.contain,
)
: null,
),
child: company.logoUrl == null
? const Icon(
Icons.business,
size: 50,
color: Colors.grey,
)
: null,
),
if (state.status ==
CompanySettingsStatus.uploadingLogo)
const Positioned.fill(
child: Center(child: CircularProgressIndicator()),
),
FloatingActionButton.small(
onPressed:
state.status ==
CompanySettingsStatus.uploadingLogo
? null
: _pickAndUploadLogo,
child: const Icon(Icons.camera_alt),
),
],
),
),
const SizedBox(height: 32),
// --- SEZIONE DATI PRINCIPALI ---
Text(
'Dati Legali',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Divider(),
const SizedBox(height: 16),
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: 'Ragione Sociale',
prefixIcon: Icon(Icons.badge),
),
validator: (val) => val == null || val.isEmpty
? 'Campo obbligatorio'
: null,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _vatCtrl,
decoration: const InputDecoration(
labelText: 'Partita IVA',
prefixIcon: Icon(Icons.receipt_long),
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _fiscalCodeCtrl,
decoration: const InputDecoration(
labelText: 'Codice Fiscale',
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _sdiCtrl,
decoration: const InputDecoration(
labelText: 'Codice SDI',
),
),
),
],
),
const SizedBox(height: 16),
// --- SEZIONE INDIRIZZO E CONTATTI ---
Text(
'Sede e Contatti',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Divider(),
const SizedBox(height: 16),
TextFormField(
controller: _addressCtrl,
decoration: const InputDecoration(
labelText: 'Indirizzo (Via e numero civico)',
prefixIcon: Icon(Icons.location_on),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
controller: _cityCtrl,
decoration: const InputDecoration(
labelText: 'Città',
),
),
),
const SizedBox(width: 16),
Expanded(
flex: 1,
child: TextFormField(
controller: _provinceCtrl,
decoration: const InputDecoration(
labelText: 'Provincia (es. MI)',
),
),
),
const SizedBox(width: 16),
Expanded(
flex: 1,
child: TextFormField(
controller: _zipCtrl,
decoration: const InputDecoration(labelText: 'CAP'),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _phoneCtrl,
decoration: const InputDecoration(
labelText: 'Telefono',
prefixIcon: Icon(Icons.phone),
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _emailCtrl,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
),
),
),
],
),
const SizedBox(height: 16),
BlocProvider(
create: (context) =>
DocumentSequenceCubit(state.company!.id!)
..loadSequences(),
child: const DocumentSequenceSection(),
),
const SizedBox(height: 16),
// Sezione Disclaimer
Text(
"Note Legali Ricevuta",
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
TextFormField(
controller: _disclaimerCtrl,
maxLines: 5,
decoration: const InputDecoration(
hintText:
"Inserisci qui la liberatoria legale che apparirà sulla ricevuta dei ticket...",
border: OutlineInputBorder(),
),
onChanged: (val) => context
.read<CompanySettingsCubit>()
.updateFields(ticketDisclaimer: val),
),
const SizedBox(height: 24),
// Sezione Etichette
Text(
"Configurazione Etichette",
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
DropdownButtonFormField<LabelFormat>(
initialValue: company.labelFormat,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.label_outline),
labelText: "Formato Stampa Etichetta",
),
items: LabelFormat.values
.map(
(f) => DropdownMenuItem(
value: f,
child: Text(
f.name.replaceAll('_', ' ').toUpperCase(),
),
),
)
.toList(),
onChanged: (val) {
if (val != null) {
_onLabelFormatChanged(val);
}
},
),
const SizedBox(height: 48),
// --- PULSANTE SALVATAGGIO ---
SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: state.status == CompanySettingsStatus.saving
? null
: () {
if (_formKey.currentState!.validate()) {
_flushToCubit();
context
.read<CompanySettingsCubit>()
.saveSettings();
}
},
icon: state.status == CompanySettingsStatus.saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Icon(Icons.save),
label: const Text(
'Salva Impostazioni',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
),
);
},
),
);
}
}

View File

@@ -1,328 +0,0 @@
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';
import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/company/models/company_model.dart';
class CreateCompanyScreen extends StatefulWidget {
const CreateCompanyScreen({super.key});
@override
State<CreateCompanyScreen> createState() => _CreateCompanyScreenState();
}
// lib/ui/setup/create_company_screen.dart
class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
final _formKey = GlobalKey<FormState>();
// Controller per i campi obbligatori
final _ragioneSocialeController = TextEditingController();
final _indirizzoController = TextEditingController();
final _capController = TextEditingController();
final _cittaController = TextEditingController();
final _provinciaController = TextEditingController();
final _pIvaController = TextEditingController();
final _cfController = TextEditingController();
final _univocoController = TextEditingController();
@override
void dispose() {
// Ricordati sempre di chiuderli!
_ragioneSocialeController.dispose();
_indirizzoController.dispose();
_capController.dispose();
_cittaController.dispose();
_provinciaController.dispose();
_pIvaController.dispose();
_cfController.dispose();
_univocoController.dispose();
super.dispose();
}
void _onSave() {
if (_formKey.currentState!.validate()) {
// Recuperiamo l'ID utente attuale da Supabase o dal SessionBloc
final userId = context.read<SessionCubit>().state.user!.id;
final company = CompanyModel(
userId: userId,
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
);
// Spariamo l'evento al Bloc
context.read<CompanyBloc>().add(CreateCompanyRequested(company: company));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.createCompanyScreenCompanyConfiguration),
actions: [
IconButton(
icon: const Icon(Icons.logout_rounded),
onPressed: () {
// Qui chiami il tuo Bloc dell'autenticazione per fare logout
// Esempio se hai un AuthBloc o SessionBloc:
//context.read<AuthBloc>().add(LogoutRequested());
// Se vuoi solo tornare brutalmente alla login per testare il logo:
// Navigator.of(context).pushReplacementNamed('/login');
},
),
],
),
body: BlocConsumer<CompanyBloc, CompanyState>(
listener: (context, state) {
if (state.status == CompanyStatus.success && state.company != null) {
// 1. Aggiorniamo la singleton con i dati reali (ID incluso)
//GetIt.I.get<AppSettings>().setCurrentCompany(state.company);
// 2. Notifichiamo il SessionBloc per cambiare pagina
//context.read<SessionCubit>().add(AppStarted());
}
if (state.status == CompanyStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
state.errorMessage ?? context.l10n.commonSavingError,
),
backgroundColor: Colors.redAccent,
),
);
}
},
builder: (context, state) {
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(context),
const SizedBox(height: 32),
// --- SEZIONE 1: IDENTITÀ FISCALE ---
_SectionTitle(
title: context.l10n.createCompanyScreenFiscalData,
),
const SizedBox(height: 16),
FluxTextField(
label: context.l10n.createCompanyScreenCompanyName,
icon: Icons.business,
controller: _ragioneSocialeController,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FluxTextField(
label: context.l10n.createCompanyScreenVatId,
icon: Icons.numbers,
controller: _pIvaController,
),
),
const SizedBox(width: 12),
Expanded(
child: FluxTextField(
label: context.l10n.createCompanyScreenFiscalCode,
icon: Icons.badge_outlined,
controller: _cfController,
),
),
],
),
const SizedBox(height: 16),
FluxTextField(
label: context.l10n.createCompanyScreenSdiPec,
icon: Icons.send_and_archive_outlined,
controller: _univocoController,
),
const SizedBox(height: 32),
// --- SEZIONE 2: SEDE LEGALE ---
_SectionTitle(
title:
context.l10n.createCompanyScreenCompanyLegalAddress,
),
const SizedBox(height: 16),
FluxTextField(
label: context.l10n.commonAddress,
icon: Icons.home_work_outlined,
controller: _indirizzoController,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
flex: 2,
child: FluxTextField(
label: context.l10n.commonCity,
icon: Icons.location_city,
controller: _cittaController,
),
),
const SizedBox(width: 12),
Expanded(
child: FluxTextField(
label: context.l10n.commonZipCode,
icon: Icons.map_outlined,
controller: _capController,
),
),
const SizedBox(width: 12),
Expanded(
child: FluxTextField(
label: context.l10n.commonProvince,
icon: Icons.explore_outlined,
controller: _provinciaController,
),
),
],
),
const SizedBox(height: 32),
// --- SEZIONE 3: LOGO AZIENDALE ---
_SectionTitle(title: 'BRANDING'),
const SizedBox(height: 16),
_buildLogoPicker(context),
const SizedBox(height: 48),
// --- BOTTONE INVIO ---
_buildSubmitButton(context, state),
],
),
),
),
);
},
),
);
}
// Placeholder per il futuro caricamento logo
Widget _buildLogoPicker(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: context.accent.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
// Bordo continuo ma sottile e semitrasparente per un look pulito
border: Border.all(
color: context.accent.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
children: [
Icon(Icons.cloud_upload_outlined, color: context.accent, size: 32),
const SizedBox(height: 12),
Text(
context.l10n.createCompanyScreenUploadLogo,
style: TextStyle(
color: context.primaryText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
context.l10n.createCompanyScreenWillBeUsedForReceipts,
textAlign: TextAlign.center,
style: TextStyle(color: context.secondaryText, fontSize: 12),
),
],
),
);
}
Widget _buildSubmitButton(BuildContext context, CompanyState state) {
return SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: state.status == CompanyStatus.loading
? null
: () => _onSave(),
child: state.status == CompanyStatus.loading
? const CircularProgressIndicator()
: Text(context.l10n.createCompanyScreenSaveCompany),
),
);
}
Widget _buildHeader(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.accent.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.domain_add_rounded,
color: context.accent,
size: 32,
),
),
const SizedBox(height: 24),
Text(
context.l10n.createCompanyScreenSetupYourCompany,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: context.primaryText,
),
),
const SizedBox(height: 12),
Text(
context.l10n.createCompanyScreenFluxNeedsYourFiscalData,
style: TextStyle(
color: context.secondaryText,
fontSize: 15,
height: 1.5,
),
),
],
);
}
}
// Widget di supporto per i titoli delle sezioni
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle({required this.title});
@override
Widget build(BuildContext context) {
return Text(
title,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
fontSize: 13,
),
);
}
}

View File

@@ -1,139 +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/features/attachments/models/attachment_model.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:get_it/get_it.dart';
part 'customer_files_events.dart';
part 'customer_files_state.dart';
class CustomerFilesBloc extends Bloc<CustomerFilesEvent, CustomerFilesState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
final String customerId;
CustomerFilesBloc(this.customerId)
: super(const CustomerFilesState(status: CustomerFilesStatus.initial)) {
on<LoadCustomerFilesEvent>(_loadCustomerFiles);
on<UploadCustomerFileEvent>(_uploadCustomerFile);
on<UploadMultipleCustomerFilesEvent>(_uploadMultipleCustomerFiles);
on<DeleteCustomerFilesEvent>(_deleteCustomerFiles);
on<ToggleCustomerFileSelectionEvent>(_toggleCustomerFileSelection);
}
void _loadCustomerFiles(
LoadCustomerFilesEvent event,
Emitter<CustomerFilesState> emit,
) async {
await emit.forEach<List<AttachmentModel>>(
_repository.getCustomerFilesStream(customerId),
onData: (customerFiles) => CustomerFilesState(
status: CustomerFilesStatus.success,
customerFiles: customerFiles,
),
onError: (error, stackTrace) => CustomerFilesState(
status: CustomerFilesStatus.failure,
error: error.toString(),
),
);
}
Future<void> _uploadCustomerFile(
UploadCustomerFileEvent event,
Emitter<CustomerFilesState> emit,
) async {
emit(state.copyWith(status: CustomerFilesStatus.uploading));
if (event.pickedFile != null) {
try {
await _repository.uploadAndRegisterFile(
customerId: customerId,
pickedFile: event.pickedFile!,
);
emit(state.copyWith(status: CustomerFilesStatus.success));
} catch (e) {
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: e.toString(),
),
);
}
}
}
FutureOr<void> _uploadMultipleCustomerFiles(
UploadMultipleCustomerFilesEvent event,
Emitter<CustomerFilesState> emit,
) async {
if (event.files.isEmpty) {
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: "Nessun file selezionato",
),
);
return;
}
emit(state.copyWith(status: CustomerFilesStatus.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.uploadAndRegisterFile(
customerId: customerId,
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: CustomerFilesStatus.success));
} catch (e) {
// Se anche un solo file fallisce, catturiamo l'errore
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: "Errore durante l'upload multiplo: $e",
),
);
}
}
Future<void> _deleteCustomerFiles(
DeleteCustomerFilesEvent event,
Emitter<CustomerFilesState> emit,
) async {
emit(state.copyWith(status: CustomerFilesStatus.loading));
try {
await _repository.deleteDocuments(state.selectedFiles);
emit(
state.copyWith(status: CustomerFilesStatus.success, selectedFiles: []),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: e.toString(),
),
);
}
}
void _toggleCustomerFileSelection(
ToggleCustomerFileSelectionEvent event,
Emitter<CustomerFilesState> emit,
) {
List<AttachmentModel> 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,30 +0,0 @@
part of 'customer_files_bloc.dart';
abstract class CustomerFilesEvent extends Equatable {
const CustomerFilesEvent();
@override
List<Object> get props => [];
}
class LoadCustomerFilesEvent extends CustomerFilesEvent {}
class UploadCustomerFileEvent extends CustomerFilesEvent {
final PlatformFile? pickedFile;
final File? photo;
const UploadCustomerFileEvent({this.pickedFile, this.photo});
}
class UploadMultipleCustomerFilesEvent extends CustomerFilesEvent {
final List<PlatformFile> files;
const UploadMultipleCustomerFilesEvent(this.files);
@override
List<Object> get props => [files];
}
class DeleteCustomerFilesEvent extends CustomerFilesEvent {}
class ToggleCustomerFileSelectionEvent extends CustomerFilesEvent {
final AttachmentModel file;
const ToggleCustomerFileSelectionEvent(this.file);
}

View File

@@ -1,34 +0,0 @@
part of 'customer_files_bloc.dart';
enum CustomerFilesStatus { initial, loading, uploading, success, failure }
class CustomerFilesState extends Equatable {
const CustomerFilesState({
required this.status,
this.error,
this.customerFiles = const [],
this.selectedFiles = const [],
});
final CustomerFilesStatus status;
final String? error;
final List<AttachmentModel> customerFiles;
final List<AttachmentModel> selectedFiles;
@override
List<Object?> get props => [status, error, customerFiles, selectedFiles];
CustomerFilesState copyWith({
CustomerFilesStatus? status,
String? error,
List<AttachmentModel>? customerFiles,
List<AttachmentModel>? selectedFiles,
}) {
return CustomerFilesState(
status: status ?? this.status,
error: error,
customerFiles: customerFiles ?? this.customerFiles,
selectedFiles: selectedFiles ?? this.selectedFiles,
);
}
}

View File

@@ -6,9 +6,10 @@ 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/blocs/attachments_bloc.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:get_it/get_it.dart';
class CustomerDetailScreen extends StatefulWidget {
final CustomerModel customer;
@@ -26,11 +27,13 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
}
void _loadFiles() {
context.read<CustomerFilesBloc>().add(LoadCustomerFilesEvent());
context.read<AttachmentsBloc>().add(
LoadAttachmentsEvent(parentId: widget.customer.id),
);
}
Future<void> _pickAndUpload() async {
CustomerFilesBloc customerFilesBloc = context.read<CustomerFilesBloc>();
AttachmentsBloc attachmentsBloc = context.read<AttachmentsBloc>();
// Chiamata statica pulita
FilePickerResult? result = await FilePicker.pickFiles(
@@ -40,17 +43,18 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
);
if (result != null) {
for (var pickedFile in result.files) {
try {
customerFilesBloc.add(
UploadCustomerFileEvent(pickedFile: pickedFile),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Errore upload ${pickedFile.name}: $e")),
);
}
try {
attachmentsBloc.add(
UploadAttachmentsEvent(
pickedFiles: result.files,
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("$e")));
}
}
}
@@ -143,7 +147,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
}
Widget _buildDocumentSection() {
return BlocBuilder<CustomerFilesBloc, CustomerFilesState>(
return BlocBuilder<AttachmentsBloc, AttachmentsState>(
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -213,9 +217,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
],
),
const SizedBox(height: 20),
if (state.status == CustomerFilesStatus.loading)
if (state.status == AttachmentsStatus.loading)
const Center(child: CircularProgressIndicator())
else if (state.customerFiles.isEmpty)
else if (state.allFiles.isEmpty)
const Center(child: Text("Nessun documento presente"))
else
Expanded(
@@ -226,9 +230,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
crossAxisSpacing: 16,
childAspectRatio: 1.2,
),
itemCount: state.customerFiles.length,
itemCount: state.allFiles.length,
itemBuilder: (context, index) =>
_FileCard(file: state.customerFiles[index], state: state),
_FileCard(file: state.allFiles[index], state: state),
),
),
],
@@ -268,14 +272,14 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
class _FileCard extends StatelessWidget {
final AttachmentModel file;
final CustomerFilesState state;
final AttachmentsState state;
const _FileCard({required this.file, required this.state});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.read<CustomerFilesBloc>().add(
ToggleCustomerFileSelectionEvent(file),
onTap: () => context.read<AttachmentsBloc>().add(
ToggleAttachmentSelectionEvent(file),
),
onDoubleTap: () => _handleDoubleClickOnFile(context, file),
child: Stack(

View File

@@ -1,12 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/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:flux/temp/migration_tools.dart';
import 'package:go_router/go_router.dart';
class CustomersContent extends StatefulWidget {
@@ -86,42 +85,6 @@ class _CustomersContentState extends State<CustomersContent> {
),
),
//TODO cancella quando import finito
ElevatedButton(
onPressed: () async {
try {
// 1. Mostra un loading (opzionale ma utile)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Caricamento JSON in corso...')),
);
// 2. Legge tutto il file come stringa
final String jsonString = await rootBundle.loadString(
'assets/schedeRiparazione-1778021345.json',
);
// 3. Lancia lo script (sostituisci l'UUID con l'ID della tua azienda su Supabase)
await TicketMigrationScript().runMigration(jsonString);
// 4. Successo!
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Migrazione Completata! Guarda i log.'),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Errore: $e')));
}
}
},
child: const Text('migra clienti'),
),
// LISTA CLIENTI
Expanded(
child: BlocBuilder<CustomersCubit, CustomersState>(
@@ -147,8 +110,9 @@ class _CustomersContentState extends State<CustomersContent> {
final customer = state.customers[index];
return _CustomerTile(
customer: customer,
onTap: () => context.push(
'/customer/${customer.id}',
onTap: () => context.pushNamed(
Routes.customerForm,
pathParameters: {'id': customer.id!},
extra: customer,
),
);

View File

@@ -1,6 +1,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/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/home/latest_store_operations/bloc/latest_store_operations_bloc.dart';
@@ -47,30 +48,30 @@ class _LatestOperationsCardContent extends StatelessWidget {
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: InkWell(
onTap: () => context.pushNamed(Routes.operations),
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,
),
),
child: const Icon(
Icons.design_services_outlined,
color: color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: TextButton(
onPressed: () => context.push('/operations'),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.homeLatestOperations,
style: TextStyle(
@@ -82,106 +83,112 @@ class _LatestOperationsCardContent extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
),
],
),
const SizedBox(height: 12),
],
),
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());
}
// --- 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,
if (state.status ==
LatestStoreOperationsStatus.failure) {
return Center(
child: Text(
"Errore di caricamento",
style: TextStyle(color: theme.colorScheme.error),
),
),
);
}
);
}
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,
),
),
],
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.pushNamed(
Routes.operationForm,
pathParameters: {'id': operation.id!},
extra: operation,
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
flex: 5,
child: Text(
operation.customer?.name ??
'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,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/staff_selector_modal.dart';
import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_card.dart';
import 'package:flux/features/home/ui/quick_actions_widget.dart';
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:go_router/go_router.dart';
class HomeScreen extends StatelessWidget {
@@ -77,12 +80,14 @@ class HomeScreen extends StatelessWidget {
context: context,
),
LatestStoreOperationsCard(),
_buildDashboardWidget(
title: context.l10n.homeLatestOperationTickets,
icon: Icons.support_agent_outlined,
color: Colors.purple,
context: context,
onTap: () => context.pushNamed(
Routes.tickets,
), // <-- Aggiunto!
),
]),
),
@@ -183,9 +188,14 @@ class HomeScreen extends StatelessWidget {
icon: Icons.add,
label: context.l10n.commonOperation,
color: Colors.blue,
onTap: () {
// Entriamo nel form! Nessun parametro extra = Nuovo Servizio
context.push('/operation-form');
onTap: () async {
StaffMemberModel? createdBy = await getStaffMember(context);
if (createdBy == null || !context.mounted) return;
context.pushNamed(
Routes.operationForm,
pathParameters: {'id': 'new'},
extra: (createdBy: createdBy, operation: null),
);
},
),
const SizedBox(width: 12),
@@ -193,9 +203,14 @@ class HomeScreen extends StatelessWidget {
icon: Icons.handyman,
label: context.l10n.homeNewOperationTicket,
color: Colors.redAccent,
onTap: () {
// TODO: Quando avrai la rotta per la nuova assistenza
// context.push('/assistance-form');
onTap: () async {
StaffMemberModel? createdBy = await getStaffMember(context);
if (createdBy == null || !context.mounted) return;
context.pushNamed(
Routes.ticketForm,
pathParameters: {'id': 'new'},
extra: (createdBy: createdBy, ticket: null),
);
},
),
const SizedBox(width: 12),
@@ -226,68 +241,73 @@ class HomeScreen extends StatelessWidget {
required String title,
required IconData icon,
required Color color,
VoidCallback? onTap,
}) {
final theme = Theme.of(context);
return Card(
elevation: 0,
clipBehavior: Clip.antiAlias,
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: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: context.primaryText,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: Icon(icon, color: color, size: 20),
),
),
IconButton(
icon: Icon(
Icons.more_vert,
size: 20,
color: context.secondaryText,
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: Icon(
Icons.more_vert,
size: 20,
color: context.secondaryText,
),
onPressed: () {},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const Spacer(),
Center(
child: Text(
context.l10n.commonComingSoon,
style: TextStyle(
color: context.secondaryText.withValues(alpha: 0.7),
fontStyle: FontStyle.italic,
fontSize: 13,
),
onPressed: () {},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const Spacer(),
Center(
child: Text(
context.l10n.commonComingSoon,
style: TextStyle(
color: context.secondaryText.withValues(alpha: 0.7),
fontStyle: FontStyle.italic,
fontSize: 13,
),
),
),
const Spacer(),
],
const Spacer(),
],
),
),
),
);
@@ -369,6 +389,7 @@ class HomeScreen extends StatelessWidget {
onTap: () {
// Cambiamo il negozio nel SessionCubit!
context.read<SessionCubit>().changeStore(store);
context.read<StaffCubit>().loadStaffForStore(store.id!);
Navigator.pop(context);
},
);

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:go_router/go_router.dart';
// Mantieni i tuoi import per il tema se usi le estensioni (es. context.accent)
// import 'package:flux/core/theme/theme.dart';
@@ -65,7 +66,7 @@ class MasterDataHubScreen extends StatelessWidget {
color: Colors.orange,
// Usiamo .push() perché avevamo detto che i clienti
// stanno FUORI dalla Shell (niente BottomBar)
onTap: () => context.push('/customers'),
onTap: () => context.pushNamed(Routes.customers),
),
_buildHubCard(
context,

View File

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

@@ -1,81 +0,0 @@
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,280 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.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:uuid/uuid.dart';
part 'operation_form_state.dart';
class OperationFormCubit extends Cubit<OperationFormState> {
final OperationsRepository _repository = GetIt.I<OperationsRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
final Uuid _uuid = const Uuid();
OperationFormCubit({
StaffMemberModel? createdBy,
OperationModel? existingOperation,
}) : super(
OperationFormState(
operation:
existingOperation ??
OperationModel.empty().copyWith(
staffId: createdBy?.id,
staffDisplayName: createdBy?.name,
),
),
);
Future<void> initForm({
OperationModel? existingOperation,
String? operationId,
}) async {
emit(state.copyWith(status: OperationFormStatus.loading));
try {
if (existingOperation != null) {
emit(
state.copyWith(
operation: existingOperation,
status: OperationFormStatus.ready,
),
);
} else if (operationId != null) {
emit(state.copyWith(status: OperationFormStatus.loading));
try {
final operation = await _repository.fetchOperationById(operationId);
emit(
state.copyWith(
operation: operation,
status: OperationFormStatus.ready,
),
);
} on Exception catch (e) {
emit(
state.copyWith(
status: OperationFormStatus.failure,
errorMessage: e.toString(),
),
);
}
} else {
// NUOVA PRATICA: Creiamo un nuovo Batch UUID
final currentStore = _sessionCubit.state.currentStore;
final companyId = _sessionCubit.state.company?.id ?? '';
final newOperation = state.operation.copyWith(
companyId: companyId,
storeId: currentStore?.id,
status: OperationStatus.success,
reference: '',
batchUuid: _uuid.v4(),
);
emit(
state.copyWith(
operation: newOperation,
status: OperationFormStatus.ready,
),
);
}
} catch (e) {
emit(
state.copyWith(
status: OperationFormStatus.failure,
errorMessage: "Errore inizializzazione form: $e",
),
);
}
}
// --- LOGICA BATCH ---
void _prepareNextOperationInBatch() {
final current = state.operation;
emit(
state.copyWith(
status: OperationFormStatus.ready, // Torna ready per il nuovo form
operation: OperationModel(
companyId: current.companyId,
storeId: current.storeId,
storeDisplayName: current.storeDisplayName,
batchUuid: current.batchUuid, // MANTIENE IL COLLEGAMENTO
customerId: current.customerId, // MANTIENE IL CLIENTE
customer: current.customer,
status: OperationStatus.draft,
createdAt: DateTime.now(),
),
),
);
}
// --- SALVATAGGIO ---
Future<void> saveOperation({
required OperationStatus targetStatus,
required bool keepAdding,
}) async {
emit(
state.copyWith(status: OperationFormStatus.saving, errorMessage: null),
);
try {
final operationToSave = state.operation.copyWith(status: targetStatus);
final savedOperation = await _repository.saveFullOperation(
operation: operationToSave,
);
if (keepAdding) {
// Salviamo nella "memoria" del batch le pratiche create finora
final updatedBatchList = List<OperationModel>.from(
state.savedBatchOperations,
)..add(savedOperation);
emit(
state.copyWith(
status: OperationFormStatus.successAndAddAnother,
savedBatchOperations: updatedBatchList,
),
);
// Pulisce i campi per la prossima operazione
_prepareNextOperationInBatch();
} else {
emit(
state.copyWith(
status: OperationFormStatus.success,
operation: savedOperation, // Aggiorniamo con l'ID restituito dal DB
),
);
}
} catch (e) {
emit(
state.copyWith(
status: OperationFormStatus.failure,
errorMessage: e.toString(),
),
);
}
}
Future<String?> saveOperationDraft() async {
try {
final operationToSave = state.operation;
if (operationToSave.customerId == null ||
operationToSave.customerId!.isEmpty) {
throw Exception('Seleziona un cliente prima di poter usare il QR');
}
final savedOperation = await _repository.saveFullOperation(
operation: operationToSave,
);
emit(
state.copyWith(
operation: savedOperation,
status: OperationFormStatus.ready,
),
);
return savedOperation.id;
} catch (e) {
emit(
state.copyWith(
status: OperationFormStatus.failure,
errorMessage: e.toString(),
),
);
return null;
}
}
// --- GESTIONE DEI CAMPI IN TEMPO REALE ---
void updateFields({
CustomerModel? customer,
String? reference,
String? note,
String? type,
String? providerId,
String? providerDisplayName,
String? subtype,
String? description,
DateTime? expirationDate,
int? quantity,
String? modelId,
String? modelDisplayName,
String? staffId,
String? staffDisplayName,
OperationStatus? status,
bool clearProvider = false,
bool clearType = false,
bool clearSubtype = false,
bool clearDescription = false,
bool clearExpiration = false,
bool clearQuantity = false,
bool clearModel = false,
}) {
final current = state.operation;
int? newQuantity;
if (clearQuantity) newQuantity = 1;
if (quantity != null && quantity <= 0) newQuantity = 0;
if (quantity != null && quantity > 0) newQuantity = quantity;
final updated = current.copyWith(
customer: customer ?? current.customer,
customerId: customer?.id ?? current.customerId,
reference: reference ?? current.reference,
note: note ?? current.note,
providerId: clearProvider ? null : (providerId ?? current.providerId),
providerDisplayName: clearProvider
? null
: (providerDisplayName ?? current.providerDisplayName),
quantity: newQuantity ?? current.quantity,
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,
status: status ?? current.status,
);
emit(state.copyWith(operation: updated));
}
// --- UTILS ---
void setTypeWithSmartDefault(String type) {
DateTime? defaultDate;
final now = DateTime.now();
if (type == 'Energy') {
defaultDate = DateTime(now.year, now.month + 24, now.day);
}
if (type == 'Fin') {
defaultDate = DateTime(now.year, now.month + 30, now.day);
}
if (type == 'Entertainment') {
defaultDate = DateTime(now.year, now.month + 12, now.day);
}
updateFields(
type: type,
expirationDate: defaultDate,
clearProvider: true,
clearSubtype: true,
clearModel: true,
clearQuantity: true,
);
}
}

View File

@@ -0,0 +1,48 @@
part of 'operation_form_cubit.dart';
enum OperationFormStatus {
initial,
loading,
ready,
saving,
success,
successAndAddAnother, // Nuovo stato in stile Ticket!
failure,
}
class OperationFormState extends Equatable {
final OperationFormStatus status;
final OperationModel operation;
final String? errorMessage;
// Teniamo traccia delle operazioni salvate in questa sessione (per UI riepilogo)
final List<OperationModel> savedBatchOperations;
const OperationFormState({
this.status = OperationFormStatus.initial,
required this.operation,
this.errorMessage,
this.savedBatchOperations = const [],
});
OperationFormState copyWith({
OperationFormStatus? status,
OperationModel? operation,
String? errorMessage,
List<OperationModel>? savedBatchOperations,
}) {
return OperationFormState(
status: status ?? this.status,
operation: operation ?? this.operation,
errorMessage: errorMessage,
savedBatchOperations: savedBatchOperations ?? this.savedBatchOperations,
);
}
@override
List<Object?> get props => [
status,
operation,
errorMessage,
savedBatchOperations,
];
}

View File

@@ -0,0 +1,83 @@
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';
part 'operation_list_state.dart';
class OperationListCubit extends Cubit<OperationListState> {
final OperationsRepository _repository = GetIt.I<OperationsRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
OperationListCubit() : super(const OperationListState()) {
loadOperations(refresh: true);
}
Future<void> loadOperations({bool refresh = false}) async {
if (state.status == OperationListStatus.loading) return;
if (!refresh && state.hasReachedMax) return;
emit(
state.copyWith(
status: OperationListStatus.loading,
errorMessage: null,
operations: refresh ? [] : state.operations,
hasReachedMax: refresh ? false : state.hasReachedMax,
),
);
try {
final currentOffset = refresh ? 0 : state.operations.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: OperationListStatus.success,
operations: refresh
? newOperations
: [...state.operations, ...newOperations],
hasReachedMax: reachedMax,
),
);
} catch (e) {
emit(
state.copyWith(
status: OperationListStatus.failure,
errorMessage: "Errore nel caricamento operazioni: $e",
),
);
}
}
void updateFilters({String? query, DateTimeRange? range}) {
emit(
state.copyWith(
query: query ?? state.query,
dateRange: range ?? state.dateRange,
),
);
loadOperations(refresh: true);
}
void clearFilters() {
emit(const OperationListState()); // Resetta tutto allo stato iniziale
loadOperations(refresh: true);
}
}

View File

@@ -0,0 +1,49 @@
part of 'operation_list_cubit.dart';
enum OperationListStatus { initial, loading, success, failure }
class OperationListState extends Equatable {
final OperationListStatus status;
final List<OperationModel> operations;
final bool hasReachedMax;
final String? errorMessage;
final String query;
final DateTimeRange? dateRange;
const OperationListState({
this.status = OperationListStatus.initial,
this.operations = const [],
this.hasReachedMax = false,
this.errorMessage,
this.query = '',
this.dateRange,
});
OperationListState copyWith({
OperationListStatus? status,
List<OperationModel>? operations,
bool? hasReachedMax,
String? errorMessage,
String? query,
DateTimeRange? dateRange,
}) {
return OperationListState(
status: status ?? this.status,
operations: operations ?? this.operations,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
errorMessage: errorMessage,
query: query ?? this.query,
dateRange: dateRange ?? this.dateRange,
);
}
@override
List<Object?> get props => [
status,
operations,
hasReachedMax,
errorMessage,
query,
dateRange,
];
}

View File

@@ -1,304 +0,0 @@
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,68 +0,0 @@
part of 'operations_cubit.dart';
enum OperationsStatus {
initial,
loading,
ready,
saving,
saved,
savedNoPop,
success,
failure,
}
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 OperationsState({
required this.status,
this.allOperations = const [],
this.currentOperation,
this.errorMessage,
this.query = '',
this.dateRange,
this.hasReachedMax = false,
this.isSavingDraft = false,
});
OperationsState copyWith({
OperationsStatus? status,
List<OperationModel>? allOperations,
OperationModel? currentOperation,
String? errorMessage,
String? query,
DateTimeRange? dateRange,
bool? hasReachedMax,
bool? isSavingDraft,
}) {
return OperationsState(
status: status ?? this.status,
allOperations: allOperations ?? this.allOperations,
currentOperation: currentOperation ?? this.currentOperation,
errorMessage: errorMessage,
query: query ?? this.query,
dateRange: dateRange ?? this.dateRange,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
isSavingDraft: isSavingDraft ?? this.isSavingDraft,
);
}
@override
List<Object?> get props => [
status,
allOperations,
currentOperation,
errorMessage,
query,
dateRange,
hasReachedMax,
isSavingDraft,
];
}

View File

@@ -18,7 +18,7 @@ class OperationsRepository {
.from('operation')
.select('''
*,
customer(name),
customer(*),
store(name),
staff_member(name),
provider(name),
@@ -47,7 +47,7 @@ class OperationsRepository {
.from('operation')
.select('''
*,
customer(name),
customer(*),
store(name),
provider(name),
model(name_with_brand),

View File

@@ -1,15 +1,14 @@
import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/customers/models/customer_model.dart';
enum OperationStatus {
ok('ok'),
waitingforaction('waiting_for_action'),
waitingforsupport('waiting_for_support'),
waitingfordeployment('waiting_for_deployment'),
ko('ko'),
draft('draft'),
canceled('canceled');
success('success', 'OK'),
waitingForAction('waiting_for_action', 'In attesa di azione'),
waitingForSupport('waiting_for_support', 'In attesa di supporto'),
failure('failure', 'KO'),
draft('draft', 'Bozza');
static OperationStatus fromString(String value) {
final normalizedValue = value.replaceAll('_', '').toLowerCase();
@@ -19,8 +18,9 @@ enum OperationStatus {
}
final String supabaseName;
final String displayName;
const OperationStatus(this.supabaseName);
const OperationStatus(this.supabaseName, this.displayName);
}
class OperationModel extends Equatable {
@@ -46,7 +46,7 @@ class OperationModel extends Equatable {
final String? lastCampaignId;
final OperationStatus status;
final String? customerId;
final String? customerDisplayName;
final CustomerModel? customer;
final String reference;
// ALLEGATI (Aggiunto)
@@ -75,7 +75,7 @@ class OperationModel extends Equatable {
this.lastCampaignId,
this.status = OperationStatus.draft,
this.customerId,
this.customerDisplayName,
this.customer,
this.reference = '',
this.attachments = const [],
});
@@ -103,7 +103,7 @@ class OperationModel extends Equatable {
String? lastCampaignId,
OperationStatus? status,
String? customerId,
String? customerDisplayName,
CustomerModel? customer,
String? reference,
List<AttachmentModel>? attachments,
}) => OperationModel(
@@ -129,7 +129,7 @@ class OperationModel extends Equatable {
lastCampaignId: lastCampaignId ?? this.lastCampaignId,
status: status ?? this.status,
customerId: customerId ?? this.customerId,
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
customer: customer ?? this.customer,
reference: reference ?? this.reference,
attachments: attachments ?? this.attachments,
);
@@ -158,13 +158,13 @@ class OperationModel extends Equatable {
lastCampaignId,
status,
customerId,
customerDisplayName,
customer,
reference,
attachments,
];
factory OperationModel.empty({required String companyId}) {
return OperationModel(id: null, createdAt: null, companyId: companyId);
factory OperationModel.empty() {
return OperationModel(id: null, createdAt: null, companyId: '');
}
factory OperationModel.fromMap(Map<String, dynamic> map) {
@@ -208,9 +208,11 @@ class OperationModel extends Equatable {
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(),
customer: map['customer'] != null
? CustomerModel.fromMap(map['customer'] as Map<String, dynamic>)
: null,
attachments:
(map['attachment'] as List?)

View File

@@ -1,14 +1,12 @@
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/attachments/blocs/attachments_bloc.dart';
import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/operations/ui/widgets/customer_section.dart';
import 'package:flux/core/widgets/shared_forms/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';
import 'package:flux/core/widgets/shared_forms/attachments_section.dart';
import 'package:flux/core/widgets/shared_forms/staff_section.dart';
class OperationFormScreen extends StatefulWidget {
final String? operationId;
@@ -49,26 +47,10 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
@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(
context.read<OperationFormCubit>().initForm(
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
@@ -76,50 +58,83 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
_referenceController.dispose();
_noteController.dispose();
_freeTextSubtypeController.dispose();
_freeTextDescriptionController.dispose();
super.dispose();
}
void _syncTextControllers(OperationModel model) {
if (_referenceController.text.isEmpty && model.reference.isNotEmpty) {
if (_referenceController.text.isEmpty) {
_referenceController.text = model.reference;
}
if (_noteController.text.isEmpty && model.note.isNotEmpty) {
if (_noteController.text.isEmpty) {
_noteController.text = model.note;
}
if (_freeTextSubtypeController.text.isEmpty &&
model.subtype != null &&
model.subtype!.isNotEmpty) {
_freeTextSubtypeController.text = model.subtype!;
if (_freeTextSubtypeController.text.isEmpty) {
_freeTextSubtypeController.text = model.subtype ?? '';
}
if (_freeTextDescriptionController.text.isEmpty &&
model.description != null &&
model.description!.isNotEmpty) {
_freeTextDescriptionController.text = model.description!;
if (_freeTextDescriptionController.text.isEmpty) {
_freeTextDescriptionController.text = model.description ?? '';
}
// Se è una nuova pratica (draft), impostiamo di default il target su OK per comodità UI
if (model.id == null && model.status == OperationStatus.draft) {
// Usiamo addPostFrameCallback per non interferire con il build attuale
WidgetsBinding.instance.addPostFrameCallback((_) {
// Supponendo tu aggiunga la possibilità di aggiornare lo status nel metodo updateFields del Cubit
// context.read<OperationFormCubit>().updateFields(status: OperationStatus.ok);
});
}
_isInitialized = true;
}
void _saveOperation({required bool keepAdding}) {
void _flushControllersToCubit() {
context.read<OperationFormCubit>().updateFields(
reference: _referenceController.text,
note: _noteController.text,
subtype: _freeTextSubtypeController.text,
description: _freeTextDescriptionController.text,
);
}
void _saveOperation({
required OperationStatus targetStatus,
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,
_flushControllersToCubit();
context.read<OperationFormCubit>().saveOperation(
targetStatus: targetStatus,
keepAdding: keepAdding,
);
}
}
cubit.initOperationForm(existingOperation: operationToSave);
cubit.saveCurrentOperation(
targetStatus: OperationStatus.ok,
shouldPop: !keepAdding,
);
Future<String?> _generateIdForQr() async {
if (!_formKey.currentState!.validate()) return null;
_flushControllersToCubit();
final attachmentsBloc = context.read<AttachmentsBloc>();
// Presumo tu abbia creato il metodo saveOperationDraft() nel Cubit!
final newId = await context.read<OperationFormCubit>().saveOperationDraft();
if (newId != null && context.mounted) {
attachmentsBloc.add(ParentEntitySavedEvent(newId));
}
return newId;
}
// Helper per assegnare un colore agli stati
Color _getStatusColor(OperationStatus status) {
switch (status) {
case OperationStatus.success:
return Colors.green;
case OperationStatus.waitingForAction:
return Colors.orange;
case OperationStatus.waitingForSupport:
return Colors.blue;
case OperationStatus.failure:
return Colors.red;
case OperationStatus.draft:
return Colors.grey;
}
}
@@ -127,33 +142,27 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
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,
return BlocConsumer<OperationFormCubit, OperationFormState>(
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
if (state.status == OperationsStatus.ready &&
state.currentOperation != null &&
!_isInitialized) {
_syncTextControllers(state.currentOperation!);
if (state.status == OperationFormStatus.ready && !_isInitialized) {
_syncTextControllers(state.operation);
}
if (state.status == OperationsStatus.saved) {
if (state.status == OperationFormStatus.success) {
Navigator.of(context).pop();
} else if (state.status == OperationsStatus.savedNoPop) {
context.read<OperationsCubit>().prepareNextOperationInBatch();
} else if (state.status == OperationFormStatus.successAndAddAnother) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Servizio aggiunto! Inserisci il prossimo.'),
content: Text('Operazione salvata! Inserisci la prossima'),
),
);
_freeTextSubtypeController.clear();
_freeTextDescriptionController.clear();
} else if (state.status == OperationsStatus.failure) {
} else if (state.status == OperationFormStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Errore'),
backgroundColor: theme.colorScheme.error,
content: Text(state.errorMessage ?? 'Errore di salvataggio'),
backgroundColor: Colors.red,
),
);
}
@@ -161,19 +170,47 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
builder: (context, state) {
if (!_isInitialized &&
(widget.operationId != null || widget.existingOperation != null) &&
state.status == OperationsStatus.loading) {
state.status == OperationFormStatus.loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
// Determiniamo lo stato da mostrare nel form.
// Se è una bozza appena creata, mostriamo visivamente "OK" come default per il salvataggio.
final displayStatus =
state.operation.status == OperationStatus.draft &&
state.operation.id == null
? OperationStatus.success
: state.operation.status;
return Scaffold(
appBar: AppBar(
title: Text(
state.currentOperation?.id == null
? 'Nuova Pratica'
: 'Modifica Pratica',
state.operation.id == null
? 'Nuova Pratica - Operatore: ${state.operation.staffDisplayName}'
: 'Modifica Pratica - Operatore: ${state.operation.staffDisplayName}',
),
// Mettiamo un piccolo indicatore visivo anche nella AppBar se non è OK
actions:
displayStatus != OperationStatus.success &&
displayStatus != OperationStatus.draft
? [
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Chip(
label: Text(
displayStatus.displayName,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
backgroundColor: _getStatusColor(displayStatus),
),
),
]
: null,
),
body: Form(
key: _formKey,
@@ -182,47 +219,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
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!,
),
),
),
],
);
return _buildUltraWide(state, theme);
} else if (isDesktop) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -231,7 +228,11 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
flex: 7,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: _buildMainFormContent(theme, state),
child: _buildMainFormContent(
theme,
state,
displayStatus,
),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
@@ -250,7 +251,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMainFormContent(theme, state),
_buildMainFormContent(theme, state, displayStatus),
const Divider(height: 32),
_buildNotesSection(isDesktop: false),
const SizedBox(height: 80),
@@ -266,26 +267,30 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
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
style: ElevatedButton.styleFrom(
// Se c'è un KO o un blocco, cambiamo il colore del bottone principale per attirare l'attenzione
backgroundColor:
displayStatus != OperationStatus.success &&
displayStatus != OperationStatus.draft
? _getStatusColor(displayStatus)
: null,
foregroundColor:
displayStatus != OperationStatus.success &&
displayStatus != OperationStatus.draft
? Colors.white
: null,
),
onPressed: state.status == OperationFormStatus.saving
? null
: () => _saveOperation(keepAdding: false),
child: state.status == OperationsStatus.saving
: () => _saveOperation(
keepAdding: false,
targetStatus:
displayStatus, // <-- Usiamo lo stato selezionato nel form!
),
child: state.status == OperationFormStatus.saving
? const SizedBox(
width: 20,
height: 20,
@@ -297,6 +302,24 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
: const Text('Salva ed Esci'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: OutlinedButton(
onPressed: state.status == OperationFormStatus.saving
? null
: () => _saveOperation(
keepAdding: true,
targetStatus:
displayStatus, // <-- Usiamo lo stato selezionato nel form!
),
child: const Text(
'Salva e Aggiungi Altro',
textAlign: TextAlign.center,
),
),
),
],
),
),
@@ -306,31 +329,166 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
);
}
Widget _buildMainFormContent(
ThemeData theme,
OperationsState state, {
bool showFiles = true,
}) {
final currentOp = state.currentOperation;
final currentType = currentOp?.type ?? 'AL';
return Column(
Widget _buildUltraWide(OperationFormState state, ThemeData theme) {
return Row(
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),
Expanded(
flex: 4,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStaffSection(state),
const Divider(height: 50),
_buildOperationStatusSection(state),
const Divider(height: 32),
_buildCustomerSection(state),
const SizedBox(height: 16),
_buildReferenceSection(state),
const Divider(height: 50),
_buildOperationTypeSection(state),
const SizedBox(height: 16),
_buildQuantitySection(state),
const Divider(height: 50),
_buildDetailsSection(state),
const Divider(height: 50),
],
),
),
),
const Divider(height: 32),
VerticalDivider(width: 1, color: theme.dividerColor),
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _buildNotesSection(isDesktop: true),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
Expanded(
flex: 3,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: _buildAttachmentSection(state),
),
),
],
);
}
Widget _buildStaffSection(OperationFormState state) {
return StaffSection(
staffId: state.operation.staffId,
staffName: state.operation.staffDisplayName,
onStaffSelected: (staff) => {
context.read<OperationFormCubit>().updateFields(
staffId: staff.id,
staffDisplayName: staff.name,
),
},
);
}
Widget _buildOperationStatusSection(OperationFormState state) {
return Column(
children: [
_buildSectionTitle('Esito / Stato Operazione'),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: _getStatusColor(
state.operation.status,
).withValues(alpha: 0.1),
border: Border.all(
color: _getStatusColor(
state.operation.status,
).withValues(alpha: 0.3),
),
borderRadius: BorderRadius.circular(12),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<OperationStatus>(
isExpanded: true,
value: state.operation.status,
icon: Icon(
Icons.arrow_drop_down,
color: _getStatusColor(state.operation.status),
),
items: OperationStatus.values
/* .where(
(s) => s != OperationStatus.draft,
) // Nascondiamo 'Bozza' dal menu */
.map(
(status) => DropdownMenuItem(
value: status,
child: Row(
children: [
Icon(
status == OperationStatus.success
? Icons.check_circle
: Icons.error_outline,
color: _getStatusColor(status),
size: 20,
),
const SizedBox(width: 12),
Text(
status.displayName,
style: TextStyle(
fontWeight: FontWeight.w600,
color: _getStatusColor(status),
),
),
],
),
),
)
.toList(),
onChanged: (newStatus) {
if (newStatus != null) {
// Assicurati che il metodo updateFields nel tuo Cubit accetti anche 'status'
context.read<OperationFormCubit>().updateFields(
status: newStatus,
);
}
},
),
),
),
const SizedBox(height: 8),
Text(
state.operation.status == OperationStatus.success
? 'Lascia OK se la pratica è stata caricata con successo.'
: 'Attenzione: la pratica verrà salvata come ${state.operation.status.displayName}.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
);
}
Widget _buildCustomerSection(OperationFormState state) {
return SharedCustomerSection(
customer: state.operation.customer,
onCustomerSelected: (customer) {
context.read<OperationFormCubit>().updateFields(customer: customer);
},
);
}
Widget _buildReferenceSection(OperationFormState state) {
return TextFormField(
controller: _referenceController,
decoration: const InputDecoration(
labelText: 'Riferimento (es. numero di telefono, targa...)',
prefixIcon: Icon(Icons.tag),
),
);
}
Widget _buildOperationTypeSection(OperationFormState state) {
return Column(
children: [
_buildSectionTitle('Cosa stiamo facendo?'),
Wrap(
spacing: 8.0,
@@ -338,59 +496,101 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
children: _availableTypes.map((type) {
return ChoiceChip(
label: Text(type),
selected: currentType == type,
selected: state.operation.type == type,
onSelected: (selected) {
if (selected) {
context.read<OperationsCubit>().setTypeWithSmartDefault(type);
context.read<OperationFormCubit>().setTypeWithSmartDefault(
type,
);
}
},
);
}).toList(),
),
const Divider(height: 32),
],
);
}
Widget _buildDetailsSection(OperationFormState state) {
return Column(
children: [
_buildSectionTitle('Dettagli Servizio'),
DetailsSection(
currentOp: currentOp,
currentType: currentType,
currentOp: state.operation,
currentType: state.operation.type,
freeTextSubtypeController: _freeTextSubtypeController,
freeTextDescriptionController: _freeTextDescriptionController,
durationQuickPicks: _buildDurationQuickPicks(currentOp),
durationQuickPicks: _buildDurationQuickPicks(state.operation),
),
],
);
}
Widget _buildQuantitySection(OperationFormState state) {
return Row(
children: [
const Text('Quantità: '),
IconButton(
icon: const Icon(Icons.remove),
onPressed: () {
final q = state.operation.quantity;
if (q > 1) {
context.read<OperationFormCubit>().updateFields(quantity: q - 1);
}
},
),
Text(
'${state.operation.quantity}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
final q = state.operation.quantity;
context.read<OperationFormCubit>().updateFields(quantity: q + 1);
},
),
],
);
}
Widget _buildAttachmentSection(OperationFormState state) {
return SharedAttachmentsSection(
parentType: AttachmentParentType.operation,
parentId: state.operation.id,
titleForUpload: state.operation.customer?.name ?? 'Nuova pratica',
onGenerateIdForQr: _generateIdForQr,
);
}
Widget _buildMainFormContent(
ThemeData theme,
OperationFormState state,
OperationStatus displayStatus, {
bool showFiles = true,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/* _buildStaffSection(state),
const Divider(height: 50), */
_buildOperationStatusSection(state),
const Divider(height: 32),
_buildCustomerSection(state),
const SizedBox(height: 16),
_buildReferenceSection(state),
const Divider(height: 50),
_buildOperationTypeSection(state),
const SizedBox(height: 16),
_buildQuantitySection(state),
const Divider(height: 50),
_buildDetailsSection(state),
const Divider(height: 50),
// 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!)],
if (showFiles) ...[_buildAttachmentSection(state)],
],
);
}
@@ -420,7 +620,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
backgroundColor: Colors.blue.withValues(alpha: 0.05),
onPressed: () {
final now = DateTime.now();
context.read<OperationsCubit>().updateOperationFields(
context.read<OperationFormCubit>().updateFields(
expirationDate: DateTime(
now.year,
now.month + months,

View File

@@ -1,18 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/widgets/staff_selector_modal.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/operations/blocs/operation_list_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 OperationsScreen extends StatefulWidget {
const OperationsScreen({super.key});
class OperationListScreen extends StatefulWidget {
const OperationListScreen({super.key});
@override
State<OperationsScreen> createState() => _OperationsScreenState();
State<OperationListScreen> createState() => _OperationListScreenState();
}
class _OperationsScreenState extends State<OperationsScreen> {
class _OperationListScreenState extends State<OperationListScreen> {
final ScrollController _scrollController = ScrollController();
@override
@@ -20,13 +23,11 @@ class _OperationsScreenState extends State<OperationsScreen> {
super.initState();
// Agganciamo il listener per la paginazione (Scroll Infinito)
_scrollController.addListener(_onScroll);
// Carichiamo i servizi iniziali
context.read<OperationsCubit>().loadOperations();
}
void _onScroll() {
if (_isBottom) {
context.read<OperationsCubit>().loadOperations();
context.read<OperationListCubit>().loadOperations();
}
}
@@ -59,16 +60,16 @@ class _OperationsScreenState extends State<OperationsScreen> {
),
],
),
body: BlocBuilder<OperationsCubit, OperationsState>(
body: BlocBuilder<OperationListCubit, OperationListState>(
builder: (context, state) {
// 1. Stato di caricamento iniziale
if (state.status == OperationsStatus.loading &&
state.allOperations.isEmpty) {
if (state.status == OperationListStatus.loading &&
state.operations.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
// 2. Lista vuota
if (state.allOperations.isEmpty) {
if (state.operations.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -77,7 +78,7 @@ class _OperationsScreenState extends State<OperationsScreen> {
const SizedBox(height: 10),
ElevatedButton(
onPressed: () => context
.read<OperationsCubit>()
.read<OperationListCubit>()
.loadOperations(refresh: true),
child: const Text("Riprova"),
),
@@ -88,16 +89,17 @@ class _OperationsScreenState extends State<OperationsScreen> {
// 3. La Lista (con Pull-to-refresh)
return RefreshIndicator(
onRefresh: () =>
context.read<OperationsCubit>().loadOperations(refresh: true),
onRefresh: () => context.read<OperationListCubit>().loadOperations(
refresh: true,
),
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.only(bottom: 80), // Spazio per il FAB
itemCount: state.hasReachedMax
? state.allOperations.length
: state.allOperations.length + 1,
? state.operations.length
: state.operations.length + 1,
itemBuilder: (context, index) {
if (index >= state.allOperations.length) {
if (index >= state.operations.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
@@ -106,7 +108,7 @@ class _OperationsScreenState extends State<OperationsScreen> {
);
}
final operation = state.allOperations[index];
final operation = state.operations[index];
return _buildOperationCard(context, operation);
},
),
@@ -114,7 +116,15 @@ class _OperationsScreenState extends State<OperationsScreen> {
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => startNewOperation(context),
onPressed: () async {
StaffMemberModel? createdBy = await getStaffMember(context);
if (createdBy == null || !context.mounted) return;
context.pushNamed(
Routes.operationForm,
pathParameters: {'id': 'new'},
extra: (createdBy: createdBy, operation: null),
);
},
child: const Icon(Icons.add),
),
);
@@ -131,7 +141,7 @@ class _OperationsScreenState extends State<OperationsScreen> {
children: [
Expanded(
child: Text(
operation.customerDisplayName ?? "Cliente sconosciuto",
operation.customer?.name ?? "Cliente sconosciuto",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
@@ -159,7 +169,7 @@ class _OperationsScreenState extends State<OperationsScreen> {
),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.pushNamed(
'operation-form',
'operations/form/id=${operation.id}',
extra: operation, // <-- LA MAGIA È QUI: Passa l'oggetto intero!
// Teniamo anche il parametro URL per coerenza di routing
queryParameters: operation.id != null
@@ -173,17 +183,16 @@ class _OperationsScreenState extends State<OperationsScreen> {
Widget _buildOperationStatus(OperationStatus status) {
Color color;
switch (status) {
case OperationStatus.canceled || OperationStatus.ko:
case OperationStatus.failure:
color = Colors.grey.shade800;
break;
case OperationStatus.waitingforaction || OperationStatus.draft:
case OperationStatus.waitingForAction || OperationStatus.draft:
color = Colors.orange;
break;
case OperationStatus.ok:
case OperationStatus.success:
color = Colors.green;
break;
case OperationStatus.waitingfordeployment ||
OperationStatus.waitingforsupport:
case OperationStatus.waitingForSupport:
color = Colors.blue;
break;
}
@@ -195,6 +204,6 @@ class _OperationsScreenState extends State<OperationsScreen> {
}
void startNewOperation(BuildContext context) {
context.pushNamed('operation-form');
context.pushNamed('operation-form', pathParameters: {'id': 'new'});
}
}

View File

@@ -1,303 +0,0 @@
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';
class OperationMobileUploadScreen extends StatefulWidget {
final String operationId;
final String operationName;
const OperationMobileUploadScreen({
super.key,
required this.operationId,
required this.operationName,
});
@override
State<OperationMobileUploadScreen> createState() =>
_OperationMobileUploadScreenState();
}
class _OperationMobileUploadScreenState
extends State<OperationMobileUploadScreen> {
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
final List<PlatformFile> _stagedFiles = [];
// 2. STATO DI CARICAMENTO GLOBALE
bool _isUploading = false;
// Funzione magica per capire se è un'immagine o un PDF dall'estensione
bool _isImage(String path) {
final ext = path.split('.').last.toLowerCase();
return ['jpg', 'jpeg', 'png', 'webp'].contains(ext);
}
@override
Widget build(BuildContext context) {
return BlocListener<OperationFilesBloc, OperationFilesState>(
listener: (context, state) {
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
if (state.status == OperationFilesStatus.success && _isUploading) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Tutti i file caricati con successo! ✅"),
),
);
Navigator.of(context).pop();
}
if (state.status == OperationFilesStatus.failure) {
setState(() => _isUploading = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Errore: ${state.error}")));
}
},
child: Scaffold(
appBar: AppBar(
title: Text("Upload Pratica:\n${widget.operationName}"),
automaticallyImplyLeading: !_isUploading,
),
body: Stack(
children: [
Column(
children: [
// --- SEZIONE PULSANTI (Fotocamera / Galleria) ---
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isUploading ? null : _handleCamera,
icon: const Icon(Icons.camera_alt),
label: const Text("SCATTA"),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _isUploading ? null : _handleFilePicker,
icon: const Icon(Icons.folder),
label: const Text("GALLERIA"),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
],
),
),
const Divider(),
// --- SEZIONE ANTEPRIME (La GridView Magica) ---
Expanded(
child: _stagedFiles.isEmpty
? const Center(
child: Text(
"Nessun file selezionato.\nScatta una foto o scegli dalla galleria.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
3, // 3 colonne come la galleria dell'iPhone
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _stagedFiles.length,
itemBuilder: (context, index) {
final file = _stagedFiles[index];
final isImg = _isImage(file.name);
return Stack(
clipBehavior: Clip.none,
children: [
// L'ANTEPRIMA
Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: isImg
? Image.file(
File(file.path!),
fit: BoxFit.cover,
)
: const Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Icons.picture_as_pdf,
color: Colors.red,
size: 36,
),
SizedBox(height: 4),
Text(
"PDF",
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
// IL PULSANTE CESTINO (In alto a destra)
Positioned(
top: -8,
right: -8,
child: GestureDetector(
onTap: () {
setState(() {
_stagedFiles.removeAt(index);
});
},
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.white,
size: 16,
),
),
),
),
],
);
},
),
),
// --- SEZIONE INVIA E CHIUDI ---
SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
// Il pulsante si accende SOLO se ci sono file nel carrello
onPressed: _stagedFiles.isEmpty || _isUploading
? null
: _submitAllFiles,
icon: const Icon(Icons.cloud_upload),
label: Text(
"INVIA ${_stagedFiles.length} FILE E CHIUDI",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimary,
),
),
),
),
),
],
),
// --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) ---
if (_isUploading)
Container(
color: Colors.black.withValues(alpha: 0.5),
child: const Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text(
"Caricamento in corso...",
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
),
),
),
],
),
),
);
}
// --- LOGICA FOTOCAMERA E LIBRERIA ---
Future<void> _handleCamera() async {
final picker = ImagePicker();
final photo = await picker.pickImage(
source: ImageSource.camera,
imageQuality: 80,
);
if (photo != null) {
final photoBytes = await photo.readAsBytes(); // Sicuro anche per Web!
final photoSize = await photo.length();
final platformFile = PlatformFile(
name: photo.name,
size: photoSize,
path: photo.path,
bytes: photoBytes, // I bytes ci salvano la vita su Supabase!
);
setState(() {
_stagedFiles.add(platformFile); // Unifichiamo tutto in un dart:io File
});
}
}
Future<void> _handleFilePicker() async {
// allowMultiple: true permette di pescare 5 foto dalla galleria in un colpo solo!
final result = await FilePicker.pickFiles(allowMultiple: true);
if (result != null) {
setState(() {
_stagedFiles.addAll(result.files);
});
}
}
// --- LOGICA DI INVIO AL BLoC ---
void _submitAllFiles() {
setState(() => _isUploading = true);
// 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<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,9 +1,8 @@
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/core/widgets/shared_forms/model_section.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/blocs/operation_form_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
class DetailsSection extends StatelessWidget {
@@ -46,6 +45,7 @@ class DetailsSection extends StatelessWidget {
}
void _showProviderModal(BuildContext context, String operationType) {
final OperationFormCubit cubit = context.read<OperationFormCubit>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -104,153 +104,31 @@ class DetailsSection extends StatelessWidget {
);
}
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,
return BlocProvider.value(
value: cubit,
child: 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);
},
);
},
onTap: () {
context.read<OperationFormCubit>().updateFields(
providerId: provider.id,
providerDisplayName: provider.name,
);
Navigator.pop(modalContext);
},
);
},
),
);
},
),
@@ -314,9 +192,7 @@ class DetailsSection extends StatelessWidget {
].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (val) {
if (val != null) {
context.read<OperationsCubit>().updateOperationFields(
subtype: val,
);
context.read<OperationFormCubit>().updateFields(subtype: val);
}
},
),
@@ -334,30 +210,16 @@ class DetailsSection extends StatelessWidget {
// 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),
SharedModelSection(
label: 'Seleziona Dispositivo/Prodotto',
modelId: currentOp?.modelId,
modelName: currentOp?.modelDisplayName,
onModelSelected: (id, name) {
context.read<OperationFormCubit>().updateFields(
modelId: id,
modelDisplayName: name,
);
},
),
const SizedBox(height: 16),
],
@@ -409,7 +271,7 @@ class DetailsSection extends StatelessWidget {
lastDate: DateTime.now().add(const Duration(days: 3650)),
);
if (date != null && context.mounted) {
context.read<OperationsCubit>().updateOperationFields(
context.read<OperationFormCubit>().updateFields(
expirationDate: date,
);
}

View File

@@ -0,0 +1,42 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
class SettingsState extends Equatable {
final bool isSingleUserMode;
const SettingsState({this.isSingleUserMode = false});
SettingsState copyWith({bool? isSingleUserMode}) {
return SettingsState(
isSingleUserMode: isSingleUserMode ?? this.isSingleUserMode,
);
}
@override
List<Object?> get props => [isSingleUserMode];
}
class SettingsCubit extends Cubit<SettingsState> {
final SharedPreferences _prefs = GetIt.I.get<SharedPreferences>();
SettingsCubit() : super(const SettingsState()) {
final bool isSingleUserMode = _prefs.getBool('isSingleUserMode') ?? false;
final sessionCubit = GetIt.I.get<SessionCubit>();
sessionCubit.setIsSingleUserMode(isSingleUserMode);
emit(state.copyWith(isSingleUserMode: isSingleUserMode));
}
void toggleSingleUserMode() {
final bool isSingleUserMode = !state.isSingleUserMode;
GetIt.I.get<SharedPreferences>().setBool(
'isSingleUserMode',
isSingleUserMode,
);
final sessionCubit = GetIt.I.get<SessionCubit>();
sessionCubit.setIsSingleUserMode(isSingleUserMode);
emit(state.copyWith(isSingleUserMode: !state.isSingleUserMode));
}
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/settings/document_sequence/models/document_sequence_model.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class DocumentSequenceState {
final List<DocumentSequence> sequences;
final bool isLoading;
final String? error;
DocumentSequenceState({
this.sequences = const [],
this.isLoading = false,
this.error,
});
}
class DocumentSequenceCubit extends Cubit<DocumentSequenceState> {
final String companyId;
final _supabase = Supabase.instance.client;
DocumentSequenceCubit(this.companyId) : super(DocumentSequenceState());
Future<void> loadSequences() async {
emit(DocumentSequenceState(isLoading: true));
try {
final data = await _supabase
.from('document_sequences')
.select()
.eq('company_id', companyId);
final list = (data as List)
.map((e) => DocumentSequence.fromMap(e))
.toList();
emit(DocumentSequenceState(sequences: list));
} catch (e) {
emit(DocumentSequenceState(error: e.toString()));
}
}
void updateLocalSequence(String docType, {String? prefix, int? nextValue}) {
final newList = state.sequences.map((s) {
if (s.docType == docType) {
return s.copyWith(prefix: prefix, nextValue: nextValue);
}
return s;
}).toList();
emit(DocumentSequenceState(sequences: newList));
}
Future<void> saveSequences() async {
try {
for (var seq in state.sequences) {
await _supabase.from('document_sequences').upsert({
'company_id': companyId,
'doc_type': seq.docType,
'next_value': seq.nextValue,
'prefix': seq.prefix,
});
}
// Opzionale: mostra un feedback di successo
} catch (e) {
emit(
DocumentSequenceState(
sequences: state.sequences,
error: "Errore nel salvataggio",
),
);
}
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flux/features/settings/document_sequence/models/document_sequence_model.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class DocumentSequenceRepository {
final _supabase = GetIt.I.get<SupabaseClient>();
Future<List<DocumentSequence>> getDocumentSequences(String companyId) async {
final response = await _supabase
.from('document_sequences')
.select()
.eq('company_id', companyId);
return (response as List).map((e) => DocumentSequence.fromMap(e)).toList();
}
Future<void> updateSequence({
required String companyId,
required String docType,
required int nextValue,
required String prefix,
}) async {
await _supabase.from('document_sequences').upsert({
'company_id': companyId,
'doc_type': docType,
'next_value': nextValue,
'prefix': prefix,
});
}
}

View File

@@ -0,0 +1,29 @@
enum DocumentType { ticket, ddt, invoice }
class DocumentSequence {
final String docType;
final int nextValue;
final String prefix;
DocumentSequence({
required this.docType,
required this.nextValue,
required this.prefix,
});
DocumentSequence copyWith({int? nextValue, String? prefix}) {
return DocumentSequence(
docType: docType,
nextValue: nextValue ?? this.nextValue,
prefix: prefix ?? this.prefix,
);
}
factory DocumentSequence.fromMap(Map<String, dynamic> map) {
return DocumentSequence(
docType: map['doc_type'],
nextValue: map['next_value'],
prefix: map['prefix'] ?? '',
);
}
}

View File

@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/settings/document_sequence/blocs/document_sequence_cubit.dart';
class DocumentSequenceSection extends StatelessWidget {
const DocumentSequenceSection({super.key});
@override
Widget build(BuildContext context) {
final year = DateTime.now().year;
return BlocBuilder<DocumentSequenceCubit, DocumentSequenceState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Text(
"Protocolli e Numerazione",
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
...state.sequences.map((seq) {
// Anteprima dinamica
final preview =
"${seq.prefix.isNotEmpty ? '${seq.prefix}-' : ''}$year-${seq.nextValue.toString().padLeft(6, '0')}";
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
seq.docType.toUpperCase(),
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
initialValue: seq.prefix,
decoration: const InputDecoration(
labelText: 'Prefisso',
hintText: 'es. TCK',
),
onChanged: (val) => context
.read<DocumentSequenceCubit>()
.updateLocalSequence(
seq.docType,
prefix: val,
),
),
),
const SizedBox(width: 16),
Expanded(
flex: 3,
child: TextFormField(
initialValue: seq.nextValue.toString(),
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Prossimo Numero',
),
onChanged: (val) => context
.read<DocumentSequenceCubit>()
.updateLocalSequence(
seq.docType,
nextValue: int.tryParse(val) ?? 1,
),
),
),
],
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(
Icons.visibility,
size: 16,
color: Colors.grey,
),
const SizedBox(width: 8),
Text(
"Anteprima prossimo: ",
style: TextStyle(
color: Colors.grey.shade700,
fontSize: 12,
),
),
Text(
preview,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
],
),
),
],
),
),
);
}),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () =>
context.read<DocumentSequenceCubit>().saveSequences(),
icon: const Icon(Icons.save),
label: const Text("SALVA PROTOCOLLI"),
),
),
],
);
},
);
}
}

View File

@@ -1,10 +1,14 @@
// lib/ui/impostazioni/impostazioni_view.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/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/settings/theme_settings_view.dart';
import 'package:flux/features/settings/blocs/settings_cubit.dart';
import 'package:go_router/go_router.dart';
class SettingsView extends StatelessWidget {
const SettingsView({super.key});
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
@@ -15,48 +19,41 @@ class SettingsView extends StatelessWidget {
children: [
_settingsSection('Account', [
_settingsTile(
Icons.person,
'Profilo Utente',
'Configura i tuoi dati',
context,
MaterialPageRoute(
builder: (context) => const ThemeSettingsView(),
icon: Icons.person,
title: 'Profilo Utente',
subtitle: 'Configura i tuoi dati',
context: context,
onTap: () {},
),
BlocBuilder<SettingsCubit, SettingsState>(
builder: (context, state) => CheckboxListTile(
value: state.isSingleUserMode,
title: const Text('Singolo Utente'),
onChanged: (_) =>
context.read<SettingsCubit>().toggleSingleUserMode(),
),
),
_settingsTile(
Icons.store,
'Mio Negozio',
'Piacenza Centro',
context,
MaterialPageRoute(
builder: (context) => const ThemeSettingsView(),
),
title: 'Impostazioni Azienda',
icon: Icons.business,
subtitle: 'Configura i dati aziendali',
context: context,
onTap: () => context.pushNamed(Routes.companySettings),
),
]),
const SizedBox(height: 16),
_settingsSection('Applicazione', [
_settingsTile(
Icons.sync,
'Sincronizzazione',
'Ultima: 5 min fa',
context,
MaterialPageRoute(
builder: (context) => const ThemeSettingsView(),
),
),
_settingsTile(
Icons.dark_mode,
'Tema (FLUX Dark)',
'Configurazione visiva',
context,
MaterialPageRoute(
builder: (context) => const ThemeSettingsView(),
),
icon: Icons.dark_mode,
title: 'Tema (FLUX Dark)',
subtitle: 'Configurazione visiva',
context: context,
onTap: () => context.pushNamed(Routes.themeSettings),
),
]),
const SizedBox(height: 24),
TextButton.icon(
onPressed: () {},
onPressed: () => context.read<SessionCubit>().signOut(),
icon: const Icon(Icons.exit_to_app, color: Colors.red),
label: const Text('Logout', style: TextStyle(color: Colors.red)),
),
@@ -83,22 +80,22 @@ class SettingsView extends StatelessWidget {
);
}
Widget _settingsTile(
IconData icon,
String title,
String subtitle,
BuildContext context,
MaterialPageRoute route,
) {
Widget _settingsTile({
required BuildContext context,
required IconData icon,
required String title,
String? subtitle,
required VoidCallback onTap,
}) {
return ListTile(
leading: Icon(icon, color: FluxColors.primaryBlue),
title: Text(title, style: Theme.of(context).textTheme.titleLarge),
subtitle: Text(subtitle),
subtitle: Text(subtitle ?? ''),
trailing: Icon(
Icons.chevron_right,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
onTap: () => Navigator.of(context).push(route),
onTap: onTap,
);
}
}

View File

@@ -0,0 +1,274 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/features/tickets/data/ticket_repository.dart';
import 'package:flux/features/tracking/data/tracking_repository.dart';
import 'package:flux/features/tracking/models/tracking_model.dart';
import 'package:get_it/get_it.dart';
import 'ticket_form_state.dart';
class TicketFormCubit extends Cubit<TicketFormState> {
final TicketRepository _repository = GetIt.I.get<TicketRepository>();
final SessionCubit _sessionCubit = GetIt.I.get<SessionCubit>();
// Costruttore: prepariamo subito il ticket base con i dati di chi lo crea
TicketFormCubit({StaffMemberModel? createdBy, TicketModel? existingTicket})
: super(
TicketFormState(
// Se c'è un ticket esistente usa quello, ALTRIMENTI ne crea uno vuoto
// e ci stampa subito il nome del creatore!
ticket:
existingTicket ??
TicketModel.empty().copyWith(
createdById: createdBy?.id,
createdByName: createdBy?.name,
),
),
);
/// 1. INIZIALIZZAZIONE
Future<void> initForm({String? id, TicketModel? existingTicket}) async {
if (existingTicket != null) {
// SCENARIO 1: Abbiamo il ticket intero passato via record
emit(
state.copyWith(ticket: existingTicket, status: TicketFormStatus.ready),
);
} else if (id != null) {
// SCENARIO 2: QR CODE o Web Refresh! (Hai solo l'ID)
emit(state.copyWith(status: TicketFormStatus.loading));
try {
// Boom! Lo scarica dal database in tempo reale
final fetchedTicket = await _repository.getTicketById(id);
emit(
state.copyWith(ticket: fetchedTicket, status: TicketFormStatus.ready),
);
} catch (e) {
emit(
state.copyWith(
status: TicketFormStatus.failure,
errorMessage: 'Ticket non trovato',
),
);
}
} else {
// SCENARIO 3: Nuovo Ticket
final currentStore = _sessionCubit.state.currentStore;
final companyId = _sessionCubit.state.company?.id ?? '';
// IL TRUCCO È QUI: Usiamo `state.ticket` invece di `TicketModel.empty()`.
// `state.ticket` HA GIÀ i dati di 'createdBy' settati nel costruttore!
final newTicket = state.ticket.copyWith(
companyId: companyId,
storeId: currentStore?.id,
ticketStatus: TicketStatus.open, // <-- O il tuo status di default
ticketType: TicketType.repair,
);
emit(state.copyWith(ticket: newTicket, status: TicketFormStatus.ready));
}
}
/// 2. AGGIORNAMENTO CLIENTE (Usato dal nostro SharedCustomerSection!)
void updateCustomer(CustomerModel customer) {
emit(
state.copyWith(
ticket: state.ticket.copyWith(
customerId: customer.id,
customer: customer,
alternativePhoneNumber:
state.ticket.alternativePhoneNumber ?? customer.phoneNumber,
),
),
);
}
/// 3. AGGIORNAMENTO MODELLO (Usato dal nostro SharedModelSection!)
void updateTargetModel({required String modelId, required String modelName}) {
emit(
state.copyWith(
ticket: state.ticket.copyWith(
targetModelId: modelId,
targetModelName: modelName,
),
),
);
}
void updateSourceModel({required String modelId, required String modelName}) {
emit(
state.copyWith(
ticket: state.ticket.copyWith(
sourceModelId: modelId,
sourceModelName: modelName,
),
),
);
}
void updateCreator({required String staffId, required String staffName}) {
emit(
state.copyWith(
ticket: state.ticket.copyWith(
createdById: staffId,
createdByName: staffName,
),
),
);
}
/// 4. AGGIORNAMENTO GENERICO DEI CAMPI
void updateFields({
TicketType? ticketType,
TicketStatus? status,
String? request,
String? targetSn,
String? sourceSn,
String? alternativePhoneNumber,
bool? hasCourtesyDevice,
String? includedAccessories,
String? publicNotes,
String? internalNotes,
double? customerPrice,
double? internalCost,
String? assignedToId,
String? assignedToName,
}) {
emit(
state.copyWith(
ticket: state.ticket.copyWith(
ticketType: ticketType ?? state.ticket.ticketType,
ticketStatus: status ?? state.ticket.ticketStatus,
request: request ?? state.ticket.request,
targetSn: targetSn ?? state.ticket.targetSn,
sourceSn: sourceSn ?? state.ticket.sourceSn,
alternativePhoneNumber:
alternativePhoneNumber ?? state.ticket.alternativePhoneNumber,
hasCourtesyDevice:
hasCourtesyDevice ?? state.ticket.hasCourtesyDevice,
includedAccessories:
includedAccessories ?? state.ticket.includedAccessories,
publicNotes: publicNotes ?? state.ticket.publicNotes,
internalNotes: internalNotes ?? state.ticket.internalNotes,
customerPrice: customerPrice ?? state.ticket.customerPrice,
internalCost: internalCost ?? state.ticket.internalCost,
assignedToId: assignedToId ?? state.ticket.assignedToId,
assignedToName: assignedToName ?? state.ticket.assignedToName,
),
),
);
}
/// 5. SALVATAGGIO
Future<void> saveTicket() async {
emit(state.copyWith(status: TicketFormStatus.saving));
try {
final ticketToSave = state.ticket;
// Validazione base
if (ticketToSave.customerId == null || ticketToSave.customerId!.isEmpty) {
throw Exception("Seleziona un cliente prima di salvare.");
}
TicketModel? savedTicket;
if (ticketToSave.id == null) {
savedTicket = await _repository.insertTicket(ticketToSave);
} else {
savedTicket = await _repository.updateTicket(ticketToSave);
}
emit(
state.copyWith(
status: TicketFormStatus.success,
ticket: ticketToSave.copyWith(
id: savedTicket.id,
referenceId: savedTicket.referenceId,
),
),
);
} catch (e) {
emit(
state.copyWith(
status: TicketFormStatus.failure,
errorMessage: e.toString(),
),
);
}
}
/// 5.1 SALVATAGGIO SILENZIOSO (Per generare il QR Code al volo)
Future<String?> saveTicketDraft() async {
// Non mettiamo lo stato 'saving' per non far sfarfallare tutta la UI,
// usiamo un caricamento invisibile.
try {
final ticketToSave = state.ticket;
if (ticketToSave.customerId == null || ticketToSave.customerId!.isEmpty) {
throw Exception("Seleziona un cliente prima di poter usare il QR.");
}
final savedTicket = await _repository.insertTicket(ticketToSave);
// Aggiorniamo silenziosamente lo stato con il ticket che ora ha un ID!
emit(state.copyWith(ticket: savedTicket, status: TicketFormStatus.ready));
return savedTicket.id;
} catch (e) {
emit(
state.copyWith(
status: TicketFormStatus.failure,
errorMessage: e.toString(),
),
);
return null;
}
}
Future<void> takeInCharge({
required String staffId,
required String staffName,
}) async {
final currentTicket = state.ticket;
// Sicurezza: non possiamo prendere in carico un ticket fantasma
if (currentTicket.id == null || currentTicket.id!.isEmpty) return;
// 1. Prepariamo il ticket aggiornato
final updatedTicket = currentTicket.copyWith(
ticketStatus: TicketStatus
.inProgress, // Assumendo che tu abbia un enum per gli stati
assignedToId: staffId,
assignedToName: staffName,
);
try {
// 2. Aggiorniamo il ticket sul Database (usa il tuo metodo esistente del repo)
await _repository.updateTicket(updatedTicket);
// 3. Spara il log automatico nella Timeline!
await GetIt.I.get<TrackingRepository>().logQuickEvent(
companyId: currentTicket.companyId,
message: "Ticket preso in carico. Inizio lavorazione.",
type: TrackingType.statusChange,
parentId: currentTicket.id!,
parentType: TrackingParentType.ticket,
staffId: staffId,
// Lo mettiamo pubblico (isInternal: false) così il cliente a casa vede che
// il suo dispositivo è ufficialmente sotto i ferri!
isInternal: false,
);
// 4. Aggiorniamo lo stato locale del Cubit per far scattare la UI
emit(state.copyWith(ticket: updatedTicket));
} catch (e) {
// Gestisci eventuali errori (es. mostrando una snackbar)
emit(
state.copyWith(
status: TicketFormStatus.failure,
errorMessage: 'Errore durante la presa in carico: $e',
),
);
}
}
}

View File

@@ -0,0 +1,32 @@
import 'package:equatable/equatable.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
// Adatta gli import al tuo progetto!
enum TicketFormStatus { initial, ready, loading, saving, success, pop, failure }
class TicketFormState extends Equatable {
final TicketModel ticket;
final TicketFormStatus status;
final String? errorMessage;
const TicketFormState({
required this.ticket,
this.status = TicketFormStatus.initial,
this.errorMessage,
});
@override
List<Object?> get props => [ticket, status, errorMessage];
TicketFormState copyWith({
TicketModel? ticket,
TicketFormStatus? status,
String? errorMessage,
}) {
return TicketFormState(
ticket: ticket ?? this.ticket,
status: status ?? this.status,
errorMessage: errorMessage,
);
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/features/tickets/data/ticket_repository.dart';
import 'package:get_it/get_it.dart';
import 'ticket_list_state.dart';
class TicketListCubit extends Cubit<TicketListState> {
final TicketRepository _repository = GetIt.I.get<TicketRepository>();
static const int _limit = 20; // Paginazione a blocchi di 20
TicketListCubit() : super(const TicketListState()) {
fetchTickets(reset: true);
}
/// Recupera i ticket. Se reset = true, svuota la lista e riparte da offset 0.
Future<void> fetchTickets({bool reset = false}) async {
if (state.isLoading) return;
if (!reset && state.hasReachedMax) return;
emit(
state.copyWith(
isLoading: true,
errorMessage: '',
tickets: reset ? [] : state.tickets,
),
);
try {
final currentOffset = reset ? 0 : state.tickets.length;
final newTickets = await _repository.fetchStoreTickets(
offset: currentOffset,
limit: _limit,
searchTerm: state.searchTerm,
dateRange: state.dateRange,
statusFilter: state.statusFilter,
ticketTypeFilter: state.ticketTypeFilter,
staffIdFilter: state.staffIdFilter,
);
emit(
state.copyWith(
tickets: reset ? newTickets : [...state.tickets, ...newTickets],
isLoading: false,
hasReachedMax: newTickets.length < _limit,
),
);
} catch (e) {
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
}
}
/// Aggiorna i filtri e ricarica tutto da zero
void updateFilters({
String? searchTerm,
DateTimeRange? dateRange,
TicketStatus? statusFilter,
TicketType? ticketTypeFilter,
String? staffIdFilter,
bool clearSearch = false,
bool clearDate = false,
bool clearStatus = false,
}) {
emit(
state.copyWith(
searchTerm: searchTerm,
dateRange: dateRange,
statusFilter: statusFilter,
ticketTypeFilter: ticketTypeFilter,
staffIdFilter: staffIdFilter,
clearSearch: clearSearch,
clearDate: clearDate,
clearStatus: clearStatus,
),
);
fetchTickets(reset: true); // Applica i filtri e ricarica
}
}

View File

@@ -0,0 +1,69 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
class TicketListState extends Equatable {
final List<TicketModel> tickets;
final bool isLoading;
final bool hasReachedMax;
final String errorMessage;
// Filtri attivi
final String? searchTerm;
final DateTimeRange? dateRange;
final TicketStatus? statusFilter;
final TicketType? ticketTypeFilter;
final String? staffIdFilter;
const TicketListState({
this.tickets = const [],
this.isLoading = false,
this.hasReachedMax = false,
this.errorMessage = '',
this.searchTerm,
this.dateRange,
this.statusFilter,
this.ticketTypeFilter,
this.staffIdFilter,
});
TicketListState copyWith({
List<TicketModel>? tickets,
bool? isLoading,
bool? hasReachedMax,
String? errorMessage,
String? searchTerm,
DateTimeRange? dateRange,
TicketStatus? statusFilter,
TicketType? ticketTypeFilter,
String? staffIdFilter,
bool clearSearch = false,
bool clearDate = false,
bool clearStatus = false,
}) {
return TicketListState(
tickets: tickets ?? this.tickets,
isLoading: isLoading ?? this.isLoading,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
errorMessage: errorMessage ?? this.errorMessage,
searchTerm: clearSearch ? null : (searchTerm ?? this.searchTerm),
dateRange: clearDate ? null : (dateRange ?? this.dateRange),
statusFilter: clearStatus ? null : (statusFilter ?? this.statusFilter),
ticketTypeFilter: ticketTypeFilter ?? this.ticketTypeFilter,
staffIdFilter: staffIdFilter ?? this.staffIdFilter,
);
}
@override
List<Object?> get props => [
tickets,
isLoading,
hasReachedMax,
errorMessage,
searchTerm,
dateRange,
statusFilter,
ticketTypeFilter,
staffIdFilter,
];
}

View File

@@ -0,0 +1,268 @@
import 'package:flutter/material.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class TicketRepository {
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
TicketRepository();
static const String _tableName = 'ticket';
// --- RECUPERO PAGINATO CON FILTRI E JOIN DEI TICKET DI UNO STORE ---
Future<List<TicketModel>> fetchStoreTickets({
required int offset,
int limit = 50,
String? searchTerm,
DateTimeRange? dateRange,
TicketStatus? statusFilter,
TicketType? ticketTypeFilter,
String? staffIdFilter,
}) async {
try {
var query = _supabase
.from(_tableName)
.select('''
*,
customer (*),
created_by:staff_member!ticket_staff_id_fkey (*),
assigned_to:staff_member!ticket_assigned_to_id_fkey (*),
target_model:model!ticket_model_id_1_fkey (*),
source_model:model!ticket_model_id_2_fkey (*)
''')
.eq('store_id', GetIt.I.get<SessionCubit>().state.currentStore!.id!);
// Filtro Range Date
if (dateRange != null) {
query = query
.gte('created_at', dateRange.start.toIso8601String())
.lte('created_at', dateRange.end.toIso8601String());
}
if (statusFilter != null) {
query = query.eq('status', statusFilter.value);
}
if (ticketTypeFilter != null) {
query = query.eq('ticket_type', ticketTypeFilter.value);
}
if (staffIdFilter != null) {
query = query.eq('staff_id', staffIdFilter);
}
if (searchTerm != null && searchTerm.isNotEmpty) {
// Filtra sui campi della tabella principale O su quelli della tabella joinata
query = query.or('customer.name.ilike.%$searchTerm%');
}
final response = await query
.order('created_at', ascending: false)
.range(offset, offset + limit - 1);
return (response as List).map((map) => TicketModel.fromMap(map)).toList();
} catch (e) {
throw Exception('$e');
}
}
// --- RECUPERO PAGINATO CON FILTRI E JOIN DEI TICKET DI TUTTA L'AZIENDA ---
Future<List<TicketModel>> fetchCompanyTickets({
required int offset,
int limit = 50,
String? searchTerm,
DateTimeRange? dateRange,
TicketStatus? ticketStatusFilter,
TicketType? ticketTypeFilter,
String? staffIdFilter,
}) async {
try {
var query = _supabase
.from(_tableName)
.select('''
*,
customer (*),
created_by:staff_member!ticket_staff_id_fkey (*),
assigned_to:staff_member!ticket_assigned_to_id_fkey (*),
target_model:model!ticket_model_id_1_fkey (*),
source_model:model!ticket_model_id_2_fkey (*)
''')
.eq('company_id', GetIt.I.get<SessionCubit>().state.company!.id!);
// Filtro Range Date
if (dateRange != null) {
query = query
.gte('created_at', dateRange.start.toIso8601String())
.lte('created_at', dateRange.end.toIso8601String());
}
if (ticketStatusFilter != null) {
query = query.eq('status', ticketStatusFilter.value);
}
if (ticketTypeFilter != null) {
query = query.eq('ticket_type', ticketTypeFilter.value);
}
if (staffIdFilter != null) {
query = query.eq('staff_id', staffIdFilter);
}
if (searchTerm != null && searchTerm.isNotEmpty) {
// Filtra sui campi della tabella principale O su quelli della tabella joinata
query = query.or('customer.name.ilike.%$searchTerm%');
}
final response = await query
.order('created_at', ascending: false)
.range(offset, offset + limit - 1);
return (response as List).map((map) => TicketModel.fromMap(map)).toList();
} catch (e) {
throw Exception('$e');
}
}
/// Stream dei ticket che necessitano attenzione (es. in scadenza oggi o in ritardo)
Stream<List<TicketModel>> getAttentionNeededTicketsStream() {
return _supabase
.from(_tableName)
.stream(primaryKey: ['id'])
.eq('store_id', GetIt.I.get<SessionCubit>().state.currentStore!.id!)
// Purtroppo lo stream accetta solo filtri base, quindi ci facciamo
// mandare i dati e li filtriamo con la potenza di Dart!
.limit(300)
.map((listOfMaps) {
final now = DateTime.now();
final endOfToday = DateTime(now.year, now.month, now.day, 23, 59, 59);
// 1. Mappiamo tutto in TicketModel
final allStoreTickets = listOfMaps
.map((map) => TicketModel.fromMap(map))
.toList();
// 2. Filtriamo in memoria!
final urgentTickets = allStoreTickets.where((ticket) {
// Escludiamo quelli già chiusi o consegnati
if (ticket.ticketStatus == TicketStatus.closed ||
ticket.ticketStatus == TicketStatus.ready) {
return false;
}
// Se c'è una data di consegna stimata ed è <= a stasera, è urgente!
if (ticket.estimatedDeliveryAt != null) {
return ticket.estimatedDeliveryAt!.isBefore(endOfToday);
}
return false;
}).toList();
// 3. Li ordiniamo mettendo i più vecchi/urgenti in cima
urgentTickets.sort(
(a, b) => a.estimatedDeliveryAt!.compareTo(b.estimatedDeliveryAt!),
);
return urgentTickets;
});
}
/// Recupera un ticket specifico CON TUTTE LE RELAZIONI espanse (Cliente e Modelli)
/// Questa è la vera magia di Supabase!
Future<TicketModel> getTicketById(String ticketId) async {
try {
// Usiamo i nomi esatti delle Foreign Key che hai definito nell'SQL!
final response = await _supabase
.from(_tableName)
.select('''
*,
customer (*),
target_model:model!ticket_model_id_1_fkey (*),
source_model:model!ticket_model_id_2_fkey (*),
created_by:staff_member!ticket_staff_id_fkey (*),
assigned_to:staff_member!ticket_assigned_to_id_fkey (*),
''')
.eq('id', ticketId)
.single();
return TicketModel.fromMap(response);
} catch (e) {
throw Exception('Errore nel recupero del dettaglio ticket: $e');
}
}
Future<String> generateTicketReference(String companyId) async {
final response = await Supabase.instance.client.rpc(
'get_next_document_number',
params: {'p_company_id': companyId, 'p_doc_type': 'ticket'},
);
// Estraiamo i dati dal JSON
final int nextValue = response['next_value'];
final String prefix = response['prefix'] ?? '';
final year = DateTime.now().year; // 2026
// Formattazione con zeri iniziali (es. 000125)
final paddedNumber = nextValue.toString().padLeft(6, '0');
// Costruiamo la stringa. Se c'è un prefisso mette "TCK-2026-000125",
// altrimenti solo "2026-000125"
if (prefix.isNotEmpty) {
return '$prefix-$year-$paddedNumber';
} else {
return '$year-$paddedNumber';
}
}
/// Salva il ticket
Future<TicketModel> insertTicket(TicketModel ticket) async {
if (ticket.id != null) {
throw Exception('Impossibile creare un ticket esistente, id not null');
}
try {
final ticketToSave = ticket.copyWith(
referenceId: await generateTicketReference(ticket.companyId),
);
final response = await _supabase
.from(_tableName)
.insert(ticketToSave.toMap())
.select()
.single();
return TicketModel.fromMap(response);
} catch (e) {
throw Exception('Errore nella creazione del ticket: $e');
}
}
/// Aggiorna un ticket esistente
Future<TicketModel> updateTicket(TicketModel ticket) async {
if (ticket.id == null) {
throw Exception('Impossibile aggiornare un ticket senza ID');
}
try {
final response = await _supabase
.from(_tableName)
.update(ticket.toMap())
.eq('id', ticket.id!)
.select()
.single();
return TicketModel.fromMap(response);
} catch (e) {
throw Exception('Errore nell\'aggiornamento del ticket: $e');
}
}
/// Elimina (o annulla) un ticket
Future<void> deleteTicket(String ticketId) async {
try {
await _supabase.from(_tableName).delete().eq('id', ticketId);
} catch (e) {
throw Exception('Errore nell\'eliminazione del ticket: $e');
}
}
}

View File

@@ -0,0 +1,363 @@
import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/customers/models/customer_model.dart';
/// Enum per il tipo di ticket
enum TicketType {
repair('repair', 'Riparazione'),
softwareSetup('software_setup', 'Impost. software'),
dataTransfer('data_transfer', 'Trasf. dati'),
operationTicket('operation_ticket', 'Ticket operazione'),
other('other', 'Altro');
final String value;
final String displayValue;
const TicketType(this.value, this.displayValue);
static TicketType fromString(String val) {
return TicketType.values.firstWhere(
(e) => e.value == val,
orElse: () => TicketType.other,
);
}
}
/// Enum per lo stato del ticket
enum TicketStatus {
open('open', 'Aperto'),
inProgress('in_progress', 'In corso'),
waitingForParts('waiting_for_parts', 'In attesa di ricambi'),
ready('ready', 'Pronto'),
closed('closed', 'Chiuso'),
waitingForShipping('waiting_for_shipping', 'In attesa di spedire'),
waitingForReturn('waiting_for_return', 'In attesa di ritorno');
final String value;
final String displayValue;
const TicketStatus(this.value, this.displayValue);
static TicketStatus fromString(String? val) {
return TicketStatus.values.firstWhere(
(e) => e.value == val,
orElse: () => TicketStatus.open,
);
}
}
/// Enum per il risultato del ticket (OK / KO)
enum TicketResult {
success('success', 'Risolto (OK)'),
failure('failure', 'Non Risolto (KO)');
final String value;
final String displayValue;
const TicketResult(this.value, this.displayValue);
static TicketResult? fromString(String? val) {
if (val == null) return null;
return TicketResult.values.firstWhere(
(e) => e.value == val,
orElse: () => TicketResult.success,
);
}
}
/// Enum per il tipo di garanzia
enum WarrantyType {
manufacturerWarranty('manufacturer_warranty', 'Garanzia produttore'),
providerWarranty('provider_warranty', 'Garanzia gestore'),
internalWarranty('internal_warranty', 'Garanzia interna'),
noWarranty('no_warranty', 'Fuori garanzia');
final String value;
final String displayValue;
const WarrantyType(this.value, this.displayValue);
static WarrantyType? fromString(String? val) {
return WarrantyType.values.firstWhere(
(e) => e.value == val,
orElse: () => WarrantyType.noWarranty,
);
}
}
class TicketModel extends Equatable {
final String? id; // Null se non ancora salvato
final DateTime? createdAt;
final String companyId;
final String? storeId;
final String? customerId;
final String? targetModelId;
final String? targetSn;
final String? sourceModelId;
final String? sourceSn;
final double customerPrice;
final double internalCost;
final DateTime? closedAt;
final DateTime? returnedAt;
final String request;
final WarrantyType? warrantyType;
final String? publicNotes;
final String? internalNotes;
final String? referenceId;
final String? alternativePhoneNumber;
final bool hasCourtesyDevice;
final TicketType ticketType;
final TicketStatus ticketStatus;
final DateTime? estimatedDeliveryAt;
final TicketResult? ticketResult;
final String? resolutionNotes;
final CustomerModel? customer;
final String? targetModelName;
final String? sourceModelName;
final String? createdById;
final String? createdByName;
final String? assignedToId;
final String? assignedToName;
final String? includedAccessories;
const TicketModel({
this.id,
this.createdAt,
required this.companyId,
this.storeId,
this.customerId,
this.targetModelId,
this.targetSn,
this.sourceModelId,
this.sourceSn,
this.customerPrice = 0.0,
this.internalCost = 0.0,
this.closedAt,
this.returnedAt,
this.request = '',
this.warrantyType,
this.publicNotes,
this.internalNotes,
this.referenceId,
this.alternativePhoneNumber,
this.hasCourtesyDevice = false,
required this.ticketType,
this.ticketStatus = TicketStatus.closed,
this.estimatedDeliveryAt,
this.ticketResult,
this.resolutionNotes,
this.customer,
this.targetModelName,
this.sourceModelName,
this.createdById,
this.createdByName,
this.assignedToId,
this.assignedToName,
this.includedAccessories,
});
/// Factory per creare un ticket vuoto (utile per i form di creazione)
factory TicketModel.empty({String? companyId, String? storeId}) {
return TicketModel(
companyId: companyId ?? '',
storeId: storeId,
ticketType: TicketType.repair, // Valore di default
ticketStatus: TicketStatus.open,
customerPrice: 0.0,
internalCost: 0.0,
hasCourtesyDevice: false,
request: '',
);
}
TicketModel copyWith({
String? id,
DateTime? createdAt,
String? companyId,
String? storeId,
String? customerId,
String? targetModelId,
String? targetSn,
String? sourceModelId,
String? sourceSn,
double? customerPrice,
double? internalCost,
DateTime? closedAt,
DateTime? returnedAt,
String? request,
WarrantyType? warrantyType,
String? publicNotes,
String? internalNotes,
String? referenceId,
String? alternativePhoneNumber,
bool? hasCourtesyDevice,
TicketType? ticketType,
TicketStatus? ticketStatus,
DateTime? estimatedDeliveryAt,
TicketResult? ticketResult,
String? resolutionNotes,
CustomerModel? customer,
String? targetModelName,
String? sourceModelName,
String? createdById,
String? createdByName,
String? assignedToId,
String? assignedToName,
String? includedAccessories,
}) {
return TicketModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
companyId: companyId ?? this.companyId,
storeId: storeId ?? this.storeId,
customerId: customerId ?? this.customerId,
targetModelId: targetModelId ?? this.targetModelId,
targetSn: targetSn ?? this.targetSn,
sourceModelId: sourceModelId ?? this.sourceModelId,
sourceSn: sourceSn ?? this.sourceSn,
customerPrice: customerPrice ?? this.customerPrice,
internalCost: internalCost ?? this.internalCost,
closedAt: closedAt ?? this.closedAt,
returnedAt: returnedAt ?? this.returnedAt,
request: request ?? this.request,
warrantyType: warrantyType ?? this.warrantyType,
publicNotes: publicNotes ?? this.publicNotes,
internalNotes: internalNotes ?? this.internalNotes,
referenceId: referenceId ?? this.referenceId,
alternativePhoneNumber:
alternativePhoneNumber ?? this.alternativePhoneNumber,
hasCourtesyDevice: hasCourtesyDevice ?? this.hasCourtesyDevice,
ticketType: ticketType ?? this.ticketType,
ticketStatus: ticketStatus ?? this.ticketStatus,
estimatedDeliveryAt: estimatedDeliveryAt ?? this.estimatedDeliveryAt,
ticketResult: ticketResult ?? this.ticketResult,
resolutionNotes: resolutionNotes ?? this.resolutionNotes,
customer: customer ?? this.customer,
targetModelName: targetModelName ?? this.targetModelName,
sourceModelName: sourceModelName ?? this.sourceModelName,
createdById: createdById ?? this.createdById,
createdByName: createdByName ?? this.createdByName,
assignedToId: assignedToId ?? this.assignedToId,
assignedToName: assignedToName ?? this.assignedToName,
includedAccessories: includedAccessories ?? this.includedAccessories,
);
}
/// Deserializzazione da Supabase
factory TicketModel.fromMap(Map<String, dynamic> map) {
return TicketModel(
id: map['id'] as String,
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at']).toLocal()
: null,
companyId: map['company_id'] as String,
storeId: map['store_id'] as String?,
customerId: map['customer_id'] as String?,
targetModelId: map['target_model_id'] as String?,
targetSn: map['target_sn'] as String?,
sourceModelId: map['source_model_id'] as String?,
sourceSn: map['source_sn'] as String?,
// Fix per i field numerici di Postgres che potrebbero arrivare come int o double
customerPrice: (map['customer_price'] as num?)?.toDouble() ?? 0.0,
internalCost: (map['internal_cost'] as num?)?.toDouble() ?? 0.0,
closedAt: map['closed_at'] != null
? DateTime.parse(map['closed_at']).toLocal()
: null,
returnedAt: map['returned_at'] != null
? DateTime.parse(map['returned_at']).toLocal()
: null,
request: map['request'] as String? ?? '',
warrantyType: WarrantyType.fromString(map['warranty_type'] as String?),
publicNotes: map['public_notes'] as String?,
internalNotes: map['internal_notes'] as String?,
referenceId: map['reference_id'] as String?,
alternativePhoneNumber: map['alternative_phone_number'] as String?,
hasCourtesyDevice: map['has_courtesy_device'] as bool? ?? false,
ticketType: TicketType.fromString(map['ticket_type'] as String),
ticketStatus: TicketStatus.fromString(map['ticket_status'] as String),
estimatedDeliveryAt: map['estimated_delivery_at'] != null
? DateTime.parse(map['estimated_delivery_at']).toLocal()
: null,
ticketResult: TicketResult.fromString(map['ticket_result'] as String?),
resolutionNotes: map['resolution_notes'] as String?,
customer: map['customer'] != null
? CustomerModel.fromMap(map['customer'] as Map<String, dynamic>)
: null,
targetModelName: (map['target_model']?['name_with_brand'] as String?)
?.myFormat(),
sourceModelName: (map['source_model']?['name_with_brand'] as String?)
?.myFormat(),
createdById: map['staff_id'] as String?,
createdByName: (map['staff']?['name'] as String?).myFormat(),
assignedToId: map['assigned_to_id'] as String?,
assignedToName: (map['assigned_to']?['name'] as String?).myFormat(),
includedAccessories: map['included_accessories'] as String?,
);
}
/// Serializzazione per Supabase
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'company_id': companyId,
'store_id': storeId,
'customer_id': customerId,
'target_model_id': targetModelId,
'target_sn': targetSn,
'source_model_id': sourceModelId,
'source_sn': sourceSn,
'customer_price': customerPrice,
'internal_cost': internalCost,
if (closedAt != null) 'closed_at': closedAt!.toUtc().toIso8601String(),
if (returnedAt != null)
'returned_at': returnedAt!.toUtc().toIso8601String(),
'request': request,
'created_by_id': createdById,
'warranty_type': warrantyType,
'public_notes': publicNotes,
'internal_notes': internalNotes,
'reference_id': referenceId,
'alternative_phone_number': alternativePhoneNumber,
'has_courtesy_device': hasCourtesyDevice,
'ticket_type': ticketType.value,
'ticket_status': ticketStatus.value,
if (estimatedDeliveryAt != null)
'estimated_delivery_at': estimatedDeliveryAt!.toUtc().toIso8601String(),
if (ticketResult != null) 'ticket_result': ticketResult!.value,
'resolution_notes': resolutionNotes,
'included_accessories': includedAccessories,
};
}
@override
List<Object?> get props => [
id,
createdAt,
companyId,
storeId,
customerId,
targetModelId,
targetSn,
sourceModelId,
sourceSn,
customerPrice,
internalCost,
closedAt,
returnedAt,
request,
warrantyType,
publicNotes,
internalNotes,
alternativePhoneNumber,
hasCourtesyDevice,
ticketType,
ticketStatus,
estimatedDeliveryAt,
ticketResult,
resolutionNotes,
includedAccessories,
customer,
targetModelName,
sourceModelName,
createdById,
createdByName,
assignedToId,
assignedToName,
];
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
extension TicketStatusVisuals on TicketStatus {
Color get color {
switch (this) {
case TicketStatus.open:
return Colors.blueGrey;
case TicketStatus.waitingForParts:
return Colors.amber.shade700;
case TicketStatus.inProgress:
return Colors.blue;
case TicketStatus.waitingForShipping:
// Il tuo rosa storico!
return Colors.pinkAccent;
case TicketStatus.waitingForReturn:
return Colors.purpleAccent;
case TicketStatus.ready:
return Colors.green;
case TicketStatus.closed:
return Colors.grey.shade400;
}
}
IconData get icon {
switch (this) {
case TicketStatus.open:
return Icons.inbox;
case TicketStatus.waitingForParts:
return Icons.hourglass_empty;
case TicketStatus.inProgress:
return Icons.build;
case TicketStatus.waitingForShipping:
return Icons.local_shipping_outlined;
case TicketStatus.waitingForReturn:
return Icons.undo;
case TicketStatus.ready:
return Icons.check_circle_outline;
case TicketStatus.closed:
return Icons.lock_outline;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,309 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/widgets/staff_selector_modal.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart';
import 'package:flux/features/tickets/blocs/ticket_list_state.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/features/tickets/models/ticket_status_extension.dart';
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
import 'package:go_router/go_router.dart';
class TicketListScreen extends StatefulWidget {
const TicketListScreen({super.key});
@override
State<TicketListScreen> createState() => _TicketListScreenState();
}
class _TicketListScreenState extends State<TicketListScreen> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
// INFINITY SCROLL: Quando arriviamo quasi in fondo, chiediamo altri ticket
_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
context.read<TicketListCubit>().fetchTickets();
}
});
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Assistenza & Riparazioni'),
actions: [
// Tasto per filtri avanzati (Data, Staff, Tipo) -> Da fare in un BottomSheet!
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: () {
// TODO: Aprire BottomSheet filtri avanzati
},
),
],
),
body: Column(
children: [
// 1. BARRA DI RICERCA
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Cerca per nome cliente...',
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
context.read<TicketListCubit>().updateFilters(
clearSearch: true,
);
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onSubmitted: (value) {
context.read<TicketListCubit>().updateFilters(
searchTerm: value,
);
},
),
),
// 2. FILTRI RAPIDI PER STATO (CHIPS)
BlocBuilder<TicketListCubit, TicketListState>(
buildWhen: (previous, current) =>
previous.statusFilter != current.statusFilter,
builder: (context, state) {
return SizedBox(
height: 50,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8.0),
children: [
_buildStatusChip(context, state, null, 'Tutti'),
...TicketStatus.values.map(
(status) => _buildStatusChip(
context,
state,
status,
status.displayValue,
),
),
],
),
);
},
),
const Divider(),
// 3. LA LISTA DEI TICKET
Expanded(
child: BlocBuilder<TicketListCubit, TicketListState>(
builder: (context, state) {
if (state.isLoading && state.tickets.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.tickets.isEmpty) {
return const Center(child: Text('Nessun ticket trovato.'));
}
return ListView.builder(
controller: _scrollController,
itemCount: state.hasReachedMax
? state.tickets.length
: state.tickets.length + 1,
itemBuilder: (context, index) {
// Se siamo all'ultimo elemento e non abbiamo raggiunto il max, mostriamo il loader
if (index >= state.tickets.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
);
}
final ticket = state.tickets[index];
return _TicketCard(ticket: ticket);
},
);
},
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('Nuovo Ticket'),
onPressed: () async {
StaffMemberModel? createdBy = await getStaffMember(context);
if (createdBy == null || !context.mounted) return;
context.pushNamed(
Routes.ticketForm,
pathParameters: {'id': 'new'},
extra: (createdBy: createdBy, ticket: null),
);
},
),
);
}
// Widget di supporto per creare le Chip di filtro
Widget _buildStatusChip(
BuildContext context,
TicketListState state,
TicketStatus? status,
String label,
) {
final isSelected = state.statusFilter == status;
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ChoiceChip(
label: Text(label),
selected: isSelected,
selectedColor:
status?.color.withValues(alpha: 0.2) ??
Colors.blue.withValues(alpha: 0.2),
onSelected: (selected) {
context.read<TicketListCubit>().updateFilters(
statusFilter: selected ? status : null,
clearStatus: !selected && status != null,
);
},
),
);
}
}
// ---------------------------------------------------------
// LA CARD DEL TICKET (Il "Colpo d'Occhio")
// ---------------------------------------------------------
class _TicketCard extends StatelessWidget {
final TicketModel ticket;
const _TicketCard({required this.ticket});
@override
Widget build(BuildContext context) {
final statusColor = ticket.ticketStatus.color;
final statusIcon = ticket.ticketStatus.icon;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
clipBehavior: Clip
.antiAlias, // Serve per tagliare il container laterale con gli angoli della card
child: IntrinsicHeight(
// Serve per far sì che il container laterale prenda tutta l'altezza
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// LA STRISCIA COLORATA LATERALE
Container(width: 6, color: statusColor),
Expanded(
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
ticket.customer?.name ?? 'Cliente Sconosciuto',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
overflow: TextOverflow.ellipsis,
),
),
// IL BADGE DELLO STATO
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: statusColor.withValues(alpha: 0.5),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(statusIcon, size: 14, color: statusColor),
const SizedBox(width: 4),
Text(
ticket.ticketStatus.displayValue,
style: TextStyle(
fontSize: 12,
color: statusColor,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
// MODELLO O TIPO DI INTERVENTO
Text(
ticket.targetModelName ?? ticket.ticketType.displayValue,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
// DATA CREAZIONE (Es: 04/05/2026)
Text(
ticket.createdAt != null
? 'Creato il: ${ticket.createdAt!.day}/${ticket.createdAt!.month}/${ticket.createdAt!.year}'
: '',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
),
),
],
),
onTap: () {
context.pushNamed(
'ticket-form',
pathParameters: {'id': ticket.id!},
extra:
ticket, // <-- LA MAGIA È QUI: Passa l'oggetto intero!
// Teniamo anche il parametro URL per coerenza di routing
);
},
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:flux/features/tracking/models/tracking_model.dart';
class TicketTimelineSection extends StatefulWidget {
final List<TrackingModel> logs;
final void Function(String message, bool isInternal) onAddNote;
const TicketTimelineSection({
super.key,
required this.logs,
required this.onAddNote,
});
@override
State<TicketTimelineSection> createState() => _TicketTimelineSectionState();
}
class _TicketTimelineSectionState extends State<TicketTimelineSection> {
final TextEditingController _textController = TextEditingController();
bool _isInternal = true; // Di default blindiamo tutto a uso interno!
@override
void dispose() {
_textController.dispose();
super.dispose();
}
void _submitNote() {
final text = _textController.text.trim();
if (text.isNotEmpty) {
widget.onAddNote(text, _isInternal);
_textController.clear();
// Chiudiamo la tastiera se siamo su mobile
FocusScope.of(context).unfocus();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// --- ZONA INPUT ---
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.dividerColor),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _textController,
decoration: const InputDecoration(
hintText: 'Scrivi un aggiornamento...',
border: InputBorder.none,
isDense: true,
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _submitNote(),
),
),
IconButton.filled(
onPressed: _submitNote,
icon: const Icon(Icons.send, size: 20),
tooltip: 'Invia',
),
],
),
const Divider(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
_isInternal ? Icons.lock : Icons.public,
size: 16,
color: _isInternal
? Colors.amber.shade700
: Colors.green,
),
const SizedBox(width: 8),
Text(
_isInternal
? 'Nota Interna (Privata)'
: 'Visibile al Cliente',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: _isInternal
? Colors.amber.shade700
: Colors.green,
),
),
],
),
Switch(
value: _isInternal,
activeThumbColor: Colors.amber.shade700,
activeTrackColor: Colors.amber,
inactiveThumbColor: Colors.green,
inactiveTrackColor: Colors.green.withValues(alpha: 0.2),
onChanged: (val) {
setState(() {
_isInternal = val;
});
},
),
],
),
],
),
),
const SizedBox(height: 24),
// --- TIMELINE SCROLLABILE ---
if (widget.logs.isEmpty)
const Padding(
padding: EdgeInsets.all(32.0),
child: Center(
child: Text(
'Nessun evento registrato.',
style: TextStyle(color: Colors.grey),
),
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 400,
), // Limite di altezza
child: ListView.builder(
shrinkWrap: true,
itemCount: widget.logs.length,
itemBuilder: (context, index) {
final log = widget.logs[index];
final isLast = index == widget.logs.length - 1;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- LINEA E PALLINO ---
SizedBox(
width: 30,
child: Column(
children: [
Container(
margin: const EdgeInsets.only(top: 4),
width: 14,
height: 14,
decoration: BoxDecoration(
color: _getEventColor(log.eventType),
shape: BoxShape.circle,
border: Border.all(
color: theme.scaffoldBackgroundColor,
width: 2,
),
),
),
if (!isLast)
Expanded(
child: Container(
width: 2,
color: theme.dividerColor,
),
),
],
),
),
// --- CONTENUTO EVENTO ---
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
log.staffName ?? 'Sistema',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
const SizedBox(width: 8),
Text(
_formatDate(log.createdAt),
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey,
),
),
if (!log.isInternal) ...[
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.green.withValues(
alpha: 0.1,
),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Colors.green.withValues(
alpha: 0.3,
),
),
),
child: const Text(
"PUBBLICO",
style: TextStyle(
color: Colors.green,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
const SizedBox(height: 6),
Text(
log.message,
style: const TextStyle(fontSize: 14),
),
],
),
),
),
],
),
);
},
),
),
],
);
}
Color _getEventColor(TrackingType type) {
switch (type) {
case TrackingType.statusChange:
return Colors.blue;
case TrackingType.assignment:
return Colors.purple;
case TrackingType.systemAlert:
return Colors.redAccent;
case TrackingType.customerContact:
return Colors.teal;
case TrackingType.manualNote:
// ignore: unreachable_switch_default
default:
return Colors.amber.shade600;
}
}
String _formatDate(DateTime date) {
final day = date.day.toString().padLeft(2, '0');
final month = date.month.toString().padLeft(2, '0');
final hour = date.hour.toString().padLeft(2, '0');
final minute = date.minute.toString().padLeft(2, '0');
return "$day/$month - $hour:$minute";
}
}

View File

@@ -0,0 +1,251 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
// Importa il tuo TicketModel, Cubit, ecc.
class TicketWorkspaceScreen extends StatelessWidget {
// Passiamo il ticket attuale per avere i dati (o il Cubit se preferisci)
final dynamic ticket; // Sostituisci con TicketModel
const TicketWorkspaceScreen({super.key, required this.ticket});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Banco di Lavoro'),
backgroundColor: theme.colorScheme.inversePrimary,
centerTitle: true,
),
// SafeArea in basso per ospitare i bottoni fissi
bottomNavigationBar: SafeArea(child: _buildBottomActions(context)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildDeviceHeader(theme),
const SizedBox(height: 24),
_buildDefectRecap(theme),
const SizedBox(height: 32),
_buildOperationsSection(theme),
],
),
),
);
}
// --- 1. HEADER DISPOSITIVO E PASSWORD ---
Widget _buildDeviceHeader(ThemeData theme) {
return Card(
elevation: 0,
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.primary.withValues(alpha: 0.2),
),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'DISPOSITIVO IN LAVORAZIONE',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
letterSpacing: 1.2,
),
),
const SizedBox(height: 8),
Text(
ticket.deviceModel ??
'Modello Sconosciuto', // Es: "iPhone 13 Pro"
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
],
),
),
// IL DATO PIÙ CERCATO DAI TECNICI: LA PASSWORD
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.dividerColor),
),
child: Column(
children: [
const Text(
'PIN / SBLOCCO',
style: TextStyle(fontSize: 10, color: Colors.grey),
),
const SizedBox(height: 4),
Text(
ticket.unlockPassword?.isNotEmpty == true
? ticket.unlockPassword!
: 'Nessuno',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
],
),
),
],
),
),
);
}
// --- 2. RECAP DIFETTO ---
Widget _buildDefectRecap(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text(
'Difetto Segnalato',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.dividerColor),
),
child: Text(
ticket.defectDescription ?? 'Nessuna descrizione inserita.',
style: const TextStyle(fontSize: 16, height: 1.5),
),
),
],
);
}
// --- 3. SEZIONE COSTI E RICAMBI (Mockup) ---
Widget _buildOperationsSection(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Ricambi e Manodopera',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
TextButton.icon(
onPressed: () {
// TODO: Apri modal per aggiungere una riga di costo
},
icon: const Icon(Icons.add),
label: const Text('Aggiungi Voce'),
),
],
),
const SizedBox(height: 12),
// Qui ci andrà un ListView.builder collegato alla tabella dei costi/operazioni
Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.dividerColor,
style: BorderStyle.solid,
),
),
child: const Center(
child: Text(
'Nessun ricambio o costo inserito.\nClicca su "Aggiungi Voce" per iniziare.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
),
),
],
);
}
// --- 4. BOTTONI AZIONE FINALI (In basso) ---
Widget _buildBottomActions(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: Row(
children: [
// Bottone Pausa / Attesa Ricambi
Expanded(
flex: 1,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
foregroundColor: Colors.orange.shade700,
side: BorderSide(color: Colors.orange.shade700),
),
onPressed: () {
// TODO: Logica Metti in Pausa
},
icon: const Icon(Icons.pause),
label: const Text(
'Metti in Pausa',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
),
const SizedBox(width: 12),
// Bottone Completa
Expanded(
flex: 2,
child: FilledButton.icon(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.green.shade600,
),
onPressed: () {
// TODO: Logica Completa Riparazione
},
icon: const Icon(Icons.check_circle_outline),
label: const Text(
'COMPLETA RIPARAZIONE',
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,343 @@
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:get_it/get_it.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/features/company/models/company_model.dart';
class TicketPdfService {
final CompanyModel company = GetIt.I.get<SessionCubit>().state.company!;
/// Funzione principale: Genera il PDF A4 con le due metà
Future<pw.Document> generateTicketReceipt(TicketModel ticket) async {
final pdf = pw.Document();
// Carichiamo il font per essere sicuri che i caratteri siano ok
final font = await PdfGoogleFonts.robotoRegular();
final boldFont = await PdfGoogleFonts.robotoBold();
pw.Widget customerHalf = await _buildTicketHalf(
ticket,
company,
font,
boldFont,
isForCustomer: true,
);
pw.Widget storeHalf = await _buildTicketHalf(
ticket,
company,
font,
boldFont,
isForCustomer: false,
);
pdf.addPage(
pw.Page(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(20),
build: (pw.Context context) {
return pw.Column(
children: [
// 1. METÀ SUPERIORE: CLIENTE
customerHalf,
pw.SizedBox(height: 10),
// Linea tratteggiata per il taglio
pw.Container(
margin: const pw.EdgeInsets.symmetric(vertical: 10),
child: pw.Text(
'-' * 100,
style: const pw.TextStyle(color: PdfColors.grey400),
),
),
pw.SizedBox(height: 10),
// 2. METÀ INFERIORE: NEGOZIO
storeHalf,
],
);
},
),
);
return pdf;
}
/// Helper per costruire una singola metà (Cliente o Negozio)
Future<pw.Widget> _buildTicketHalf(
TicketModel ticket,
CompanyModel company,
pw.Font font,
pw.Font boldFont, {
required bool isForCustomer,
}) async {
return pw.Expanded(
//height: 380, // Circa metà A4 meno i margini
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
// HEADER: Logo e Dati Azienda (Solo per cliente o ID per negozio)
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
company.name,
style: pw.TextStyle(font: boldFont, fontSize: 16),
),
if (isForCustomer) ...[
pw.Text(
"${company.address}, ${company.city}",
style: const pw.TextStyle(fontSize: 10),
),
pw.Text(
"P.IVA: ${company.vatId}",
style: const pw.TextStyle(fontSize: 10),
),
],
],
),
pw.Row(
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(
isForCustomer
? "RICEVUTA CLIENTE"
: "COPIA INTERNA NEGOZIO",
style: pw.TextStyle(
font: boldFont,
fontSize: 12,
color: PdfColors.grey700,
),
),
pw.Text(
"Rif: ${ticket.referenceId}",
style: pw.TextStyle(font: boldFont, fontSize: 14),
),
pw.Text(
"Data: ${ticket.createdAt?.toString().substring(0, 10) ?? ''}",
style: const pw.TextStyle(fontSize: 10),
),
],
),
pw.SizedBox(width: 10),
// IL NOSTRO QR CODE MAGICO
pw.BarcodeWidget(
barcode: pw.Barcode.qrCode(),
data: ticket.id!, // Salviamo l'ID univoco nel QR!
width: 45,
height: 45,
),
],
),
],
),
pw.Divider(thickness: 1),
pw.SizedBox(height: 10),
// DATI CLIENTE
pw.Row(
children: [
pw.Expanded(
child: _infoBlock(
"CLIENTE",
ticket.customer?.name ?? 'Cliente Sconosciuto',
font,
boldFont,
),
),
pw.Expanded(
child: _infoBlock(
"CONTATTO ALTERNATIVO",
ticket.alternativePhoneNumber ?? 'N/D',
font,
boldFont,
),
),
],
),
pw.SizedBox(height: 15),
// DETTAGLI LAVORAZIONE
_infoBlock(
"DESCRIZIONE PROBLEMA / LAVORAZIONE RICHIESTA",
ticket.request,
font,
boldFont,
),
pw.SizedBox(height: 8),
pw.Row(
children: [
pw.Expanded(
child: _infoBlock(
"ACCESSORI CONSEGNATI",
ticket.includedAccessories ?? 'Nessuno',
font,
boldFont,
),
),
pw.Expanded(
child: _infoBlock(
"GARANZIA",
ticket.warrantyType?.displayValue ?? 'Standard',
font,
boldFont,
),
),
],
),
pw.SizedBox(height: 15),
// NOTE (Pubbliche o Private a seconda della copia)
if (isForCustomer)
_infoBlock("NOTE", ticket.publicNotes ?? '-', font, boldFont)
else
_infoBlock(
"NOTE INTERNE (PRIVATE)",
ticket.internalNotes ?? '-',
font,
boldFont,
),
pw.Spacer(),
// FOOTER: Disclaimer e Firma
if (!isForCustomer) ...[
pw.Text(
"CONDIZIONI E LIBERATORIA:",
style: pw.TextStyle(font: boldFont, fontSize: 8),
),
pw.Text(
company.ticketDisclaimer ??
'Firma per accettazione delle condizioni di riparazione.',
style: const pw.TextStyle(fontSize: 7),
textAlign: pw.TextAlign.justify,
),
pw.SizedBox(height: 20),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Container(
width: 150,
decoration: pw.BoxDecoration(
border: const pw.Border(top: pw.BorderSide(width: 0.5)),
),
),
pw.Text(
"Firma del Cliente per accettazione",
style: const pw.TextStyle(fontSize: 8),
),
],
),
] else
pw.Align(
alignment: pw.Alignment.centerRight,
child: pw.Text(
"Grazie per averci scelto!",
style: pw.TextStyle(
font: font,
fontSize: 10,
fontStyle: pw.FontStyle.italic,
),
),
),
],
),
);
}
pw.Widget _infoBlock(
String label,
String value,
pw.Font font,
pw.Font boldFont,
) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
label,
style: pw.TextStyle(
font: boldFont,
fontSize: 8,
color: PdfColors.grey600,
),
),
pw.Text(value, style: pw.TextStyle(font: font, fontSize: 11)),
],
);
}
Future<pw.Document> generateLabelPdf(TicketModel ticket) async {
final pdf = pw.Document();
final font = await PdfGoogleFonts.robotoRegular();
final boldFont = await PdfGoogleFonts.robotoBold();
// Prendiamo le misure salvate (se custom) o usiamo default
final widthMm = company.labelWidth ?? 62.0;
final heightMm = company.labelHeight ?? 29.0;
// Creiamo il formato fisico esatto!
final format = company.isLabelVertical
? PdfPageFormat(heightMm * PdfPageFormat.mm, widthMm * PdfPageFormat.mm)
: PdfPageFormat(
widthMm * PdfPageFormat.mm,
heightMm * PdfPageFormat.mm,
);
pdf.addPage(
pw.Page(
pageFormat: format,
margin: const pw.EdgeInsets.all(2), // Margini minimi per le etichette
build: (context) {
return pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
mainAxisAlignment: pw.MainAxisAlignment.center,
children: [
pw.Text(
ticket.referenceId ?? '',
style: pw.TextStyle(font: boldFont, fontSize: 10),
),
pw.Text(
ticket.customer?.name ?? 'Cliente sconosciuto',
style: pw.TextStyle(font: font, fontSize: 9),
),
pw.Text(
ticket.createdAt?.toString().substring(0, 10) ?? '',
style: const pw.TextStyle(fontSize: 7),
),
],
),
),
// QR Code compatto
pw.BarcodeWidget(
barcode: pw.Barcode.qrCode(),
data: ticket.id!,
width: 20,
height: 20,
),
],
);
},
),
);
return pdf;
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/tracking/data/tracking_repository.dart';
import 'package:flux/features/tracking/models/tracking_model.dart';
import 'package:get_it/get_it.dart';
// Stati base: initial, loading, loaded, error
class TrackingState {
final bool isLoading;
final List<TrackingModel> logs;
TrackingState({this.isLoading = false, this.logs = const []});
}
class TrackingCubit extends Cubit<TrackingState> {
final TrackingRepository _repo = GetIt.I.get<TrackingRepository>();
final String parentId;
final TrackingParentType parentType;
final String companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
TrackingCubit({required this.parentId, required this.parentType})
: super(TrackingState()) {
loadTrackings();
}
Future<void> loadTrackings() async {
emit(TrackingState(isLoading: true, logs: state.logs));
final trackings = await _repo.getTrackingsByParent(
parentId: parentId,
parentType: parentType,
);
emit(TrackingState(isLoading: false, logs: trackings));
}
Future<void> addManualNote(
String message,
bool isInternal, {
String? staffId,
}) async {
// Aggiungiamo un feedback visivo immediato (Optimistic UI) se vogliamo,
// oppure semplicemente mostriamo il loading
await _repo.logQuickEvent(
companyId: companyId,
message: message,
type: TrackingType.manualNote,
parentId: parentId!,
parentType: parentType,
staffId: staffId,
isInternal: isInternal,
);
// Ricarichiamo la lista fresca dal server
await loadTrackings();
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flux/features/tracking/models/tracking_model.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class TrackingRepository {
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
TrackingRepository();
/// Recupera la cronologia di un'entità (Ticket o Operazione)
Future<List<TrackingModel>> getTrackingsByParent({
required String parentId, // <-- Reso obbligatorio
required TrackingParentType parentType, // <-- Reso obbligatorio
}) async {
// Facciamo la query con la JOIN per recuperare il nome dello staff al volo
final response = await _supabase
.from('tracking')
.select('*, staff_member(name)')
.eq('parent_id', parentId)
.eq('parent_type', parentType.name)
.order(
'created_at',
ascending: true,
); // ascending: true per avere la timeline dall'alto (vecchi) al basso (nuovi)
return response.map((map) => TrackingModel.fromMap(map)).toList();
}
/// Inserisce un nuovo evento di tracking
Future<void> logEvent(TrackingModel tracking) async {
await _supabase.from('tracking').insert(tracking.toMap());
}
/// Metodo helper rapido per loggare un cambio di stato o una nota
Future<void> logQuickEvent({
required String companyId,
required String message,
required TrackingType type,
required String parentId, // <-- Reso obbligatorio
required TrackingParentType parentType, // <-- Reso obbligatorio
String? staffId,
bool isInternal = true,
}) async {
final log = TrackingModel(
createdAt:
DateTime.now(), // Questo verrà ignorato dal toMap in fase di insert, ma serve al modello
companyId: companyId,
staffId: staffId,
parentId: parentId,
parentType: parentType,
eventType: type,
isInternal: isInternal,
message: message,
);
await logEvent(log);
}
}

View File

@@ -0,0 +1,132 @@
import 'package:equatable/equatable.dart';
enum TrackingType {
statusChange,
manualNote,
systemAlert,
customerContact,
assignment;
static TrackingType fromString(String value) {
return TrackingType.values.firstWhere(
(e) => e.name == value,
orElse: () => TrackingType.manualNote,
);
}
}
enum TrackingParentType {
ticket,
operation;
String get value => name;
static TrackingParentType fromString(String val) {
return TrackingParentType.values.firstWhere(
(e) => e.name == val,
orElse: () => TrackingParentType.ticket, // Default di sicurezza
);
}
}
class TrackingModel extends Equatable {
final String? id;
final DateTime createdAt;
final String companyId;
final String? staffId;
final String? staffName; // Per non fare mille join, lo prendiamo dal repo
final String parentId;
final TrackingParentType parentType;
final TrackingType eventType;
final bool isInternal;
final String message;
const TrackingModel({
this.id,
required this.createdAt,
required this.companyId,
this.staffId,
this.staffName,
required this.parentId,
required this.parentType,
required this.eventType,
required this.isInternal,
required this.message,
});
TrackingModel copyWith({
String? id,
DateTime? createdAt,
String? companyId,
String? staffId,
String? staffName,
TrackingParentType? parentType,
String? parentId,
TrackingType? eventType,
bool? isInternal,
String? message,
}) {
return TrackingModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
companyId: companyId ?? this.companyId,
staffId: staffId ?? this.staffId,
staffName: staffName ?? this.staffName,
parentId: parentId ?? this.parentId,
parentType: parentType ?? this.parentType,
eventType: eventType ?? this.eventType,
isInternal: isInternal ?? this.isInternal,
message: message ?? this.message,
);
}
factory TrackingModel.fromMap(Map<String, dynamic> map) {
return TrackingModel(
id: map['id'],
createdAt: DateTime.parse(map['created_at']),
companyId: map['company_id'],
staffId: map['staff_id'],
staffName: map['staff_member']?['name'], // Se fai la join su staff_member
parentId: map['parent_id'] as String,
parentType: TrackingParentType.fromString(map['parent_type']),
eventType: TrackingType.fromString(map['event_type']),
isInternal: map['is_internal'] ?? true,
message: map['message'],
);
}
Map<String, dynamic> toMap() {
final map = <String, dynamic>{
'company_id': companyId,
'staff_id': staffId,
'parent_id': parentId,
'parent_type': parentType.name,
'event_type': eventType.name,
'is_internal': isInternal,
'message': message,
};
// Aggiungiamo id e data SOLO se stiamo aggiornando un record esistente.
// In fase di creazione (insert), li omettiamo così Supabase usa i valori di default (gen_random_uuid e now()).
if (id != null) {
map['id'] = id;
map['created_at'] = createdAt.toIso8601String();
}
return map;
}
@override
List<Object?> get props => [
id,
createdAt,
companyId,
staffId,
staffName,
parentId,
parentType,
eventType,
isInternal,
message,
];
}

View File

@@ -1,88 +0,0 @@
// File generated by FlutterFire CLI.
// ignore_for_file: type=lint
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps.
///
/// Example:
/// ```dart
/// import 'firebase_options.dart';
/// // ...
/// await Firebase.initializeApp(
/// options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
return web;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
case TargetPlatform.macOS:
return macos;
case TargetPlatform.windows:
return windows;
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions web = FirebaseOptions(
apiKey: 'AIzaSyA8vQbyEt81DoAuRVDc_3W_VIKY-9F-XTw',
appId: '1:872447580790:web:10745e7f9afb447d5d9d57',
messagingSenderId: '872447580790',
projectId: 'assistenza-catelli',
authDomain: 'assistenza-catelli.firebaseapp.com',
storageBucket: 'assistenza-catelli.firebasestorage.app',
measurementId: 'G-HTSSNQJ15P',
);
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyBSxpdLDlPnN0xjejlX_5JL19BDeSzKOr8',
appId: '1:872447580790:android:a1d8d57960451f935d9d57',
messagingSenderId: '872447580790',
projectId: 'assistenza-catelli',
storageBucket: 'assistenza-catelli.firebasestorage.app',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyCkjOTW6BlckKIxQdp5TPnHuRfXFoVC3bY',
appId: '1:872447580790:ios:a87d56c718aa61e05d9d57',
messagingSenderId: '872447580790',
projectId: 'assistenza-catelli',
storageBucket: 'assistenza-catelli.firebasestorage.app',
iosBundleId: 'com.catellisrl.flux',
);
static const FirebaseOptions macos = FirebaseOptions(
apiKey: 'AIzaSyCkjOTW6BlckKIxQdp5TPnHuRfXFoVC3bY',
appId: '1:872447580790:ios:a87d56c718aa61e05d9d57',
messagingSenderId: '872447580790',
projectId: 'assistenza-catelli',
storageBucket: 'assistenza-catelli.firebasestorage.app',
iosBundleId: 'com.catellisrl.flux',
);
static const FirebaseOptions windows = FirebaseOptions(
apiKey: 'AIzaSyA5uJhb8ksqKqdEWbMD5ra6JYXIGoaIdIM',
appId: '1:872447580790:web:3b1623eda6abdac75d9d57',
messagingSenderId: '872447580790',
projectId: 'assistenza-catelli',
authDomain: 'assistenza-catelli.firebaseapp.com',
storageBucket: 'assistenza-catelli.firebasestorage.app',
measurementId: 'G-J8LZTQ9NHB',
);
}

View File

@@ -1,14 +1,18 @@
import 'dart:io';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flux/features/attachments/data/attachments_repository.dart';
import 'package:flux/features/auth/bloc/auth_cubit.dart';
import 'package:flux/features/company/data/company_repository.dart';
import 'package:flux/features/operations/blocs/operation_list_cubit.dart';
import 'package:flux/features/operations/data/operations_repository.dart';
import 'package:flux/firebase_options.dart';
import 'package:flux/features/settings/blocs/settings_cubit.dart';
import 'package:flux/features/settings/document_sequence/data/document_sequence_repository.dart';
import 'package:flux/features/tickets/data/ticket_repository.dart';
import 'package:flux/features/tracking/data/tracking_repository.dart';
import 'package:flux/l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
@@ -29,8 +33,8 @@ import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/master_data/staff/data/staff_repository.dart';
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
import 'package:flux/features/master_data/store/data/store_repository.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/settings/settings.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -38,7 +42,8 @@ void main() async {
// Inizializza le dipendenze PRIMA di lanciare l'app
await setupLocator();
// RIMUOVE IL CARATTERE # DAGLI URL WEB!
usePathUrlStrategy();
runApp(
MultiBlocProvider(
providers: [
@@ -53,9 +58,15 @@ void main() async {
BlocProvider<StoreCubit>(create: (_) => StoreCubit()),
BlocProvider<CustomersCubit>(create: (_) => CustomersCubit()),
BlocProvider<ProductsCubit>(create: (_) => ProductsCubit()),
BlocProvider<StaffCubit>(create: (_) => StaffCubit()),
BlocProvider<OperationsCubit>(create: (_) => OperationsCubit()),
BlocProvider<StaffCubit>(
create: (_) => StaffCubit()
..loadStaffForStore(
GetIt.I.get<SessionCubit>().state.currentStore!.id!,
),
),
BlocProvider<OperationListCubit>(create: (_) => OperationListCubit()),
BlocProvider<ProvidersCubit>(create: (_) => ProvidersCubit()),
BlocProvider<SettingsCubit>(create: (_) => SettingsCubit()),
],
child: const FluxApp(),
),
@@ -94,6 +105,10 @@ Future<void> setupLocator() async {
getIt.registerLazySingleton<AttachmentsRepository>(
() => AttachmentsRepository(),
);
getIt.registerLazySingleton<TicketRepository>(() => TicketRepository());
getIt.registerLazySingleton<DocumentSequenceRepository>(
() => DocumentSequenceRepository(),
);
// NOTA: CompanyRepository l'ho tolto perché la logica della Company
// ora è gestita dal CoreRepository durante l'Onboarding.
@@ -104,9 +119,8 @@ Future<void> setupLocator() async {
getIt.registerSingleton<SessionCubit>(
SessionCubit(getIt<CoreRepository>(), getIt<SharedPreferences>()),
);
//TODO rimuovere dopo gli import
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
getIt.registerLazySingleton<CompanyRepository>(() => CompanyRepository());
getIt.registerLazySingleton<TrackingRepository>(() => TrackingRepository());
}
class FluxApp extends StatefulWidget {

View File

@@ -1,336 +0,0 @@
import 'dart:convert';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
Future<void> migrateCustomersToSupabase() async {
// 1. IL TUO COMPANY ID REALE SU SUPABASE
// Vai nel database Supabase, copia l'UUID della tua azienda e incollalo qui
final String myRealCompanyId = '6c4b2323-2a60-4d33-bf21-c5a8eb6b4a5b';
try {
print("Inizio download modello da Firebase...");
// 2. Scarichiamo TUTTI i clienti da Firebase
final snapshot = await FirebaseFirestore.instance.collection('marca').get();
if (snapshot.docs.isEmpty) {
print("Nessun marca trovato su Firebase!");
return;
}
// Questa lista conterrà i dati formattati pronti per Supabase
List<Map<String, dynamic>> supabaseBrands = [];
// 3. Cicliamo i documenti di Firebase e li trasformiamo
for (var doc in snapshot.docs) {
final data = doc.data();
// Creiamo la riga per Supabase
supabaseBrands.add({
'legacy_id': doc.id, // L'ID vecchio di Firebase
//'company_id': myRealCompanyId, // ECCO IL TUO COMPANY ID!
// Mappa i campi (attento a far combaciare i nomi esatti delle colonne Supabase!)
'name': (data['nome'] as String).trim().toLowerCase(),
'company_id': myRealCompanyId,
// Se avevi una data di creazione su Firebase, convertila, altrimenti ignorala
// e Supabase userà il suo 'default now()'
// 'created_at': (data['createdAt'] as Timestamp?)?.toDate().toIso8601String(),
});
}
print("Sto per inviare ${supabaseBrands.length} brand a Supabase...");
// 4. Invio a Supabase con UPSERT
await Supabase.instance.client
.from('brand')
.upsert(
supabaseBrands,
onConflict:
'legacy_id', // Se il legacy_id c'è già, aggiorna invece di duplicare
);
print("BOOM! Migrazione brand completata con successo! 🚀");
} catch (e) {
print("Porca miseria, errore durante la migrazione: $e");
}
}
Future<void> migrateModelsToSupabase() async {
final String myRealCompanyId = '6c4b2323-2a60-4d33-bf21-c5a8eb6b4a5b';
try {
print("Inizio migrazione Modelli...");
// ==========================================================
// FASE 1: CREAZIONE DEL DIZIONARIO DI TRADUZIONE (LA MAGIA)
// ==========================================================
print("Scarico i Brand da Supabase per tradurre gli ID...");
// Chiediamo a Supabase solo 2 colonne: il nuovo UUID e il vecchio ID di Firebase
final List<dynamic> brandResponse = await Supabase.instance.client
.from('brand')
.select('id, legacy_id');
// Creiamo la mappa: la chiave è il vecchio ID, il valore è il nuovo UUID
Map<String, String> brandTranslationMap = {};
for (var b in brandResponse) {
if (b['legacy_id'] != null) {
brandTranslationMap[b['legacy_id']] = b['id'];
}
}
print("Dizionario pronto! Trovati ${brandTranslationMap.length} Brand.");
// ==========================================================
// FASE 2: SCARICAMENTO E TRADUZIONE DEI MODELLI
// ==========================================================
final snapshot = await FirebaseFirestore.instance
.collection('modello')
.get(); // Controlla il nome esatto della collection!
if (snapshot.docs.isEmpty) {
print("Nessun modello trovato su Firebase!");
return;
}
List<Map<String, dynamic>> supabaseModels = [];
for (var doc in snapshot.docs) {
final data = doc.data();
// 1. Prendiamo il vecchio ID del brand salvato su Firebase
String? oldFirebaseBrandId = data['idMarca'];
// 2. TRADUZIONE ISTANTANEA! Cerchiamo il nuovo UUID nel nostro dizionario
String? newSupabaseBrandUuid;
if (oldFirebaseBrandId != null) {
newSupabaseBrandUuid = brandTranslationMap[oldFirebaseBrandId];
}
// 3. Controllo di sicurezza: se il brand non esiste su Supabase, saltiamo il record o mettiamo null?
// Se nella tua tabella 'model' il 'brand_id' NON PUÒ essere null, devi per forza avere un match!
if (newSupabaseBrandUuid == null && oldFirebaseBrandId != null) {
print(
"ATTENZIONE: Il modello ${data['nome']} ha un brand_id ($oldFirebaseBrandId) che non esiste su Supabase. Salto o metto null.",
);
continue; // Decommenta questo se vuoi saltare i modelli orfani
}
// Creiamo la riga per Supabase
supabaseModels.add({
'legacy_id': doc.id,
// ECCO LA CHIAVE ESTERNA TRADOTTA!
'brand_id': newSupabaseBrandUuid,
// Mappa gli altri campi
'name': (data['nome'] as String).trim().toLowerCase(),
'name_with_brand': (data['nomeConMarca'] as String)
.toLowerCase()
.trim(),
});
}
// ==========================================================
// FASE 3: INVIO A SUPABASE
// ==========================================================
print("Sto per inviare ${supabaseModels.length} modelli a Supabase...");
await Supabase.instance.client
.from('model')
.upsert(supabaseModels, onConflict: 'legacy_id');
print("BOOM! Migrazione modelli completata con successo! 🚀");
} catch (e) {
print("Errore durante la migrazione dei modelli: $e");
}
}
class TicketMigrationScript {
final SupabaseClient supabase = Supabase.instance.client;
final String companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
final String storeId = GetIt.I.get<SessionCubit>().state.currentStore!.id!;
/// Esegui questa funzione passandole la stringa JSON grezza (es. copiata da un file)
/// e l'ID della tua Company su Supabase (visto che Firebase non lo aveva).
Future<void> runMigration(String jsonString) async {
debugPrint('🚀 INIZIO MIGRAZIONE TICKET...');
try {
// 1. Parsing del JSON
final Map<String, dynamic> decoded = jsonDecode(jsonString);
// Scendiamo al piano di sotto, direttamente nella "pancia" dei dati!
final Map<String, dynamic> rawData = decoded['data'];
debugPrint('Trovati ${rawData.length} elementi alla radice.');
if (rawData.isNotEmpty) {
debugPrint(
'Il primo elemento contiene: ${rawData.entries.first.value}',
);
}
// 2. CREAZIONE DELLA CACHE (IL TRUCCO PER NON IMPAZZIRE CON LE JOIN)
debugPrint('📥 Scarico le mappe dei legacy_id da Supabase...');
final customersRes = await supabase
.from('customer')
.select('id, legacy_id')
.not('legacy_id', 'is', null);
final modelsRes = await supabase
.from('model')
.select('id, legacy_id')
.not('legacy_id', 'is', null);
// Creiamo i dizionari: chiave = legacy_id (Firebase), valore = uuid (Supabase)
final Map<String, String> customerMap = {
for (var row in customersRes)
if (row['legacy_id'] != null)
row['legacy_id'].toString(): row['id'].toString(),
};
final Map<String, String> modelMap = {
for (var row in modelsRes)
if (row['legacy_id'] != null)
row['legacy_id'].toString(): row['id'].toString(),
};
debugPrint(
'✅ Mappe pronte: ${customerMap.length} clienti, ${modelMap.length} modelli.',
);
// 3. MAPPATURA DEI DATI
List<Map<String, dynamic>> ticketsToInsert = [];
for (var entry in rawData.entries) {
final data = entry.value as Map<String, dynamic>;
// Recuperiamo le relazioni usando i nostri dizionari
final String? customerId = customerMap[data['idCliente']];
final String? modelId = modelMap[data['idModello']];
// Se non troviamo il cliente o il modello, magari loggiamo e saltiamo (o mettiamo null)
// Per ora li mettiamo null, ma almeno non spacca il DB
// Risoluzione Date
DateTime? createdAt = _parseFirebaseDate(data['dataAperturaScheda']);
//DateTime? closedAt = _parseFirebaseDate(data['dataChiusuraScheda']);
//DateTime? returnedAt = _parseFirebaseDate(data['dataRiconsegnaCliente']);
// Costruzione del Ticket
ticketsToInsert.add({
'legacy_id': data['fsId'], // Il vecchio ID del doc Firebase
'company_id': companyId,
'store_id': storeId,
'customer_id': customerId,
'target_model_id': modelId,
'target_sn': data['seriale'] ?? '',
'customer_price': data['costoTotaleCliente'] ?? 0.0,
'internal_cost': data['costoTotaleNostro'] ?? 0.0,
'created_at':
createdAt?.toUtc().toIso8601String() ??
DateTime.now().toUtc().toIso8601String(),
//'closed_at': closedAt?.toUtc().toIso8601String(),
//'returned_at': returnedAt?.toUtc().toIso8601String(),
'request': (data['guasto']?.toString() ?? ''),
'included_accessories': data['accessoriConsegnati'],
'public_notes': data['note'],
'internal_notes': data['noteInterne'],
'resolution_notes':
data['operazioneEffettuata'], // Il nuovo campo di cui parlavamo!
'alternative_phone_number': data['recapitoCliente'],
'has_courtesy_device': data['prestatoMuletto'] ?? false,
// Mappatura Enums
'ticket_type': _mapTicketType(data),
'ticket_status': _mapTicketStatus(data),
'ticket_result': _mapTicketResult(data['risultato']),
// 'warranty_type': _mapWarranty(data['nomeTipoGaranzia']), // De-commenta se hai la logica pronta
});
}
// 4. INSERIMENTO BATCH (A botte di 100 per non far arrabbiare Postgres)
debugPrint(
'🚀 Inizio inserimento di ${ticketsToInsert.length} ticket su Supabase...',
);
const int batchSize = 100;
for (int i = 0; i < ticketsToInsert.length; i += batchSize) {
final end = (i + batchSize < ticketsToInsert.length)
? i + batchSize
: ticketsToInsert.length;
final batch = ticketsToInsert.sublist(i, end);
await supabase.from('ticket').insert(batch);
debugPrint('✅ Inseriti ticket da $i a $end');
}
debugPrint('🎉 MIGRAZIONE COMPLETATA CON SUCCESSO!');
} catch (e, stacktrace) {
debugPrint('❌ ERRORE DURANTE LA MIGRAZIONE: $e');
debugPrint(stacktrace.toString());
}
}
// --- FUNZIONI DI AIUTO (PARSER E MAPPER) ---
/// Estrae la data dalla fastidiosa struttura {"__time__": "..."} di Firestore export
DateTime? _parseFirebaseDate(dynamic dateData) {
if (dateData == null) return null;
if (dateData is Map && dateData.containsKey('__time__')) {
return DateTime.tryParse(dateData['__time__'].toString());
}
if (dateData is String) {
return DateTime.tryParse(dateData);
}
return null;
}
/// Converte i boolean di Firebase nel tuo Enum TicketType
String _mapTicketType(Map<String, dynamic> data) {
if (data['tipoLavorazionePassaggioDati'] == true) return 'data_transfer';
if (data['tipoLavorazioneRiparazione'] == true) return 'repair';
if (data['tipoLavorazioneConfigurazione'] == true) return 'software_setup';
return 'other'; // Include tipoLavorazioneAltro o fallback
}
/// Converte la logica di stato di Firebase nel tuo Enum TicketStatus
String _mapTicketStatus(Map<String, dynamic> data) {
// Se è stato riconsegnato al cliente o ritirato, è chiuso/consegnato
if (data['riconsegnato'] == true ||
data['nomeStatoScheda'] == 'Ritirato da cliente') {
return 'closed'; // o 'closed', in base alla tua logica
}
// Altrimenti valutiamo le stringhe
final String statoFirebase =
data['nomeStatoScheda']?.toString().toLowerCase() ?? '';
if (statoFirebase.contains('accettazione')) return 'open';
if (statoFirebase.contains('da inviare centro esterno'))
return 'waiting_for_shipping';
if (statoFirebase.contains('attesa ricambi')) return 'waiting_for_parts';
if (statoFirebase.contains('pronto')) return 'ready';
if (data['daLavorare'] == true) return 'in_progress';
return 'closed'; // Fallback
}
String? _mapTicketResult(dynamic risultato) {
if (risultato == null || risultato.toString().isEmpty) return null;
final r = risultato.toString().toUpperCase();
if (r == 'OK') return 'success';
if (r == 'KO' || r == 'NON RIPARATO') return 'failure';
return null;
}
}

View File

@@ -8,6 +8,7 @@
#include <file_selector_linux/file_selector_plugin.h>
#include <gtk/gtk_plugin.h>
#include <printing/printing_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
@@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);
g_autoptr(FlPluginRegistrar) printing_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
printing_plugin_register_with_registrar(printing_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
gtk
printing
url_launcher_linux
)

View File

@@ -6,23 +6,19 @@ import FlutterMacOS
import Foundation
import app_links
import cloud_firestore
import file_picker
import file_selector_macos
import firebase_auth
import firebase_core
import pdfx
import printing
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
PdfxPlugin.register(with: registry.registrar(forPlugin: "PdfxPlugin"))
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

File diff suppressed because it is too large Load Diff

View File

@@ -23,5 +23,7 @@
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.print</key>
<true/>
</dict>
</plist>

View File

@@ -18,6 +18,8 @@
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.print</key>
<true/>
</dict>
</plist>

View File

@@ -1,14 +1,6 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: bda3b7b55958bfd867addc40d067b4b11f7b8846d57671f5b5a6e7f9a56fe3ad
url: "https://pub.dev"
source: hosted
version: "1.3.69"
app_links:
dependency: transitive
description:
@@ -113,30 +105,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
cloud_firestore:
dependency: "direct main"
description:
name: cloud_firestore
sha256: "3ac242332166ae5037bd87bc343744bb96d88d7b13f791492b00958ce5cc6c63"
url: "https://pub.dev"
source: hosted
version: "6.3.0"
cloud_firestore_platform_interface:
dependency: transitive
description:
name: cloud_firestore_platform_interface
sha256: "1bd08b736e1015e8bf5448f5ef67b2087a2380c2c1c7972f8403c1c7b41f5359"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
cloud_firestore_web:
dependency: transitive
description:
name: cloud_firestore_web
sha256: "18617275ffa2331d3ea058c515ef218bcce2ae13a14bee922563ca6ae2507c26"
url: "https://pub.dev"
source: hosted
version: "5.3.0"
code_assets:
dependency: transitive
description:
@@ -273,54 +241,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
firebase_auth:
dependency: "direct main"
description:
name: firebase_auth
sha256: b12cb1e2e87797d27e0041100b73ebf890dbafcff2e7e991d4593f5e8e309808
url: "https://pub.dev"
source: hosted
version: "6.4.0"
firebase_auth_platform_interface:
dependency: transitive
description:
name: firebase_auth_platform_interface
sha256: c71517b3c78480be42789b05316a7692d69296c17848bd6a9e798300abae1ec7
url: "https://pub.dev"
source: hosted
version: "8.1.9"
firebase_auth_web:
dependency: transitive
description:
name: firebase_auth_web
sha256: "52b0224eb46b09f387e99710707be2d3f48da67c74fe14202e4b942cbe8ce9fd"
url: "https://pub.dev"
source: hosted
version: "6.1.5"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: d5a94b884dcb1e6d3430298e94bfe002238094cdfd5e29202d536ee2120f9158
url: "https://pub.dev"
source: hosted
version: "4.7.0"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: dc5096257cd67292d34d78ceeb90836f02a4be921b5f3934311a02bb2376118c
url: "https://pub.dev"
source: hosted
version: "3.6.0"
fixnum:
dependency: transitive
description:
@@ -385,10 +305,18 @@ packages:
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
font_awesome_flutter:
dependency: "direct main"
description:
name: font_awesome_flutter
sha256: "09dcde8ab90ffae1a7d65ff2ef96fc62a17ad9d0ce7c127b317ded676b0d5935"
url: "https://pub.dev"
source: hosted
version: "11.0.0"
functions_client:
dependency: transitive
description:
@@ -757,6 +685,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.12.0"
pdf_widget_wrapper:
dependency: transitive
description:
name: pdf_widget_wrapper
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
url: "https://pub.dev"
source: hosted
version: "1.0.4"
pdfx:
dependency: "direct main"
description:
@@ -869,6 +805,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.7.0"
printing:
dependency: "direct main"
description:
name: printing
sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692"
url: "https://pub.dev"
source: hosted
version: "5.14.3"
provider:
dependency: transitive
description:
@@ -1107,7 +1051,7 @@ packages:
source: hosted
version: "1.1.0"
url_launcher:
dependency: transitive
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8

View File

@@ -12,6 +12,8 @@ dependencies:
file_picker: ^11.0.2
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter
flutter_localizations:
sdk: flutter
flutter_bloc: ^9.1.1
@@ -31,9 +33,9 @@ dependencies:
uuid: ^4.5.3
pdf: ^3.12.0
universal_io: ^2.3.1
firebase_core: ^4.7.0
firebase_auth: ^6.4.0
cloud_firestore: ^6.3.0
url_launcher: ^6.3.2
printing: ^5.14.3
font_awesome_flutter: ^11.0.0
dev_dependencies:
flutter_test:
@@ -47,5 +49,4 @@ flutter:
assets:
- assets/images/
- assets/svg/
- assets/schedeRiparazione-1778021345.json
- .env

1
web/_redirects Normal file
View File

@@ -0,0 +1 @@
/* /index.html 200

View File

View File

@@ -7,29 +7,23 @@
#include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.h>
#include <cloud_firestore/cloud_firestore_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <pdfx/pdfx_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <printing/printing_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
CloudFirestorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("CloudFirestorePluginCApi"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseAuthPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
PdfxPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PdfxPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
PrintingPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PrintingPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@@ -4,12 +4,10 @@
list(APPEND FLUTTER_PLUGIN_LIST
app_links
cloud_firestore
file_selector_windows
firebase_auth
firebase_core
pdfx
permission_handler_windows
printing
url_launcher_windows
)