diff --git a/lib/features/barcode_scan/model/barcode_model.dart b/lib/features/barcode_scan/model/barcode_model.dart new file mode 100644 index 0000000..af62725 --- /dev/null +++ b/lib/features/barcode_scan/model/barcode_model.dart @@ -0,0 +1,13 @@ +class Barcode { + final String value; + + Barcode(String value) : value = value.trim(); + + bool isValid() { + return value.isNotEmpty; + } + + String formattedValue() { + return 'Barcode: $value'; + } +} diff --git a/lib/features/barcode_scan/service/barcode_cache_service.dart b/lib/features/barcode_scan/service/barcode_cache_service.dart new file mode 100644 index 0000000..988610a --- /dev/null +++ b/lib/features/barcode_scan/service/barcode_cache_service.dart @@ -0,0 +1,19 @@ +import 'package:nutrient_scanner/util/cache_manager.dart'; + +class BarcodeCacheService { + final CacheManager _cacheManager = CacheManager(); + + Future save(String barcode, String data) async { + final cacheKey = _getKey(barcode); + await _cacheManager.save(cacheKey, data); + } + + Future load(String barcode) async { + final cacheKey = _getKey(barcode); + return await _cacheManager.load(cacheKey); + } + + String _getKey(String barcode) { + return barcode; + } +} diff --git a/lib/features/barcode_scan/service/barcode_scan_service.dart b/lib/features/barcode_scan/service/barcode_scan_service.dart new file mode 100644 index 0000000..ced1396 --- /dev/null +++ b/lib/features/barcode_scan/service/barcode_scan_service.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:nutrient_scanner/features/barcode_scan/model/barcode_model.dart'; +import 'package:simple_barcode_scanner/simple_barcode_scanner.dart'; + +class BarcodeScanService { + Future scanBarcode(BuildContext context) async { + try { + final String? result = await SimpleBarcodeScanner.scanBarcode( + context, + isShowFlashIcon: true, + delayMillis: 500, + cameraFace: CameraFace.back, + scanFormat: ScanFormat.ONLY_BARCODE, + ); + + if (result != null && result.isNotEmpty) { + final barcode = Barcode(result); + return barcode.isValid() ? barcode : null; + } + return null; + } catch (e) { + debugPrint('Error during barcode scanning: $e'); + return null; + } + } +} diff --git a/lib/features/barcode_scan/view/scan_view.dart b/lib/features/barcode_scan/view/scan_view.dart new file mode 100644 index 0000000..be84eca --- /dev/null +++ b/lib/features/barcode_scan/view/scan_view.dart @@ -0,0 +1,29 @@ +part of '../viewmodel/scan_viewmodel.dart'; + +class _BarcodeScanView extends StatelessWidget { + final Function() onScanButtonPressed; + const _BarcodeScanView({ + required this.onScanButtonPressed, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: onScanButtonPressed, + child: Text('바코드 스캔', + style: Theme.of(context).textTheme.headlineMedium), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/barcode_scan/viewmodel/scan_viewmodel.dart b/lib/features/barcode_scan/viewmodel/scan_viewmodel.dart new file mode 100644 index 0000000..72b68c6 --- /dev/null +++ b/lib/features/barcode_scan/viewmodel/scan_viewmodel.dart @@ -0,0 +1,121 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:nutrient_scanner/features/barcode_scan/model/barcode_model.dart'; +import 'package:nutrient_scanner/features/barcode_scan/service/barcode_cache_service.dart'; +import 'package:nutrient_scanner/features/nutrient_intake_guide/viewmodel/guide_viewmodel.dart'; +import 'package:nutrient_scanner/util/error_util.dart'; + +import '../../nutrient_scan/model/recognized_text_model.dart'; +import '../../nutrient_scan/viewmodel/scan_viewmodel.dart'; +import '../service/barcode_scan_service.dart'; + +part '../view/scan_view.dart'; + +class BarcodeScanViewModel extends StatefulWidget { + const BarcodeScanViewModel({super.key}); + + @override + State createState() => _BarcodeScanState(); +} + +class _BarcodeScanState extends State { + final BarcodeScanService _scanService = BarcodeScanService(); + final BarcodeCacheService _cacheService = BarcodeCacheService(); + String? scannedBarcode; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Barcode Label Scan'), + ), + body: _BarcodeScanView( + onScanButtonPressed: () => startBarcodeScan(context), + ), + ); + } + + Future startBarcodeScan(BuildContext context) async { + try { + final barcode = await _performBarcodeScan(context); + if (barcode != null && context.mounted) { + await _handleScannerBarcode(context, barcode.value); + } + } catch (e) { + _handleError(e); + } finally { + if (context.mounted) { + await _checkDebugModeCachedData(context); + } + } + } + + Future _performBarcodeScan(BuildContext context) async { + return await _scanService.scanBarcode(context); + } + + Future _handleScannerBarcode( + BuildContext context, String barcodeValue) async { + _updateScannedBarcode(barcodeValue); + if (context.mounted) { + await _checkCachedData(context, barcodeValue); + } + } + + Future _checkDebugModeCachedData(BuildContext context) async { + if (kDebugMode) { + _setDebugBarcodeTemp(); + } + + await _checkCachedData(context, scannedBarcode ?? ''); + } + + void _setDebugBarcodeTemp() { + _updateScannedBarcode('1234567890123'); + } + + Future _checkCachedData(BuildContext context, String? barcode) async { + final cachedData = await _cacheService.load(barcode ?? ''); + if (!context.mounted) return; + if (cachedData != null) { + _navigateToIntakeGuide(context, cachedData); + } else { + _navigateToNutrientScan(context); + } + } + + void _updateScannedBarcode(String barcode) { + setState(() { + scannedBarcode = barcode; + }); + } + + void _navigateToIntakeGuide(BuildContext context, String cachedData) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NutrientIntakeGuideViewModel( + recognizedText: NutrientRecognizedText(cachedData), + ), + ), + ); + } + + void _navigateToNutrientScan(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NutrientLabelScanViewModel( + barcode: Barcode(scannedBarcode ?? ''), + ), + ), + ); + } + + void _handleError(Object error) { + final errorMessage = ErrorUtil.formatErrorMessage(error); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(errorMessage)), + ); + } +} diff --git a/lib/features/nutrient_intake_guide/viewmodel/analysis_result_viewmodel.dart b/lib/features/nutrient_intake_guide/viewmodel/analysis_result_viewmodel.dart new file mode 100644 index 0000000..6f5ac85 --- /dev/null +++ b/lib/features/nutrient_intake_guide/viewmodel/analysis_result_viewmodel.dart @@ -0,0 +1,25 @@ +import '../../../util/cache_manager.dart'; +import '../model/analysis_result.dart'; + +class AnalysisResultViewModel { + final CacheManager _cacheManager = CacheManager(); + + Future saveToCache(String barcode, AnalysisResult result) async { + final cacheKey = _getCacheKey(barcode); + await _cacheManager.save(cacheKey, result.answer); + } + + Future loadFromCache(String barcode) async { + final cacheKey = _getCacheKey(barcode); + final cachedAnswer = await _cacheManager.load(cacheKey); + + if (cachedAnswer != null) { + return AnalysisResult(cachedAnswer); + } + return null; + } + + String _getCacheKey(String barcode) { + return barcode; + } +} diff --git a/lib/features/nutrient_intake_guide/viewmodel/guide_viewmodel.dart b/lib/features/nutrient_intake_guide/viewmodel/guide_viewmodel.dart index 5101178..5530d52 100644 --- a/lib/features/nutrient_intake_guide/viewmodel/guide_viewmodel.dart +++ b/lib/features/nutrient_intake_guide/viewmodel/guide_viewmodel.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:nutrient_scanner/features/nutrient_intake_guide/model/analysis_result.dart'; import 'package:nutrient_scanner/features/nutrient_intake_guide/service/openai_service.dart'; -import 'package:nutrient_scanner/features/nutrient_scanner/model/recognized_text_model.dart'; +import 'package:nutrient_scanner/features/nutrient_scan/model/recognized_text_model.dart'; import 'package:nutrient_scanner/util/error_util.dart'; part '../view/guide_view.dart'; diff --git a/lib/features/nutrient_scanner/model/recognized_text_model.dart b/lib/features/nutrient_scan/model/recognized_text_model.dart similarity index 100% rename from lib/features/nutrient_scanner/model/recognized_text_model.dart rename to lib/features/nutrient_scan/model/recognized_text_model.dart diff --git a/lib/features/nutrient_scanner/service/scanner_service.dart b/lib/features/nutrient_scan/service/ocr_scan_service.dart similarity index 97% rename from lib/features/nutrient_scanner/service/scanner_service.dart rename to lib/features/nutrient_scan/service/ocr_scan_service.dart index 440b4b2..7036c7b 100644 --- a/lib/features/nutrient_scanner/service/scanner_service.dart +++ b/lib/features/nutrient_scan/service/ocr_scan_service.dart @@ -2,7 +2,7 @@ import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart import 'package:image_picker/image_picker.dart'; import 'package:nutrient_scanner/util/text_util.dart'; -class ScannerService { +class OCRScanService { final ImagePicker _picker = ImagePicker(); final TextRecognizer _textRecognizer = TextRecognizer(script: TextRecognitionScript.korean); diff --git a/lib/features/nutrient_scanner/view/scanner_view.dart b/lib/features/nutrient_scan/view/scan_view.dart similarity index 93% rename from lib/features/nutrient_scanner/view/scanner_view.dart rename to lib/features/nutrient_scan/view/scan_view.dart index 713d6a3..7e3b381 100644 --- a/lib/features/nutrient_scanner/view/scanner_view.dart +++ b/lib/features/nutrient_scan/view/scan_view.dart @@ -1,10 +1,10 @@ -part of '../viewmodel/scanner_viewmodel.dart'; +part of '../viewmodel/scan_viewmodel.dart'; -class _NutrientLabelScannerView extends StatelessWidget { +class _NutrientLabelScanView extends StatelessWidget { final NutrientRecognizedText? recognizedText; final bool isLoading; final Function(ImageSource imageSource) getImage; - const _NutrientLabelScannerView({ + const _NutrientLabelScanView({ required this.recognizedText, required this.isLoading, required this.getImage, diff --git a/lib/features/nutrient_scanner/viewmodel/scanner_viewmodel.dart b/lib/features/nutrient_scan/viewmodel/scan_viewmodel.dart similarity index 54% rename from lib/features/nutrient_scanner/viewmodel/scanner_viewmodel.dart rename to lib/features/nutrient_scan/viewmodel/scan_viewmodel.dart index ada33da..7222e7b 100644 --- a/lib/features/nutrient_scanner/viewmodel/scanner_viewmodel.dart +++ b/lib/features/nutrient_scan/viewmodel/scan_viewmodel.dart @@ -1,22 +1,29 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:nutrient_scanner/features/nutrient_intake_guide/viewmodel/guide_viewmodel.dart'; -import 'package:nutrient_scanner/features/nutrient_scanner/model/recognized_text_model.dart'; -import 'package:nutrient_scanner/features/nutrient_scanner/service/scanner_service.dart'; +import 'package:nutrient_scanner/features/nutrient_scan/model/recognized_text_model.dart'; +import 'package:nutrient_scanner/features/nutrient_scan/service/ocr_scan_service.dart'; import 'package:nutrient_scanner/util/error_util.dart'; -part '../view/scanner_view.dart'; +import '../../barcode_scan/model/barcode_model.dart'; +import '../../barcode_scan/service/barcode_cache_service.dart'; -class NutrientLabelScannerViewModel extends StatefulWidget { - const NutrientLabelScannerViewModel({super.key}); +part '../view/scan_view.dart'; + +class NutrientLabelScanViewModel extends StatefulWidget { + final Barcode? barcode; + const NutrientLabelScanViewModel({ + super.key, + required this.barcode, + }); @override - State createState() => - _NutrientLabelScannerState(); + State createState() => _NutrientLabelScanState(); } -class _NutrientLabelScannerState extends State { - final ScannerService _scannerService = ScannerService(); +class _NutrientLabelScanState extends State { + final OCRScanService _scanService = OCRScanService(); + final BarcodeCacheService _cacheService = BarcodeCacheService(); NutrientRecognizedText? recognizedText; bool isLoading = false; @@ -24,9 +31,9 @@ class _NutrientLabelScannerState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Nutrient Label Scanner'), + title: const Text('Nutrient Label Scan'), ), - body: _NutrientLabelScannerView( + body: _NutrientLabelScanView( recognizedText: recognizedText, isLoading: isLoading, getImage: getImage, @@ -51,14 +58,16 @@ class _NutrientLabelScannerState extends State { final text = await _processImage(pickedImage.path); _updateRecognizedText(text); + + await _cacheService.save(widget.barcode?.value ?? '', text); } Future _pickImage(ImageSource imageSource) async { - return await _scannerService.pickImage(imageSource); + return await _scanService.pickImage(imageSource); } Future _processImage(String imagePath) async { - return await _scannerService.recognizeTextFromImage(imagePath); + return await _scanService.recognizeTextFromImage(imagePath); } void _updateRecognizedText(String text) { @@ -79,4 +88,17 @@ class _NutrientLabelScannerState extends State { isLoading = value; }); } + + Future loadCachedData(String barcode) async { + if (barcode.isEmpty) return; + + try { + final cachedData = await _cacheService.load(barcode); + if (cachedData != null) { + _updateRecognizedText(cachedData); + } + } catch (e) { + _handleError(e); + } + } } diff --git a/lib/main.dart b/lib/main.dart index 2ca7203..19b4cc4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:nutrient_scanner/features/nutrient_scanner/viewmodel/scanner_viewmodel.dart'; +import 'package:nutrient_scanner/features/barcode_scan/viewmodel/scan_viewmodel.dart'; void main() { runApp( @@ -50,12 +50,11 @@ class _MyHomePageState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => const NutrientLabelScannerViewModel(), + builder: (context) => const BarcodeScanViewModel(), ), ); }, - child: const Text('Scan Nutrient Label', - style: TextStyle(fontSize: 20)), + child: const Text('Scan Barcode', style: TextStyle(fontSize: 20)), ), ], ), diff --git a/lib/util/cache_manager.dart b/lib/util/cache_manager.dart new file mode 100644 index 0000000..db28172 --- /dev/null +++ b/lib/util/cache_manager.dart @@ -0,0 +1,57 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class CacheManager { + static const _cacheDuration = Duration(days: 7); + + Future save(String key, String value) async { + final prefs = await SharedPreferences.getInstance(); + final expiryDate = DateTime.now().add(_cacheDuration).toIso8601String(); + + await prefs.setString(key, value); + await prefs.setString('${key}_expiry', expiryDate); + } + + Future load(String key) async { + final prefs = await SharedPreferences.getInstance(); + final cachedValue = _getCachedValue(prefs, key); + final expiryDate = _getExpiryDate(prefs, key); + + if (cachedValue == null || expiryDate == null) { + return null; + } + if (_isExpired(expiryDate)) { + await _removeKey(prefs, key); + return null; + } + return cachedValue; + } + + String? _getCachedValue(SharedPreferences prefs, String key) { + return prefs.getString(key); + } + + DateTime? _getExpiryDate(SharedPreferences prefs, String key) { + final expiryDateStr = prefs.getString('${key}_expiry'); + if (expiryDateStr == null) return null; + + try { + return DateTime.parse(expiryDateStr); + } catch (_) { + return null; + } + } + + bool _isExpired(DateTime expiryDate) { + return DateTime.now().isAfter(expiryDate); + } + + Future remove(String key) async { + final prefs = await SharedPreferences.getInstance(); + await _removeKey(prefs, key); + } + + Future _removeKey(SharedPreferences prefs, String key) async { + await prefs.remove(key); + await prefs.remove('${key}_expiry'); + } +} diff --git a/pubspec.lock b/pubspec.lock index 2004568..f04176b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -238,6 +238,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" file: dependency: transitive description: @@ -604,6 +612,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: transitive + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" platform: dependency: transitive description: @@ -668,6 +748,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -684,6 +820,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + simple_barcode_scanner: + dependency: "direct main" + description: + name: simple_barcode_scanner + sha256: "2b6ec05e10fbf4f07687f3687c5cf46d3dcf873492e0a5758211bd957c854113" + url: "https://pub.dev" + source: hosted + version: "0.3.0" sky_engine: dependency: transitive description: flutter @@ -841,6 +985,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webview_windows: + dependency: transitive + description: + name: webview_windows + sha256: "47fcad5875a45db29dbb5c9e6709bf5c88dcc429049872701343f91ed7255730" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 26c42b0..b2170af 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,8 @@ dependencies: openai_dart: ^0.4.5 envied: ^1.1.1 flutter_riverpod: ^2.6.1 + shared_preferences: ^2.5.3 + simple_barcode_scanner: ^0.3.0 dev_dependencies: integration_test: