runable
This commit is contained in:
2
lib/app_router.dart
Normal file
2
lib/app_router.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
// Re-export the app router from the core routing module
|
||||
export 'core/routing/app_router.dart';
|
||||
80
lib/core/constants/app_constants.dart
Normal file
80
lib/core/constants/app_constants.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
/// Application-wide constants
|
||||
class AppConstants {
|
||||
// Private constructor to prevent instantiation
|
||||
AppConstants._();
|
||||
|
||||
// API Configuration
|
||||
static const String apiBaseUrl = 'https://api.example.com'; // Replace with actual API base URL
|
||||
static const String apiVersion = 'v1';
|
||||
static const String scansEndpoint = '/api/scans';
|
||||
|
||||
// Network Timeouts (in milliseconds)
|
||||
static const int connectionTimeout = 30000; // 30 seconds
|
||||
static const int receiveTimeout = 30000; // 30 seconds
|
||||
static const int sendTimeout = 30000; // 30 seconds
|
||||
|
||||
// Local Storage Keys
|
||||
static const String scanHistoryBox = 'scan_history';
|
||||
static const String settingsBox = 'settings';
|
||||
static const String userPreferencesKey = 'user_preferences';
|
||||
|
||||
// Scanner Configuration
|
||||
static const List<String> supportedBarcodeFormats = [
|
||||
'CODE_128',
|
||||
'CODE_39',
|
||||
'CODE_93',
|
||||
'EAN_13',
|
||||
'EAN_8',
|
||||
'UPC_A',
|
||||
'UPC_E',
|
||||
'QR_CODE',
|
||||
'DATA_MATRIX',
|
||||
];
|
||||
|
||||
// UI Configuration
|
||||
static const int maxHistoryItems = 100;
|
||||
static const int scanResultDisplayDuration = 3; // seconds
|
||||
|
||||
// Form Field Labels
|
||||
static const String field1Label = 'Field 1';
|
||||
static const String field2Label = 'Field 2';
|
||||
static const String field3Label = 'Field 3';
|
||||
static const String field4Label = 'Field 4';
|
||||
|
||||
// Error Messages
|
||||
static const String networkErrorMessage = 'Network error occurred. Please check your connection.';
|
||||
static const String serverErrorMessage = 'Server error occurred. Please try again later.';
|
||||
static const String unknownErrorMessage = 'An unexpected error occurred.';
|
||||
static const String noDataMessage = 'No data available';
|
||||
static const String scannerPermissionMessage = 'Camera permission is required to scan barcodes.';
|
||||
|
||||
// Success Messages
|
||||
static const String saveSuccessMessage = 'Data saved successfully!';
|
||||
static const String printSuccessMessage = 'Print job completed successfully!';
|
||||
|
||||
// App Info
|
||||
static const String appName = 'Barcode Scanner';
|
||||
static const String appVersion = '1.0.0';
|
||||
static const String appDescription = 'Simple barcode scanner with form data entry';
|
||||
|
||||
// Animation Durations
|
||||
static const Duration shortAnimationDuration = Duration(milliseconds: 200);
|
||||
static const Duration mediumAnimationDuration = Duration(milliseconds: 400);
|
||||
static const Duration longAnimationDuration = Duration(milliseconds: 600);
|
||||
|
||||
// Spacing and Sizes
|
||||
static const double defaultPadding = 16.0;
|
||||
static const double smallPadding = 8.0;
|
||||
static const double largePadding = 24.0;
|
||||
static const double borderRadius = 8.0;
|
||||
static const double buttonHeight = 48.0;
|
||||
static const double textFieldHeight = 56.0;
|
||||
|
||||
// Scanner View Configuration
|
||||
static const double scannerAspectRatio = 1.0;
|
||||
static const double scannerBorderWidth = 2.0;
|
||||
|
||||
// Print Configuration
|
||||
static const String printJobName = 'Barcode Scan Data';
|
||||
static const double printPageMargin = 72.0; // 1 inch in points
|
||||
}
|
||||
7
lib/core/core.dart
Normal file
7
lib/core/core.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
// Core module exports
|
||||
export 'constants/app_constants.dart';
|
||||
export 'errors/exceptions.dart';
|
||||
export 'errors/failures.dart';
|
||||
export 'network/api_client.dart';
|
||||
export 'theme/app_theme.dart';
|
||||
export 'routing/app_router.dart';
|
||||
123
lib/core/errors/exceptions.dart
Normal file
123
lib/core/errors/exceptions.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
/// Base class for all exceptions in the application
|
||||
/// Exceptions are thrown during runtime and should be caught and converted to failures
|
||||
abstract class AppException implements Exception {
|
||||
final String message;
|
||||
final String? code;
|
||||
|
||||
const AppException(this.message, {this.code});
|
||||
|
||||
@override
|
||||
String toString() => 'AppException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception thrown when there's a server-related error
|
||||
/// This includes HTTP errors, API response errors, etc.
|
||||
class ServerException extends AppException {
|
||||
const ServerException(super.message, {super.code});
|
||||
|
||||
@override
|
||||
String toString() => 'ServerException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception thrown when there's a network-related error
|
||||
/// This includes connection timeouts, no internet connection, etc.
|
||||
class NetworkException extends AppException {
|
||||
const NetworkException(super.message, {super.code});
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception thrown when there's a local storage error
|
||||
/// This includes Hive errors, file system errors, etc.
|
||||
class CacheException extends AppException {
|
||||
const CacheException(super.message, {super.code});
|
||||
|
||||
@override
|
||||
String toString() => 'CacheException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception thrown when input validation fails
|
||||
class ValidationException extends AppException {
|
||||
final Map<String, String>? fieldErrors;
|
||||
|
||||
const ValidationException(
|
||||
super.message, {
|
||||
super.code,
|
||||
this.fieldErrors,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
var result = 'ValidationException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
if (fieldErrors != null && fieldErrors!.isNotEmpty) {
|
||||
result += '\nField errors: ${fieldErrors.toString()}';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception thrown when a required permission is denied
|
||||
class PermissionException extends AppException {
|
||||
final String permissionType;
|
||||
|
||||
const PermissionException(
|
||||
super.message,
|
||||
this.permissionType, {
|
||||
super.code,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'PermissionException: $message (Permission: $permissionType)${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception thrown when scanning operation fails
|
||||
class ScannerException extends AppException {
|
||||
const ScannerException(super.message, {super.code});
|
||||
|
||||
@override
|
||||
String toString() => 'ScannerException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception thrown when printing operation fails
|
||||
class PrintException extends AppException {
|
||||
const PrintException(super.message, {super.code});
|
||||
|
||||
@override
|
||||
String toString() => 'PrintException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception thrown for JSON parsing errors
|
||||
class JsonException extends AppException {
|
||||
const JsonException(super.message, {super.code});
|
||||
|
||||
@override
|
||||
String toString() => 'JsonException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception thrown for format-related errors (e.g., invalid barcode format)
|
||||
class FormatException extends AppException {
|
||||
final String expectedFormat;
|
||||
final String receivedFormat;
|
||||
|
||||
const FormatException(
|
||||
super.message,
|
||||
this.expectedFormat,
|
||||
this.receivedFormat, {
|
||||
super.code,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'FormatException: $message (Expected: $expectedFormat, Received: $receivedFormat)${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Generic exception for unexpected errors
|
||||
class UnknownException extends AppException {
|
||||
const UnknownException([super.message = 'An unexpected error occurred', String? code])
|
||||
: super(code: code);
|
||||
|
||||
@override
|
||||
String toString() => 'UnknownException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
79
lib/core/errors/failures.dart
Normal file
79
lib/core/errors/failures.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Base class for all failures in the application
|
||||
/// Failures represent errors that can be handled gracefully
|
||||
abstract class Failure extends Equatable {
|
||||
final String message;
|
||||
|
||||
const Failure(this.message);
|
||||
|
||||
@override
|
||||
List<Object> get props => [message];
|
||||
}
|
||||
|
||||
/// Failure that occurs when there's a server-related error
|
||||
/// This includes HTTP errors, API errors, etc.
|
||||
class ServerFailure extends Failure {
|
||||
const ServerFailure(super.message);
|
||||
|
||||
@override
|
||||
String toString() => 'ServerFailure: $message';
|
||||
}
|
||||
|
||||
/// Failure that occurs when there's a network-related error
|
||||
/// This includes connection timeouts, no internet, etc.
|
||||
class NetworkFailure extends Failure {
|
||||
const NetworkFailure(super.message);
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkFailure: $message';
|
||||
}
|
||||
|
||||
/// Failure that occurs when there's a local storage error
|
||||
/// This includes cache errors, database errors, etc.
|
||||
class CacheFailure extends Failure {
|
||||
const CacheFailure(super.message);
|
||||
|
||||
@override
|
||||
String toString() => 'CacheFailure: $message';
|
||||
}
|
||||
|
||||
/// Failure that occurs when input validation fails
|
||||
class ValidationFailure extends Failure {
|
||||
const ValidationFailure(super.message);
|
||||
|
||||
@override
|
||||
String toString() => 'ValidationFailure: $message';
|
||||
}
|
||||
|
||||
/// Failure that occurs when a required permission is denied
|
||||
class PermissionFailure extends Failure {
|
||||
const PermissionFailure(super.message);
|
||||
|
||||
@override
|
||||
String toString() => 'PermissionFailure: $message';
|
||||
}
|
||||
|
||||
/// Failure that occurs when scanning operation fails
|
||||
class ScannerFailure extends Failure {
|
||||
const ScannerFailure(super.message);
|
||||
|
||||
@override
|
||||
String toString() => 'ScannerFailure: $message';
|
||||
}
|
||||
|
||||
/// Failure that occurs when printing operation fails
|
||||
class PrintFailure extends Failure {
|
||||
const PrintFailure(super.message);
|
||||
|
||||
@override
|
||||
String toString() => 'PrintFailure: $message';
|
||||
}
|
||||
|
||||
/// Generic failure for unexpected errors
|
||||
class UnknownFailure extends Failure {
|
||||
const UnknownFailure([super.message = 'An unexpected error occurred']);
|
||||
|
||||
@override
|
||||
String toString() => 'UnknownFailure: $message';
|
||||
}
|
||||
175
lib/core/network/api_client.dart
Normal file
175
lib/core/network/api_client.dart
Normal file
@@ -0,0 +1,175 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../errors/exceptions.dart';
|
||||
|
||||
/// API client for making HTTP requests using Dio
|
||||
class ApiClient {
|
||||
late final Dio _dio;
|
||||
|
||||
ApiClient() {
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConstants.apiBaseUrl,
|
||||
connectTimeout: const Duration(milliseconds: AppConstants.connectionTimeout),
|
||||
receiveTimeout: const Duration(milliseconds: AppConstants.receiveTimeout),
|
||||
sendTimeout: const Duration(milliseconds: AppConstants.sendTimeout),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Add request/response interceptors for logging and error handling
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
// Log request details in debug mode
|
||||
handler.next(options);
|
||||
},
|
||||
onResponse: (response, handler) {
|
||||
// Log response details in debug mode
|
||||
handler.next(response);
|
||||
},
|
||||
onError: (error, handler) {
|
||||
// Handle different types of errors
|
||||
_handleDioError(error);
|
||||
handler.next(error);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Make a GET request
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.get<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a POST request
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.post<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a PUT request
|
||||
Future<Response<T>> put<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.put<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a DELETE request
|
||||
Future<Response<T>> delete<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.delete<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Dio errors and convert them to custom exceptions
|
||||
Exception _handleDioError(DioException error) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return const NetworkException('Connection timeout. Please check your internet connection.');
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
final message = error.response?.data?['message'] ?? 'Server error occurred';
|
||||
|
||||
if (statusCode != null) {
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
return ServerException('Client error: $message (Status: $statusCode)');
|
||||
} else if (statusCode >= 500) {
|
||||
return ServerException('Server error: $message (Status: $statusCode)');
|
||||
}
|
||||
}
|
||||
return ServerException('HTTP error: $message');
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return const NetworkException('Request was cancelled');
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
return const NetworkException('No internet connection. Please check your network settings.');
|
||||
|
||||
case DioExceptionType.badCertificate:
|
||||
return const NetworkException('Certificate verification failed');
|
||||
|
||||
case DioExceptionType.unknown:
|
||||
default:
|
||||
return ServerException('An unexpected error occurred: ${error.message}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Add authorization header
|
||||
void addAuthorizationHeader(String token) {
|
||||
_dio.options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
/// Remove authorization header
|
||||
void removeAuthorizationHeader() {
|
||||
_dio.options.headers.remove('Authorization');
|
||||
}
|
||||
|
||||
/// Update base URL (useful for different environments)
|
||||
void updateBaseUrl(String newBaseUrl) {
|
||||
_dio.options.baseUrl = newBaseUrl;
|
||||
}
|
||||
}
|
||||
211
lib/core/routing/app_router.dart
Normal file
211
lib/core/routing/app_router.dart
Normal file
@@ -0,0 +1,211 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../features/scanner/presentation/pages/home_page.dart';
|
||||
import '../../features/scanner/presentation/pages/detail_page.dart';
|
||||
|
||||
/// Application router configuration using GoRouter
|
||||
final GoRouter appRouter = GoRouter(
|
||||
initialLocation: '/',
|
||||
debugLogDiagnostics: true,
|
||||
routes: [
|
||||
// Home route - Main scanner screen
|
||||
GoRoute(
|
||||
path: '/',
|
||||
name: 'home',
|
||||
builder: (BuildContext context, GoRouterState state) {
|
||||
return const HomePage();
|
||||
},
|
||||
),
|
||||
|
||||
// Detail route - Edit scan data
|
||||
GoRoute(
|
||||
path: '/detail/:barcode',
|
||||
name: 'detail',
|
||||
builder: (BuildContext context, GoRouterState state) {
|
||||
final barcode = state.pathParameters['barcode']!;
|
||||
return DetailPage(barcode: barcode);
|
||||
},
|
||||
redirect: (BuildContext context, GoRouterState state) {
|
||||
final barcode = state.pathParameters['barcode'];
|
||||
|
||||
// Ensure barcode is not empty
|
||||
if (barcode == null || barcode.trim().isEmpty) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return null; // No redirect needed
|
||||
},
|
||||
),
|
||||
|
||||
// Settings route (optional for future expansion)
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
builder: (BuildContext context, GoRouterState state) {
|
||||
return const SettingsPlaceholderPage();
|
||||
},
|
||||
),
|
||||
|
||||
// About route (optional for future expansion)
|
||||
GoRoute(
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
builder: (BuildContext context, GoRouterState state) {
|
||||
return const AboutPlaceholderPage();
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
// Error handling
|
||||
errorBuilder: (context, state) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Page Not Found'),
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Page Not Found',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'The page "${state.path}" does not exist.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/'),
|
||||
child: const Text('Go Home'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
// Redirect handler for authentication or onboarding (optional)
|
||||
redirect: (BuildContext context, GoRouterState state) {
|
||||
// Add any global redirect logic here
|
||||
// For example, redirect to onboarding or login if needed
|
||||
|
||||
return null; // No global redirect
|
||||
},
|
||||
);
|
||||
|
||||
/// Placeholder page for settings (for future implementation)
|
||||
class SettingsPlaceholderPage extends StatelessWidget {
|
||||
const SettingsPlaceholderPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.settings,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Settings',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Settings page coming soon',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder page for about (for future implementation)
|
||||
class AboutPlaceholderPage extends StatelessWidget {
|
||||
const AboutPlaceholderPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('About'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Barcode Scanner App',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Version 1.0.0',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension methods for easier navigation
|
||||
extension AppRouterExtension on BuildContext {
|
||||
/// Navigate to home page
|
||||
void goHome() => go('/');
|
||||
|
||||
/// Navigate to detail page with barcode
|
||||
void goToDetail(String barcode) => go('/detail/$barcode');
|
||||
|
||||
/// Navigate to settings
|
||||
void goToSettings() => go('/settings');
|
||||
|
||||
/// Navigate to about page
|
||||
void goToAbout() => go('/about');
|
||||
}
|
||||
298
lib/core/theme/app_theme.dart
Normal file
298
lib/core/theme/app_theme.dart
Normal file
@@ -0,0 +1,298 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
|
||||
/// Application theme configuration using Material Design 3
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
// Color scheme for light theme
|
||||
static const ColorScheme _lightColorScheme = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primary: Color(0xFF1976D2), // Blue
|
||||
onPrimary: Color(0xFFFFFFFF),
|
||||
primaryContainer: Color(0xFFE3F2FD),
|
||||
onPrimaryContainer: Color(0xFF0D47A1),
|
||||
secondary: Color(0xFF757575), // Grey
|
||||
onSecondary: Color(0xFFFFFFFF),
|
||||
secondaryContainer: Color(0xFFE0E0E0),
|
||||
onSecondaryContainer: Color(0xFF424242),
|
||||
tertiary: Color(0xFF4CAF50), // Green
|
||||
onTertiary: Color(0xFFFFFFFF),
|
||||
tertiaryContainer: Color(0xFFE8F5E8),
|
||||
onTertiaryContainer: Color(0xFF2E7D32),
|
||||
error: Color(0xFFD32F2F),
|
||||
onError: Color(0xFFFFFFFF),
|
||||
errorContainer: Color(0xFFFFEBEE),
|
||||
onErrorContainer: Color(0xFFB71C1C),
|
||||
surface: Color(0xFFFFFFFF),
|
||||
onSurface: Color(0xFF212121),
|
||||
background: Color(0xFFF5F5F5),
|
||||
onBackground: Color(0xFF616161),
|
||||
onSurfaceVariant: Color(0xFF616161),
|
||||
outline: Color(0xFFBDBDBD),
|
||||
outlineVariant: Color(0xFFE0E0E0),
|
||||
shadow: Color(0xFF000000),
|
||||
scrim: Color(0xFF000000),
|
||||
inverseSurface: Color(0xFF303030),
|
||||
onInverseSurface: Color(0xFFF5F5F5),
|
||||
inversePrimary: Color(0xFF90CAF9),
|
||||
surfaceTint: Color(0xFF1976D2),
|
||||
);
|
||||
|
||||
// Color scheme for dark theme
|
||||
static const ColorScheme _darkColorScheme = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primary: Color(0xFF90CAF9), // Light Blue
|
||||
onPrimary: Color(0xFF0D47A1),
|
||||
primaryContainer: Color(0xFF1565C0),
|
||||
onPrimaryContainer: Color(0xFFE3F2FD),
|
||||
secondary: Color(0xFFBDBDBD), // Light Grey
|
||||
onSecondary: Color(0xFF424242),
|
||||
secondaryContainer: Color(0xFF616161),
|
||||
onSecondaryContainer: Color(0xFFE0E0E0),
|
||||
tertiary: Color(0xFF81C784), // Light Green
|
||||
onTertiary: Color(0xFF2E7D32),
|
||||
tertiaryContainer: Color(0xFF388E3C),
|
||||
onTertiaryContainer: Color(0xFFE8F5E8),
|
||||
error: Color(0xFFEF5350),
|
||||
onError: Color(0xFFB71C1C),
|
||||
errorContainer: Color(0xFFD32F2F),
|
||||
onErrorContainer: Color(0xFFFFEBEE),
|
||||
surface: Color(0xFF121212),
|
||||
onSurface: Color(0xFFE0E0E0),
|
||||
background: Color(0xFF2C2C2C),
|
||||
onBackground: Color(0xFFBDBDBD),
|
||||
onSurfaceVariant: Color(0xFFBDBDBD),
|
||||
outline: Color(0xFF757575),
|
||||
outlineVariant: Color(0xFF424242),
|
||||
shadow: Color(0xFF000000),
|
||||
scrim: Color(0xFF000000),
|
||||
inverseSurface: Color(0xFFE0E0E0),
|
||||
onInverseSurface: Color(0xFF303030),
|
||||
inversePrimary: Color(0xFF1976D2),
|
||||
surfaceTint: Color(0xFF90CAF9),
|
||||
);
|
||||
|
||||
/// Light theme configuration
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: _lightColorScheme,
|
||||
scaffoldBackgroundColor: _lightColorScheme.surface,
|
||||
|
||||
// App Bar Theme
|
||||
appBarTheme: AppBarTheme(
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
backgroundColor: _lightColorScheme.surface,
|
||||
foregroundColor: _lightColorScheme.onSurface,
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _lightColorScheme.onSurface,
|
||||
),
|
||||
systemOverlayStyle: SystemUiOverlayStyle.dark,
|
||||
),
|
||||
|
||||
// Elevated Button Theme
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
elevation: 0,
|
||||
minimumSize: const Size(double.infinity, AppConstants.buttonHeight),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Text Button Theme
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, AppConstants.buttonHeight),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Input Decoration Theme
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: _lightColorScheme.background,
|
||||
contentPadding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
borderSide: BorderSide(color: _lightColorScheme.outline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
borderSide: BorderSide(color: _lightColorScheme.outline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
borderSide: BorderSide(color: _lightColorScheme.primary, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
borderSide: BorderSide(color: _lightColorScheme.error),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
borderSide: BorderSide(color: _lightColorScheme.error, width: 2),
|
||||
),
|
||||
labelStyle: TextStyle(color: _lightColorScheme.onSurfaceVariant),
|
||||
hintStyle: TextStyle(color: _lightColorScheme.onSurfaceVariant),
|
||||
),
|
||||
|
||||
|
||||
// List Tile Theme
|
||||
listTileTheme: const ListTileThemeData(
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: AppConstants.defaultPadding,
|
||||
vertical: AppConstants.smallPadding,
|
||||
),
|
||||
),
|
||||
|
||||
// Divider Theme
|
||||
dividerTheme: DividerThemeData(
|
||||
color: _lightColorScheme.outline,
|
||||
thickness: 0.5,
|
||||
),
|
||||
|
||||
// Progress Indicator Theme
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(
|
||||
color: _lightColorScheme.primary,
|
||||
),
|
||||
|
||||
// Snack Bar Theme
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: _lightColorScheme.inverseSurface,
|
||||
contentTextStyle: TextStyle(color: _lightColorScheme.onInverseSurface),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Dark theme configuration
|
||||
static ThemeData get darkTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: _darkColorScheme,
|
||||
scaffoldBackgroundColor: _darkColorScheme.surface,
|
||||
|
||||
// App Bar Theme
|
||||
appBarTheme: AppBarTheme(
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
backgroundColor: _darkColorScheme.surface,
|
||||
foregroundColor: _darkColorScheme.onSurface,
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _darkColorScheme.onSurface,
|
||||
),
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
),
|
||||
|
||||
// Elevated Button Theme
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
elevation: 0,
|
||||
minimumSize: const Size(double.infinity, AppConstants.buttonHeight),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Text Button Theme
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, AppConstants.buttonHeight),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Input Decoration Theme
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: _darkColorScheme.background,
|
||||
contentPadding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
borderSide: BorderSide(color: _darkColorScheme.outline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
borderSide: BorderSide(color: _darkColorScheme.outline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
borderSide: BorderSide(color: _darkColorScheme.primary, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
borderSide: BorderSide(color: _darkColorScheme.error),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
borderSide: BorderSide(color: _darkColorScheme.error, width: 2),
|
||||
),
|
||||
labelStyle: TextStyle(color: _darkColorScheme.onSurfaceVariant),
|
||||
hintStyle: TextStyle(color: _darkColorScheme.onSurfaceVariant),
|
||||
),
|
||||
|
||||
|
||||
// List Tile Theme
|
||||
listTileTheme: const ListTileThemeData(
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: AppConstants.defaultPadding,
|
||||
vertical: AppConstants.smallPadding,
|
||||
),
|
||||
),
|
||||
|
||||
// Divider Theme
|
||||
dividerTheme: DividerThemeData(
|
||||
color: _darkColorScheme.outline,
|
||||
thickness: 0.5,
|
||||
),
|
||||
|
||||
// Progress Indicator Theme
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(
|
||||
color: _darkColorScheme.primary,
|
||||
),
|
||||
|
||||
// Snack Bar Theme
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: _darkColorScheme.inverseSurface,
|
||||
contentTextStyle: TextStyle(color: _darkColorScheme.onInverseSurface),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:hive_ce_flutter/adapters.dart';
|
||||
import 'package:minhthu/core/constants/app_constants.dart';
|
||||
import 'package:minhthu/core/theme/app_theme.dart';
|
||||
import 'package:minhthu/features/scanner/data/models/scan_item.dart';
|
||||
import 'package:minhthu/features/scanner/presentation/pages/home_page.dart';
|
||||
import 'package:minhthu/features/scanner/presentation/pages/detail_page.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize Hive
|
||||
await Hive.initFlutter();
|
||||
|
||||
// Register Hive adapters
|
||||
Hive.registerAdapter(ScanItemAdapter());
|
||||
|
||||
// Open Hive boxes
|
||||
await Hive.openBox<ScanItem>(AppConstants.scanHistoryBox);
|
||||
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: MyApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
@@ -10,14 +33,30 @@ class MyApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Barcode Scanner App',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
||||
useMaterial3: true,
|
||||
),
|
||||
final router = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const HomePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/detail/:barcode',
|
||||
builder: (context, state) {
|
||||
final barcode = state.pathParameters['barcode'] ?? '';
|
||||
return DetailPage(barcode: barcode);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'Barcode Scanner',
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: ThemeMode.system,
|
||||
routerConfig: router,
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: const HomeScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class DataScreen extends StatelessWidget {
|
||||
final List<String> scannedHistory;
|
||||
|
||||
const DataScreen({
|
||||
super.key,
|
||||
required this.scannedHistory,
|
||||
});
|
||||
|
||||
void _copyToClipboard(BuildContext context, String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Copied to clipboard'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _shareAllData(BuildContext context) {
|
||||
final allData = scannedHistory.join('\n');
|
||||
Clipboard.setData(ClipboardData(text: allData));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('All data copied to clipboard'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Scanned Data'),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share),
|
||||
onPressed: scannedHistory.isEmpty
|
||||
? null
|
||||
: () => _shareAllData(context),
|
||||
tooltip: 'Copy all data',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: scannedHistory.isEmpty
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inbox,
|
||||
size: 80,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
Text(
|
||||
'No scanned data yet',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
Text(
|
||||
'Start scanning barcodes from the home screen',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.blue[50],
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Total Items: ${scannedHistory.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _shareAllData(context),
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
label: const Text('Copy All'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: scannedHistory.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = scannedHistory[index];
|
||||
return Card(
|
||||
elevation: 2,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
item,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Scanned item #${index + 1}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 20),
|
||||
onPressed: () => _copyToClipboard(context, item),
|
||||
tooltip: 'Copy',
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green[100],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Active',
|
||||
style: TextStyle(
|
||||
color: Colors.green[800],
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
label: const Text('Scan More'),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'data_screen.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
String? scannedData;
|
||||
List<String> scannedHistory = [];
|
||||
|
||||
void _navigateToDataScreen() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => DataScreen(scannedHistory: scannedHistory),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openScanner() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ScannerScreen(
|
||||
onScan: (String code) {
|
||||
setState(() {
|
||||
scannedData = code;
|
||||
scannedHistory.add(code);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Barcode Scanner'),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _openScanner,
|
||||
icon: const Icon(Icons.qr_code_scanner, size: 32),
|
||||
label: const Text(
|
||||
'Scan Barcode',
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 40,
|
||||
vertical: 15,
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
if (scannedData != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'Last Scanned:',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
scannedData!,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (scannedHistory.isNotEmpty)
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Scan History (${scannedHistory.length} items)',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: scannedHistory.length,
|
||||
itemBuilder: (context, index) {
|
||||
final reversedIndex =
|
||||
scannedHistory.length - 1 - index;
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
child: Text('${reversedIndex + 1}'),
|
||||
),
|
||||
title: Text(scannedHistory[reversedIndex]),
|
||||
subtitle: Text('Item ${reversedIndex + 1}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.3),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, -3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: scannedHistory.isEmpty ? null : () {
|
||||
setState(() {
|
||||
scannedHistory.clear();
|
||||
scannedData = null;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.clear_all),
|
||||
label: const Text('Clear'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: scannedHistory.isEmpty ? null : _navigateToDataScreen,
|
||||
icon: const Icon(Icons.list),
|
||||
label: const Text('View All Data'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScannerScreen extends StatefulWidget {
|
||||
final Function(String) onScan;
|
||||
|
||||
const ScannerScreen({super.key, required this.onScan});
|
||||
|
||||
@override
|
||||
State<ScannerScreen> createState() => _ScannerScreenState();
|
||||
}
|
||||
|
||||
class _ScannerScreenState extends State<ScannerScreen> {
|
||||
MobileScannerController cameraController = MobileScannerController(
|
||||
formats: [BarcodeFormat.code128]
|
||||
);
|
||||
bool hasScanned = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Scan Barcode'),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
actions: [
|
||||
// IconButton(
|
||||
// icon: ValueListenableBuilder(
|
||||
// valueListenable: cameraController.torchStateNotifier,
|
||||
// builder: (context, state, child) {
|
||||
// switch (state) {
|
||||
// case TorchState.off:
|
||||
// return const Icon(Icons.flash_off, color: Colors.grey);
|
||||
// case TorchState.on:
|
||||
// return const Icon(Icons.flash_on, color: Colors.yellow);
|
||||
// }
|
||||
// },
|
||||
// ),
|
||||
// onPressed: () => cameraController.toggleTorch(),
|
||||
// ),
|
||||
// IconButton(
|
||||
// icon: ValueListenableBuilder(
|
||||
// valueListenable: cameraController.cameraFacingState,
|
||||
// builder: (context, state, child) {
|
||||
// switch (state) {
|
||||
// case CameraFacing.front:
|
||||
// return const Icon(Icons.camera_front);
|
||||
// case CameraFacing.back:
|
||||
// return const Icon(Icons.camera_rear);
|
||||
// }
|
||||
// },
|
||||
// ),
|
||||
// onPressed: () => cameraController.switchCamera(),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: cameraController,
|
||||
onDetect: (capture) {
|
||||
if (!hasScanned) {
|
||||
final List<Barcode> barcodes = capture.barcodes;
|
||||
for (final barcode in barcodes) {
|
||||
if (barcode.rawValue != null) {
|
||||
hasScanned = true;
|
||||
widget.onScan(barcode.rawValue!);
|
||||
Navigator.pop(context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 300,
|
||||
height: 300,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.green,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 100,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'Align barcode within the frame',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
cameraController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
143
lib/simple_main.dart
Normal file
143
lib/simple_main.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
void main() {
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: MyApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Barcode Scanner',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const HomePage(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Barcode Scanner'),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Scanner area placeholder
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.qr_code_scanner,
|
||||
size: 100,
|
||||
color: Colors.white,
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
Text(
|
||||
'Scanner will be here',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18),
|
||||
),
|
||||
Text(
|
||||
'(Camera permissions required)',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Scan result display
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.grey[100],
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.qr_code, size: 40),
|
||||
const SizedBox(width: 16),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Last Scanned:',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
Text(
|
||||
'No barcode scanned yet',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
// Navigate to detail page
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// History list
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: const Text(
|
||||
'Scan History',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.qr_code_2),
|
||||
title: Text('Barcode ${1234567890 + index}'),
|
||||
subtitle: Text('Scanned at ${DateTime.now().subtract(Duration(minutes: index * 5)).toString().substring(11, 16)}'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
// Navigate to detail
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user