runable
This commit is contained in:
334
lib/features/scanner/presentation/pages/detail_page.dart
Normal file
334
lib/features/scanner/presentation/pages/detail_page.dart
Normal file
@@ -0,0 +1,334 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../data/models/scan_item.dart';
|
||||
import '../providers/form_provider.dart';
|
||||
import '../providers/scanner_provider.dart';
|
||||
|
||||
/// Detail page for editing scan data with 4 text fields and Save/Print buttons
|
||||
class DetailPage extends ConsumerStatefulWidget {
|
||||
final String barcode;
|
||||
|
||||
const DetailPage({
|
||||
required this.barcode,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<DetailPage> createState() => _DetailPageState();
|
||||
}
|
||||
|
||||
class _DetailPageState extends ConsumerState<DetailPage> {
|
||||
late final TextEditingController _field1Controller;
|
||||
late final TextEditingController _field2Controller;
|
||||
late final TextEditingController _field3Controller;
|
||||
late final TextEditingController _field4Controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_field1Controller = TextEditingController();
|
||||
_field2Controller = TextEditingController();
|
||||
_field3Controller = TextEditingController();
|
||||
_field4Controller = TextEditingController();
|
||||
|
||||
// Initialize controllers with existing data if available
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadExistingData();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_field1Controller.dispose();
|
||||
_field2Controller.dispose();
|
||||
_field3Controller.dispose();
|
||||
_field4Controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Load existing data from history if available
|
||||
void _loadExistingData() {
|
||||
final history = ref.read(scanHistoryProvider);
|
||||
final existingScan = history.firstWhere(
|
||||
(item) => item.barcode == widget.barcode,
|
||||
orElse: () => ScanItem(barcode: widget.barcode, timestamp: DateTime.now()),
|
||||
);
|
||||
|
||||
_field1Controller.text = existingScan.field1;
|
||||
_field2Controller.text = existingScan.field2;
|
||||
_field3Controller.text = existingScan.field3;
|
||||
_field4Controller.text = existingScan.field4;
|
||||
|
||||
// Update form provider with existing data
|
||||
final formNotifier = ref.read(formProviderFamily(widget.barcode).notifier);
|
||||
formNotifier.populateWithScanItem(existingScan);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formState = ref.watch(formProviderFamily(widget.barcode));
|
||||
final formNotifier = ref.read(formProviderFamily(widget.barcode).notifier);
|
||||
|
||||
// Listen to form state changes for navigation
|
||||
ref.listen<FormDetailState>(
|
||||
formProviderFamily(widget.barcode),
|
||||
(previous, next) {
|
||||
if (next.isSaveSuccess && (previous?.isSaveSuccess != true)) {
|
||||
_showSuccessAndNavigateBack(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Edit Details'),
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Barcode Header
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Barcode',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.barcode,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Form Fields
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Field 1
|
||||
_buildTextField(
|
||||
controller: _field1Controller,
|
||||
label: 'Field 1',
|
||||
onChanged: formNotifier.updateField1,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Field 2
|
||||
_buildTextField(
|
||||
controller: _field2Controller,
|
||||
label: 'Field 2',
|
||||
onChanged: formNotifier.updateField2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Field 3
|
||||
_buildTextField(
|
||||
controller: _field3Controller,
|
||||
label: 'Field 3',
|
||||
onChanged: formNotifier.updateField3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Field 4
|
||||
_buildTextField(
|
||||
controller: _field4Controller,
|
||||
label: 'Field 4',
|
||||
onChanged: formNotifier.updateField4,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Error Message
|
||||
if (formState.error != null) ...[
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
formState.error!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Action Buttons
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
// Save Button
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: formState.isLoading ? null : () => _saveData(formNotifier),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
child: formState.isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text('Save'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Print Button
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: formState.isLoading ? null : () => _printData(formNotifier),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
child: const Text('Print'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build text field widget
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required void Function(String) onChanged,
|
||||
}) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
),
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
|
||||
/// Save form data
|
||||
Future<void> _saveData(FormNotifier formNotifier) async {
|
||||
// Clear any previous errors
|
||||
formNotifier.clearError();
|
||||
|
||||
// Attempt to save
|
||||
await formNotifier.saveData();
|
||||
}
|
||||
|
||||
/// Print form data
|
||||
Future<void> _printData(FormNotifier formNotifier) async {
|
||||
try {
|
||||
await formNotifier.printData();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Print dialog opened'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Print failed: ${e.toString()}'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show success message and navigate back
|
||||
void _showSuccessAndNavigateBack(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Data saved successfully!'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
// Navigate back after a short delay
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
if (mounted) {
|
||||
context.pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
193
lib/features/scanner/presentation/pages/home_page.dart
Normal file
193
lib/features/scanner/presentation/pages/home_page.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../providers/scanner_provider.dart';
|
||||
import '../widgets/barcode_scanner_widget.dart';
|
||||
import '../widgets/scan_result_display.dart';
|
||||
import '../widgets/scan_history_list.dart';
|
||||
|
||||
/// Home page with barcode scanner, result display, and history list
|
||||
class HomePage extends ConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scannerState = ref.watch(scannerProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Barcode Scanner'),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
ref.read(scannerProvider.notifier).refreshHistory();
|
||||
},
|
||||
tooltip: 'Refresh History',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Barcode Scanner Section (Top Half)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const BarcodeScannerWidget(),
|
||||
),
|
||||
),
|
||||
|
||||
// Scan Result Display
|
||||
ScanResultDisplay(
|
||||
barcode: scannerState.currentBarcode,
|
||||
onTap: scannerState.currentBarcode != null
|
||||
? () => _navigateToDetail(context, scannerState.currentBarcode!)
|
||||
: null,
|
||||
),
|
||||
|
||||
// Divider
|
||||
const Divider(height: 1),
|
||||
|
||||
// History Section (Bottom Half)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// History Header
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Scan History',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (scannerState.history.isNotEmpty)
|
||||
Text(
|
||||
'${scannerState.history.length} items',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// History List
|
||||
Expanded(
|
||||
child: _buildHistorySection(context, ref, scannerState),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build history section based on current state
|
||||
Widget _buildHistorySection(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ScannerState scannerState,
|
||||
) {
|
||||
if (scannerState.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (scannerState.error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Error loading history',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
scannerState.error!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.read(scannerProvider.notifier).refreshHistory();
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (scannerState.history.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.qr_code_scanner,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No scans yet',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Start scanning barcodes to see your history here',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ScanHistoryList(
|
||||
history: scannerState.history,
|
||||
onItemTap: (scanItem) => _navigateToDetail(context, scanItem.barcode),
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigate to detail page with barcode
|
||||
void _navigateToDetail(BuildContext context, String barcode) {
|
||||
context.push('/detail/$barcode');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../data/datasources/scanner_local_datasource.dart';
|
||||
import '../../data/datasources/scanner_remote_datasource.dart';
|
||||
import '../../data/models/scan_item.dart';
|
||||
import '../../data/repositories/scanner_repository_impl.dart';
|
||||
import '../../domain/repositories/scanner_repository.dart';
|
||||
import '../../domain/usecases/get_scan_history_usecase.dart';
|
||||
import '../../domain/usecases/save_scan_usecase.dart';
|
||||
|
||||
/// Network layer providers
|
||||
final dioProvider = Provider<Dio>((ref) {
|
||||
final dio = Dio();
|
||||
dio.options.baseUrl = 'https://api.example.com'; // Replace with actual API URL
|
||||
dio.options.connectTimeout = const Duration(seconds: 30);
|
||||
dio.options.receiveTimeout = const Duration(seconds: 30);
|
||||
dio.options.headers['Content-Type'] = 'application/json';
|
||||
dio.options.headers['Accept'] = 'application/json';
|
||||
|
||||
// Add interceptors for logging, authentication, etc.
|
||||
dio.interceptors.add(
|
||||
LogInterceptor(
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
logPrint: (obj) {
|
||||
// Log to console in debug mode using debugPrint
|
||||
// This will only log in debug mode
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return dio;
|
||||
});
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
return ApiClient();
|
||||
});
|
||||
|
||||
/// Local storage providers
|
||||
final hiveBoxProvider = Provider<Box<ScanItem>>((ref) {
|
||||
return Hive.box<ScanItem>('scans');
|
||||
});
|
||||
|
||||
/// Settings box provider
|
||||
final settingsBoxProvider = Provider<Box>((ref) {
|
||||
return Hive.box('settings');
|
||||
});
|
||||
|
||||
/// Data source providers
|
||||
final scannerRemoteDataSourceProvider = Provider<ScannerRemoteDataSource>((ref) {
|
||||
return ScannerRemoteDataSourceImpl(apiClient: ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final scannerLocalDataSourceProvider = Provider<ScannerLocalDataSource>((ref) {
|
||||
return ScannerLocalDataSourceImpl();
|
||||
});
|
||||
|
||||
/// Repository providers
|
||||
final scannerRepositoryProvider = Provider<ScannerRepository>((ref) {
|
||||
return ScannerRepositoryImpl(
|
||||
remoteDataSource: ref.watch(scannerRemoteDataSourceProvider),
|
||||
localDataSource: ref.watch(scannerLocalDataSourceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
/// Use case providers
|
||||
final saveScanUseCaseProvider = Provider<SaveScanUseCase>((ref) {
|
||||
return SaveScanUseCase(ref.watch(scannerRepositoryProvider));
|
||||
});
|
||||
|
||||
final getScanHistoryUseCaseProvider = Provider<GetScanHistoryUseCase>((ref) {
|
||||
return GetScanHistoryUseCase(ref.watch(scannerRepositoryProvider));
|
||||
});
|
||||
|
||||
/// Additional utility providers
|
||||
final currentTimestampProvider = Provider<DateTime>((ref) {
|
||||
return DateTime.now();
|
||||
});
|
||||
|
||||
/// Provider for checking network connectivity
|
||||
final networkStatusProvider = Provider<bool>((ref) {
|
||||
// This would typically use connectivity_plus package
|
||||
// For now, returning true as a placeholder
|
||||
return true;
|
||||
});
|
||||
|
||||
/// Provider for app configuration
|
||||
final appConfigProvider = Provider<Map<String, dynamic>>((ref) {
|
||||
return {
|
||||
'apiBaseUrl': 'https://api.example.com',
|
||||
'apiTimeout': 30000,
|
||||
'maxHistoryItems': 100,
|
||||
'enableLogging': !const bool.fromEnvironment('dart.vm.product'),
|
||||
};
|
||||
});
|
||||
|
||||
/// Provider for error handling configuration
|
||||
final errorHandlingConfigProvider = Provider<Map<String, String>>((ref) {
|
||||
return {
|
||||
'networkError': 'Network connection failed. Please check your internet connection.',
|
||||
'serverError': 'Server error occurred. Please try again later.',
|
||||
'validationError': 'Please check your input and try again.',
|
||||
'unknownError': 'An unexpected error occurred. Please try again.',
|
||||
};
|
||||
});
|
||||
|
||||
/// Provider for checking if required dependencies are initialized
|
||||
final dependenciesInitializedProvider = Provider<bool>((ref) {
|
||||
try {
|
||||
// Check if all critical dependencies are available
|
||||
ref.read(scannerRepositoryProvider);
|
||||
ref.read(saveScanUseCaseProvider);
|
||||
ref.read(getScanHistoryUseCaseProvider);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
/// Helper provider for getting localized error messages
|
||||
final errorMessageProvider = Provider.family<String, String>((ref, errorKey) {
|
||||
final config = ref.watch(errorHandlingConfigProvider);
|
||||
return config[errorKey] ?? config['unknownError']!;
|
||||
});
|
||||
253
lib/features/scanner/presentation/providers/form_provider.dart
Normal file
253
lib/features/scanner/presentation/providers/form_provider.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../data/models/scan_item.dart';
|
||||
import '../../domain/usecases/save_scan_usecase.dart';
|
||||
import 'dependency_injection.dart';
|
||||
import 'scanner_provider.dart';
|
||||
|
||||
/// State for the form functionality
|
||||
class FormDetailState {
|
||||
final String barcode;
|
||||
final String field1;
|
||||
final String field2;
|
||||
final String field3;
|
||||
final String field4;
|
||||
final bool isLoading;
|
||||
final bool isSaveSuccess;
|
||||
final String? error;
|
||||
|
||||
const FormDetailState({
|
||||
required this.barcode,
|
||||
this.field1 = '',
|
||||
this.field2 = '',
|
||||
this.field3 = '',
|
||||
this.field4 = '',
|
||||
this.isLoading = false,
|
||||
this.isSaveSuccess = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
FormDetailState copyWith({
|
||||
String? barcode,
|
||||
String? field1,
|
||||
String? field2,
|
||||
String? field3,
|
||||
String? field4,
|
||||
bool? isLoading,
|
||||
bool? isSaveSuccess,
|
||||
String? error,
|
||||
}) {
|
||||
return FormDetailState(
|
||||
barcode: barcode ?? this.barcode,
|
||||
field1: field1 ?? this.field1,
|
||||
field2: field2 ?? this.field2,
|
||||
field3: field3 ?? this.field3,
|
||||
field4: field4 ?? this.field4,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isSaveSuccess: isSaveSuccess ?? this.isSaveSuccess,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if all required fields are filled
|
||||
bool get isValid {
|
||||
return barcode.trim().isNotEmpty &&
|
||||
field1.trim().isNotEmpty &&
|
||||
field2.trim().isNotEmpty &&
|
||||
field3.trim().isNotEmpty &&
|
||||
field4.trim().isNotEmpty;
|
||||
}
|
||||
|
||||
/// Get validation error messages
|
||||
List<String> get validationErrors {
|
||||
final errors = <String>[];
|
||||
|
||||
if (barcode.trim().isEmpty) {
|
||||
errors.add('Barcode is required');
|
||||
}
|
||||
if (field1.trim().isEmpty) {
|
||||
errors.add('Field 1 is required');
|
||||
}
|
||||
if (field2.trim().isEmpty) {
|
||||
errors.add('Field 2 is required');
|
||||
}
|
||||
if (field3.trim().isEmpty) {
|
||||
errors.add('Field 3 is required');
|
||||
}
|
||||
if (field4.trim().isEmpty) {
|
||||
errors.add('Field 4 is required');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is FormDetailState &&
|
||||
runtimeType == other.runtimeType &&
|
||||
barcode == other.barcode &&
|
||||
field1 == other.field1 &&
|
||||
field2 == other.field2 &&
|
||||
field3 == other.field3 &&
|
||||
field4 == other.field4 &&
|
||||
isLoading == other.isLoading &&
|
||||
isSaveSuccess == other.isSaveSuccess &&
|
||||
error == other.error;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
barcode.hashCode ^
|
||||
field1.hashCode ^
|
||||
field2.hashCode ^
|
||||
field3.hashCode ^
|
||||
field4.hashCode ^
|
||||
isLoading.hashCode ^
|
||||
isSaveSuccess.hashCode ^
|
||||
error.hashCode;
|
||||
}
|
||||
|
||||
/// Form state notifier
|
||||
class FormNotifier extends StateNotifier<FormDetailState> {
|
||||
final SaveScanUseCase _saveScanUseCase;
|
||||
final Ref _ref;
|
||||
|
||||
FormNotifier(
|
||||
this._saveScanUseCase,
|
||||
this._ref,
|
||||
String barcode,
|
||||
) : super(FormDetailState(barcode: barcode));
|
||||
|
||||
/// Update field 1
|
||||
void updateField1(String value) {
|
||||
state = state.copyWith(field1: value, error: null);
|
||||
}
|
||||
|
||||
/// Update field 2
|
||||
void updateField2(String value) {
|
||||
state = state.copyWith(field2: value, error: null);
|
||||
}
|
||||
|
||||
/// Update field 3
|
||||
void updateField3(String value) {
|
||||
state = state.copyWith(field3: value, error: null);
|
||||
}
|
||||
|
||||
/// Update field 4
|
||||
void updateField4(String value) {
|
||||
state = state.copyWith(field4: value, error: null);
|
||||
}
|
||||
|
||||
/// Update barcode
|
||||
void updateBarcode(String value) {
|
||||
state = state.copyWith(barcode: value, error: null);
|
||||
}
|
||||
|
||||
/// Clear all fields
|
||||
void clearFields() {
|
||||
state = FormDetailState(barcode: state.barcode);
|
||||
}
|
||||
|
||||
/// Populate form with existing scan data
|
||||
void populateWithScanItem(ScanItem scanItem) {
|
||||
state = state.copyWith(
|
||||
barcode: scanItem.barcode,
|
||||
field1: scanItem.field1,
|
||||
field2: scanItem.field2,
|
||||
field3: scanItem.field3,
|
||||
field4: scanItem.field4,
|
||||
error: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Save form data to server and local storage
|
||||
Future<void> saveData() async {
|
||||
if (!state.isValid) {
|
||||
final errors = state.validationErrors;
|
||||
state = state.copyWith(error: errors.join(', '));
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null, isSaveSuccess: false);
|
||||
|
||||
final params = SaveScanParams(
|
||||
barcode: state.barcode,
|
||||
field1: state.field1,
|
||||
field2: state.field2,
|
||||
field3: state.field3,
|
||||
field4: state.field4,
|
||||
);
|
||||
|
||||
final result = await _saveScanUseCase.call(params);
|
||||
|
||||
result.fold(
|
||||
(failure) => state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: failure.message,
|
||||
isSaveSuccess: false,
|
||||
),
|
||||
(_) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
isSaveSuccess: true,
|
||||
error: null,
|
||||
);
|
||||
|
||||
// Update the scanner history with saved data
|
||||
final savedScanItem = ScanItem(
|
||||
barcode: state.barcode,
|
||||
timestamp: DateTime.now(),
|
||||
field1: state.field1,
|
||||
field2: state.field2,
|
||||
field3: state.field3,
|
||||
field4: state.field4,
|
||||
);
|
||||
|
||||
_ref.read(scannerProvider.notifier).updateScanItem(savedScanItem);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Print form data
|
||||
Future<void> printData() async {
|
||||
try {
|
||||
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: 'Failed to print: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Clear error message
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
|
||||
/// Reset save success state
|
||||
void resetSaveSuccess() {
|
||||
state = state.copyWith(isSaveSuccess: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider factory for form state (requires barcode parameter)
|
||||
final formProviderFamily = StateNotifierProvider.family<FormNotifier, FormDetailState, String>(
|
||||
(ref, barcode) => FormNotifier(
|
||||
ref.watch(saveScanUseCaseProvider),
|
||||
ref,
|
||||
barcode,
|
||||
),
|
||||
);
|
||||
|
||||
/// Convenience provider for accessing form state with a specific barcode
|
||||
/// This should be used with Provider.of or ref.watch(formProvider(barcode))
|
||||
Provider<FormNotifier> formProvider(String barcode) {
|
||||
return Provider<FormNotifier>((ref) {
|
||||
return ref.watch(formProviderFamily(barcode).notifier);
|
||||
});
|
||||
}
|
||||
|
||||
/// Convenience provider for accessing form state
|
||||
Provider<FormDetailState> formStateProvider(String barcode) {
|
||||
return Provider<FormDetailState>((ref) {
|
||||
return ref.watch(formProviderFamily(barcode));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../data/models/scan_item.dart';
|
||||
import '../../domain/usecases/get_scan_history_usecase.dart';
|
||||
import 'dependency_injection.dart';
|
||||
|
||||
/// State for the scanner functionality
|
||||
class ScannerState {
|
||||
final String? currentBarcode;
|
||||
final List<ScanItem> history;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const ScannerState({
|
||||
this.currentBarcode,
|
||||
this.history = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
ScannerState copyWith({
|
||||
String? currentBarcode,
|
||||
List<ScanItem>? history,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return ScannerState(
|
||||
currentBarcode: currentBarcode ?? this.currentBarcode,
|
||||
history: history ?? this.history,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error ?? this.error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ScannerState &&
|
||||
runtimeType == other.runtimeType &&
|
||||
currentBarcode == other.currentBarcode &&
|
||||
history == other.history &&
|
||||
isLoading == other.isLoading &&
|
||||
error == other.error;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
currentBarcode.hashCode ^
|
||||
history.hashCode ^
|
||||
isLoading.hashCode ^
|
||||
error.hashCode;
|
||||
}
|
||||
|
||||
/// Scanner state notifier
|
||||
class ScannerNotifier extends StateNotifier<ScannerState> {
|
||||
final GetScanHistoryUseCase _getScanHistoryUseCase;
|
||||
|
||||
ScannerNotifier(this._getScanHistoryUseCase) : super(const ScannerState()) {
|
||||
_loadHistory();
|
||||
}
|
||||
|
||||
/// Load scan history from local storage
|
||||
Future<void> _loadHistory() async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
final result = await _getScanHistoryUseCase();
|
||||
result.fold(
|
||||
(failure) => state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: failure.message,
|
||||
),
|
||||
(history) => state = state.copyWith(
|
||||
isLoading: false,
|
||||
history: history.map((entity) => ScanItem.fromEntity(entity)).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Update current scanned barcode
|
||||
void updateBarcode(String barcode) {
|
||||
if (barcode.trim().isEmpty) return;
|
||||
|
||||
state = state.copyWith(currentBarcode: barcode);
|
||||
|
||||
// Add to history if not already present
|
||||
final existingIndex = state.history.indexWhere((item) => item.barcode == barcode);
|
||||
if (existingIndex == -1) {
|
||||
final newScanItem = ScanItem(
|
||||
barcode: barcode,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
final updatedHistory = [newScanItem, ...state.history];
|
||||
state = state.copyWith(history: updatedHistory);
|
||||
} else {
|
||||
// Move existing item to top
|
||||
final existingItem = state.history[existingIndex];
|
||||
final updatedHistory = List<ScanItem>.from(state.history);
|
||||
updatedHistory.removeAt(existingIndex);
|
||||
updatedHistory.insert(0, existingItem.copyWith(timestamp: DateTime.now()));
|
||||
state = state.copyWith(history: updatedHistory);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear current barcode
|
||||
void clearBarcode() {
|
||||
state = state.copyWith(currentBarcode: null);
|
||||
}
|
||||
|
||||
/// Refresh history from storage
|
||||
Future<void> refreshHistory() async {
|
||||
await _loadHistory();
|
||||
}
|
||||
|
||||
/// Add or update scan item in history
|
||||
void updateScanItem(ScanItem scanItem) {
|
||||
final existingIndex = state.history.indexWhere(
|
||||
(item) => item.barcode == scanItem.barcode,
|
||||
);
|
||||
|
||||
List<ScanItem> updatedHistory;
|
||||
if (existingIndex != -1) {
|
||||
// Update existing item
|
||||
updatedHistory = List<ScanItem>.from(state.history);
|
||||
updatedHistory[existingIndex] = scanItem;
|
||||
} else {
|
||||
// Add new item at the beginning
|
||||
updatedHistory = [scanItem, ...state.history];
|
||||
}
|
||||
|
||||
state = state.copyWith(history: updatedHistory);
|
||||
}
|
||||
|
||||
/// Clear error message
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for scanner state
|
||||
final scannerProvider = StateNotifierProvider<ScannerNotifier, ScannerState>(
|
||||
(ref) => ScannerNotifier(
|
||||
ref.watch(getScanHistoryUseCaseProvider),
|
||||
),
|
||||
);
|
||||
|
||||
/// Provider for current barcode (for easy access)
|
||||
final currentBarcodeProvider = Provider<String?>((ref) {
|
||||
return ref.watch(scannerProvider).currentBarcode;
|
||||
});
|
||||
|
||||
/// Provider for scan history (for easy access)
|
||||
final scanHistoryProvider = Provider<List<ScanItem>>((ref) {
|
||||
return ref.watch(scannerProvider).history;
|
||||
});
|
||||
|
||||
/// Provider for scanner loading state
|
||||
final scannerLoadingProvider = Provider<bool>((ref) {
|
||||
return ref.watch(scannerProvider).isLoading;
|
||||
});
|
||||
|
||||
/// Provider for scanner error state
|
||||
final scannerErrorProvider = Provider<String?>((ref) {
|
||||
return ref.watch(scannerProvider).error;
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
import '../providers/scanner_provider.dart';
|
||||
|
||||
/// Widget that provides barcode scanning functionality using device camera
|
||||
class BarcodeScannerWidget extends ConsumerStatefulWidget {
|
||||
const BarcodeScannerWidget({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BarcodeScannerWidget> createState() => _BarcodeScannerWidgetState();
|
||||
}
|
||||
|
||||
class _BarcodeScannerWidgetState extends ConsumerState<BarcodeScannerWidget>
|
||||
with WidgetsBindingObserver {
|
||||
late MobileScannerController _controller;
|
||||
bool _isStarted = false;
|
||||
String? _lastScannedCode;
|
||||
DateTime? _lastScanTime;
|
||||
bool _isTorchOn = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_controller = MobileScannerController(
|
||||
formats: [
|
||||
BarcodeFormat.code128,
|
||||
],
|
||||
facing: CameraFacing.back,
|
||||
torchEnabled: false,
|
||||
);
|
||||
_startScanner();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
|
||||
switch (state) {
|
||||
case AppLifecycleState.paused:
|
||||
_stopScanner();
|
||||
break;
|
||||
case AppLifecycleState.resumed:
|
||||
_startScanner();
|
||||
break;
|
||||
case AppLifecycleState.detached:
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.hidden:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startScanner() async {
|
||||
if (!_isStarted && mounted) {
|
||||
try {
|
||||
await _controller.start();
|
||||
setState(() {
|
||||
_isStarted = true;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Failed to start scanner: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopScanner() async {
|
||||
if (_isStarted) {
|
||||
try {
|
||||
await _controller.stop();
|
||||
setState(() {
|
||||
_isStarted = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Failed to stop scanner: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onBarcodeDetected(BarcodeCapture capture) {
|
||||
final List<Barcode> barcodes = capture.barcodes;
|
||||
|
||||
if (barcodes.isNotEmpty) {
|
||||
final barcode = barcodes.first;
|
||||
final code = barcode.rawValue;
|
||||
|
||||
if (code != null && code.isNotEmpty) {
|
||||
// Prevent duplicate scans within 2 seconds
|
||||
final now = DateTime.now();
|
||||
if (_lastScannedCode == code &&
|
||||
_lastScanTime != null &&
|
||||
now.difference(_lastScanTime!).inSeconds < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
_lastScannedCode = code;
|
||||
_lastScanTime = now;
|
||||
|
||||
// Update scanner provider with new barcode
|
||||
ref.read(scannerProvider.notifier).updateBarcode(code);
|
||||
|
||||
// Provide haptic feedback
|
||||
_provideHapticFeedback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _provideHapticFeedback() {
|
||||
// Haptic feedback is handled by the system
|
||||
// You can add custom vibration here if needed
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Camera View
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
child: MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: _onBarcodeDetected,
|
||||
),
|
||||
),
|
||||
|
||||
// Overlay with scanner frame
|
||||
_buildScannerOverlay(context),
|
||||
|
||||
// Control buttons
|
||||
_buildControlButtons(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build scanner overlay with frame and guidance
|
||||
Widget _buildScannerOverlay(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Dark overlay with cutout
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 250,
|
||||
height: 150,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Instructions
|
||||
Positioned(
|
||||
bottom: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
child: Text(
|
||||
'Position barcode within the frame',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build control buttons (torch, camera switch)
|
||||
Widget _buildControlButtons(BuildContext context) {
|
||||
return Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: Column(
|
||||
children: [
|
||||
// Torch Toggle
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
_isTorchOn ? Icons.flash_on : Icons.flash_off,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: _toggleTorch,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Camera Switch
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.cameraswitch,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: _switchCamera,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build error widget when camera fails
|
||||
Widget _buildErrorWidget(MobileScannerException error) {
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.camera_alt_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Camera Error',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
_getErrorMessage(error),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white70,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _restartScanner,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build placeholder while camera is loading
|
||||
Widget _buildPlaceholderWidget() {
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get user-friendly error message
|
||||
String _getErrorMessage(MobileScannerException error) {
|
||||
switch (error.errorCode) {
|
||||
case MobileScannerErrorCode.permissionDenied:
|
||||
return 'Camera permission is required to scan barcodes. Please enable camera access in settings.';
|
||||
case MobileScannerErrorCode.unsupported:
|
||||
return 'Your device does not support barcode scanning.';
|
||||
default:
|
||||
return 'Unable to access camera. Please check your device settings and try again.';
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle torch/flashlight
|
||||
void _toggleTorch() async {
|
||||
try {
|
||||
await _controller.toggleTorch();
|
||||
setState(() {
|
||||
_isTorchOn = !_isTorchOn;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Failed to toggle torch: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch between front and back camera
|
||||
void _switchCamera() async {
|
||||
try {
|
||||
await _controller.switchCamera();
|
||||
} catch (e) {
|
||||
debugPrint('Failed to switch camera: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart scanner after error
|
||||
void _restartScanner() async {
|
||||
try {
|
||||
await _controller.stop();
|
||||
await _controller.start();
|
||||
setState(() {
|
||||
_isStarted = true;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Failed to restart scanner: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
236
lib/features/scanner/presentation/widgets/scan_history_list.dart
Normal file
236
lib/features/scanner/presentation/widgets/scan_history_list.dart
Normal file
@@ -0,0 +1,236 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../data/models/scan_item.dart';
|
||||
|
||||
/// Widget to display a scrollable list of scan history items
|
||||
class ScanHistoryList extends StatelessWidget {
|
||||
final List<ScanItem> history;
|
||||
final Function(ScanItem)? onItemTap;
|
||||
final Function(ScanItem)? onItemLongPress;
|
||||
final bool showTimestamp;
|
||||
|
||||
const ScanHistoryList({
|
||||
required this.history,
|
||||
this.onItemTap,
|
||||
this.onItemLongPress,
|
||||
this.showTimestamp = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (history.isEmpty) {
|
||||
return _buildEmptyState(context);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: history.length,
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final scanItem = history[index];
|
||||
return _buildHistoryItem(context, scanItem, index);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build individual history item
|
||||
Widget _buildHistoryItem(BuildContext context, ScanItem scanItem, int index) {
|
||||
final hasData = scanItem.field1.isNotEmpty ||
|
||||
scanItem.field2.isNotEmpty ||
|
||||
scanItem.field3.isNotEmpty ||
|
||||
scanItem.field4.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
elevation: 1,
|
||||
child: InkWell(
|
||||
onTap: onItemTap != null ? () => onItemTap!(scanItem) : null,
|
||||
onLongPress: onItemLongPress != null ? () => onItemLongPress!(scanItem) : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Icon indicating scan status
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: hasData
|
||||
? Colors.green.withOpacity(0.1)
|
||||
: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
hasData ? Icons.check_circle : Icons.qr_code,
|
||||
size: 20,
|
||||
color: hasData
|
||||
? Colors.green
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Barcode and details
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Barcode
|
||||
Text(
|
||||
scanItem.barcode,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Status and timestamp
|
||||
Row(
|
||||
children: [
|
||||
// Status indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: hasData
|
||||
? Colors.green.withOpacity(0.2)
|
||||
: Theme.of(context).colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
hasData ? 'Saved' : 'Scanned',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: hasData
|
||||
? Colors.green.shade700
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (showTimestamp) ...[
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_formatTimestamp(scanItem.timestamp),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
// Data preview (if available)
|
||||
if (hasData) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_buildDataPreview(scanItem),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Chevron icon
|
||||
if (onItemTap != null)
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build empty state when no history is available
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No scan history',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Scanned barcodes will appear here',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Format timestamp for display
|
||||
String _formatTimestamp(DateTime timestamp) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(timestamp);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return DateFormat('MMM dd, yyyy').format(timestamp);
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours}h ago';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes}m ago';
|
||||
} else {
|
||||
return 'Just now';
|
||||
}
|
||||
}
|
||||
|
||||
/// Build preview of saved data
|
||||
String _buildDataPreview(ScanItem scanItem) {
|
||||
final fields = [
|
||||
scanItem.field1,
|
||||
scanItem.field2,
|
||||
scanItem.field3,
|
||||
scanItem.field4,
|
||||
].where((field) => field.isNotEmpty).toList();
|
||||
|
||||
if (fields.isEmpty) {
|
||||
return 'No data saved';
|
||||
}
|
||||
|
||||
return fields.join(' • ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Widget to display the most recent scan result with tap to edit functionality
|
||||
class ScanResultDisplay extends StatelessWidget {
|
||||
final String? barcode;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
const ScanResultDisplay({
|
||||
required this.barcode,
|
||||
this.onTap,
|
||||
this.onCopy,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: barcode != null ? _buildScannedResult(context) : _buildEmptyState(context),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build widget when barcode is scanned
|
||||
Widget _buildScannedResult(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Barcode icon
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.qr_code,
|
||||
size: 24,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Barcode text and label
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Last Scanned',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
barcode!,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (onTap != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Tap to edit',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Copy button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.copy,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () => _copyToClipboard(context),
|
||||
tooltip: 'Copy to clipboard',
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
|
||||
// Edit button (if tap is enabled)
|
||||
if (onTap != null)
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build empty state when no barcode is scanned
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Placeholder icon
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.qr_code_scanner,
|
||||
size: 24,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Placeholder text
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'No barcode scanned',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Point camera at barcode to scan',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Scan animation (optional visual feedback)
|
||||
_buildScanAnimation(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build scanning animation indicator
|
||||
Widget _buildScanAnimation(BuildContext context) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
duration: const Duration(seconds: 2),
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
builder: (context, value, child) {
|
||||
return Opacity(
|
||||
opacity: (1.0 - value).clamp(0.3, 1.0),
|
||||
child: Container(
|
||||
width: 4,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onEnd: () {
|
||||
// Restart animation (this creates a continuous effect)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Copy barcode to clipboard
|
||||
void _copyToClipboard(BuildContext context) {
|
||||
if (barcode != null) {
|
||||
Clipboard.setData(ClipboardData(text: barcode!));
|
||||
|
||||
// Show feedback
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Copied "$barcode" to clipboard'),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
action: SnackBarAction(
|
||||
label: 'OK',
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Call custom onCopy callback if provided
|
||||
onCopy?.call();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user