feat-tickets #14
38
.gitea/workflows/deploy.yml
Normal file
38
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Deploy to Cloudflare Pages
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main # O il nome del tuo branch principale
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest # O il runner configurato sul tuo Gitea
|
||||||
|
steps:
|
||||||
|
- name: Checkout del codice
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: 'stable'
|
||||||
|
|
||||||
|
- name: Crea il file .env per flutter_dotenv
|
||||||
|
run: |
|
||||||
|
echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> .env
|
||||||
|
echo "SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}" >> .env
|
||||||
|
|
||||||
|
- name: Ottieni le dipendenze
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Compila per il Web
|
||||||
|
run: flutter build web --release
|
||||||
|
|
||||||
|
- name: Crea file _redirects per Cloudflare
|
||||||
|
run: echo "/* /index.html 200" > build/web/_redirects
|
||||||
|
|
||||||
|
- name: Deploy su Cloudflare Pages
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
# QUESTA È LA RIGA MAGICA: Gli diciamo di prendere SOLO la cartella build/web
|
||||||
|
run: npx wrangler@latest pages deploy build/web --project-name Flux --branch main
|
||||||
@@ -24,11 +24,11 @@
|
|||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter android:label="flux_deep_link">
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="fluxapp" />
|
<data android:scheme="flux" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://operations.gradle.org/distributions/gradle-8.14-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import 'package:flux/core/data/core_repository.dart';
|
|||||||
import 'package:flux/core/layout/app_shell.dart';
|
import 'package:flux/core/layout/app_shell.dart';
|
||||||
import 'package:flux/core/utils/extensions.dart';
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
import 'package:flux/core/widgets/set_password_screen.dart';
|
import 'package:flux/core/widgets/set_password_screen.dart';
|
||||||
|
import 'package:flux/core/widgets/shared_forms/mobile_upload_screen.dart';
|
||||||
|
import 'package:flux/core/widgets/shared_forms/upload_success_screen.dart';
|
||||||
import 'package:flux/features/auth/ui/auth_screen.dart';
|
import 'package:flux/features/auth/ui/auth_screen.dart';
|
||||||
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
|
|
||||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
||||||
import 'package:flux/features/customers/models/customer_model.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_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/customers/ui/customers_content.dart';
|
||||||
import 'package:flux/features/home/ui/home_screen.dart';
|
import 'package:flux/features/home/ui/home_screen.dart';
|
||||||
import 'package:flux/features/master_data/master_data_hub_content.dart';
|
import 'package:flux/features/master_data/master_data_hub_content.dart';
|
||||||
@@ -24,11 +24,15 @@ 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/master_data/store/ui/stores_screen.dart';
|
||||||
import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
|
import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
|
||||||
import 'package:flux/features/onboarding/ui/onboarding_screen.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/models/operation_model.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_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/operations_screen.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:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -146,34 +150,85 @@ class AppRouter {
|
|||||||
builder: (context, state) =>
|
builder: (context, state) =>
|
||||||
const CustomersContent(), // O come si chiama il tuo widget della lista!
|
const CustomersContent(), // O come si chiama il tuo widget della lista!
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/tickets',
|
||||||
|
builder: (context, state) => BlocProvider(
|
||||||
|
create: (context) => TicketListCubit(),
|
||||||
|
child: const TicketListScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
|
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/customer/:id',
|
// Il path sarà es. /tickets/form/123 oppure /tickets/form/new
|
||||||
|
path: '/tickets/form/:id',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final customer = state.extra as CustomerModel;
|
// 1. Leggiamo l'ID dall'URL
|
||||||
return BlocProvider(
|
final String pathId = state.pathParameters['id'] ?? 'new';
|
||||||
create: (context) => CustomerFilesBloc(customer.id!),
|
|
||||||
child: CustomerDetailScreen(customer: customer),
|
// 2. Leggiamo l'oggetto dalla RAM (se arriviamo da un tap interno all'app)
|
||||||
|
final TicketModel? ticketFromExtra = state.extra as TicketModel?;
|
||||||
|
|
||||||
|
// 3. Capiamo se è un nuovo ticket o una modifica
|
||||||
|
final String? realTicketId = pathId == 'new' ? null : pathId;
|
||||||
|
context.read<StaffCubit>().loadStaffForStore(
|
||||||
|
GetIt.I.get<SessionCubit>().state.currentStore!.id!,
|
||||||
|
);
|
||||||
|
context.read<CustomersCubit>().loadCustomers();
|
||||||
|
|
||||||
|
return MultiBlocProvider(
|
||||||
|
providers: [
|
||||||
|
BlocProvider(
|
||||||
|
create: (context) => AttachmentsBloc(
|
||||||
|
parentType: AttachmentParentType.ticket,
|
||||||
|
parentId: realTicketId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlocProvider(create: (context) => TicketFormCubit()),
|
||||||
|
],
|
||||||
|
|
||||||
|
child: TicketFormScreen(
|
||||||
|
ticketId: realTicketId,
|
||||||
|
existingTicket: ticketFromExtra,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
path: '/upload-success',
|
||||||
|
builder: (context, state) => const UploadSuccessScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/customer/:id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final customer = state.extra as CustomerModel;
|
||||||
|
return BlocProvider(
|
||||||
|
create: (context) => AttachmentsBloc(
|
||||||
|
parentType: AttachmentParentType.customer,
|
||||||
|
parentId: customer.id,
|
||||||
|
),
|
||||||
|
child: CustomerDetailScreen(customer: customer),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/* GoRoute(
|
||||||
path: '/customer/:id/upload',
|
path: '/customer/:id/upload',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final customerId = state.pathParameters['id']!;
|
final customerId = state.pathParameters['id']!;
|
||||||
final customerName = state.uri.queryParameters['name'] ?? 'Cliente';
|
final customerName = state.uri.queryParameters['name'] ?? 'Cliente';
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (context) => CustomerFilesBloc(customerId),
|
create: (context) => AttachmentsBloc(
|
||||||
child: CustomerMobileUploadScreen(
|
parentType: AttachmentParentType.customer,
|
||||||
customerId: customerId,
|
parentId: customerId,
|
||||||
customerName: customerName,
|
),
|
||||||
|
child: SharedMobileUploadScreen(
|
||||||
|
title: 'Aggiungi allegati al cliente $customerName',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
), */
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/operation-form',
|
path: '/operation-form',
|
||||||
name: 'operation-form',
|
name: 'operation-form',
|
||||||
@@ -194,8 +249,9 @@ class AppRouter {
|
|||||||
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
|
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
|
||||||
|
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (context) => OperationFilesBloc(
|
create: (context) => AttachmentsBloc(
|
||||||
operationId: operationId ?? existingOperation?.id,
|
parentId: operationId ?? existingOperation?.id,
|
||||||
|
parentType: AttachmentParentType.operation,
|
||||||
),
|
),
|
||||||
child: OperationFormScreen(
|
child: OperationFormScreen(
|
||||||
operationId: operationId ?? existingOperation?.id,
|
operationId: operationId ?? existingOperation?.id,
|
||||||
@@ -204,7 +260,7 @@ class AppRouter {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
/* GoRoute(
|
||||||
path: '/operation/:id/upload',
|
path: '/operation/:id/upload',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final operationId = state.pathParameters['id']!;
|
final operationId = state.pathParameters['id']!;
|
||||||
@@ -223,10 +279,35 @@ class AppRouter {
|
|||||||
context.read<ProductsCubit>().loadBrands();
|
context.read<ProductsCubit>().loadBrands();
|
||||||
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
|
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (context) => OperationFilesBloc(operationId: operationId),
|
create: (context) => AttachmentsBloc(
|
||||||
child: OperationMobileUploadScreen(
|
parentId: operationId,
|
||||||
operationId: operationId,
|
parentType: AttachmentParentType.operation,
|
||||||
operationName: operationName,
|
),
|
||||||
|
child: SharedMobileUploadScreen(
|
||||||
|
title: 'Aggiungi allegati alla pratica $operationName',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
), */
|
||||||
|
GoRoute(
|
||||||
|
path: '/upload/:type/:id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final typeString = state.pathParameters['type']!;
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
|
||||||
|
// 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 BlocProvider(
|
||||||
|
create: (context) =>
|
||||||
|
AttachmentsBloc(parentId: id, parentType: parentType),
|
||||||
|
child: const SharedMobileUploadScreen(
|
||||||
|
title: 'Caricamento Rapido',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ import 'package:file_picker/file_picker.dart';
|
|||||||
import 'package:flux/features/attachments/data/attachments_repository.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/attachment_viewer_screen.dart';
|
||||||
import 'package:flux/features/attachments/ui/quick_rename_dialog.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:get_it/get_it.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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:flux/features/attachments/models/attachment_model.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
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
|
import 'package:pdfx/pdfx.dart' as px; // Isoliamo pdfx
|
||||||
|
|
||||||
class _ExportItem {
|
class _ExportItem {
|
||||||
@@ -30,16 +28,24 @@ class _ExportItem {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class OperationFilesSection extends StatefulWidget {
|
class SharedAttachmentsSection extends StatefulWidget {
|
||||||
final OperationModel currentOp;
|
final String? parentId;
|
||||||
|
final String customerDisplayName;
|
||||||
|
final AttachmentParentType parentType;
|
||||||
|
|
||||||
const OperationFilesSection({super.key, required this.currentOp});
|
const SharedAttachmentsSection({
|
||||||
|
super.key,
|
||||||
|
this.parentId,
|
||||||
|
this.customerDisplayName = 'Cliente_sconosciuto',
|
||||||
|
required this.parentType,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<OperationFilesSection> createState() => _OperationFilesSectionState();
|
State<SharedAttachmentsSection> createState() =>
|
||||||
|
_SharedAttachmentsSectionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OperationFilesSectionState extends State<OperationFilesSection> {
|
class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
||||||
String? _exportDirectory;
|
String? _exportDirectory;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -89,16 +95,14 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
|
|||||||
|
|
||||||
if (result != null && mounted) {
|
if (result != null && mounted) {
|
||||||
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
|
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
|
||||||
context.read<OperationFilesBloc>().add(
|
context.read<AttachmentsBloc>().add(AddAttachmentsEvent(result.files));
|
||||||
AddOperationFilesEvent(result.files),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- APERTURA VIEWER ---
|
// --- APERTURA VIEWER ---
|
||||||
void _openFile(AttachmentModel file) {
|
void _openFile(AttachmentModel file) {
|
||||||
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
|
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
|
||||||
final operationFilesBloc = context.read<OperationFilesBloc>();
|
final operationFilesBloc = context.read<AttachmentsBloc>();
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -108,10 +112,10 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
|
|||||||
attachment: file,
|
attachment: file,
|
||||||
onRename: (newName) {
|
onRename: (newName) {
|
||||||
// Spara l'evento al BLoC e lui farà il resto!
|
// Spara l'evento al BLoC e lui farà il resto!
|
||||||
operationFilesBloc.add(RenameOperationFileEvent(file, newName));
|
operationFilesBloc.add(RenameAttachmentEvent(file, newName));
|
||||||
},
|
},
|
||||||
onDelete: () {
|
onDelete: () {
|
||||||
operationFilesBloc.add(DeleteSpecificOperationFileEvent(file));
|
operationFilesBloc.add(DeleteSpecificAttachmentEvent(file));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -184,7 +188,8 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
|
|||||||
suggestedName = selectedFiles.first.name;
|
suggestedName = selectedFiles.first.name;
|
||||||
} else {
|
} else {
|
||||||
// Se sono più file uniti
|
// Se sono più file uniti
|
||||||
suggestedName = '${widget.currentOp.customerDisplayName}_Unito';
|
|
||||||
|
suggestedName = '${widget.customerDisplayName}_Unito';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -281,7 +286,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
|
|||||||
if (fileBytes == null) continue;
|
if (fileBytes == null) continue;
|
||||||
|
|
||||||
// Recuperiamo il nome che l'utente ha (magari) già impostato
|
// Recuperiamo il nome che l'utente ha (magari) già impostato
|
||||||
final baseName = file.name ?? 'Documento';
|
final baseName = file.name;
|
||||||
|
|
||||||
if (file.extension == 'pdf') {
|
if (file.extension == 'pdf') {
|
||||||
final document = await px.PdfDocument.openData(fileBytes);
|
final document = await px.PdfDocument.openData(fileBytes);
|
||||||
@@ -393,7 +398,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
// USIAMO IL TUO BLOC!
|
// USIAMO IL TUO BLOC!
|
||||||
return BlocBuilder<OperationFilesBloc, OperationFilesState>(
|
return BlocBuilder<AttachmentsBloc, AttachmentsState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final allFiles = state.allFiles;
|
final allFiles = state.allFiles;
|
||||||
final selectedFiles = state.selectedFiles;
|
final selectedFiles = state.selectedFiles;
|
||||||
@@ -443,7 +448,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
|
|||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.add_photo_alternate),
|
icon: const Icon(Icons.add_photo_alternate),
|
||||||
label: const Text('Aggiungi File'),
|
label: const Text('Aggiungi File'),
|
||||||
onPressed: state.status == OperationFilesStatus.uploading
|
onPressed: state.status == AttachmentsStatus.uploading
|
||||||
? null
|
? null
|
||||||
: _pickFiles,
|
: _pickFiles,
|
||||||
),
|
),
|
||||||
@@ -464,12 +469,12 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
|
|||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (selectedFiles.length == allFiles.length) {
|
if (selectedFiles.length == allFiles.length) {
|
||||||
context.read<OperationFilesBloc>().add(
|
context.read<AttachmentsBloc>().add(
|
||||||
ClearOperationFileSelectionEvent(),
|
ClearAttachmentSelectionEvent(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
context.read<OperationFilesBloc>().add(
|
context.read<AttachmentsBloc>().add(
|
||||||
SelectAllOperationFilesEvent(),
|
SelectAllAttachmentsEvent(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -478,7 +483,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Loader di upload
|
// Loader di upload
|
||||||
if (state.status == OperationFilesStatus.uploading)
|
if (state.status == AttachmentsStatus.uploading)
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
@@ -494,21 +499,21 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
|
|||||||
icon: const Icon(Icons.delete, color: Colors.red),
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
tooltip: 'Elimina selezionati',
|
tooltip: 'Elimina selezionati',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<OperationFilesBloc>().add(
|
context.read<AttachmentsBloc>().add(
|
||||||
DeleteOperationFilesEvent(),
|
DeleteAttachmentsEvent(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Bottone Associa a Cliente
|
// Bottone Associa a Cliente
|
||||||
if (widget.currentOp.customerId != null &&
|
if (widget.parentId != null && widget.parentId != '')
|
||||||
widget.currentOp.customerId!.isNotEmpty)
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.person_add, color: Colors.blue),
|
icon: const Icon(Icons.person_add, color: Colors.blue),
|
||||||
tooltip: 'Copia nei documenti del Cliente',
|
tooltip: 'Copia nei documenti del Cliente',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<OperationFilesBloc>().add(
|
context.read<AttachmentsBloc>().add(
|
||||||
LinkFilesToCustomerEvent(
|
LinkAttachmentsToEntityEvent(
|
||||||
customerId: widget.currentOp.customerId!,
|
targetId: widget.parentId!,
|
||||||
|
targetType: AttachmentParentType.customer,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -622,8 +627,8 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
|
|||||||
onTap: () => _openFile(file),
|
onTap: () => _openFile(file),
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
// Selezione rapida con long press!
|
// Selezione rapida con long press!
|
||||||
context.read<OperationFilesBloc>().add(
|
context.read<AttachmentsBloc>().add(
|
||||||
ToggleOperationFileSelectionEvent(file),
|
ToggleAttachmentSelectionEvent(file),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -697,8 +702,8 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
|
|||||||
right: 4,
|
right: 4,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.read<OperationFilesBloc>().add(
|
context.read<AttachmentsBloc>().add(
|
||||||
ToggleOperationFileSelectionEvent(file),
|
ToggleAttachmentSelectionEvent(file),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
||||||
|
import 'package:flux/features/customers/models/customer_model.dart';
|
||||||
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
|
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
|
||||||
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
|
||||||
import 'package:flux/features/operations/models/operation_model.dart';
|
|
||||||
|
|
||||||
class CustomerSection extends StatelessWidget {
|
class SharedCustomerSection extends StatelessWidget {
|
||||||
final OperationModel? currentOp;
|
final String? customerId;
|
||||||
const CustomerSection({super.key, required this.currentOp});
|
final String? customerName;
|
||||||
|
final ValueChanged<CustomerModel> onCustomerSelected;
|
||||||
|
|
||||||
|
const SharedCustomerSection({
|
||||||
|
super.key,
|
||||||
|
this.customerId,
|
||||||
|
this.customerName,
|
||||||
|
required this.onCustomerSelected,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final hasCustomer =
|
final hasCustomer = customerId != null && customerId!.isNotEmpty;
|
||||||
currentOp?.customerId != null && currentOp!.customerId!.isNotEmpty;
|
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -41,9 +47,7 @@ class CustomerSection extends StatelessWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
hasCustomer
|
hasCustomer ? customerName! : 'Seleziona Cliente *',
|
||||||
? currentOp!.customerDisplayName!
|
|
||||||
: 'Seleziona Cliente *',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: hasCustomer
|
fontWeight: hasCustomer
|
||||||
? FontWeight.bold
|
? FontWeight.bold
|
||||||
@@ -125,9 +129,6 @@ class CustomerSection extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.person_add),
|
icon: const Icon(Icons.person_add),
|
||||||
label: const Text('Crea Nuovo Cliente'),
|
label: const Text('Crea Nuovo Cliente'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final OperationsCubit operationsCubit = context
|
|
||||||
.read<OperationsCubit>();
|
|
||||||
|
|
||||||
// APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER
|
// APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER
|
||||||
final newCustomer = await showDialog(
|
final newCustomer = await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -145,10 +146,7 @@ class CustomerSection extends StatelessWidget {
|
|||||||
// Se l'ha creato davvero (e non ha premuto annulla)...
|
// Se l'ha creato davvero (e non ha premuto annulla)...
|
||||||
if (newCustomer != null) {
|
if (newCustomer != null) {
|
||||||
// 1. Aggiorniamo il form delle operazioni
|
// 1. Aggiorniamo il form delle operazioni
|
||||||
operationsCubit.updateOperationFields(
|
onCustomerSelected(newCustomer);
|
||||||
customerId: newCustomer.id,
|
|
||||||
customerDisplayName: newCustomer.name,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Chiudiamo la BottomSheet dei clienti per tornare alla form!
|
// 2. Chiudiamo la BottomSheet dei clienti per tornare alla form!
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -196,14 +194,7 @@ class CustomerSection extends StatelessWidget {
|
|||||||
'${customer.phoneNumber} • ${customer.email}',
|
'${customer.phoneNumber} • ${customer.email}',
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Aggiorniamo il form tramite il Cubit delle operazioni
|
onCustomerSelected(customer);
|
||||||
context
|
|
||||||
.read<OperationsCubit>()
|
|
||||||
.updateOperationFields(
|
|
||||||
customerId: customer.id, // customer.id
|
|
||||||
customerDisplayName:
|
|
||||||
customer.name, // customer.name
|
|
||||||
);
|
|
||||||
Navigator.pop(modalContext);
|
Navigator.pop(modalContext);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1,27 +1,22 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:file_picker/file_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 {
|
class SharedMobileUploadScreen extends StatefulWidget {
|
||||||
final String customerId;
|
final String title;
|
||||||
final String customerName;
|
|
||||||
|
|
||||||
const CustomerMobileUploadScreen({
|
const SharedMobileUploadScreen({super.key, required this.title});
|
||||||
super.key,
|
|
||||||
required this.customerId,
|
|
||||||
required this.customerName,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CustomerMobileUploadScreen> createState() =>
|
State<SharedMobileUploadScreen> createState() =>
|
||||||
_CustomerMobileUploadScreenState();
|
_SharedMobileUploadScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CustomerMobileUploadScreenState
|
class _SharedMobileUploadScreenState extends State<SharedMobileUploadScreen> {
|
||||||
extends State<CustomerMobileUploadScreen> {
|
|
||||||
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
|
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
|
||||||
final List<PlatformFile> _stagedFiles = [];
|
final List<PlatformFile> _stagedFiles = [];
|
||||||
|
|
||||||
@@ -36,18 +31,25 @@ class _CustomerMobileUploadScreenState
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocListener<CustomerFilesBloc, CustomerFilesState>(
|
return BlocListener<AttachmentsBloc, AttachmentsState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
|
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
|
||||||
if (state.status == CustomerFilesStatus.success && _isUploading) {
|
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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(content: Text("File caricati con successo! ✅")),
|
||||||
content: Text("Tutti i file caricati con successo! ✅"),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
Navigator.of(context).pop();
|
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);
|
setState(() => _isUploading = false);
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
@@ -56,7 +58,7 @@ class _CustomerMobileUploadScreenState
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text("Upload: ${widget.customerName}"),
|
title: Text("Upload: ${widget.title}"),
|
||||||
// Togliamo la freccia indietro se stiamo caricando per evitare disastri
|
// Togliamo la freccia indietro se stiamo caricando per evitare disastri
|
||||||
automaticallyImplyLeading: !_isUploading,
|
automaticallyImplyLeading: !_isUploading,
|
||||||
),
|
),
|
||||||
@@ -110,8 +112,7 @@ class _CustomerMobileUploadScreenState
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
gridDelegate:
|
gridDelegate:
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount:
|
crossAxisCount: 3, // 3 colonne stile galleria
|
||||||
3, // 3 colonne come la galleria dell'iPhone
|
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: 12,
|
||||||
mainAxisSpacing: 12,
|
mainAxisSpacing: 12,
|
||||||
),
|
),
|
||||||
@@ -137,10 +138,17 @@ class _CustomerMobileUploadScreenState
|
|||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: isImg
|
child: isImg
|
||||||
? Image.file(
|
? (file.bytes != null
|
||||||
File(file.path!),
|
// Se abbiamo i bytes (es. scatto da fotocamera) usiamo quelli (a prova di Web!)
|
||||||
|
? Image.memory(
|
||||||
|
file.bytes!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
)
|
)
|
||||||
|
// Altrimenti andiamo di file fisico
|
||||||
|
: Image.file(
|
||||||
|
File(file.path!),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
))
|
||||||
: const Column(
|
: const Column(
|
||||||
mainAxisAlignment:
|
mainAxisAlignment:
|
||||||
MainAxisAlignment.center,
|
MainAxisAlignment.center,
|
||||||
@@ -228,9 +236,10 @@ class _CustomerMobileUploadScreenState
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) ---
|
// --- OVERLAY DI CARICAMENTO ---
|
||||||
if (_isUploading)
|
if (_isUploading)
|
||||||
Container(
|
Container(
|
||||||
|
// Usa il metodo non deprecato che hai giustamente suggerito!
|
||||||
color: Colors.black.withValues(alpha: 0.5),
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Card(
|
child: Card(
|
||||||
@@ -265,7 +274,7 @@ class _CustomerMobileUploadScreenState
|
|||||||
imageQuality: 80,
|
imageQuality: 80,
|
||||||
);
|
);
|
||||||
if (photo != null) {
|
if (photo != null) {
|
||||||
final photoBytes = await photo.readAsBytes(); // Sicuro anche per Web!
|
final photoBytes = await photo.readAsBytes();
|
||||||
final photoSize = await photo.length();
|
final photoSize = await photo.length();
|
||||||
|
|
||||||
final platformFile = PlatformFile(
|
final platformFile = PlatformFile(
|
||||||
@@ -275,13 +284,12 @@ class _CustomerMobileUploadScreenState
|
|||||||
bytes: photoBytes, // I bytes ci salvano la vita su Supabase!
|
bytes: photoBytes, // I bytes ci salvano la vita su Supabase!
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_stagedFiles.add(platformFile); // Unifichiamo tutto in un dart:io File
|
_stagedFiles.add(platformFile);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleFilePicker() async {
|
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);
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -294,11 +302,9 @@ class _CustomerMobileUploadScreenState
|
|||||||
void _submitAllFiles() {
|
void _submitAllFiles() {
|
||||||
setState(() => _isUploading = true);
|
setState(() => _isUploading = true);
|
||||||
|
|
||||||
// Diciamo al BLoC di caricare tutti i file.
|
// Lanciamo l'evento del nostro nuovo AttachmentsBloc Agnostico!
|
||||||
// Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda)
|
context.read<AttachmentsBloc>().add(
|
||||||
final bloc = context.read<CustomerFilesBloc>();
|
UploadAttachmentsEvent(pickedFiles: _stagedFiles),
|
||||||
bloc.add(UploadMultipleCustomerFilesEvent(_stagedFiles));
|
);
|
||||||
|
|
||||||
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
165
lib/core/widgets/shared_forms/model_section.dart
Normal file
165
lib/core/widgets/shared_forms/model_section.dart
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final hasModel = modelId != null && modelId!.isNotEmpty;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
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: 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
230
lib/core/widgets/shared_forms/shared_files_section.dart
Normal file
230
lib/core/widgets/shared_forms/shared_files_section.dart
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/core/widgets/qr_upload_dialog.dart';
|
||||||
|
import 'package:flux/core/widgets/shared_forms/mobile_upload_screen.dart';
|
||||||
|
import 'package:flux/features/attachments/blocs/attachments_bloc.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 deepLink =
|
||||||
|
'flux://app/upload/${state.parentType.name}/$targetId';
|
||||||
|
|
||||||
|
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: SharedMobileUploadScreen(
|
||||||
|
title: titleNameForUpload,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,23 +2,29 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.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/blocs/staff_cubit.dart';
|
||||||
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||||
import 'package:flux/features/operations/models/operation_model.dart';
|
|
||||||
import 'package:get_it/get_it.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 {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
// Se staffId è nullo, proviamo a preselezionare l'utente loggato
|
||||||
final selectedStaffId =
|
final selectedStaffId =
|
||||||
currentOp?.staffId ??
|
staffId ?? GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
|
||||||
GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -26,7 +32,8 @@ class StaffSection extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12.0),
|
padding: const EdgeInsets.only(bottom: 12.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Operatore',
|
label ??
|
||||||
|
'Operatore', // <-- FIX: Ora usa l'etichetta passata dal form!
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -34,8 +41,28 @@ class StaffSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
BlocBuilder<StaffCubit, StaffState>(
|
BlocBuilder<StaffCubit, StaffState>(
|
||||||
builder: (context, state) {
|
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;
|
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
|
final currentLoggedStaffMember = GetIt.I
|
||||||
.get<SessionCubit>()
|
.get<SessionCubit>()
|
||||||
.state
|
.state
|
||||||
@@ -49,11 +76,7 @@ class StaffSection extends StatelessWidget {
|
|||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Aggiorniamo la form con un solo tap!
|
onStaffSelected(staff);
|
||||||
context.read<OperationsCubit>().updateOperationFields(
|
|
||||||
staffId: staff.id,
|
|
||||||
staffDisplayName: staff.name,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
52
lib/core/widgets/shared_forms/upload_success_screen.dart
Normal file
52
lib/core/widgets/shared_forms/upload_success_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
391
lib/features/attachments/blocs/attachments_bloc.dart
Normal file
391
lib/features/attachments/blocs/attachments_bloc.dart
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
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>();
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}).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.success));
|
||||||
|
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.success,
|
||||||
|
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 companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
|
||||||
|
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.success,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
await Future.wait(uploadTasks);
|
||||||
|
emit(state.copyWith(status: AttachmentsStatus.success));
|
||||||
|
} 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.success, 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.success,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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.success, 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.success));
|
||||||
|
} 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
lib/features/attachments/blocs/attachments_events.dart
Normal file
68
lib/features/attachments/blocs/attachments_events.dart
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
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;
|
||||||
|
const UploadAttachmentsEvent({this.pickedFiles, this.photos});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,10 +1,28 @@
|
|||||||
part of 'operation_files_bloc.dart';
|
part of 'attachments_bloc.dart';
|
||||||
|
|
||||||
enum OperationFilesStatus { initial, loading, uploading, success, failure }
|
enum AttachmentsStatus { initial, loading, uploading, success, failure }
|
||||||
|
|
||||||
class OperationFilesState extends Equatable {
|
enum AttachmentParentType {
|
||||||
const OperationFilesState({
|
operation('operation_id'),
|
||||||
this.operationId,
|
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,
|
required this.status,
|
||||||
this.error,
|
this.error,
|
||||||
this.localFiles = const [],
|
this.localFiles = const [],
|
||||||
@@ -12,17 +30,10 @@ class OperationFilesState extends Equatable {
|
|||||||
this.selectedFiles = const [],
|
this.selectedFiles = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? operationId;
|
|
||||||
final OperationFilesStatus status;
|
|
||||||
final String? error;
|
|
||||||
final List<AttachmentModel> localFiles;
|
|
||||||
final List<AttachmentModel> remoteFiles;
|
|
||||||
|
|
||||||
final List<AttachmentModel> selectedFiles;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
operationId,
|
parentId,
|
||||||
|
parentType,
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
localFiles,
|
localFiles,
|
||||||
@@ -32,16 +43,18 @@ class OperationFilesState extends Equatable {
|
|||||||
|
|
||||||
List<AttachmentModel> get allFiles => [...remoteFiles, ...localFiles];
|
List<AttachmentModel> get allFiles => [...remoteFiles, ...localFiles];
|
||||||
|
|
||||||
OperationFilesState copyWith({
|
AttachmentsState copyWith({
|
||||||
String? operationId,
|
String? parentId,
|
||||||
OperationFilesStatus? status,
|
AttachmentParentType? parentType,
|
||||||
|
AttachmentsStatus? status,
|
||||||
String? error,
|
String? error,
|
||||||
List<AttachmentModel>? localFiles,
|
List<AttachmentModel>? localFiles,
|
||||||
List<AttachmentModel>? remoteFiles,
|
List<AttachmentModel>? remoteFiles,
|
||||||
List<AttachmentModel>? selectedFiles,
|
List<AttachmentModel>? selectedFiles,
|
||||||
}) {
|
}) {
|
||||||
return OperationFilesState(
|
return AttachmentsState(
|
||||||
operationId: operationId ?? this.operationId,
|
parentId: parentId ?? this.parentId,
|
||||||
|
parentType: parentType ?? this.parentType,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
error: error,
|
error: error,
|
||||||
localFiles: localFiles ?? this.localFiles,
|
localFiles: localFiles ?? this.localFiles,
|
||||||
@@ -1,23 +1,198 @@
|
|||||||
import 'dart:typed_data';
|
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:supabase_flutter/supabase_flutter.dart';
|
||||||
|
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
|
||||||
class AttachmentsRepository {
|
class AttachmentsRepository {
|
||||||
final _supabase = Supabase.instance.client;
|
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
|
/// Scarica i byte di un file direttamente da Supabase Storage
|
||||||
Future<Uint8List> downloadAttachmentBytes(String storagePath) async {
|
Future<Uint8List> downloadAttachmentBytes(String storagePath) async {
|
||||||
try {
|
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
|
final Uint8List bytes = await _supabase.storage
|
||||||
.from('attachments') // <--- NOME DEL TUO BUCKET
|
.from(_bucketName)
|
||||||
.download(storagePath);
|
.download(storagePath);
|
||||||
|
|
||||||
return bytes;
|
return bytes;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception("Impossibile scaricare il documento dal cloud: $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,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
|
||||||
|
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(
|
||||||
|
upsert: true,
|
||||||
|
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 nel caricamento del file: $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';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class AttachmentModel extends Equatable {
|
|||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
final String? customerId;
|
final String? customerId;
|
||||||
final String? operationId;
|
final String? operationId;
|
||||||
|
final String? ticketId;
|
||||||
final String name;
|
final String name;
|
||||||
final String extension;
|
final String extension;
|
||||||
final String? storagePath;
|
final String? storagePath;
|
||||||
@@ -19,6 +20,7 @@ class AttachmentModel extends Equatable {
|
|||||||
this.createdAt,
|
this.createdAt,
|
||||||
this.customerId,
|
this.customerId,
|
||||||
this.operationId,
|
this.operationId,
|
||||||
|
this.ticketId,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.extension,
|
required this.extension,
|
||||||
this.storagePath,
|
this.storagePath,
|
||||||
@@ -33,6 +35,7 @@ class AttachmentModel extends Equatable {
|
|||||||
createdAt,
|
createdAt,
|
||||||
customerId,
|
customerId,
|
||||||
operationId,
|
operationId,
|
||||||
|
ticketId,
|
||||||
name,
|
name,
|
||||||
extension,
|
extension,
|
||||||
storagePath,
|
storagePath,
|
||||||
@@ -59,6 +62,7 @@ class AttachmentModel extends Equatable {
|
|||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
String? customerId,
|
String? customerId,
|
||||||
String? operationId,
|
String? operationId,
|
||||||
|
String? ticketId,
|
||||||
String? name,
|
String? name,
|
||||||
String? extension,
|
String? extension,
|
||||||
String? storagePath,
|
String? storagePath,
|
||||||
@@ -70,6 +74,7 @@ class AttachmentModel extends Equatable {
|
|||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
customerId: customerId ?? this.customerId,
|
customerId: customerId ?? this.customerId,
|
||||||
operationId: operationId ?? this.operationId,
|
operationId: operationId ?? this.operationId,
|
||||||
|
ticketId: ticketId ?? this.ticketId,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
extension: extension ?? this.extension,
|
extension: extension ?? this.extension,
|
||||||
storagePath: storagePath ?? this.storagePath,
|
storagePath: storagePath ?? this.storagePath,
|
||||||
@@ -86,6 +91,7 @@ class AttachmentModel extends Equatable {
|
|||||||
: null,
|
: null,
|
||||||
customerId: map['customer_id'] as String?,
|
customerId: map['customer_id'] as String?,
|
||||||
operationId: map['operation_id'] as String?,
|
operationId: map['operation_id'] as String?,
|
||||||
|
ticketId: map['ticket_id'] as String?,
|
||||||
name: map['name'] as String,
|
name: map['name'] as String,
|
||||||
extension: map['extension'] as String,
|
extension: map['extension'] as String,
|
||||||
storagePath: map['storage_path'] as String?,
|
storagePath: map['storage_path'] as String?,
|
||||||
@@ -104,6 +110,7 @@ class AttachmentModel extends Equatable {
|
|||||||
'storage_path': storagePath,
|
'storage_path': storagePath,
|
||||||
'customer_id': customerId,
|
'customer_id': customerId,
|
||||||
'operation_id': operationId,
|
'operation_id': operationId,
|
||||||
|
'ticket_id': ticketId,
|
||||||
'file_size': fileSize,
|
'file_size': fileSize,
|
||||||
'company_id': companyId,
|
'company_id': companyId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,8 @@ import 'package:flux/core/theme/theme.dart';
|
|||||||
import 'package:flux/core/widgets/image_viewer_widget.dart';
|
import 'package:flux/core/widgets/image_viewer_widget.dart';
|
||||||
import 'package:flux/core/widgets/pdf_viewer_widget.dart';
|
import 'package:flux/core/widgets/pdf_viewer_widget.dart';
|
||||||
import 'package:flux/core/widgets/qr_upload_dialog.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/attachments/models/attachment_model.dart';
|
||||||
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
|
|
||||||
import 'package:flux/features/customers/models/customer_model.dart';
|
import 'package:flux/features/customers/models/customer_model.dart';
|
||||||
|
|
||||||
class CustomerDetailScreen extends StatefulWidget {
|
class CustomerDetailScreen extends StatefulWidget {
|
||||||
@@ -26,11 +26,13 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _loadFiles() {
|
void _loadFiles() {
|
||||||
context.read<CustomerFilesBloc>().add(LoadCustomerFilesEvent());
|
context.read<AttachmentsBloc>().add(
|
||||||
|
LoadAttachmentsEvent(parentId: widget.customer.id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickAndUpload() async {
|
Future<void> _pickAndUpload() async {
|
||||||
CustomerFilesBloc customerFilesBloc = context.read<CustomerFilesBloc>();
|
AttachmentsBloc attachmentsBloc = context.read<AttachmentsBloc>();
|
||||||
|
|
||||||
// Chiamata statica pulita
|
// Chiamata statica pulita
|
||||||
FilePickerResult? result = await FilePicker.pickFiles(
|
FilePickerResult? result = await FilePicker.pickFiles(
|
||||||
@@ -40,17 +42,13 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
for (var pickedFile in result.files) {
|
|
||||||
try {
|
try {
|
||||||
customerFilesBloc.add(
|
attachmentsBloc.add(UploadAttachmentsEvent(pickedFiles: result.files));
|
||||||
UploadCustomerFileEvent(pickedFile: pickedFile),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text("Errore upload ${pickedFile.name}: $e")),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text("$e")));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +141,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDocumentSection() {
|
Widget _buildDocumentSection() {
|
||||||
return BlocBuilder<CustomerFilesBloc, CustomerFilesState>(
|
return BlocBuilder<AttachmentsBloc, AttachmentsState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -213,9 +211,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
if (state.status == CustomerFilesStatus.loading)
|
if (state.status == AttachmentsStatus.loading)
|
||||||
const Center(child: CircularProgressIndicator())
|
const Center(child: CircularProgressIndicator())
|
||||||
else if (state.customerFiles.isEmpty)
|
else if (state.allFiles.isEmpty)
|
||||||
const Center(child: Text("Nessun documento presente"))
|
const Center(child: Text("Nessun documento presente"))
|
||||||
else
|
else
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -226,9 +224,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
crossAxisSpacing: 16,
|
crossAxisSpacing: 16,
|
||||||
childAspectRatio: 1.2,
|
childAspectRatio: 1.2,
|
||||||
),
|
),
|
||||||
itemCount: state.customerFiles.length,
|
itemCount: state.allFiles.length,
|
||||||
itemBuilder: (context, index) =>
|
itemBuilder: (context, index) =>
|
||||||
_FileCard(file: state.customerFiles[index], state: state),
|
_FileCard(file: state.allFiles[index], state: state),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -268,14 +266,14 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
|
|
||||||
class _FileCard extends StatelessWidget {
|
class _FileCard extends StatelessWidget {
|
||||||
final AttachmentModel file;
|
final AttachmentModel file;
|
||||||
final CustomerFilesState state;
|
final AttachmentsState state;
|
||||||
const _FileCard({required this.file, required this.state});
|
const _FileCard({required this.file, required this.state});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => context.read<CustomerFilesBloc>().add(
|
onTap: () => context.read<AttachmentsBloc>().add(
|
||||||
ToggleCustomerFileSelectionEvent(file),
|
ToggleAttachmentSelectionEvent(file),
|
||||||
),
|
),
|
||||||
onDoubleTap: () => _handleDoubleClickOnFile(context, file),
|
onDoubleTap: () => _handleDoubleClickOnFile(context, file),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => context.push('/operations'),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -69,8 +71,6 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextButton(
|
|
||||||
onPressed: () => context.push('/operations'),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
context.l10n.homeLatestOperations,
|
context.l10n.homeLatestOperations,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -82,7 +82,6 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -95,12 +94,17 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
LatestStoreOperationsState
|
LatestStoreOperationsState
|
||||||
>(
|
>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.status == LatestStoreOperationsStatus.loading ||
|
if (state.status ==
|
||||||
state.status == LatestStoreOperationsStatus.initial) {
|
LatestStoreOperationsStatus.loading ||
|
||||||
return const Center(child: CircularProgressIndicator());
|
state.status ==
|
||||||
|
LatestStoreOperationsStatus.initial) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.status == LatestStoreOperationsStatus.failure) {
|
if (state.status ==
|
||||||
|
LatestStoreOperationsStatus.failure) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
"Errore di caricamento",
|
"Errore di caricamento",
|
||||||
@@ -184,6 +188,7 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,12 +77,13 @@ class HomeScreen extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
),
|
),
|
||||||
LatestStoreOperationsCard(),
|
LatestStoreOperationsCard(),
|
||||||
|
|
||||||
_buildDashboardWidget(
|
_buildDashboardWidget(
|
||||||
title: context.l10n.homeLatestOperationTickets,
|
title: context.l10n.homeLatestOperationTickets,
|
||||||
icon: Icons.support_agent_outlined,
|
icon: Icons.support_agent_outlined,
|
||||||
color: Colors.purple,
|
color: Colors.purple,
|
||||||
context: context,
|
context: context,
|
||||||
|
onTap: () =>
|
||||||
|
context.push('/tickets'), // <-- Aggiunto!
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
@@ -194,8 +195,8 @@ class HomeScreen extends StatelessWidget {
|
|||||||
label: context.l10n.homeNewOperationTicket,
|
label: context.l10n.homeNewOperationTicket,
|
||||||
color: Colors.redAccent,
|
color: Colors.redAccent,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// TODO: Quando avrai la rotta per la nuova assistenza
|
// Andiamo alla lista! (Da lì poi aggiungeremo il tasto "+" per il form)
|
||||||
// context.push('/assistance-form');
|
context.push('/tickets/form/new');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -226,15 +227,19 @@ class HomeScreen extends StatelessWidget {
|
|||||||
required String title,
|
required String title,
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required Color color,
|
required Color color,
|
||||||
|
VoidCallback? onTap,
|
||||||
}) {
|
}) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -290,6 +295,7 @@ class HomeScreen extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
||||||
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||||
import 'package:flux/features/operations/models/operation_model.dart';
|
import 'package:flux/features/operations/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/details_section.dart';
|
||||||
import 'package:flux/features/operations/ui/widgets/operation_files_section.dart';
|
import 'package:flux/core/widgets/shared_forms/attachments_section.dart';
|
||||||
import 'package:flux/features/operations/ui/widgets/staff_section.dart';
|
import 'package:flux/core/widgets/shared_forms/staff_section.dart';
|
||||||
import 'package:get_it/get_it.dart'; // ASSICURATI DEL PATH
|
import 'package:get_it/get_it.dart';
|
||||||
// import 'package:flux/features/attachments/ui/operation_files_section.dart';
|
|
||||||
|
|
||||||
class OperationFormScreen extends StatefulWidget {
|
class OperationFormScreen extends StatefulWidget {
|
||||||
final String? operationId;
|
final String? operationId;
|
||||||
@@ -216,8 +216,9 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
flex: 3,
|
flex: 3,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: OperationFilesSection(
|
child: SharedAttachmentsSection(
|
||||||
currentOp: state.currentOperation!,
|
parentType: AttachmentParentType.operation,
|
||||||
|
parentId: state.currentOperation?.id,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -317,10 +318,28 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
StaffSection(currentOp: currentOp),
|
StaffSection(
|
||||||
|
staffId: currentOp?.staffId,
|
||||||
|
staffName: currentOp?.staffDisplayName,
|
||||||
|
onStaffSelected: (staff) => {
|
||||||
|
context.read<OperationsCubit>().updateOperationFields(
|
||||||
|
staffId: staff.id,
|
||||||
|
staffDisplayName: staff.name,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
const Divider(height: 50),
|
const Divider(height: 50),
|
||||||
_buildSectionTitle('Cliente & Riferimento'),
|
_buildSectionTitle('Cliente & Riferimento'),
|
||||||
CustomerSection(currentOp: currentOp),
|
SharedCustomerSection(
|
||||||
|
customerId: currentOp?.customerId,
|
||||||
|
customerName: currentOp?.customerDisplayName,
|
||||||
|
onCustomerSelected: (customer) {
|
||||||
|
context.read<OperationsCubit>().updateOperationFields(
|
||||||
|
customerId: customer.id,
|
||||||
|
customerDisplayName: customer.name,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _referenceController,
|
controller: _referenceController,
|
||||||
@@ -390,7 +409,12 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
|
|
||||||
if (showFiles) ...[OperationFilesSection(currentOp: currentOp!)],
|
if (showFiles) ...[
|
||||||
|
SharedAttachmentsSection(
|
||||||
|
parentType: AttachmentParentType.operation,
|
||||||
|
parentId: currentOp?.id,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
import 'package:flux/core/widgets/shared_forms/model_section.dart';
|
||||||
import 'package:flux/features/master_data/products/ui/quick_product_dialog.dart';
|
|
||||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
||||||
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||||
import 'package:flux/features/operations/models/operation_model.dart';
|
import 'package:flux/features/operations/models/operation_model.dart';
|
||||||
@@ -140,129 +139,6 @@ class DetailsSection extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showModelModal(BuildContext context) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
||||||
),
|
|
||||||
builder: (modalContext) {
|
|
||||||
return DraggableScrollableSheet(
|
|
||||||
initialChildSize: 0.6,
|
|
||||||
minChildSize: 0.4,
|
|
||||||
maxChildSize: 0.9,
|
|
||||||
expand: false,
|
|
||||||
builder: (_, scrollController) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Seleziona Modello',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
onPressed: () => Navigator.pop(modalContext),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: TextField(
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Cerca modello (es. iPhone 15...)',
|
|
||||||
prefixIcon: const Icon(Icons.search),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onChanged: (query) =>
|
|
||||||
context.read<ProductsCubit>().searchModels(query),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
minimumSize: const Size.fromHeight(48),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('Aggiungi Modello al Volo'),
|
|
||||||
onPressed: () async {
|
|
||||||
final operationsCubit = context.read<OperationsCubit>();
|
|
||||||
final existingBrands = context
|
|
||||||
.read<ProductsCubit>()
|
|
||||||
.state
|
|
||||||
.brands;
|
|
||||||
|
|
||||||
final newModel = await showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (dialogContext) {
|
|
||||||
return BlocProvider.value(
|
|
||||||
value: context.read<ProductsCubit>(),
|
|
||||||
child: QuickProductDialog(
|
|
||||||
existingBrands: existingBrands,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newModel != null) {
|
|
||||||
operationsCubit.updateOperationFields(
|
|
||||||
modelId: newModel.id,
|
|
||||||
modelDisplayName: newModel.nameWithBrand,
|
|
||||||
);
|
|
||||||
if (context.mounted) Navigator.pop(modalContext);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
Expanded(
|
|
||||||
child: BlocBuilder<ProductsCubit, ProductState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
return ListView.builder(
|
|
||||||
controller: scrollController,
|
|
||||||
itemCount: state.models.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final deviceModel = state.models[index];
|
|
||||||
return ListTile(
|
|
||||||
leading: const Icon(Icons.devices),
|
|
||||||
title: Text(
|
|
||||||
deviceModel.nameWithBrand,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
context
|
|
||||||
.read<OperationsCubit>()
|
|
||||||
.updateOperationFields(
|
|
||||||
modelId: deviceModel.id,
|
|
||||||
modelDisplayName: deviceModel.nameWithBrand,
|
|
||||||
);
|
|
||||||
Navigator.pop(modalContext);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -334,30 +210,16 @@ class DetailsSection extends StatelessWidget {
|
|||||||
|
|
||||||
// 2. SCENARIO FIN (Ricerca Modello/Prodotto)
|
// 2. SCENARIO FIN (Ricerca Modello/Prodotto)
|
||||||
if (currentType == 'Fin') ...[
|
if (currentType == 'Fin') ...[
|
||||||
ListTile(
|
SharedModelSection(
|
||||||
title: const Text('Seleziona Dispositivo/Prodotto'),
|
label: 'Seleziona Dispositivo/Prodotto',
|
||||||
subtitle: Text(
|
modelId: currentOp?.modelId,
|
||||||
(currentOp?.modelDisplayName != null &&
|
modelName: currentOp?.modelDisplayName,
|
||||||
currentOp!.modelDisplayName!.isNotEmpty)
|
onModelSelected: (id, name) {
|
||||||
? currentOp!.modelDisplayName!
|
context.read<OperationsCubit>().updateOperationFields(
|
||||||
: 'Nessun modello selezionato',
|
modelId: id,
|
||||||
style: TextStyle(
|
modelDisplayName: name,
|
||||||
color:
|
);
|
||||||
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
|
},
|
||||||
? Colors.grey
|
|
||||||
: null,
|
|
||||||
fontWeight:
|
|
||||||
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
|
|
||||||
? FontWeight.normal
|
|
||||||
: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: const Icon(Icons.arrow_drop_down),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
side: BorderSide(color: theme.dividerColor),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
onTap: () => _showModelModal(context),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
|
|||||||
217
lib/features/tickets/blocs/ticket_form_cubit.dart
Normal file
217
lib/features/tickets/blocs/ticket_form_cubit.dart
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
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/tickets/models/ticket_model.dart';
|
||||||
|
import 'package:flux/features/tickets/data/ticket_repository.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>();
|
||||||
|
|
||||||
|
TicketFormCubit()
|
||||||
|
: super(
|
||||||
|
// Inizializziamo con un ticket vuoto di default
|
||||||
|
TicketFormState(ticket: TicketModel.empty()),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 1. INIZIALIZZAZIONE (Se stiamo modificando un ticket esistente)
|
||||||
|
Future<void> initForm({String? id, TicketModel? existingTicket}) async {
|
||||||
|
if (existingTicket != null) {
|
||||||
|
// SCENARIO 1 (App Native / Navigazione interna Web):
|
||||||
|
// Abbiamo l'oggetto intero passato via 'extra'. Lo mostriamo all'istante!
|
||||||
|
emit(
|
||||||
|
state.copyWith(ticket: existingTicket, status: TicketFormStatus.ready),
|
||||||
|
);
|
||||||
|
} else if (id != null) {
|
||||||
|
// SCENARIO 2 (Web Refresh o Link condiviso):
|
||||||
|
// L'utente ha premuto F5 su /tickets/form/123. L'extra è andato perso, ma abbiamo l'ID!
|
||||||
|
emit(
|
||||||
|
state.copyWith(status: TicketFormStatus.loading),
|
||||||
|
); // Mostriamo uno spinner
|
||||||
|
try {
|
||||||
|
final fetchedTicket = await _repository.getTicketById(
|
||||||
|
id,
|
||||||
|
); // Lo scarichiamo!
|
||||||
|
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):
|
||||||
|
// È un nuovo ticket! Inseriamo i default base (Azienda, Negozio, Creatore)
|
||||||
|
final currentUser = _sessionCubit.state.currentStaffMember;
|
||||||
|
final currentStore = _sessionCubit.state.currentStore;
|
||||||
|
final companyId = _sessionCubit.state.company?.id ?? '';
|
||||||
|
|
||||||
|
final newTicket = TicketModel.empty().copyWith(
|
||||||
|
companyId: companyId,
|
||||||
|
storeId: currentStore?.id,
|
||||||
|
createdById: currentUser?.id,
|
||||||
|
createdByName: currentUser?.name,
|
||||||
|
// Impostiamo lo stato iniziale
|
||||||
|
ticketStatus: TicketStatus.open,
|
||||||
|
ticketType: TicketType.repair, // Default
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
customerName: customer.name,
|
||||||
|
alternativePhoneNumber: customer.phoneNumber, // Comodo come fallback!
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 3. AGGIORNAMENTO MODELLO (Usato dal nostro SharedModelSection!)
|
||||||
|
void updateModel({required String modelId, required String modelName}) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
ticket: state.ticket.copyWith(
|
||||||
|
targetModelId: modelId,
|
||||||
|
targetModelName: 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? 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,
|
||||||
|
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({required bool keepAdding}) 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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final savedTicket = await _repository.saveTicket(ticketToSave);
|
||||||
|
|
||||||
|
if (keepAdding) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: TicketFormStatus.successAndAddAnother,
|
||||||
|
// Svuotiamo il form per il prossimo, mantenendo Store e Creatore ATTUALI
|
||||||
|
ticket: TicketModel.empty().copyWith(
|
||||||
|
companyId: savedTicket.companyId,
|
||||||
|
storeId: savedTicket.storeId,
|
||||||
|
createdById: ticketToSave
|
||||||
|
.createdById, // Manteniamo quello selezionato nella tendina!
|
||||||
|
createdByName: ticketToSave.createdByName,
|
||||||
|
ticketStatus: TicketStatus.open,
|
||||||
|
ticketType: TicketType.repair,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
emit(
|
||||||
|
state.copyWith(status: TicketFormStatus.success, ticket: savedTicket),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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.saveTicket(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
lib/features/tickets/blocs/ticket_form_state.dart
Normal file
40
lib/features/tickets/blocs/ticket_form_state.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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,
|
||||||
|
successAndAddAnother,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
lib/features/tickets/blocs/ticket_list_cubit.dart
Normal file
79
lib/features/tickets/blocs/ticket_list_cubit.dart
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
69
lib/features/tickets/blocs/ticket_list_state.dart
Normal file
69
lib/features/tickets/blocs/ticket_list_state.dart
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
238
lib/features/tickets/data/ticket_repository.dart
Normal file
238
lib/features/tickets/data/ticket_repository.dart
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Salva il ticket con upsert
|
||||||
|
Future<TicketModel> saveTicket(TicketModel ticket) async {
|
||||||
|
try {
|
||||||
|
final response = await _supabase
|
||||||
|
.from(_tableName)
|
||||||
|
.upsert(ticket.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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
367
lib/features/tickets/models/ticket_model.dart
Normal file
367
lib/features/tickets/models/ticket_model.dart
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
|
|
||||||
|
/// Enum per il tipo di ticket
|
||||||
|
enum TicketType {
|
||||||
|
repair('repair', 'Riparazione'),
|
||||||
|
softwareSetup('software_setup', 'Setup software'),
|
||||||
|
dataTransfer('data_transfer', 'Trasferimento dati'),
|
||||||
|
operationTicket('operation_ticket', 'Ticket di 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 int? referenceNumber;
|
||||||
|
final String? alternativePhoneNumber;
|
||||||
|
final bool hasCourtesyDevice;
|
||||||
|
final TicketType ticketType;
|
||||||
|
final TicketStatus ticketStatus;
|
||||||
|
final DateTime? estimatedDeliveryAt;
|
||||||
|
final TicketResult? ticketResult;
|
||||||
|
final String? resolutionNotes;
|
||||||
|
final String? legacyId;
|
||||||
|
final String? customerName;
|
||||||
|
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.referenceNumber,
|
||||||
|
this.alternativePhoneNumber,
|
||||||
|
this.hasCourtesyDevice = false,
|
||||||
|
required this.ticketType,
|
||||||
|
this.ticketStatus = TicketStatus.closed,
|
||||||
|
this.estimatedDeliveryAt,
|
||||||
|
this.ticketResult,
|
||||||
|
this.resolutionNotes,
|
||||||
|
this.legacyId,
|
||||||
|
this.customerName,
|
||||||
|
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,
|
||||||
|
int? referenceNumber,
|
||||||
|
String? alternativePhoneNumber,
|
||||||
|
bool? hasCourtesyDevice,
|
||||||
|
TicketType? ticketType,
|
||||||
|
TicketStatus? ticketStatus,
|
||||||
|
DateTime? estimatedDeliveryAt,
|
||||||
|
TicketResult? ticketResult,
|
||||||
|
String? resolutionNotes,
|
||||||
|
String? legacyId,
|
||||||
|
String? customerName,
|
||||||
|
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,
|
||||||
|
referenceNumber: referenceNumber ?? this.referenceNumber,
|
||||||
|
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,
|
||||||
|
legacyId: legacyId ?? this.legacyId,
|
||||||
|
customerName: customerName ?? this.customerName,
|
||||||
|
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?,
|
||||||
|
referenceNumber: map['reference_number'] as int?,
|
||||||
|
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?,
|
||||||
|
legacyId: map['legacy_id'] as String?,
|
||||||
|
customerName: (map['customer']?['name'] as String?).myFormat(),
|
||||||
|
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,
|
||||||
|
'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,
|
||||||
|
'legacy_id': legacyId,
|
||||||
|
'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,
|
||||||
|
referenceNumber,
|
||||||
|
alternativePhoneNumber,
|
||||||
|
hasCourtesyDevice,
|
||||||
|
ticketType,
|
||||||
|
ticketStatus,
|
||||||
|
estimatedDeliveryAt,
|
||||||
|
ticketResult,
|
||||||
|
resolutionNotes,
|
||||||
|
legacyId,
|
||||||
|
includedAccessories,
|
||||||
|
customerName,
|
||||||
|
targetModelName,
|
||||||
|
sourceModelName,
|
||||||
|
createdById,
|
||||||
|
createdByName,
|
||||||
|
assignedToId,
|
||||||
|
assignedToName,
|
||||||
|
];
|
||||||
|
}
|
||||||
43
lib/features/tickets/models/ticket_status_extension.dart
Normal file
43
lib/features/tickets/models/ticket_status_extension.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
597
lib/features/tickets/ui/ticket_form_screen.dart
Normal file
597
lib/features/tickets/ui/ticket_form_screen.dart
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/core/widgets/shared_forms/customer_section.dart';
|
||||||
|
import 'package:flux/core/widgets/shared_forms/model_section.dart';
|
||||||
|
import 'package:flux/core/widgets/shared_forms/shared_files_section.dart';
|
||||||
|
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
||||||
|
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
|
||||||
|
import 'package:flux/features/tickets/blocs/ticket_form_state.dart';
|
||||||
|
import 'package:flux/features/tickets/models/ticket_model.dart';
|
||||||
|
import 'package:flux/core/widgets/shared_forms/staff_section.dart';
|
||||||
|
import 'package:flux/features/tickets/models/ticket_status_extension.dart';
|
||||||
|
|
||||||
|
class TicketFormScreen extends StatefulWidget {
|
||||||
|
final TicketModel? existingTicket;
|
||||||
|
final String? ticketId;
|
||||||
|
|
||||||
|
const TicketFormScreen({super.key, this.existingTicket, this.ticketId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TicketFormScreen> createState() => _TicketFormScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TicketFormScreenState extends State<TicketFormScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
final _altPhoneCtrl = TextEditingController();
|
||||||
|
final _serialCtrl = TextEditingController();
|
||||||
|
final _requestCtrl = TextEditingController();
|
||||||
|
final _accessoriesCtrl = TextEditingController();
|
||||||
|
final _publicNotesCtrl = TextEditingController();
|
||||||
|
final _internalNotesCtrl = TextEditingController();
|
||||||
|
final _priceCtrl = TextEditingController();
|
||||||
|
final _costCtrl = TextEditingController();
|
||||||
|
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
context.read<TicketFormCubit>().initForm(
|
||||||
|
existingTicket: widget.existingTicket,
|
||||||
|
id: widget.ticketId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_altPhoneCtrl.dispose();
|
||||||
|
_serialCtrl.dispose();
|
||||||
|
_requestCtrl.dispose();
|
||||||
|
_accessoriesCtrl.dispose();
|
||||||
|
_publicNotesCtrl.dispose();
|
||||||
|
_internalNotesCtrl.dispose();
|
||||||
|
_priceCtrl.dispose();
|
||||||
|
_costCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncTextControllers(TicketModel model) {
|
||||||
|
if (_altPhoneCtrl.text.isEmpty) {
|
||||||
|
_altPhoneCtrl.text = model.alternativePhoneNumber ?? '';
|
||||||
|
}
|
||||||
|
if (_serialCtrl.text.isEmpty) _serialCtrl.text = model.targetSn ?? '';
|
||||||
|
if (_requestCtrl.text.isEmpty) _requestCtrl.text = model.request;
|
||||||
|
if (_accessoriesCtrl.text.isEmpty) {
|
||||||
|
_accessoriesCtrl.text = model.includedAccessories ?? '';
|
||||||
|
}
|
||||||
|
if (_publicNotesCtrl.text.isEmpty) {
|
||||||
|
_publicNotesCtrl.text = model.publicNotes ?? '';
|
||||||
|
}
|
||||||
|
if (_internalNotesCtrl.text.isEmpty) {
|
||||||
|
_internalNotesCtrl.text = model.internalNotes ?? '';
|
||||||
|
}
|
||||||
|
if (_priceCtrl.text.isEmpty && model.customerPrice > 0) {
|
||||||
|
_priceCtrl.text = model.customerPrice.toString();
|
||||||
|
}
|
||||||
|
if (_costCtrl.text.isEmpty && model.internalCost > 0) {
|
||||||
|
_costCtrl.text = model.internalCost.toString();
|
||||||
|
}
|
||||||
|
_isInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _flushControllersToCubit() {
|
||||||
|
context.read<TicketFormCubit>().updateFields(
|
||||||
|
alternativePhoneNumber: _altPhoneCtrl.text,
|
||||||
|
targetSn: _serialCtrl.text,
|
||||||
|
request: _requestCtrl.text,
|
||||||
|
includedAccessories: _accessoriesCtrl.text,
|
||||||
|
publicNotes: _publicNotesCtrl.text,
|
||||||
|
internalNotes: _internalNotesCtrl.text,
|
||||||
|
customerPrice: double.tryParse(_priceCtrl.text) ?? 0.0,
|
||||||
|
internalCost: double.tryParse(_costCtrl.text) ?? 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _saveTicket({required bool keepAdding}) {
|
||||||
|
if (_formKey.currentState!.validate()) {
|
||||||
|
_flushControllersToCubit();
|
||||||
|
context.read<TicketFormCubit>().saveTicket(keepAdding: keepAdding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _generateIdForQr() async {
|
||||||
|
// 1. Validiamo i campi obbligatori (es. il cliente)
|
||||||
|
if (!_formKey.currentState!.validate()) return null;
|
||||||
|
|
||||||
|
// 2. Sincronizziamo i testi scritti a mano nel Cubit
|
||||||
|
_flushControllersToCubit();
|
||||||
|
|
||||||
|
// 3. Facciamo il salvataggio silenzioso
|
||||||
|
final newId = await context.read<TicketFormCubit>().saveTicketDraft();
|
||||||
|
|
||||||
|
if (newId != null && context.mounted) {
|
||||||
|
// 4. IL TOCCO DI CLASSE: Diciamo all'AttachmentsBloc che ora la pratica ha un ID!
|
||||||
|
// Questo farà partire l'upload automatico di eventuali file "in bozza"
|
||||||
|
context.read<AttachmentsBloc>().add(ParentEntitySavedEvent(newId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return newId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return BlocConsumer<TicketFormCubit, TicketFormState>(
|
||||||
|
listenWhen: (previous, current) => previous.status != current.status,
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state.status == TicketFormStatus.ready && !_isInitialized) {
|
||||||
|
_syncTextControllers(state.ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status == TicketFormStatus.success) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
} else if (state.status == TicketFormStatus.successAndAddAnother) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Scheda salvata! Inserisci la prossima.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_altPhoneCtrl.clear();
|
||||||
|
_serialCtrl.clear();
|
||||||
|
_requestCtrl.clear();
|
||||||
|
_accessoriesCtrl.clear();
|
||||||
|
_publicNotesCtrl.clear();
|
||||||
|
_internalNotesCtrl.clear();
|
||||||
|
_priceCtrl.clear();
|
||||||
|
_costCtrl.clear();
|
||||||
|
_isInitialized = false;
|
||||||
|
} else if (state.status == TicketFormStatus.failure) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(state.errorMessage ?? 'Errore di salvataggio'),
|
||||||
|
backgroundColor: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
final ticket = state.ticket;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(
|
||||||
|
ticket.id == null ? 'Nuova Scheda Assistenza' : 'Modifica Scheda',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16.0),
|
||||||
|
child: Chip(
|
||||||
|
label: Text(
|
||||||
|
ticket.ticketStatus.name.toUpperCase(),
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||||
|
),
|
||||||
|
backgroundColor: ticket.ticketStatus.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Form(
|
||||||
|
key: _formKey,
|
||||||
|
// IL TRUCCO PER LA TASTIERA: Obblighiamo il tab a seguire il DOM
|
||||||
|
child: FocusTraversalGroup(
|
||||||
|
policy: WidgetOrderTraversalPolicy(),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final isUltraWide = constraints.maxWidth > 1400;
|
||||||
|
final isDesktop = constraints.maxWidth > 900;
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: isUltraWide
|
||||||
|
? 1600
|
||||||
|
: (isDesktop ? 1200 : 800),
|
||||||
|
),
|
||||||
|
child: _buildResponsiveLayout(
|
||||||
|
isUltraWide,
|
||||||
|
isDesktop,
|
||||||
|
ticket,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: SafeArea(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.scaffoldBackgroundColor,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, -3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: FocusTraversalGroup(
|
||||||
|
// Un gruppo a parte per il footer, così viene visitato per ultimo
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: state.status == TicketFormStatus.saving
|
||||||
|
? null
|
||||||
|
: () => _saveTicket(keepAdding: true),
|
||||||
|
child: const Text(
|
||||||
|
'Salva e Aggiungi Altro',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: state.status == TicketFormStatus.saving
|
||||||
|
? null
|
||||||
|
: () => _saveTicket(keepAdding: false),
|
||||||
|
child: state.status == TicketFormStatus.saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('Salva ed Esci'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LOGICA DI IMPAGINAZIONE RESPONSIVE ---
|
||||||
|
Widget _buildResponsiveLayout(
|
||||||
|
bool isUltraWide,
|
||||||
|
bool isDesktop,
|
||||||
|
TicketModel ticket,
|
||||||
|
) {
|
||||||
|
if (isUltraWide) {
|
||||||
|
// 3 COLONNE
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [_cardAnagrafica(ticket), _cardDispositivo(ticket)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
Expanded(child: Column(children: [_cardDettagli(ticket)])),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [_cardCosti(ticket), _cardAssegnazione(ticket)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else if (isDesktop) {
|
||||||
|
// 2 COLONNE
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_cardAnagrafica(ticket),
|
||||||
|
_cardDispositivo(ticket),
|
||||||
|
_cardAssegnazione(ticket),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [_cardDettagli(ticket), _cardCosti(ticket)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 1 COLONNA (Mobile)
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_cardAnagrafica(ticket),
|
||||||
|
_cardDispositivo(ticket),
|
||||||
|
_cardDettagli(ticket),
|
||||||
|
_cardCosti(ticket),
|
||||||
|
_cardAssegnazione(ticket),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LE 5 CARD (MODULARIZZATE E COLORATE) ---
|
||||||
|
|
||||||
|
Widget _cardAnagrafica(TicketModel ticket) {
|
||||||
|
return _buildCard(
|
||||||
|
title: 'Anagrafica',
|
||||||
|
icon: Icons.person,
|
||||||
|
themeColor: Colors.indigo,
|
||||||
|
children: [
|
||||||
|
StaffSection(
|
||||||
|
label: 'Creato Da',
|
||||||
|
staffId: ticket.createdById,
|
||||||
|
staffName: ticket.createdByName,
|
||||||
|
onStaffSelected: (staff) => context
|
||||||
|
.read<TicketFormCubit>()
|
||||||
|
.updateCreator(staffId: staff.id!, staffName: staff.name),
|
||||||
|
),
|
||||||
|
const Divider(height: 32),
|
||||||
|
SharedCustomerSection(
|
||||||
|
customerId: ticket.customerId,
|
||||||
|
customerName: ticket.customerName,
|
||||||
|
onCustomerSelected: (customer) =>
|
||||||
|
context.read<TicketFormCubit>().updateCustomer(customer),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _altPhoneCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Recapito Alternativo',
|
||||||
|
prefixIcon: Icon(Icons.phone),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _cardDispositivo(TicketModel ticket) {
|
||||||
|
return _buildCard(
|
||||||
|
title: 'Dispositivo',
|
||||||
|
icon: Icons.devices,
|
||||||
|
themeColor: Colors.deepOrange,
|
||||||
|
children: [
|
||||||
|
SharedModelSection(
|
||||||
|
label: 'Modello da Riparare',
|
||||||
|
modelId: ticket.targetModelId,
|
||||||
|
modelName: ticket.targetModelName,
|
||||||
|
onModelSelected: (id, name) => context
|
||||||
|
.read<TicketFormCubit>()
|
||||||
|
.updateModel(modelId: id, modelName: name),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _serialCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Seriale / IMEI',
|
||||||
|
prefixIcon: Icon(Icons.qr_code),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _cardDettagli(TicketModel ticket) {
|
||||||
|
return _buildCard(
|
||||||
|
title: 'Dettagli Riparazione',
|
||||||
|
icon: Icons.build,
|
||||||
|
themeColor: Colors.pink,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField<TicketType>(
|
||||||
|
initialValue: ticket.ticketType,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Tipo Lavorazione',
|
||||||
|
),
|
||||||
|
items: TicketType.values
|
||||||
|
.map((t) => DropdownMenuItem(value: t, child: Text(t.name)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (val) => context
|
||||||
|
.read<TicketFormCubit>()
|
||||||
|
.updateFields(ticketType: val),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField<TicketStatus>(
|
||||||
|
initialValue: ticket.ticketStatus,
|
||||||
|
decoration: const InputDecoration(labelText: 'Stato Attuale'),
|
||||||
|
items: TicketStatus.values
|
||||||
|
.map((s) => DropdownMenuItem(value: s, child: Text(s.name)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (val) =>
|
||||||
|
context.read<TicketFormCubit>().updateFields(status: val),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _requestCtrl,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Difetto dichiarato o Richiesta del cliente',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _accessoriesCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Accessori Consegnati',
|
||||||
|
prefixIcon: Icon(Icons.cable),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Prestato Telefono di Cortesia?'),
|
||||||
|
value: ticket.hasCourtesyDevice,
|
||||||
|
onChanged: (val) => context.read<TicketFormCubit>().updateFields(
|
||||||
|
hasCourtesyDevice: val,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
side: BorderSide(color: Theme.of(context).dividerColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _cardCosti(TicketModel ticket) {
|
||||||
|
return _buildCard(
|
||||||
|
title: 'Costi & Note',
|
||||||
|
icon: Icons.euro,
|
||||||
|
themeColor: Colors.teal,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _priceCtrl,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Preventivo Cliente (€)',
|
||||||
|
prefixIcon: Icon(Icons.sell_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _costCtrl,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nostro Costo (€)',
|
||||||
|
prefixIcon: Icon(Icons.shopping_cart_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _publicNotesCtrl,
|
||||||
|
maxLines: 2,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Note Pubbliche (Visibili su ricevuta)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _internalNotesCtrl,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Note Interne (Solo per lo Staff)',
|
||||||
|
fillColor: Colors.amber.withValues(alpha: 0.1),
|
||||||
|
filled: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _cardAssegnazione(TicketModel ticket) {
|
||||||
|
return _buildCard(
|
||||||
|
title: 'Assegnazione e Allegati',
|
||||||
|
icon: Icons.engineering,
|
||||||
|
themeColor: Colors.deepPurple,
|
||||||
|
children: [
|
||||||
|
StaffSection(
|
||||||
|
label: 'Assegnato A',
|
||||||
|
staffId: ticket.assignedToId,
|
||||||
|
staffName: ticket.assignedToName,
|
||||||
|
onStaffSelected: (staff) => context
|
||||||
|
.read<TicketFormCubit>()
|
||||||
|
.updateFields(assignedToId: staff.id, assignedToName: staff.name),
|
||||||
|
),
|
||||||
|
const Divider(height: 32),
|
||||||
|
// ECCO LA MAGIA:
|
||||||
|
SharedFilesSection(
|
||||||
|
titleNameForUpload: ticket.customerName ?? 'Nuovo Ticket',
|
||||||
|
onGenerateIdForQr: _generateIdForQr,
|
||||||
|
),
|
||||||
|
/* SharedAttachmentsSection(
|
||||||
|
parentType: AttachmentParentType.ticket,
|
||||||
|
parentId: ticket.id,
|
||||||
|
), */
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WIDGET BASE PER LA CARD ---
|
||||||
|
Widget _buildCard({
|
||||||
|
required String title,
|
||||||
|
required IconData icon,
|
||||||
|
required Color themeColor,
|
||||||
|
required List<Widget> children,
|
||||||
|
}) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 24),
|
||||||
|
elevation: 0, // Tolta l'ombra standard
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(
|
||||||
|
color: themeColor.withValues(alpha: 0.3),
|
||||||
|
width: 1,
|
||||||
|
), // Bordo colorato delicato
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Pallino colorato con l'icona dentro
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: themeColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: themeColor),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: themeColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 32),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
291
lib/features/tickets/ui/ticket_list_screen.dart
Normal file
291
lib/features/tickets/ui/ticket_list_screen.dart
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.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';
|
||||||
|
|
||||||
|
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(
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Navigare alla creazione di un nuovo ticket
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Nuovo Ticket'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.customerName ?? '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: () {
|
||||||
|
// TODO: Aprire il dettaglio del ticket!
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|||||||
import 'package:flux/features/attachments/data/attachments_repository.dart';
|
import 'package:flux/features/attachments/data/attachments_repository.dart';
|
||||||
import 'package:flux/features/auth/bloc/auth_cubit.dart';
|
import 'package:flux/features/auth/bloc/auth_cubit.dart';
|
||||||
import 'package:flux/features/operations/data/operations_repository.dart';
|
import 'package:flux/features/operations/data/operations_repository.dart';
|
||||||
|
import 'package:flux/features/tickets/data/ticket_repository.dart';
|
||||||
import 'package:flux/l10n/app_localizations.dart';
|
import 'package:flux/l10n/app_localizations.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -92,6 +93,7 @@ Future<void> setupLocator() async {
|
|||||||
getIt.registerLazySingleton<AttachmentsRepository>(
|
getIt.registerLazySingleton<AttachmentsRepository>(
|
||||||
() => AttachmentsRepository(),
|
() => AttachmentsRepository(),
|
||||||
);
|
);
|
||||||
|
getIt.registerLazySingleton<TicketRepository>(() => TicketRepository());
|
||||||
|
|
||||||
// NOTA: CompanyRepository l'ho tolto perché la logica della Company
|
// NOTA: CompanyRepository l'ho tolto perché la logica della Company
|
||||||
// ora è gestita dal CoreRepository durante l'Onboarding.
|
// ora è gestita dal CoreRepository durante l'Onboarding.
|
||||||
|
|||||||
1
web/_redirects
Normal file
1
web/_redirects
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/* /index.html 200
|
||||||
Reference in New Issue
Block a user