runable
This commit is contained in:
6
lib/features/scanner/data/data.dart
Normal file
6
lib/features/scanner/data/data.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
// Data layer exports
|
||||
export 'datasources/scanner_local_datasource.dart';
|
||||
export 'datasources/scanner_remote_datasource.dart';
|
||||
export 'models/save_request_model.dart';
|
||||
export 'models/scan_item.dart';
|
||||
export 'repositories/scanner_repository_impl.dart';
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import '../../../../core/errors/exceptions.dart';
|
||||
import '../models/scan_item.dart';
|
||||
|
||||
/// Abstract local data source for scanner operations
|
||||
abstract class ScannerLocalDataSource {
|
||||
/// Save scan to local storage
|
||||
Future<void> saveScan(ScanItem scan);
|
||||
|
||||
/// Get all scans from local storage
|
||||
Future<List<ScanItem>> getAllScans();
|
||||
|
||||
/// Get scan by barcode from local storage
|
||||
Future<ScanItem?> getScanByBarcode(String barcode);
|
||||
|
||||
/// Update scan in local storage
|
||||
Future<void> updateScan(ScanItem scan);
|
||||
|
||||
/// Delete scan from local storage
|
||||
Future<void> deleteScan(String barcode);
|
||||
|
||||
/// Clear all scans from local storage
|
||||
Future<void> clearAllScans();
|
||||
}
|
||||
|
||||
/// Implementation of ScannerLocalDataSource using Hive
|
||||
class ScannerLocalDataSourceImpl implements ScannerLocalDataSource {
|
||||
static const String _boxName = 'scans';
|
||||
Box<ScanItem>? _box;
|
||||
|
||||
/// Initialize Hive box
|
||||
Future<Box<ScanItem>> _getBox() async {
|
||||
if (_box == null || !_box!.isOpen) {
|
||||
try {
|
||||
_box = await Hive.openBox<ScanItem>(_boxName);
|
||||
} catch (e) {
|
||||
throw CacheException('Failed to open Hive box: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
return _box!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveScan(ScanItem scan) async {
|
||||
try {
|
||||
final box = await _getBox();
|
||||
|
||||
// Use barcode as key to avoid duplicates
|
||||
await box.put(scan.barcode, scan);
|
||||
|
||||
// Optional: Log the save operation
|
||||
// print('Scan saved locally: ${scan.barcode}');
|
||||
|
||||
} on CacheException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw CacheException('Failed to save scan locally: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ScanItem>> getAllScans() async {
|
||||
try {
|
||||
final box = await _getBox();
|
||||
|
||||
// Get all values from the box
|
||||
final scans = box.values.toList();
|
||||
|
||||
// Sort by timestamp (most recent first)
|
||||
scans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
|
||||
return scans;
|
||||
|
||||
} on CacheException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw CacheException('Failed to get scans from local storage: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ScanItem?> getScanByBarcode(String barcode) async {
|
||||
try {
|
||||
if (barcode.trim().isEmpty) {
|
||||
throw const ValidationException('Barcode cannot be empty');
|
||||
}
|
||||
|
||||
final box = await _getBox();
|
||||
|
||||
// Get scan by barcode key
|
||||
return box.get(barcode);
|
||||
|
||||
} on ValidationException {
|
||||
rethrow;
|
||||
} on CacheException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw CacheException('Failed to get scan by barcode: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateScan(ScanItem scan) async {
|
||||
try {
|
||||
final box = await _getBox();
|
||||
|
||||
// Check if scan exists
|
||||
if (!box.containsKey(scan.barcode)) {
|
||||
throw CacheException('Scan with barcode ${scan.barcode} not found');
|
||||
}
|
||||
|
||||
// Update the scan
|
||||
await box.put(scan.barcode, scan);
|
||||
|
||||
// Optional: Log the update operation
|
||||
// print('Scan updated locally: ${scan.barcode}');
|
||||
|
||||
} on CacheException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw CacheException('Failed to update scan locally: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteScan(String barcode) async {
|
||||
try {
|
||||
if (barcode.trim().isEmpty) {
|
||||
throw const ValidationException('Barcode cannot be empty');
|
||||
}
|
||||
|
||||
final box = await _getBox();
|
||||
|
||||
// Check if scan exists
|
||||
if (!box.containsKey(barcode)) {
|
||||
throw CacheException('Scan with barcode $barcode not found');
|
||||
}
|
||||
|
||||
// Delete the scan
|
||||
await box.delete(barcode);
|
||||
|
||||
// Optional: Log the delete operation
|
||||
// print('Scan deleted locally: $barcode');
|
||||
|
||||
} on ValidationException {
|
||||
rethrow;
|
||||
} on CacheException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw CacheException('Failed to delete scan locally: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearAllScans() async {
|
||||
try {
|
||||
final box = await _getBox();
|
||||
|
||||
// Clear all scans
|
||||
await box.clear();
|
||||
|
||||
// Optional: Log the clear operation
|
||||
// print('All scans cleared from local storage');
|
||||
|
||||
} on CacheException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw CacheException('Failed to clear all scans: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get scans count (utility method)
|
||||
Future<int> getScansCount() async {
|
||||
try {
|
||||
final box = await _getBox();
|
||||
return box.length;
|
||||
} on CacheException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw CacheException('Failed to get scans count: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if scan exists (utility method)
|
||||
Future<bool> scanExists(String barcode) async {
|
||||
try {
|
||||
if (barcode.trim().isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final box = await _getBox();
|
||||
return box.containsKey(barcode);
|
||||
} on CacheException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw CacheException('Failed to check if scan exists: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get scans within date range (utility method)
|
||||
Future<List<ScanItem>> getScansByDateRange({
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
try {
|
||||
final allScans = await getAllScans();
|
||||
|
||||
// Filter by date range
|
||||
final filteredScans = allScans.where((scan) {
|
||||
return scan.timestamp.isAfter(startDate) &&
|
||||
scan.timestamp.isBefore(endDate);
|
||||
}).toList();
|
||||
|
||||
return filteredScans;
|
||||
} on CacheException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw CacheException('Failed to get scans by date range: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the Hive box (call this when app is closing)
|
||||
Future<void> dispose() async {
|
||||
if (_box != null && _box!.isOpen) {
|
||||
await _box!.close();
|
||||
_box = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../core/errors/exceptions.dart';
|
||||
import '../models/save_request_model.dart';
|
||||
|
||||
/// Abstract remote data source for scanner operations
|
||||
abstract class ScannerRemoteDataSource {
|
||||
/// Save scan data to remote server
|
||||
Future<void> saveScan(SaveRequestModel request);
|
||||
|
||||
/// Get scan data from remote server (optional for future use)
|
||||
Future<Map<String, dynamic>?> getScanData(String barcode);
|
||||
}
|
||||
|
||||
/// Implementation of ScannerRemoteDataSource using HTTP API
|
||||
class ScannerRemoteDataSourceImpl implements ScannerRemoteDataSource {
|
||||
final ApiClient apiClient;
|
||||
|
||||
ScannerRemoteDataSourceImpl({required this.apiClient});
|
||||
|
||||
@override
|
||||
Future<void> saveScan(SaveRequestModel request) async {
|
||||
try {
|
||||
// Validate request before sending
|
||||
if (!request.isValid) {
|
||||
throw ValidationException('Invalid request data: ${request.validationErrors.join(', ')}');
|
||||
}
|
||||
|
||||
final response = await apiClient.post(
|
||||
'/api/scans',
|
||||
data: request.toJson(),
|
||||
);
|
||||
|
||||
// Check if the response indicates success
|
||||
if (response.statusCode == null ||
|
||||
(response.statusCode! < 200 || response.statusCode! >= 300)) {
|
||||
final errorMessage = response.data?['message'] ?? 'Unknown server error';
|
||||
throw ServerException('Failed to save scan: $errorMessage');
|
||||
}
|
||||
|
||||
// Log successful save (in production, use proper logging)
|
||||
// print('Scan saved successfully: ${request.barcode}');
|
||||
|
||||
} on ValidationException {
|
||||
rethrow;
|
||||
} on ServerException {
|
||||
rethrow;
|
||||
} on NetworkException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
// Handle any unexpected errors
|
||||
throw ServerException('Unexpected error occurred while saving scan: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> getScanData(String barcode) async {
|
||||
try {
|
||||
if (barcode.trim().isEmpty) {
|
||||
throw const ValidationException('Barcode cannot be empty');
|
||||
}
|
||||
|
||||
final response = await apiClient.get(
|
||||
'/api/scans/$barcode',
|
||||
);
|
||||
|
||||
if (response.statusCode == 404) {
|
||||
// Scan not found is not an error, just return null
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.statusCode == null ||
|
||||
(response.statusCode! < 200 || response.statusCode! >= 300)) {
|
||||
final errorMessage = response.data?['message'] ?? 'Unknown server error';
|
||||
throw ServerException('Failed to get scan data: $errorMessage');
|
||||
}
|
||||
|
||||
return response.data as Map<String, dynamic>?;
|
||||
|
||||
} on ValidationException {
|
||||
rethrow;
|
||||
} on ServerException {
|
||||
rethrow;
|
||||
} on NetworkException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw ServerException('Unexpected error occurred while getting scan data: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Update scan data on remote server (optional for future use)
|
||||
Future<void> updateScan(String barcode, SaveRequestModel request) async {
|
||||
try {
|
||||
if (barcode.trim().isEmpty) {
|
||||
throw const ValidationException('Barcode cannot be empty');
|
||||
}
|
||||
|
||||
if (!request.isValid) {
|
||||
throw ValidationException('Invalid request data: ${request.validationErrors.join(', ')}');
|
||||
}
|
||||
|
||||
final response = await apiClient.put(
|
||||
'/api/scans/$barcode',
|
||||
data: request.toJson(),
|
||||
);
|
||||
|
||||
if (response.statusCode == null ||
|
||||
(response.statusCode! < 200 || response.statusCode! >= 300)) {
|
||||
final errorMessage = response.data?['message'] ?? 'Unknown server error';
|
||||
throw ServerException('Failed to update scan: $errorMessage');
|
||||
}
|
||||
|
||||
} on ValidationException {
|
||||
rethrow;
|
||||
} on ServerException {
|
||||
rethrow;
|
||||
} on NetworkException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw ServerException('Unexpected error occurred while updating scan: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete scan data from remote server (optional for future use)
|
||||
Future<void> deleteScan(String barcode) async {
|
||||
try {
|
||||
if (barcode.trim().isEmpty) {
|
||||
throw const ValidationException('Barcode cannot be empty');
|
||||
}
|
||||
|
||||
final response = await apiClient.delete('/api/scans/$barcode');
|
||||
|
||||
if (response.statusCode == null ||
|
||||
(response.statusCode! < 200 || response.statusCode! >= 300)) {
|
||||
final errorMessage = response.data?['message'] ?? 'Unknown server error';
|
||||
throw ServerException('Failed to delete scan: $errorMessage');
|
||||
}
|
||||
|
||||
} on ValidationException {
|
||||
rethrow;
|
||||
} on ServerException {
|
||||
rethrow;
|
||||
} on NetworkException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw ServerException('Unexpected error occurred while deleting scan: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
134
lib/features/scanner/data/models/save_request_model.dart
Normal file
134
lib/features/scanner/data/models/save_request_model.dart
Normal file
@@ -0,0 +1,134 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import '../../domain/entities/scan_entity.dart';
|
||||
|
||||
part 'save_request_model.g.dart';
|
||||
|
||||
/// API request model for saving scan data to the server
|
||||
@JsonSerializable()
|
||||
class SaveRequestModel {
|
||||
final String barcode;
|
||||
final String field1;
|
||||
final String field2;
|
||||
final String field3;
|
||||
final String field4;
|
||||
|
||||
SaveRequestModel({
|
||||
required this.barcode,
|
||||
required this.field1,
|
||||
required this.field2,
|
||||
required this.field3,
|
||||
required this.field4,
|
||||
});
|
||||
|
||||
/// Create from domain entity
|
||||
factory SaveRequestModel.fromEntity(ScanEntity entity) {
|
||||
return SaveRequestModel(
|
||||
barcode: entity.barcode,
|
||||
field1: entity.field1,
|
||||
field2: entity.field2,
|
||||
field3: entity.field3,
|
||||
field4: entity.field4,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create from parameters
|
||||
factory SaveRequestModel.fromParams({
|
||||
required String barcode,
|
||||
required String field1,
|
||||
required String field2,
|
||||
required String field3,
|
||||
required String field4,
|
||||
}) {
|
||||
return SaveRequestModel(
|
||||
barcode: barcode,
|
||||
field1: field1,
|
||||
field2: field2,
|
||||
field3: field3,
|
||||
field4: field4,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create from JSON
|
||||
factory SaveRequestModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$SaveRequestModelFromJson(json);
|
||||
|
||||
/// Convert to JSON for API requests
|
||||
Map<String, dynamic> toJson() => _$SaveRequestModelToJson(this);
|
||||
|
||||
/// Create a copy with updated fields
|
||||
SaveRequestModel copyWith({
|
||||
String? barcode,
|
||||
String? field1,
|
||||
String? field2,
|
||||
String? field3,
|
||||
String? field4,
|
||||
}) {
|
||||
return SaveRequestModel(
|
||||
barcode: barcode ?? this.barcode,
|
||||
field1: field1 ?? this.field1,
|
||||
field2: field2 ?? this.field2,
|
||||
field3: field3 ?? this.field3,
|
||||
field4: field4 ?? this.field4,
|
||||
);
|
||||
}
|
||||
|
||||
/// Validate the request data
|
||||
bool get isValid {
|
||||
return barcode.trim().isNotEmpty &&
|
||||
field1.trim().isNotEmpty &&
|
||||
field2.trim().isNotEmpty &&
|
||||
field3.trim().isNotEmpty &&
|
||||
field4.trim().isNotEmpty;
|
||||
}
|
||||
|
||||
/// Get validation errors
|
||||
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
|
||||
String toString() {
|
||||
return 'SaveRequestModel{barcode: $barcode, field1: $field1, field2: $field2, field3: $field3, field4: $field4}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SaveRequestModel &&
|
||||
runtimeType == other.runtimeType &&
|
||||
barcode == other.barcode &&
|
||||
field1 == other.field1 &&
|
||||
field2 == other.field2 &&
|
||||
field3 == other.field3 &&
|
||||
field4 == other.field4;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
barcode.hashCode ^
|
||||
field1.hashCode ^
|
||||
field2.hashCode ^
|
||||
field3.hashCode ^
|
||||
field4.hashCode;
|
||||
}
|
||||
25
lib/features/scanner/data/models/save_request_model.g.dart
Normal file
25
lib/features/scanner/data/models/save_request_model.g.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'save_request_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SaveRequestModel _$SaveRequestModelFromJson(Map<String, dynamic> json) =>
|
||||
SaveRequestModel(
|
||||
barcode: json['barcode'] as String,
|
||||
field1: json['field1'] as String,
|
||||
field2: json['field2'] as String,
|
||||
field3: json['field3'] as String,
|
||||
field4: json['field4'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SaveRequestModelToJson(SaveRequestModel instance) =>
|
||||
<String, dynamic>{
|
||||
'barcode': instance.barcode,
|
||||
'field1': instance.field1,
|
||||
'field2': instance.field2,
|
||||
'field3': instance.field3,
|
||||
'field4': instance.field4,
|
||||
};
|
||||
131
lib/features/scanner/data/models/scan_item.dart
Normal file
131
lib/features/scanner/data/models/scan_item.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import '../../domain/entities/scan_entity.dart';
|
||||
|
||||
part 'scan_item.g.dart';
|
||||
|
||||
/// Data model for ScanEntity with Hive annotations for local storage
|
||||
/// This is the data layer representation that can be persisted
|
||||
@HiveType(typeId: 0)
|
||||
class ScanItem extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String barcode;
|
||||
|
||||
@HiveField(1)
|
||||
final DateTime timestamp;
|
||||
|
||||
@HiveField(2)
|
||||
final String field1;
|
||||
|
||||
@HiveField(3)
|
||||
final String field2;
|
||||
|
||||
@HiveField(4)
|
||||
final String field3;
|
||||
|
||||
@HiveField(5)
|
||||
final String field4;
|
||||
|
||||
ScanItem({
|
||||
required this.barcode,
|
||||
required this.timestamp,
|
||||
this.field1 = '',
|
||||
this.field2 = '',
|
||||
this.field3 = '',
|
||||
this.field4 = '',
|
||||
});
|
||||
|
||||
/// Convert from domain entity to data model
|
||||
factory ScanItem.fromEntity(ScanEntity entity) {
|
||||
return ScanItem(
|
||||
barcode: entity.barcode,
|
||||
timestamp: entity.timestamp,
|
||||
field1: entity.field1,
|
||||
field2: entity.field2,
|
||||
field3: entity.field3,
|
||||
field4: entity.field4,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to domain entity
|
||||
ScanEntity toEntity() {
|
||||
return ScanEntity(
|
||||
barcode: barcode,
|
||||
timestamp: timestamp,
|
||||
field1: field1,
|
||||
field2: field2,
|
||||
field3: field3,
|
||||
field4: field4,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create from JSON (useful for API responses)
|
||||
factory ScanItem.fromJson(Map<String, dynamic> json) {
|
||||
return ScanItem(
|
||||
barcode: json['barcode'] ?? '',
|
||||
timestamp: json['timestamp'] != null
|
||||
? DateTime.parse(json['timestamp'])
|
||||
: DateTime.now(),
|
||||
field1: json['field1'] ?? '',
|
||||
field2: json['field2'] ?? '',
|
||||
field3: json['field3'] ?? '',
|
||||
field4: json['field4'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to JSON (useful for API requests)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'barcode': barcode,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'field1': field1,
|
||||
'field2': field2,
|
||||
'field3': field3,
|
||||
'field4': field4,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create a copy with updated fields
|
||||
ScanItem copyWith({
|
||||
String? barcode,
|
||||
DateTime? timestamp,
|
||||
String? field1,
|
||||
String? field2,
|
||||
String? field3,
|
||||
String? field4,
|
||||
}) {
|
||||
return ScanItem(
|
||||
barcode: barcode ?? this.barcode,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
field1: field1 ?? this.field1,
|
||||
field2: field2 ?? this.field2,
|
||||
field3: field3 ?? this.field3,
|
||||
field4: field4 ?? this.field4,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ScanItem{barcode: $barcode, timestamp: $timestamp, field1: $field1, field2: $field2, field3: $field3, field4: $field4}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ScanItem &&
|
||||
runtimeType == other.runtimeType &&
|
||||
barcode == other.barcode &&
|
||||
timestamp == other.timestamp &&
|
||||
field1 == other.field1 &&
|
||||
field2 == other.field2 &&
|
||||
field3 == other.field3 &&
|
||||
field4 == other.field4;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
barcode.hashCode ^
|
||||
timestamp.hashCode ^
|
||||
field1.hashCode ^
|
||||
field2.hashCode ^
|
||||
field3.hashCode ^
|
||||
field4.hashCode;
|
||||
}
|
||||
56
lib/features/scanner/data/models/scan_item.g.dart
Normal file
56
lib/features/scanner/data/models/scan_item.g.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'scan_item.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class ScanItemAdapter extends TypeAdapter<ScanItem> {
|
||||
@override
|
||||
final int typeId = 0;
|
||||
|
||||
@override
|
||||
ScanItem read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return ScanItem(
|
||||
barcode: fields[0] as String,
|
||||
timestamp: fields[1] as DateTime,
|
||||
field1: fields[2] as String,
|
||||
field2: fields[3] as String,
|
||||
field3: fields[4] as String,
|
||||
field4: fields[5] as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, ScanItem obj) {
|
||||
writer
|
||||
..writeByte(6)
|
||||
..writeByte(0)
|
||||
..write(obj.barcode)
|
||||
..writeByte(1)
|
||||
..write(obj.timestamp)
|
||||
..writeByte(2)
|
||||
..write(obj.field1)
|
||||
..writeByte(3)
|
||||
..write(obj.field2)
|
||||
..writeByte(4)
|
||||
..write(obj.field3)
|
||||
..writeByte(5)
|
||||
..write(obj.field4);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ScanItemAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../../../../core/errors/exceptions.dart';
|
||||
import '../../domain/entities/scan_entity.dart';
|
||||
import '../../domain/repositories/scanner_repository.dart';
|
||||
import '../datasources/scanner_local_datasource.dart';
|
||||
import '../datasources/scanner_remote_datasource.dart';
|
||||
import '../models/save_request_model.dart';
|
||||
import '../models/scan_item.dart';
|
||||
|
||||
/// Implementation of ScannerRepository
|
||||
/// This class handles the coordination between remote and local data sources
|
||||
class ScannerRepositoryImpl implements ScannerRepository {
|
||||
final ScannerRemoteDataSource remoteDataSource;
|
||||
final ScannerLocalDataSource localDataSource;
|
||||
|
||||
ScannerRepositoryImpl({
|
||||
required this.remoteDataSource,
|
||||
required this.localDataSource,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> saveScan({
|
||||
required String barcode,
|
||||
required String field1,
|
||||
required String field2,
|
||||
required String field3,
|
||||
required String field4,
|
||||
}) async {
|
||||
try {
|
||||
// Create the request model
|
||||
final request = SaveRequestModel.fromParams(
|
||||
barcode: barcode,
|
||||
field1: field1,
|
||||
field2: field2,
|
||||
field3: field3,
|
||||
field4: field4,
|
||||
);
|
||||
|
||||
// Validate the request
|
||||
if (!request.isValid) {
|
||||
return Left(ValidationFailure(request.validationErrors.join(', ')));
|
||||
}
|
||||
|
||||
// Save to remote server
|
||||
await remoteDataSource.saveScan(request);
|
||||
|
||||
// If remote save succeeds, we return success
|
||||
// Local save will be handled separately by the use case if needed
|
||||
return const Right(null);
|
||||
|
||||
} on ValidationException catch (e) {
|
||||
return Left(ValidationFailure(e.message));
|
||||
} on NetworkException catch (e) {
|
||||
return Left(NetworkFailure(e.message));
|
||||
} on ServerException catch (e) {
|
||||
return Left(ServerFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to save scan: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<ScanEntity>>> getScanHistory() async {
|
||||
try {
|
||||
// Get scans from local storage
|
||||
final scanItems = await localDataSource.getAllScans();
|
||||
|
||||
// Convert to domain entities
|
||||
final entities = scanItems.map((item) => item.toEntity()).toList();
|
||||
|
||||
return Right(entities);
|
||||
|
||||
} on CacheException catch (e) {
|
||||
return Left(CacheFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to get scan history: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> saveScanLocally(ScanEntity scan) async {
|
||||
try {
|
||||
// Convert entity to data model
|
||||
final scanItem = ScanItem.fromEntity(scan);
|
||||
|
||||
// Save to local storage
|
||||
await localDataSource.saveScan(scanItem);
|
||||
|
||||
return const Right(null);
|
||||
|
||||
} on CacheException catch (e) {
|
||||
return Left(CacheFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to save scan locally: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> deleteScanLocally(String barcode) async {
|
||||
try {
|
||||
if (barcode.trim().isEmpty) {
|
||||
return const Left(ValidationFailure('Barcode cannot be empty'));
|
||||
}
|
||||
|
||||
// Delete from local storage
|
||||
await localDataSource.deleteScan(barcode);
|
||||
|
||||
return const Right(null);
|
||||
|
||||
} on ValidationException catch (e) {
|
||||
return Left(ValidationFailure(e.message));
|
||||
} on CacheException catch (e) {
|
||||
return Left(CacheFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to delete scan: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> clearScanHistory() async {
|
||||
try {
|
||||
// Clear all scans from local storage
|
||||
await localDataSource.clearAllScans();
|
||||
|
||||
return const Right(null);
|
||||
|
||||
} on CacheException catch (e) {
|
||||
return Left(CacheFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to clear scan history: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, ScanEntity?>> getScanByBarcode(String barcode) async {
|
||||
try {
|
||||
if (barcode.trim().isEmpty) {
|
||||
return const Left(ValidationFailure('Barcode cannot be empty'));
|
||||
}
|
||||
|
||||
// Get scan from local storage
|
||||
final scanItem = await localDataSource.getScanByBarcode(barcode);
|
||||
|
||||
if (scanItem == null) {
|
||||
return const Right(null);
|
||||
}
|
||||
|
||||
// Convert to domain entity
|
||||
final entity = scanItem.toEntity();
|
||||
|
||||
return Right(entity);
|
||||
|
||||
} on ValidationException catch (e) {
|
||||
return Left(ValidationFailure(e.message));
|
||||
} on CacheException catch (e) {
|
||||
return Left(CacheFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to get scan by barcode: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> updateScanLocally(ScanEntity scan) async {
|
||||
try {
|
||||
// Convert entity to data model
|
||||
final scanItem = ScanItem.fromEntity(scan);
|
||||
|
||||
// Update in local storage
|
||||
await localDataSource.updateScan(scanItem);
|
||||
|
||||
return const Right(null);
|
||||
|
||||
} on CacheException catch (e) {
|
||||
return Left(CacheFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to update scan: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Additional utility methods for repository
|
||||
|
||||
/// Get scans count
|
||||
Future<Either<Failure, int>> getScansCount() async {
|
||||
try {
|
||||
if (localDataSource is ScannerLocalDataSourceImpl) {
|
||||
final impl = localDataSource as ScannerLocalDataSourceImpl;
|
||||
final count = await impl.getScansCount();
|
||||
return Right(count);
|
||||
}
|
||||
|
||||
// Fallback: get all scans and count them
|
||||
final result = await getScanHistory();
|
||||
return result.fold(
|
||||
(failure) => Left(failure),
|
||||
(scans) => Right(scans.length),
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to get scans count: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if scan exists locally
|
||||
Future<Either<Failure, bool>> scanExistsLocally(String barcode) async {
|
||||
try {
|
||||
if (barcode.trim().isEmpty) {
|
||||
return const Left(ValidationFailure('Barcode cannot be empty'));
|
||||
}
|
||||
|
||||
if (localDataSource is ScannerLocalDataSourceImpl) {
|
||||
final impl = localDataSource as ScannerLocalDataSourceImpl;
|
||||
final exists = await impl.scanExists(barcode);
|
||||
return Right(exists);
|
||||
}
|
||||
|
||||
// Fallback: get scan by barcode
|
||||
final result = await getScanByBarcode(barcode);
|
||||
return result.fold(
|
||||
(failure) => Left(failure),
|
||||
(scan) => Right(scan != null),
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to check if scan exists: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get scans by date range
|
||||
Future<Either<Failure, List<ScanEntity>>> getScansByDateRange({
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
try {
|
||||
if (localDataSource is ScannerLocalDataSourceImpl) {
|
||||
final impl = localDataSource as ScannerLocalDataSourceImpl;
|
||||
final scanItems = await impl.getScansByDateRange(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
|
||||
// Convert to domain entities
|
||||
final entities = scanItems.map((item) => item.toEntity()).toList();
|
||||
return Right(entities);
|
||||
}
|
||||
|
||||
// Fallback: get all scans and filter
|
||||
final result = await getScanHistory();
|
||||
return result.fold(
|
||||
(failure) => Left(failure),
|
||||
(scans) {
|
||||
final filteredScans = scans
|
||||
.where((scan) =>
|
||||
scan.timestamp.isAfter(startDate) &&
|
||||
scan.timestamp.isBefore(endDate))
|
||||
.toList();
|
||||
return Right(filteredScans);
|
||||
},
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to get scans by date range: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
}
|
||||
5
lib/features/scanner/domain/domain.dart
Normal file
5
lib/features/scanner/domain/domain.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
// Domain layer exports
|
||||
export 'entities/scan_entity.dart';
|
||||
export 'repositories/scanner_repository.dart';
|
||||
export 'usecases/get_scan_history_usecase.dart';
|
||||
export 'usecases/save_scan_usecase.dart';
|
||||
71
lib/features/scanner/domain/entities/scan_entity.dart
Normal file
71
lib/features/scanner/domain/entities/scan_entity.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Domain entity representing a scan item
|
||||
/// This is the business logic representation without any external dependencies
|
||||
class ScanEntity extends Equatable {
|
||||
final String barcode;
|
||||
final DateTime timestamp;
|
||||
final String field1;
|
||||
final String field2;
|
||||
final String field3;
|
||||
final String field4;
|
||||
|
||||
const ScanEntity({
|
||||
required this.barcode,
|
||||
required this.timestamp,
|
||||
this.field1 = '',
|
||||
this.field2 = '',
|
||||
this.field3 = '',
|
||||
this.field4 = '',
|
||||
});
|
||||
|
||||
/// Create a copy with updated fields
|
||||
ScanEntity copyWith({
|
||||
String? barcode,
|
||||
DateTime? timestamp,
|
||||
String? field1,
|
||||
String? field2,
|
||||
String? field3,
|
||||
String? field4,
|
||||
}) {
|
||||
return ScanEntity(
|
||||
barcode: barcode ?? this.barcode,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
field1: field1 ?? this.field1,
|
||||
field2: field2 ?? this.field2,
|
||||
field3: field3 ?? this.field3,
|
||||
field4: field4 ?? this.field4,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if the entity has any form data
|
||||
bool get hasFormData {
|
||||
return field1.isNotEmpty ||
|
||||
field2.isNotEmpty ||
|
||||
field3.isNotEmpty ||
|
||||
field4.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Check if all form fields are filled
|
||||
bool get isFormComplete {
|
||||
return field1.isNotEmpty &&
|
||||
field2.isNotEmpty &&
|
||||
field3.isNotEmpty &&
|
||||
field4.isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object> get props => [
|
||||
barcode,
|
||||
timestamp,
|
||||
field1,
|
||||
field2,
|
||||
field3,
|
||||
field4,
|
||||
];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ScanEntity{barcode: $barcode, timestamp: $timestamp, field1: $field1, field2: $field2, field3: $field3, field4: $field4}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../entities/scan_entity.dart';
|
||||
|
||||
/// Abstract repository interface for scanner operations
|
||||
/// This defines the contract that the data layer must implement
|
||||
abstract class ScannerRepository {
|
||||
/// Save scan data to remote server
|
||||
Future<Either<Failure, void>> saveScan({
|
||||
required String barcode,
|
||||
required String field1,
|
||||
required String field2,
|
||||
required String field3,
|
||||
required String field4,
|
||||
});
|
||||
|
||||
/// Get scan history from local storage
|
||||
Future<Either<Failure, List<ScanEntity>>> getScanHistory();
|
||||
|
||||
/// Save scan to local storage
|
||||
Future<Either<Failure, void>> saveScanLocally(ScanEntity scan);
|
||||
|
||||
/// Delete a scan from local storage
|
||||
Future<Either<Failure, void>> deleteScanLocally(String barcode);
|
||||
|
||||
/// Clear all scan history from local storage
|
||||
Future<Either<Failure, void>> clearScanHistory();
|
||||
|
||||
/// Get a specific scan by barcode from local storage
|
||||
Future<Either<Failure, ScanEntity?>> getScanByBarcode(String barcode);
|
||||
|
||||
/// Update a scan in local storage
|
||||
Future<Either<Failure, void>> updateScanLocally(ScanEntity scan);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../entities/scan_entity.dart';
|
||||
import '../repositories/scanner_repository.dart';
|
||||
|
||||
/// Use case for retrieving scan history
|
||||
/// Handles the business logic for fetching scan history from local storage
|
||||
class GetScanHistoryUseCase {
|
||||
final ScannerRepository repository;
|
||||
|
||||
GetScanHistoryUseCase(this.repository);
|
||||
|
||||
/// Execute the get scan history operation
|
||||
///
|
||||
/// Returns a list of scan entities sorted by timestamp (most recent first)
|
||||
Future<Either<Failure, List<ScanEntity>>> call() async {
|
||||
try {
|
||||
final result = await repository.getScanHistory();
|
||||
|
||||
return result.fold(
|
||||
(failure) => Left(failure),
|
||||
(scans) {
|
||||
// Sort scans by timestamp (most recent first)
|
||||
final sortedScans = List<ScanEntity>.from(scans);
|
||||
sortedScans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
return Right(sortedScans);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to get scan history: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get scan history filtered by date range
|
||||
Future<Either<Failure, List<ScanEntity>>> getHistoryInDateRange({
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
try {
|
||||
final result = await repository.getScanHistory();
|
||||
|
||||
return result.fold(
|
||||
(failure) => Left(failure),
|
||||
(scans) {
|
||||
// Filter scans by date range
|
||||
final filteredScans = scans
|
||||
.where((scan) =>
|
||||
scan.timestamp.isAfter(startDate) &&
|
||||
scan.timestamp.isBefore(endDate))
|
||||
.toList();
|
||||
|
||||
// Sort by timestamp (most recent first)
|
||||
filteredScans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
|
||||
return Right(filteredScans);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to get scan history: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get scans that have form data (non-empty fields)
|
||||
Future<Either<Failure, List<ScanEntity>>> getScansWithFormData() async {
|
||||
try {
|
||||
final result = await repository.getScanHistory();
|
||||
|
||||
return result.fold(
|
||||
(failure) => Left(failure),
|
||||
(scans) {
|
||||
// Filter scans that have form data
|
||||
final filteredScans = scans.where((scan) => scan.hasFormData).toList();
|
||||
|
||||
// Sort by timestamp (most recent first)
|
||||
filteredScans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
|
||||
return Right(filteredScans);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to get scan history: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Search scans by barcode pattern
|
||||
Future<Either<Failure, List<ScanEntity>>> searchByBarcode(String pattern) async {
|
||||
try {
|
||||
if (pattern.trim().isEmpty) {
|
||||
return const Right([]);
|
||||
}
|
||||
|
||||
final result = await repository.getScanHistory();
|
||||
|
||||
return result.fold(
|
||||
(failure) => Left(failure),
|
||||
(scans) {
|
||||
// Filter scans by barcode pattern (case-insensitive)
|
||||
final filteredScans = scans
|
||||
.where((scan) =>
|
||||
scan.barcode.toLowerCase().contains(pattern.toLowerCase()))
|
||||
.toList();
|
||||
|
||||
// Sort by timestamp (most recent first)
|
||||
filteredScans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
|
||||
return Right(filteredScans);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to search scans: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
}
|
||||
109
lib/features/scanner/domain/usecases/save_scan_usecase.dart
Normal file
109
lib/features/scanner/domain/usecases/save_scan_usecase.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../entities/scan_entity.dart';
|
||||
import '../repositories/scanner_repository.dart';
|
||||
|
||||
/// Use case for saving scan data
|
||||
/// Handles the business logic for saving scan information to both remote and local storage
|
||||
class SaveScanUseCase {
|
||||
final ScannerRepository repository;
|
||||
|
||||
SaveScanUseCase(this.repository);
|
||||
|
||||
/// Execute the save scan operation
|
||||
///
|
||||
/// First saves to remote server, then saves locally only if remote save succeeds
|
||||
/// This ensures data consistency and allows for offline-first behavior
|
||||
Future<Either<Failure, void>> call(SaveScanParams params) async {
|
||||
// Validate input parameters
|
||||
final validationResult = _validateParams(params);
|
||||
if (validationResult != null) {
|
||||
return Left(ValidationFailure(validationResult));
|
||||
}
|
||||
|
||||
try {
|
||||
// Save to remote server first
|
||||
final remoteResult = await repository.saveScan(
|
||||
barcode: params.barcode,
|
||||
field1: params.field1,
|
||||
field2: params.field2,
|
||||
field3: params.field3,
|
||||
field4: params.field4,
|
||||
);
|
||||
|
||||
return remoteResult.fold(
|
||||
(failure) => Left(failure),
|
||||
(_) async {
|
||||
// If remote save succeeds, save to local storage
|
||||
final scanEntity = ScanEntity(
|
||||
barcode: params.barcode,
|
||||
timestamp: DateTime.now(),
|
||||
field1: params.field1,
|
||||
field2: params.field2,
|
||||
field3: params.field3,
|
||||
field4: params.field4,
|
||||
);
|
||||
|
||||
final localResult = await repository.saveScanLocally(scanEntity);
|
||||
return localResult.fold(
|
||||
(failure) {
|
||||
// Log the local save failure but don't fail the entire operation
|
||||
// since remote save succeeded
|
||||
return const Right(null);
|
||||
},
|
||||
(_) => const Right(null),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
return Left(UnknownFailure('Failed to save scan: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the input parameters
|
||||
String? _validateParams(SaveScanParams params) {
|
||||
if (params.barcode.trim().isEmpty) {
|
||||
return 'Barcode cannot be empty';
|
||||
}
|
||||
|
||||
if (params.field1.trim().isEmpty) {
|
||||
return 'Field 1 cannot be empty';
|
||||
}
|
||||
|
||||
if (params.field2.trim().isEmpty) {
|
||||
return 'Field 2 cannot be empty';
|
||||
}
|
||||
|
||||
if (params.field3.trim().isEmpty) {
|
||||
return 'Field 3 cannot be empty';
|
||||
}
|
||||
|
||||
if (params.field4.trim().isEmpty) {
|
||||
return 'Field 4 cannot be empty';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for the SaveScanUseCase
|
||||
class SaveScanParams {
|
||||
final String barcode;
|
||||
final String field1;
|
||||
final String field2;
|
||||
final String field3;
|
||||
final String field4;
|
||||
|
||||
SaveScanParams({
|
||||
required this.barcode,
|
||||
required this.field1,
|
||||
required this.field2,
|
||||
required this.field3,
|
||||
required this.field4,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SaveScanParams{barcode: $barcode, field1: $field1, field2: $field2, field3: $field3, field4: $field4}';
|
||||
}
|
||||
}
|
||||
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