This commit is contained in:
2025-09-16 23:14:35 +07:00
parent be2ad0a8fd
commit 9ebe7c2919
55 changed files with 5953 additions and 893 deletions

2
lib/app_router.dart Normal file
View File

@@ -0,0 +1,2 @@
// Re-export the app router from the core routing module
export 'core/routing/app_router.dart';

View 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
View 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';

View 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)' : ''}';
}

View 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';
}

View 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;
}
}

View 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');
}

View 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,
),
);
}
}

View 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';

View File

@@ -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;
}
}
}

View File

@@ -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()}');
}
}
}

View 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;
}

View 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,
};

View 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;
}

View 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;
}

View File

@@ -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()}'));
}
}
}

View 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';

View 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}';
}
}

View File

@@ -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);
}

View File

@@ -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()}'));
}
}
}

View 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}';
}
}

View 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();
}
});
}
}

View 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');
}
}

View File

@@ -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']!;
});

View 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));
});
}

View File

@@ -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;
});

View File

@@ -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');
}
}
}

View 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('');
}
}

View File

@@ -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();
}
}

View File

@@ -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(),
);
}
}
}

View File

@@ -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,
),
);
}
}

View File

@@ -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
View 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
},
);
},
),
),
],
),
),
),
],
),
);
}
}