diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index aaed85e..6b4d2a1 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flux/core/blocs/session/session_bloc.dart'; import 'package:flux/features/auth/ui/auth_screen.dart'; import 'package:flux/features/company/ui/create_company_screen.dart'; +import 'package:flux/features/customers/models/customer_model.dart'; +import 'package:flux/features/customers/ui/customer_detail_screen.dart'; import 'package:flux/features/home_and_dashboard/ui/home_screen.dart'; import 'package:flux/features/store/ui/create_store_screen.dart'; import 'package:go_router/go_router.dart'; @@ -58,6 +60,14 @@ class AppRouter { path: '/create-store', builder: (context, state) => const CreateStoreScreen(), ), + GoRoute( + path: '/customer/:id', + builder: (context, state) { + // Recuperiamo l'oggetto customer passato tramite extra + final customer = state.extra as CustomerModel; + return CustomerDetailScreen(customer: customer); + }, + ), ], ); } diff --git a/lib/features/auth/ui/auth_screen.dart b/lib/features/auth/ui/auth_screen.dart index d17e3fe..addb7e8 100644 --- a/lib/features/auth/ui/auth_screen.dart +++ b/lib/features/auth/ui/auth_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/widgets/flux_logo.dart'; import 'package:flux/core/widgets/flux_text_field.dart'; diff --git a/lib/features/company/ui/create_company_screen.dart b/lib/features/company/ui/create_company_screen.dart index acc7341..255e2a0 100644 --- a/lib/features/company/ui/create_company_screen.dart +++ b/lib/features/company/ui/create_company_screen.dart @@ -6,8 +6,6 @@ import 'package:flux/core/blocs/session/session_bloc.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/widgets/flux_text_field.dart'; import 'package:flux/features/company/models/company_model.dart'; -import 'package:flux/features/settings/settings.dart'; -import 'package:get_it/get_it.dart'; class CreateCompanyScreen extends StatefulWidget { const CreateCompanyScreen({super.key}); diff --git a/lib/features/customers/data/customer_repository.dart b/lib/features/customers/data/customer_repository.dart index 9b83948..7d8d9fe 100644 --- a/lib/features/customers/data/customer_repository.dart +++ b/lib/features/customers/data/customer_repository.dart @@ -1,3 +1,7 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flux/features/customers/models/customer_file_model.dart'; import 'package:get_it/get_it.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/customer_model.dart'; @@ -38,7 +42,7 @@ class CustomerRepository { try { final response = await _client .from('customer') - .select() + .select('*, customer_file(count)') .eq('company_id', companyId) .eq('is_active', true) .order('nome'); @@ -67,4 +71,89 @@ class CustomerRepository { return []; } } + + /// Recupera i file di un cliente specifico + Future> getCustomerFiles(String customerId) async { + try { + final response = await _client + .from('customer_file') + .select() + .eq('customer_id', customerId); + + return (response as List) + .map((f) => CustomerFileModel.fromJson(f)) + .toList(); + } catch (e) { + throw 'Errore recupero file: $e'; + } + } + + /// Salva il riferimento del file nel DB + Future saveFileReference(CustomerFileModel file) async { + await _client.from('customer_file').insert(file.toJson()); + } + + /// Carica un file e salva il riferimento nel database + Future uploadAndRegisterFile({ + required String customerId, + required PlatformFile pickedFile, + }) async { + try { + final user = _client.auth.currentUser; + if (user == null) throw 'Utente non autenticato'; + + final fileName = pickedFile.name; + final extension = pickedFile.extension ?? ''; + final path = + '${user.id}/$customerId/${DateTime.now().millisecondsSinceEpoch}_$fileName'; + + // Usiamo bytes invece del path per massima compatibilità + if (pickedFile.bytes == null && pickedFile.path == null) { + throw 'Impossibile leggere il contenuto del file'; + } + + // Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes + if (pickedFile.bytes != null) { + await _client.storage + .from('documents') + .uploadBinary(path, pickedFile.bytes!); + } else { + final file = File(pickedFile.path!); + await _client.storage.from('documents').upload(path, file); + } + + final String publicUrl = _client.storage + .from('documents') + .getPublicUrl(path); + + final fileRecord = CustomerFileModel( + customerId: customerId, + name: fileName, + url: publicUrl, + extension: extension, + ); + + final response = await _client + .from('customer_file') + .insert(fileRecord.toJson()) + .select() + .single(); + + return CustomerFileModel.fromJson(response); + } catch (e) { + throw 'Errore durante l\'upload: $e'; + } + } + + /// Aggiorna la lista degli URL nel database + Future updateCustomerDocuments(int id, List urls) async { + await _client.from('customer').update({'document_urls': urls}).eq('id', id); + } + + /// Elimina un file dallo storage + Future deleteDocument(String fullPath) async { + // Il path dovrebbe essere ricavato dall'URL + final path = fullPath.split('documents/').last; + await _client.storage.from('documents').remove([path]); + } } diff --git a/lib/features/customers/models/customer_file_model.dart b/lib/features/customers/models/customer_file_model.dart new file mode 100644 index 0000000..f03cb24 --- /dev/null +++ b/lib/features/customers/models/customer_file_model.dart @@ -0,0 +1,45 @@ +import 'package:equatable/equatable.dart'; + +class CustomerFileModel extends Equatable { + final int? id; + final String customerId; // Riferimento UUID + final String name; + final String url; + final String extension; + final DateTime? createdAt; + + const CustomerFileModel({ + this.id, + required this.customerId, + required this.name, + required this.url, + required this.extension, + this.createdAt, + }); + + factory CustomerFileModel.fromJson(Map json) { + return CustomerFileModel( + id: json['id'], + customerId: json['customer_id'], + name: json['name'], + url: json['url'], + extension: json['extension'] ?? '', + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'customer_id': customerId, + 'name': name, + 'url': url, + 'extension': extension, + }; + } + + @override + List get props => [id, customerId, name, url, extension, createdAt]; +} diff --git a/lib/features/customers/models/customer_model.dart b/lib/features/customers/models/customer_model.dart index 2f14bdc..97f0f57 100644 --- a/lib/features/customers/models/customer_model.dart +++ b/lib/features/customers/models/customer_model.dart @@ -1,7 +1,7 @@ import 'package:equatable/equatable.dart'; class CustomerModel extends Equatable { - final int? id; // Bigint in SQL + final String? id; // Bigint in SQL final DateTime? createdAt; final String nome; final String telefono; @@ -11,6 +11,7 @@ class CustomerModel extends Equatable { final bool nonDisturbare; final String companyId; // UUID final bool isActive; + final int fileCount; const CustomerModel({ this.id, @@ -23,6 +24,7 @@ class CustomerModel extends Equatable { this.nonDisturbare = false, required this.companyId, this.isActive = true, + this.fileCount = 0, }); @override @@ -37,10 +39,11 @@ class CustomerModel extends Equatable { nonDisturbare, companyId, isActive, + fileCount, ]; CustomerModel copyWith({ - int? id, + String? id, DateTime? createdAt, String? nome, String? telefono, @@ -50,6 +53,7 @@ class CustomerModel extends Equatable { bool? nonDisturbare, String? companyId, bool? isActive, + int? fileCount, }) { return CustomerModel( id: id ?? this.id, @@ -62,12 +66,18 @@ class CustomerModel extends Equatable { nonDisturbare: nonDisturbare ?? this.nonDisturbare, companyId: companyId ?? this.companyId, isActive: isActive ?? this.isActive, + fileCount: fileCount ?? this.fileCount, ); } factory CustomerModel.fromJson(Map json) { + int count = 0; + if (json['customer_file'] != null && + (json['customer_file'] as List).isNotEmpty) { + count = json['customer_file'][0]['count'] ?? 0; + } return CustomerModel( - id: json['id'], + id: json['id'] as String, createdAt: json['created_at'] != null ? DateTime.parse(json['created_at']) : null, @@ -79,8 +89,9 @@ class CustomerModel extends Equatable { ? DateTime.parse(json['data_ultimo_contatto']) : null, nonDisturbare: json['non_disturbare'] ?? false, - companyId: json['company_id'], + companyId: json['company_id'] as String, isActive: json['is_active'] ?? true, + fileCount: count, ); } diff --git a/lib/features/customers/ui/customer_detail_screen.dart b/lib/features/customers/ui/customer_detail_screen.dart new file mode 100644 index 0000000..36f67b1 --- /dev/null +++ b/lib/features/customers/ui/customer_detail_screen.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flux/core/theme/theme.dart'; +import 'package:flux/features/customers/data/customer_repository.dart'; +import 'package:flux/features/customers/models/customer_model.dart'; +import 'package:flux/features/customers/models/customer_file_model.dart'; +import 'package:get_it/get_it.dart'; + +class CustomerDetailScreen extends StatefulWidget { + final CustomerModel customer; + const CustomerDetailScreen({super.key, required this.customer}); + + @override + State createState() => _CustomerDetailScreenState(); +} + +class _CustomerDetailScreenState extends State { + final _repository = GetIt.I(); + List _files = []; + bool _isLoadingFiles = true; + + @override + void initState() { + super.initState(); + _loadFiles(); + } + + Future _loadFiles() async { + try { + final files = await _repository.getCustomerFiles( + widget.customer.id.toString(), + ); + setState(() { + _files = files; + _isLoadingFiles = false; + }); + } catch (e) { + setState(() => _isLoadingFiles = false); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(e.toString()))); + } + } + } + + Future _pickAndUpload() async { + // Chiamata statica pulita + FilePickerResult? result = await FilePicker.pickFiles( + allowMultiple: true, + type: FileType.any, + withData: true, // Fondamentale per avere i bytes pronti se servono + ); + + if (result != null) { + for (var pickedFile in result.files) { + try { + final newFile = await _repository.uploadAndRegisterFile( + customerId: widget.customer.id.toString(), + pickedFile: pickedFile, + ); + setState(() => _files.add(newFile)); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Errore upload ${pickedFile.name}: $e")), + ); + } + } + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: context.background, + appBar: AppBar( + title: Text( + widget.customer.nome, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + backgroundColor: context.background, + elevation: 0, + ), + body: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // COLONNA SINISTRA: ANAGRAFICA + Expanded( + flex: 1, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: _buildInfoSection(), + ), + ), + + // DIVISORE VERTICALE + VerticalDivider( + width: 1, + color: context.accent.withValues(alpha: 0.1), + ), + + // COLONNA DESTRA: DOCUMENTI + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.all(24), + child: _buildDocumentSection(), + ), + ), + ], + ), + ); + } + + Widget _buildInfoSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _infoTile(Icons.phone_android, "Telefono", widget.customer.telefono), + _infoTile( + Icons.email_outlined, + "Email", + widget.customer.email.isEmpty ? "Non fornita" : widget.customer.email, + ), + _infoTile( + Icons.notes_outlined, + "Note", + widget.customer.note.isEmpty + ? "Nessun appunto" + : widget.customer.note, + ), + const SizedBox(height: 20), + if (widget.customer.nonDisturbare) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + Icon(Icons.privacy_tip, color: Colors.red, size: 20), + SizedBox(width: 10), + Text( + "PRIVACY: Non disturbare", + style: TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildDocumentSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "DOCUMENTI", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: context.accent, + ), + ), + ElevatedButton.icon( + onPressed: _pickAndUpload, + icon: const Icon(Icons.add_circle_outline), + label: const Text("CARICA FILE"), + ), + ], + ), + const SizedBox(height: 20), + if (_isLoadingFiles) + const Center(child: CircularProgressIndicator()) + else if (_files.isEmpty) + const Center(child: Text("Nessun documento presente")) + else + Expanded( + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisSpacing: 16, + crossAxisSpacing: 16, + childAspectRatio: 1.2, + ), + itemCount: _files.length, + itemBuilder: (context, index) => _FileCard(file: _files[index]), + ), + ), + ], + ); + } + + Widget _infoTile(IconData icon, String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: context.accent), + const SizedBox(width: 8), + Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), + ], + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle(color: context.secondaryText, fontSize: 16), + ), + ], + ), + ); + } +} + +class _FileCard extends StatelessWidget { + final CustomerFileModel file; + const _FileCard({required this.file}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: context.background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: context.accent.withValues(alpha: 0.1)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(_getFileIcon(file.extension), size: 48, color: context.accent), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + file.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ), + ), + ], + ), + ); + } + + IconData _getFileIcon(String ext) { + switch (ext.toLowerCase()) { + case 'pdf': + return Icons.picture_as_pdf; + case 'jpg': + case 'jpeg': + case 'png': + return Icons.image; + default: + return Icons.insert_drive_file; + } + } +} diff --git a/lib/features/customers/ui/customers_content.dart b/lib/features/customers/ui/customers_content.dart index b681a9d..eea3bd4 100644 --- a/lib/features/customers/ui/customers_content.dart +++ b/lib/features/customers/ui/customers_content.dart @@ -5,6 +5,7 @@ import 'package:flux/core/theme/theme.dart'; import 'package:flux/features/customers/blocs/customer_bloc.dart'; import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/ui/customer_form.dart'; +import 'package:go_router/go_router.dart'; class CustomersContent extends StatefulWidget { const CustomersContent({super.key}); @@ -147,7 +148,10 @@ class _CustomersContentState extends State { final customer = state.customers[index]; return _CustomerTile( customer: customer, - onTap: () => _openCustomerForm(customer: customer), + onTap: () => context.push( + '/customer/${customer.id}', + extra: customer, + ), ); }, ); @@ -222,6 +226,27 @@ class _CustomerTile extends StatelessWidget { customer.telefono, style: TextStyle(color: context.secondaryText), ), + if (customer.email.isNotEmpty) ...[ + Text(' - ', style: TextStyle(color: context.secondaryText)), + Icon(Icons.email, size: 14, color: context.secondaryText), + const SizedBox(width: 4), + Text( + customer.email, + style: TextStyle(color: context.secondaryText), + ), + ], + if (customer.fileCount > 0) ...[ + Text(' - ', style: TextStyle(color: context.secondaryText)), + Icon(Icons.attach_file, size: 14, color: context.accent), + Text( + '${customer.fileCount} doc', + style: TextStyle( + color: context.accent, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], ], ), ), diff --git a/lib/features/store/ui/create_store_screen.dart b/lib/features/store/ui/create_store_screen.dart index 7e18114..47cd8cf 100644 --- a/lib/features/store/ui/create_store_screen.dart +++ b/lib/features/store/ui/create_store_screen.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/features/settings/settings.dart'; import 'package:flux/features/store/bloc/store_bloc.dart'; import 'package:flux/features/store/models/store_model.dart'; -import 'package:get_it/get_it.dart'; import 'package:flux/core/blocs/session/session_bloc.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/widgets/flux_text_field.dart'; @@ -23,7 +21,6 @@ class _CreateStoreScreenState extends State { final _capController = TextEditingController(); final _comuneController = TextEditingController(); final _provinciaController = TextEditingController(); - final AppSettings _settings = GetIt.I(); @override void dispose() { diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a620c94..9738716 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,13 @@ import FlutterMacOS import Foundation import app_links +import file_picker import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 5b8e299..4e9e7fb 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,6 +1,8 @@ PODS: - app_links (6.4.1): - FlutterMacOS + - file_picker (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) - shared_preferences_foundation (0.0.1): - Flutter @@ -10,6 +12,7 @@ PODS: DEPENDENCIES: - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -17,6 +20,8 @@ DEPENDENCIES: EXTERNAL SOURCES: app_links: :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos FlutterMacOS: :path: Flutter/ephemeral shared_preferences_foundation: @@ -26,6 +31,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 08c3ab1..fa448f7 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,13 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-write + + + com.apple.security.files.downloads.read-write + + + com.apple.security.network.client + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index ee95ab7..547d827 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -6,5 +6,13 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-write + + + com.apple.security.files.downloads.read-write + + + com.apple.security.network.client + diff --git a/pubspec.lock b/pubspec.lock index 9fbb496..4f159cf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -121,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" equatable: dependency: "direct main" description: @@ -153,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + url: "https://pub.dev" + source: hosted + version: "11.0.2" flutter: dependency: "direct main" description: flutter @@ -174,6 +198,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_svg: dependency: "direct main" description: @@ -797,6 +829,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 487a4e2..cc84a06 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,7 @@ environment: dependencies: equatable: ^2.0.8 + file_picker: ^11.0.2 flutter: sdk: flutter flutter_bloc: ^9.1.1