ticket labels e ticket receipt

This commit is contained in:
2026-05-10 14:09:57 +02:00
parent 385c3da0a5
commit 5c86483563
20 changed files with 1024 additions and 157 deletions

View File

@@ -37,6 +37,11 @@ class CompanySettingsCubit extends Cubit<CompanySettingsState> {
String? zipCode,
String? phone,
String? email,
String? ticketDisclaimer,
LabelFormat? labelFormat,
double? labelWidth,
double? labelHeight,
bool? isVertical,
}) {
if (state.company == null) return;
@@ -51,6 +56,11 @@ class CompanySettingsCubit extends Cubit<CompanySettingsState> {
zipCode: zipCode ?? state.company!.zipCode,
phone: phone ?? state.company!.phone,
email: email ?? state.company!.email,
ticketDisclaimer: ticketDisclaimer ?? state.company!.ticketDisclaimer,
labelFormat: labelFormat ?? state.company!.labelFormat,
labelWidth: labelWidth ?? state.company!.labelWidth,
labelHeight: labelHeight ?? state.company!.labelHeight,
isLabelVertical: isVertical ?? state.company!.isLabelVertical,
);
emit(state.copyWith(company: updated));
}

View File

@@ -35,6 +35,21 @@ enum SubscriptionStatus {
}
}
enum LabelFormat {
none,
small_62x29,
medium_54x101,
large_102x152,
custom;
static LabelFormat fromString(String? value) {
return LabelFormat.values.firstWhere(
(e) => e.name == value,
orElse: () => LabelFormat.none,
);
}
}
// ===================================================================
// IL MODELLO ESATTO
// ===================================================================
@@ -56,7 +71,11 @@ class CompanyModel extends Equatable {
final String? phone;
final String? email;
final String? logoUrl;
final String? ticketDisclaimer;
final LabelFormat labelFormat;
final double? labelWidth;
final double? labelHeight;
final bool isLabelVertical;
// Stato Pagamenti (Ibride: manuale + Stripe)
final bool isPaid;
final DateTime? paymentExpiration;
@@ -83,6 +102,11 @@ class CompanyModel extends Equatable {
this.phone,
this.email,
this.logoUrl,
this.ticketDisclaimer,
this.labelFormat = LabelFormat.none,
this.labelWidth,
this.labelHeight,
this.isLabelVertical = false,
this.isPaid = false,
this.paymentExpiration,
this.subscriptionTier = SubscriptionTier.free,
@@ -105,6 +129,11 @@ class CompanyModel extends Equatable {
String? fiscalCode,
String? sdi,
String? logoUrl,
String? ticketDisclaimer,
LabelFormat? labelFormat,
double? labelWidth,
double? labelHeight,
bool? isLabelVertical,
String? phone,
String? email,
bool? isPaid,
@@ -130,6 +159,11 @@ class CompanyModel extends Equatable {
logoUrl: logoUrl ?? this.logoUrl,
phone: phone ?? this.phone,
email: email ?? this.email,
ticketDisclaimer: ticketDisclaimer ?? this.ticketDisclaimer,
labelFormat: labelFormat ?? this.labelFormat,
labelWidth: labelWidth ?? this.labelWidth,
labelHeight: labelHeight ?? this.labelHeight,
isLabelVertical: isLabelVertical ?? this.isLabelVertical,
isPaid: isPaid ?? this.isPaid,
paymentExpiration: paymentExpiration ?? this.paymentExpiration,
subscriptionTier: subscriptionTier ?? this.subscriptionTier,
@@ -171,9 +205,18 @@ class CompanyModel extends Equatable {
vatId: map['vat_id'] ?? '',
fiscalCode: map['fiscal_code'] ?? '',
sdi: map['sdi'] ?? '',
logoUrl: map['company_logo'],
logoUrl: map['logo_url'],
phone: map['phone'] ?? '',
email: map['email'] ?? '',
ticketDisclaimer: map['ticket_disclaimer'],
labelFormat: LabelFormat.fromString(map['label_format']),
labelWidth: map['label_width'] != null
? (map['label_width'] as num).toDouble()
: null,
labelHeight: map['label_height'] != null
? (map['label_height'] as num).toDouble()
: null,
isLabelVertical: map['is_label_vertical'] ?? false,
isPaid: map['is_paid'] ?? false,
paymentExpiration: map['payment_expiration'] != null
? DateTime.tryParse(map['payment_expiration'])
@@ -203,9 +246,14 @@ class CompanyModel extends Equatable {
'vat_id': vatId,
'fiscal_code': fiscalCode,
'sdi': sdi,
'company_logo': logoUrl,
'logo_url': logoUrl,
'phone': phone,
'email': email,
'ticket_disclaimer': ticketDisclaimer,
'label_format': labelFormat.name,
'label_width': labelWidth,
'label_height': labelHeight,
'is_label_vertical': isLabelVertical,
'is_paid': isPaid,
if (paymentExpiration != null)
'payment_expiration': paymentExpiration!.toIso8601String(),
@@ -236,6 +284,11 @@ class CompanyModel extends Equatable {
logoUrl,
phone,
email,
ticketDisclaimer,
labelFormat,
labelWidth,
labelHeight,
isLabelVertical,
isPaid,
paymentExpiration,
subscriptionTier,

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/company/bloc/company_settings_cubit.dart';
import 'package:flux/features/company/models/company_model.dart';
import 'package:flux/features/settings/document_sequence/blocs/document_sequence_cubit.dart';
import 'package:flux/features/settings/document_sequence/ui/document_sequence_section.dart';
import 'package:image_picker/image_picker.dart';
class CompanySettingsScreen extends StatefulWidget {
@@ -24,6 +26,7 @@ class _CompanySettingsScreenState extends State<CompanySettingsScreen> {
final _zipCtrl = TextEditingController();
final _phoneCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _disclaimerCtrl = TextEditingController();
bool _isInitialized = false;
@@ -50,6 +53,8 @@ class _CompanySettingsScreenState extends State<CompanySettingsScreen> {
_zipCtrl.dispose();
_phoneCtrl.dispose();
_emailCtrl.dispose();
_disclaimerCtrl.dispose();
super.dispose();
}
@@ -69,6 +74,9 @@ class _CompanySettingsScreenState extends State<CompanySettingsScreen> {
if (_phoneCtrl.text.isEmpty) _phoneCtrl.text = company.phone ?? '';
if (_emailCtrl.text.isEmpty) _emailCtrl.text = company.email ?? '';
_isInitialized = true;
if (_disclaimerCtrl.text.isEmpty) {
_disclaimerCtrl.text = company.ticketDisclaimer ?? '';
}
}
void _flushToCubit() {
@@ -83,6 +91,7 @@ class _CompanySettingsScreenState extends State<CompanySettingsScreen> {
zipCode: _zipCtrl.text,
phone: _phoneCtrl.text,
email: _emailCtrl.text,
ticketDisclaimer: _disclaimerCtrl.text,
);
}
@@ -99,6 +108,36 @@ class _CompanySettingsScreenState extends State<CompanySettingsScreen> {
}
}
void _onLabelFormatChanged(LabelFormat selectedFormat) {
double? w;
double? h;
switch (selectedFormat) {
case LabelFormat.small_62x29:
w = 62.0;
h = 29.0;
break;
case LabelFormat.medium_54x101:
w = 54.0;
h = 101.0;
break;
case LabelFormat.large_102x152:
w = 102.0;
h = 152.0;
break;
case LabelFormat.custom:
case LabelFormat.none:
// Lasciamo i valori null o quelli vecchi
break;
}
context.read<CompanySettingsCubit>().updateFields(
labelFormat: selectedFormat,
labelWidth: w,
labelHeight: h,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -312,6 +351,63 @@ class _CompanySettingsScreenState extends State<CompanySettingsScreen> {
),
],
),
const SizedBox(height: 16),
BlocProvider(
create: (context) =>
DocumentSequenceCubit(state.company!.id!)
..loadSequences(),
child: const DocumentSequenceSection(),
),
const SizedBox(height: 16),
// Sezione Disclaimer
Text(
"Note Legali Ricevuta",
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
TextFormField(
controller: _disclaimerCtrl,
maxLines: 5,
decoration: const InputDecoration(
hintText:
"Inserisci qui la liberatoria legale che apparirà sulla ricevuta dei ticket...",
border: OutlineInputBorder(),
),
onChanged: (val) => context
.read<CompanySettingsCubit>()
.updateFields(ticketDisclaimer: val),
),
const SizedBox(height: 24),
// Sezione Etichette
Text(
"Configurazione Etichette",
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
DropdownButtonFormField<LabelFormat>(
initialValue: company.labelFormat,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.label_outline),
labelText: "Formato Stampa Etichetta",
),
items: LabelFormat.values
.map(
(f) => DropdownMenuItem(
value: f,
child: Text(
f.name.replaceAll('_', ' ').toUpperCase(),
),
),
)
.toList(),
onChanged: (val) {
if (val != null) {
_onLabelFormatChanged(val);
}
},
),
const SizedBox(height: 48),
// --- PULSANTE SALVATAGGIO ---

View File

@@ -217,46 +217,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
final isUltraWide = constraints.maxWidth > 1400;
final isDesktop = constraints.maxWidth > 900;
if (isUltraWide) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 4,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: _buildMainFormContent(
theme,
state,
displayStatus,
showFiles: false,
),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _buildNotesSection(isDesktop: true),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
Expanded(
flex: 3,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: SharedAttachmentsSection(
parentType: AttachmentParentType.operation,
parentId: state.operation.id,
titleForUpload:
state.operation.customerDisplayName ??
'Nuova pratica',
onGenerateIdForQr: _generateIdForQr,
),
),
),
],
);
return _buildUltraWide(state, theme);
} else if (isDesktop) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -365,48 +326,92 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
);
}
Widget _buildMainFormContent(
ThemeData theme,
OperationFormState state,
OperationStatus displayStatus, {
bool showFiles = true,
}) {
final currentOp = state.operation;
final currentType = currentOp.type;
return Column(
Widget _buildUltraWide(OperationFormState state, ThemeData theme) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StaffSection(
staffId: currentOp.staffId,
staffName: currentOp.staffDisplayName,
Expanded(
flex: 4,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStaffSection(state),
const Divider(height: 50),
_buildOperationStatusSection(state),
const Divider(height: 32),
_buildCustomerSection(state),
const SizedBox(height: 16),
_buildReferenceSection(state),
const Divider(height: 50),
_buildOperationTypeSection(state),
const SizedBox(height: 16),
_buildQuantitySection(state),
const Divider(height: 50),
_buildDetailsSection(state),
const Divider(height: 50),
],
),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _buildNotesSection(isDesktop: true),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
Expanded(
flex: 3,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: _buildAttachmentSection(state),
),
),
],
);
}
Widget _buildStaffSection(OperationFormState state) {
return StaffSection(
staffId: state.operation.staffId,
staffName: state.operation.staffDisplayName,
onStaffSelected: (staff) => {
context.read<OperationFormCubit>().updateFields(
staffId: staff.id,
staffDisplayName: staff.name,
),
},
),
const Divider(height: 50),
);
}
// --- SEZIONE STATO OPERAZIONE ---
Widget _buildOperationStatusSection(OperationFormState state) {
return Column(
children: [
_buildSectionTitle('Esito / Stato Operazione'),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: _getStatusColor(displayStatus).withValues(alpha: 0.1),
color: _getStatusColor(
state.operation.status,
).withValues(alpha: 0.1),
border: Border.all(
color: _getStatusColor(displayStatus).withValues(alpha: 0.3),
color: _getStatusColor(
state.operation.status,
).withValues(alpha: 0.3),
),
borderRadius: BorderRadius.circular(12),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<OperationStatus>(
isExpanded: true,
value: displayStatus,
value: state.operation.status,
icon: Icon(
Icons.arrow_drop_down,
color: _getStatusColor(displayStatus),
color: _getStatusColor(state.operation.status),
),
items: OperationStatus.values
/* .where(
@@ -450,34 +455,41 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
),
const SizedBox(height: 8),
Text(
displayStatus == OperationStatus.success
state.operation.status == OperationStatus.success
? 'Lascia OK se la pratica è stata caricata con successo.'
: 'Attenzione: la pratica verrà salvata come ${displayStatus.displayName}.',
: 'Attenzione: la pratica verrà salvata come ${state.operation.status.displayName}.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
const Divider(height: 32),
],
);
}
//_buildSectionTitle('Cliente & Riferimento'),
SharedCustomerSection(
customerId: currentOp.customerId,
customerName: currentOp.customerDisplayName,
Widget _buildCustomerSection(OperationFormState state) {
return SharedCustomerSection(
customerId: state.operation.customerId,
customerName: state.operation.customerDisplayName,
onCustomerSelected: (customer) {
context.read<OperationFormCubit>().updateFields(
customerId: customer.id,
customerDisplayName: customer.name,
);
},
),
const SizedBox(height: 16),
TextFormField(
);
}
Widget _buildReferenceSection(OperationFormState state) {
return TextFormField(
controller: _referenceController,
decoration: const InputDecoration(
labelText: 'Riferimento (es. numero di telefono, targa...)',
prefixIcon: Icon(Icons.tag),
),
),
const Divider(height: 32),
);
}
Widget _buildOperationTypeSection(OperationFormState state) {
return Column(
children: [
_buildSectionTitle('Cosa stiamo facendo?'),
Wrap(
spacing: 8.0,
@@ -485,7 +497,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
children: _availableTypes.map((type) {
return ChoiceChip(
label: Text(type),
selected: currentType == type,
selected: state.operation.type == type,
onSelected: (selected) {
if (selected) {
context.read<OperationFormCubit>().setTypeWithSmartDefault(
@@ -496,63 +508,90 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
);
}).toList(),
),
const Divider(height: 32),
],
);
}
Widget _buildDetailsSection(OperationFormState state) {
return Column(
children: [
_buildSectionTitle('Dettagli Servizio'),
DetailsSection(
currentOp: currentOp,
currentType: currentType,
currentOp: state.operation,
currentType: state.operation.type,
freeTextSubtypeController: _freeTextSubtypeController,
freeTextDescriptionController: _freeTextDescriptionController,
durationQuickPicks: _buildDurationQuickPicks(currentOp),
durationQuickPicks: _buildDurationQuickPicks(state.operation),
),
],
);
}
// QUANTITÀ
Row(
Widget _buildQuantitySection(OperationFormState state) {
return Row(
children: [
const Text('Quantità: '),
IconButton(
icon: const Icon(Icons.remove),
onPressed: () {
final q = currentOp.quantity;
final q = state.operation.quantity;
if (q > 1) {
context.read<OperationFormCubit>().updateFields(
quantity: q - 1,
);
context.read<OperationFormCubit>().updateFields(quantity: q - 1);
}
},
),
Text(
'${currentOp.quantity}',
'${state.operation.quantity}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
final q = currentOp.quantity;
context.read<OperationFormCubit>().updateFields(
quantity: q + 1,
);
final q = state.operation.quantity;
context.read<OperationFormCubit>().updateFields(quantity: q + 1);
},
),
],
),
);
}
Widget _buildAttachmentSection(OperationFormState state) {
return SharedAttachmentsSection(
parentType: AttachmentParentType.operation,
parentId: state.operation.id,
titleForUpload: state.operation.customerDisplayName ?? 'Nuova pratica',
onGenerateIdForQr: _generateIdForQr,
);
}
Widget _buildMainFormContent(
ThemeData theme,
OperationFormState state,
OperationStatus displayStatus, {
bool showFiles = true,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStaffSection(state),
const Divider(height: 50),
_buildOperationStatusSection(state),
const Divider(height: 32),
_buildCustomerSection(state),
const SizedBox(height: 16),
_buildReferenceSection(state),
const Divider(height: 50),
_buildOperationTypeSection(state),
const SizedBox(height: 16),
_buildQuantitySection(state),
const Divider(height: 50),
_buildDetailsSection(state),
const Divider(height: 50),
// QUANTITÀ
const Divider(height: 32),
if (showFiles) ...[
SharedAttachmentsSection(
parentType: AttachmentParentType.operation,
parentId: currentOp.id,
titleForUpload:
state.operation.customerDisplayName ?? 'Nuova pratica',
onGenerateIdForQr: _generateIdForQr,
),
/* SharedFilesSection(
titleNameForUpload:
state.operation.customerDisplayName ?? 'Nuova pratica',
onGenerateIdForQr: _generateIdForQr,
), */
],
if (showFiles) ...[_buildAttachmentSection(state)],
],
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -153,8 +153,12 @@ class TicketFormCubit extends Cubit<TicketFormState> {
if (ticketToSave.customerId == null || ticketToSave.customerId!.isEmpty) {
throw Exception("Seleziona un cliente prima di salvare.");
}
final savedTicket = await _repository.saveTicket(ticketToSave);
TicketModel? savedTicket;
if (ticketToSave.id == null) {
savedTicket = await _repository.insertTicket(ticketToSave);
} else {
savedTicket = await _repository.updateTicket(ticketToSave);
}
if (keepAdding) {
emit(
@@ -198,7 +202,7 @@ class TicketFormCubit extends Cubit<TicketFormState> {
throw Exception("Seleziona un cliente prima di poter usare il QR.");
}
final savedTicket = await _repository.saveTicket(ticketToSave);
final savedTicket = await _repository.insertTicket(ticketToSave);
// Aggiorniamo silenziosamente lo stato con il ticket che ora ha un ID!
emit(state.copyWith(ticket: savedTicket, status: TicketFormStatus.ready));

View File

@@ -192,12 +192,42 @@ class TicketRepository {
}
}
/// Salva il ticket con upsert
Future<TicketModel> saveTicket(TicketModel ticket) async {
Future<String> generateTicketReference(String companyId) async {
final response = await Supabase.instance.client.rpc(
'get_next_document_number',
params: {'p_company_id': companyId, 'p_doc_type': 'ticket'},
);
// Estraiamo i dati dal JSON
final int nextValue = response['next_value'];
final String prefix = response['prefix'] ?? '';
final year = DateTime.now().year; // 2026
// Formattazione con zeri iniziali (es. 000125)
final paddedNumber = nextValue.toString().padLeft(6, '0');
// Costruiamo la stringa. Se c'è un prefisso mette "TCK-2026-000125",
// altrimenti solo "2026-000125"
if (prefix.isNotEmpty) {
return '$prefix-$year-$paddedNumber';
} else {
return '$year-$paddedNumber';
}
}
/// Salva il ticket
Future<TicketModel> insertTicket(TicketModel ticket) async {
if (ticket.id != null) {
throw Exception('Impossibile creare un ticket esistente, id not null');
}
try {
final ticketToSave = ticket.copyWith(
referenceId: await generateTicketReference(ticket.companyId),
);
final response = await _supabase
.from(_tableName)
.upsert(ticket.toMap())
.insert(ticketToSave.toMap())
.select()
.single();

View File

@@ -98,7 +98,7 @@ class TicketModel extends Equatable {
final WarrantyType? warrantyType;
final String? publicNotes;
final String? internalNotes;
final int? referenceNumber;
final String? referenceId;
final String? alternativePhoneNumber;
final bool hasCourtesyDevice;
final TicketType ticketType;
@@ -106,7 +106,6 @@ class TicketModel extends Equatable {
final DateTime? estimatedDeliveryAt;
final TicketResult? ticketResult;
final String? resolutionNotes;
final String? legacyId;
final String? customerName;
final String? targetModelName;
final String? sourceModelName;
@@ -134,7 +133,7 @@ class TicketModel extends Equatable {
this.warrantyType,
this.publicNotes,
this.internalNotes,
this.referenceNumber,
this.referenceId,
this.alternativePhoneNumber,
this.hasCourtesyDevice = false,
required this.ticketType,
@@ -142,7 +141,6 @@ class TicketModel extends Equatable {
this.estimatedDeliveryAt,
this.ticketResult,
this.resolutionNotes,
this.legacyId,
this.customerName,
this.targetModelName,
this.sourceModelName,
@@ -185,7 +183,7 @@ class TicketModel extends Equatable {
WarrantyType? warrantyType,
String? publicNotes,
String? internalNotes,
int? referenceNumber,
String? referenceId,
String? alternativePhoneNumber,
bool? hasCourtesyDevice,
TicketType? ticketType,
@@ -193,7 +191,6 @@ class TicketModel extends Equatable {
DateTime? estimatedDeliveryAt,
TicketResult? ticketResult,
String? resolutionNotes,
String? legacyId,
String? customerName,
String? targetModelName,
String? sourceModelName,
@@ -221,7 +218,7 @@ class TicketModel extends Equatable {
warrantyType: warrantyType ?? this.warrantyType,
publicNotes: publicNotes ?? this.publicNotes,
internalNotes: internalNotes ?? this.internalNotes,
referenceNumber: referenceNumber ?? this.referenceNumber,
referenceId: referenceId ?? this.referenceId,
alternativePhoneNumber:
alternativePhoneNumber ?? this.alternativePhoneNumber,
hasCourtesyDevice: hasCourtesyDevice ?? this.hasCourtesyDevice,
@@ -230,7 +227,6 @@ class TicketModel extends Equatable {
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,
@@ -269,7 +265,7 @@ class TicketModel extends Equatable {
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?,
referenceId: map['reference_id'] as String?,
alternativePhoneNumber: map['alternative_phone_number'] as String?,
hasCourtesyDevice: map['has_courtesy_device'] as bool? ?? false,
ticketType: TicketType.fromString(map['ticket_type'] as String),
@@ -279,7 +275,6 @@ class TicketModel extends Equatable {
: 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(),
@@ -314,6 +309,7 @@ class TicketModel extends Equatable {
'warranty_type': warrantyType,
'public_notes': publicNotes,
'internal_notes': internalNotes,
'reference_id': referenceId,
'alternative_phone_number': alternativePhoneNumber,
'has_courtesy_device': hasCourtesyDevice,
'ticket_type': ticketType.value,
@@ -322,7 +318,6 @@ class TicketModel extends Equatable {
'estimated_delivery_at': estimatedDeliveryAt!.toUtc().toIso8601String(),
if (ticketResult != null) 'ticket_result': ticketResult!.value,
'resolution_notes': resolutionNotes,
'legacy_id': legacyId,
'included_accessories': includedAccessories,
};
}
@@ -346,7 +341,6 @@ class TicketModel extends Equatable {
warrantyType,
publicNotes,
internalNotes,
referenceNumber,
alternativePhoneNumber,
hasCourtesyDevice,
ticketType,
@@ -354,7 +348,6 @@ class TicketModel extends Equatable {
estimatedDeliveryAt,
ticketResult,
resolutionNotes,
legacyId,
includedAccessories,
customerName,
targetModelName,

View File

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

View File

@@ -9,6 +9,7 @@ import 'package:flux/features/auth/bloc/auth_cubit.dart';
import 'package:flux/features/company/data/company_repository.dart';
import 'package:flux/features/operations/blocs/operation_list_cubit.dart';
import 'package:flux/features/operations/data/operations_repository.dart';
import 'package:flux/features/settings/document_sequence/data/document_sequence_repository.dart';
import 'package:flux/features/tickets/data/ticket_repository.dart';
import 'package:flux/l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
@@ -97,6 +98,9 @@ Future<void> setupLocator() async {
() => AttachmentsRepository(),
);
getIt.registerLazySingleton<TicketRepository>(() => TicketRepository());
getIt.registerLazySingleton<DocumentSequenceRepository>(
() => DocumentSequenceRepository(),
);
// NOTA: CompanyRepository l'ho tolto perché la logica della Company
// ora è gestita dal CoreRepository durante l'Onboarding.

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import app_links
import file_picker
import file_selector_macos
import pdfx
import printing
import shared_preferences_foundation
import url_launcher_macos
@@ -17,6 +18,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
PdfxPlugin.register(with: registry.registrar(forPlugin: "PdfxPlugin"))
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View File

@@ -677,6 +677,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.12.0"
pdf_widget_wrapper:
dependency: transitive
description:
name: pdf_widget_wrapper
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
url: "https://pub.dev"
source: hosted
version: "1.0.4"
pdfx:
dependency: "direct main"
description:
@@ -789,6 +797,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.7.0"
printing:
dependency: "direct main"
description:
name: printing
sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692"
url: "https://pub.dev"
source: hosted
version: "5.14.3"
provider:
dependency: transitive
description:

View File

@@ -33,6 +33,7 @@ dependencies:
uuid: ^4.5.3
pdf: ^3.12.0
universal_io: ^2.3.1
printing: ^5.14.3
dev_dependencies:
flutter_test:

View File

@@ -10,6 +10,7 @@
#include <file_selector_windows/file_selector_windows.h>
#include <pdfx/pdfx_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <printing/printing_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
@@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("PdfxPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
PrintingPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PrintingPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
pdfx
permission_handler_windows
printing
url_launcher_windows
)