From 5f5d6c938ea9a59574e73d371d23a47d7d112dbe Mon Sep 17 00:00:00 2001 From: Hampus Hammarlund Date: Sun, 10 Aug 2025 15:21:46 +0900 Subject: [PATCH 01/10] refactor: combine textfield and split widgets --- lib/ui/views/search/search_view.dart | 320 ++++-------------- .../search/widgets/hand_writing_input.dart | 146 ++++++++ 2 files changed, 216 insertions(+), 250 deletions(-) create mode 100644 lib/ui/views/search/widgets/hand_writing_input.dart diff --git a/lib/ui/views/search/search_view.dart b/lib/ui/views/search/search_view.dart index 98191d7..03a317c 100644 --- a/lib/ui/views/search/search_view.dart +++ b/lib/ui/views/search/search_view.dart @@ -15,6 +15,7 @@ import 'package:stacked_hooks/stacked_hooks.dart'; import 'search_viewmodel.dart'; import 'widgets/analysis_prompt.dart'; +import 'widgets/hand_writing_input.dart'; class SearchView extends StatelessWidget { const SearchView({super.key}); @@ -52,137 +53,11 @@ class _Body extends StackedHookView { ? _SearchHistory(searchController) : const _SearchResults(), if (viewModel.showHandWriting) - Expanded( - child: Column( - children: [ - const Divider(height: 1, thickness: 1), - SizedBox( - height: 40, - child: Row( - children: [ - Expanded( - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: viewModel.handWritingResult.length, - itemBuilder: (context, index) { - return GestureDetector( - onTap: () { - int cursorPosition = - searchController.selection.base.offset; - if (cursorPosition == 0) { - searchController.text = - viewModel.handWritingResult[index] + - searchController.text; - } else { - searchController.text = searchController - .text - .substring(0, cursorPosition) + - viewModel.handWritingResult[index] + - searchController.text - .substring(cursorPosition); - } - - searchController.selection = - TextSelection.fromPosition(TextPosition( - offset: cursorPosition + - viewModel - .handWritingResult[index].length, - )); - handWritingController.clear(); - viewModel - .searchOnChange(searchController.text); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 13, - ), - decoration: const BoxDecoration( - border: Border( - right: BorderSide(color: Colors.black), - ), - ), - child: Center( - child: Text( - viewModel.handWritingResult[index], - ), - ), - ), - ); - }, - ), - ), - const VerticalDivider(width: 1, thickness: 1), - IconButton( - onPressed: () { - viewModel.toggleHandWriting(); - keyboardFocusNode.requestFocus(); - }, - icon: const Icon(Icons.text_fields), - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - ), - IconButton( - onPressed: viewModel.toggleHandWriting, - icon: const Icon(Icons.keyboard_hide), - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - ), - ], - ), - ), - const Divider(height: 1, thickness: 1), - Expanded( - child: HandWritingCanvas( - onHandWritingChanged: viewModel.recognizeWriting, - controller: handWritingController, - ), - ), - const Divider(indent: 16, endIndent: 16, height: 1), - Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - onPressed: () => handWritingController.undo(), - icon: const Icon(Icons.undo), - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - ), - IconButton( - onPressed: () => handWritingController.clear(), - icon: const Icon(Icons.delete), - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - ), - IconButton( - onPressed: () { - int cursorPosition = - searchController.selection.base.offset; - if (cursorPosition != 0) { - searchController.text = searchController.text - .substring(0, cursorPosition - 1) + - searchController.text - .substring(cursorPosition); - - searchController.selection = - TextSelection.fromPosition( - TextPosition(offset: cursorPosition - 1)); - viewModel.searchOnChange(searchController.text); - } - }, - icon: const Icon(Icons.backspace), - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - ), - ], - ), - ), - ], - ), - ), + HandWritingInput( + searchController: searchController, + handWritingController: handWritingController, + keyboardFocusNode: keyboardFocusNode, + ) ], ), ); @@ -241,126 +116,71 @@ class _SearchTextField extends ViewModelWidget { child: Row( children: [ Expanded( - child: viewModel.showHandWriting - ? TextField( - autofocus: true, - readOnly: true, - showCursor: true, - autocorrect: false, - enableIMEPersonalizedLearning: false, - maxLines: 1, - focusNode: handWritingFocusNode, - controller: searchController, - inputFormatters: [ - LengthLimitingTextInputFormatter(1000), - FilteringTextInputFormatter.deny( - RegExp(r'‘|’'), - replacementString: '\'', - ), - FilteringTextInputFormatter.deny( - RegExp(r'“|”'), - replacementString: '"', - ), - ], - decoration: InputDecoration( - hintText: viewModel.searchFilter.displayTitle, - contentPadding: const EdgeInsets.symmetric( - horizontal: 24, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: const BorderSide( - color: Colors.transparent, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: const BorderSide( - color: Colors.transparent, - ), - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: const BorderSide( - color: Colors.transparent, - ), - ), - filled: true, - fillColor: Theme.of(context).scaffoldBackgroundColor, - suffixIcon: IconButton( - onPressed: () { - viewModel.searchOnChange(''); - searchController.clear(); - handWritingFocusNode.requestFocus(); - }, - icon: Icon( - Icons.clear, - color: Theme.of(context).iconTheme.color, - ), - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - ), - ), - ) - : TextField( - autocorrect: false, - enableIMEPersonalizedLearning: false, - maxLines: 1, - textInputAction: TextInputAction.done, - focusNode: keyboardFocusNode, - controller: searchController, - onChanged: viewModel.searchOnChange, - inputFormatters: [ - LengthLimitingTextInputFormatter(1000), - FilteringTextInputFormatter.deny( - RegExp(r'‘|’'), - replacementString: '\'', - ), - FilteringTextInputFormatter.deny( - RegExp(r'“|”'), - replacementString: '"', - ), - ], - decoration: InputDecoration( - hintText: viewModel.searchFilter.displayTitle, - contentPadding: const EdgeInsets.symmetric( - horizontal: 24, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: const BorderSide( - color: Colors.transparent, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: const BorderSide( - color: Colors.transparent, - ), - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: const BorderSide( - color: Colors.transparent, - ), - ), - filled: true, - fillColor: Theme.of(context).scaffoldBackgroundColor, - suffixIcon: IconButton( - onPressed: () { - viewModel.searchOnChange(''); - searchController.clear(); - keyboardFocusNode.requestFocus(); - }, - icon: Icon( - Icons.clear, - color: Theme.of(context).iconTheme.color, - ), - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - ), - ), + child: TextField( + autofocus: viewModel.showHandWriting, + readOnly: viewModel.showHandWriting, + showCursor: true, + autocorrect: false, + enableIMEPersonalizedLearning: false, + maxLines: 1, + textInputAction: + viewModel.showHandWriting ? null : TextInputAction.done, + focusNode: viewModel.showHandWriting + ? handWritingFocusNode + : keyboardFocusNode, + controller: searchController, + onChanged: viewModel.searchOnChange, + inputFormatters: [ + LengthLimitingTextInputFormatter(1000), + FilteringTextInputFormatter.deny( + RegExp(r'‘|’'), + replacementString: '\'', + ), + FilteringTextInputFormatter.deny( + RegExp(r'“|”'), + replacementString: '"', + ), + ], + decoration: InputDecoration( + hintText: viewModel.searchFilter.displayTitle, + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: const BorderSide( + color: Colors.transparent, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: const BorderSide( + color: Colors.transparent, + ), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: const BorderSide( + color: Colors.transparent, + ), + ), + filled: true, + fillColor: Theme.of(context).scaffoldBackgroundColor, + suffixIcon: IconButton( + onPressed: () { + viewModel.searchOnChange(''); + searchController.clear(); + handWritingFocusNode.requestFocus(); + }, + icon: Icon( + Icons.clear, + color: Theme.of(context).iconTheme.color, ), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + ), + ), ), IconButton( onPressed: viewModel.setSearchFilter, diff --git a/lib/ui/views/search/widgets/hand_writing_input.dart b/lib/ui/views/search/widgets/hand_writing_input.dart new file mode 100644 index 0000000..e3a1a7e --- /dev/null +++ b/lib/ui/views/search/widgets/hand_writing_input.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:sagase/ui/widgets/hand_writing_canvas.dart'; +import 'package:stacked/stacked.dart'; + +import '../search_viewmodel.dart'; + +class HandWritingInput extends ViewModelWidget { + final TextEditingController searchController; + final HandWritingController handWritingController; + final FocusNode keyboardFocusNode; + + const HandWritingInput({ + super.key, + required this.searchController, + required this.handWritingController, + required this.keyboardFocusNode, + }); + + @override + Widget build(BuildContext context, SearchViewModel viewModel) { + return Expanded( + child: Column( + children: [ + const Divider(height: 1, thickness: 1), + SizedBox( + height: 40, + child: Row( + children: [ + Expanded( + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: viewModel.handWritingResult.length, + itemBuilder: (context, index) { + return GestureDetector( + onTap: () { + int cursorPosition = + searchController.selection.base.offset; + if (cursorPosition == 0) { + searchController.text = + viewModel.handWritingResult[index] + + searchController.text; + } else { + searchController.text = searchController.text + .substring(0, cursorPosition) + + viewModel.handWritingResult[index] + + searchController.text.substring(cursorPosition); + } + + searchController.selection = + TextSelection.fromPosition(TextPosition( + offset: cursorPosition + + viewModel.handWritingResult[index].length, + )); + handWritingController.clear(); + viewModel.searchOnChange(searchController.text); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 13, + ), + decoration: const BoxDecoration( + border: Border( + right: BorderSide(color: Colors.black), + ), + ), + child: Center( + child: Text( + viewModel.handWritingResult[index], + ), + ), + ), + ); + }, + ), + ), + const VerticalDivider(width: 1, thickness: 1), + IconButton( + onPressed: () { + viewModel.toggleHandWriting(); + keyboardFocusNode.requestFocus(); + }, + icon: const Icon(Icons.text_fields), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + IconButton( + onPressed: viewModel.toggleHandWriting, + icon: const Icon(Icons.keyboard_hide), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + ], + ), + ), + const Divider(height: 1, thickness: 1), + Expanded( + child: HandWritingCanvas( + onHandWritingChanged: viewModel.recognizeWriting, + controller: handWritingController, + ), + ), + const Divider(indent: 16, endIndent: 16, height: 1), + Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + onPressed: () => handWritingController.undo(), + icon: const Icon(Icons.undo), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + IconButton( + onPressed: () => handWritingController.clear(), + icon: const Icon(Icons.delete), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + IconButton( + onPressed: () { + int cursorPosition = searchController.selection.base.offset; + if (cursorPosition != 0) { + searchController.text = searchController.text + .substring(0, cursorPosition - 1) + + searchController.text.substring(cursorPosition); + + searchController.selection = TextSelection.fromPosition( + TextPosition(offset: cursorPosition - 1)); + viewModel.searchOnChange(searchController.text); + } + }, + icon: const Icon(Icons.backspace), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + ], + ), + ), + ], + ), + ); + } +} From 4476ce0909b0f7a1c530b964156ee6dac47ca0f8 Mon Sep 17 00:00:00 2001 From: Hampus Hammarlund Date: Sun, 14 Sep 2025 09:30:32 +0900 Subject: [PATCH 02/10] feat: implement camera and ocr image widgets --- .../ocr_painter.dart} | 13 +- lib/ui/views/ocr/ocr_view.dart | 104 ++---- lib/ui/views/ocr/ocr_viewmodel.dart | 122 ++----- lib/ui/views/search/search_view.dart | 36 +- lib/ui/views/search/search_viewmodel.dart | 84 ++++- .../search/widgets/hand_writing_input.dart | 14 +- lib/ui/views/search/widgets/ocr_widget.dart | 83 +++++ lib/ui/widgets/camera_viewfinder.dart | 132 ++++++++ lib/ui/widgets/ocr_image.dart | 307 ++++++++++++++++++ pubspec.lock | 40 +++ pubspec.yaml | 1 + 11 files changed, 726 insertions(+), 210 deletions(-) rename lib/ui/{views/ocr/painters/text_detector_painter.dart => painters/ocr_painter.dart} (88%) create mode 100644 lib/ui/views/search/widgets/ocr_widget.dart create mode 100644 lib/ui/widgets/camera_viewfinder.dart create mode 100644 lib/ui/widgets/ocr_image.dart diff --git a/lib/ui/views/ocr/painters/text_detector_painter.dart b/lib/ui/painters/ocr_painter.dart similarity index 88% rename from lib/ui/views/ocr/painters/text_detector_painter.dart rename to lib/ui/painters/ocr_painter.dart index d385cb6..8447556 100644 --- a/lib/ui/views/ocr/painters/text_detector_painter.dart +++ b/lib/ui/painters/ocr_painter.dart @@ -4,11 +4,12 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:sagase/datamodels/recognized_text_block.dart'; -class TextRecognizerPainter extends CustomPainter { +class OcrPainter extends CustomPainter { final List recognizedTextBlocks; final Size imageSize; + final void Function(RecognizedTextBlock) onSelect; - TextRecognizerPainter(this.recognizedTextBlocks, this.imageSize); + OcrPainter(this.recognizedTextBlocks, this.imageSize, this.onSelect); @override void paint(Canvas canvas, Size size) { @@ -42,17 +43,15 @@ class TextRecognizerPainter extends CustomPainter { } @override - bool shouldRepaint(TextRecognizerPainter oldDelegate) { - //TODO the painting isn't that intense so probably fine? - return true; - // return oldDelegate.recognizedTextBlocks != recognizedTextBlocks; + bool shouldRepaint(OcrPainter oldDelegate) { + return oldDelegate.recognizedTextBlocks != recognizedTextBlocks; } @override bool? hitTest(Offset position) { for (final textBlock in recognizedTextBlocks) { if (_pointInsideRectangle(position, textBlock.offsets)) { - textBlock.selected = true; + onSelect(textBlock); return true; } } diff --git a/lib/ui/views/ocr/ocr_view.dart b/lib/ui/views/ocr/ocr_view.dart index f4dd94a..e61fdfd 100644 --- a/lib/ui/views/ocr/ocr_view.dart +++ b/lib/ui/views/ocr/ocr_view.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:sagase/ui/widgets/list_item_loading.dart'; +import 'package:sagase/ui/widgets/ocr_image.dart'; import 'package:shimmer/shimmer.dart'; import 'package:stacked/stacked.dart'; import 'ocr_viewmodel.dart'; -import 'painters/text_detector_painter.dart'; class OcrView extends StackedView { final bool cameraStart; @@ -19,7 +19,7 @@ class OcrView extends StackedView { return Scaffold( appBar: AppBar( title: Text('Character Detection'), - actions: viewModel.currentImageBytes == null + actions: viewModel.image == null ? null : [ IconButton( @@ -35,13 +35,21 @@ class OcrView extends StackedView { body: Column( children: [ Expanded( - child: viewModel.currentImageBytes == null + child: viewModel.image == null ? _OcrImageLoading() - : _OcrImage(), + : OcrImage( + key: ValueKey(viewModel.image!.path.hashCode), + image: viewModel.image!, + onImageProcessed: viewModel.handleImageProcessed, + onImageError: viewModel.handleImageError, + onTextSelected: viewModel.handleTextSelected, + locked: false, + singleSelection: false, + ), ), const Divider(indent: 8, endIndent: 8), Expanded( - child: viewModel.recognizedTextBlocks == null + child: viewModel.selectedText == null ? Column( children: [ ListItemLoading(), @@ -80,79 +88,29 @@ class _OcrImageLoading extends StatelessWidget { } } -class _OcrImage extends ViewModelWidget { - @override - Widget build(BuildContext context, OcrViewModel viewModel) { - return Center( - child: Container( - padding: const EdgeInsets.all(8), - child: GestureDetector( - onTap: () => viewModel.rebuildUi(), - child: CustomPaint( - foregroundPainter: viewModel.recognizedTextBlocks == null - ? null - : TextRecognizerPainter( - viewModel.recognizedTextBlocks!, - viewModel.imageSize, - ), - child: IgnorePointer( - child: Image.memory(viewModel.currentImageBytes!), - ), - ), - ), - ), - ); - } -} - class _SelectedText extends ViewModelWidget { @override Widget build(BuildContext context, OcrViewModel viewModel) { - if (viewModel.recognizedTextBlocks!.isEmpty) { - return Center(child: Text('No Japanese text was found')); - } - - late Widget textSection; - - if (viewModel.recognizedTextBlocks!.length == 1) { - textSection = SelectionArea( - child: SingleChildScrollView( - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(8), - child: Text( - viewModel.recognizedTextBlocks![0].text, - textAlign: TextAlign.left, - style: TextStyle(fontSize: 16), - ), - ), - ), - ); - } else { - textSection = ReorderableListView.builder( - itemCount: viewModel.recognizedTextBlocks!.length, - onReorder: viewModel.reorderList, - itemBuilder: (context, index) { - final current = viewModel.recognizedTextBlocks![index]; - return ListTile( - key: ValueKey(current), - leading: Checkbox( - value: current.selected, - onChanged: (value) { - if (value == null) return; - viewModel.toggleCheckBox(index, value); - }, - ), - title: Text(current.text), - trailing: Icon(Icons.drag_indicator), - ); - }, - ); + if (viewModel.selectedText!.isEmpty) { + return Center(child: Text('Select text from the image')); } return Column( children: [ - Expanded(child: textSection), + Expanded( + child: ReorderableListView.builder( + itemCount: viewModel.selectedText!.length, + onReorder: viewModel.reorderList, + itemBuilder: (context, index) { + final current = viewModel.selectedText![index]; + return ListTile( + key: ValueKey(current), + title: Text(current), + trailing: Icon(Icons.drag_indicator), + ); + }, + ), + ), Container( padding: EdgeInsets.only( bottom: MediaQuery.of(context).padding.bottom, @@ -162,9 +120,7 @@ class _SelectedText extends ViewModelWidget { child: TextButton.icon( icon: const Icon(Icons.text_snippet, color: Colors.white), label: Text( - viewModel.recognizedTextBlocks!.length == 1 - ? 'Analyze text' - : 'Analyze selected text', + 'Analyze text', style: TextStyle(color: Colors.white), ), onPressed: viewModel.analyzeSelectedText, diff --git a/lib/ui/views/ocr/ocr_viewmodel.dart b/lib/ui/views/ocr/ocr_viewmodel.dart index c447e6d..0325faa 100644 --- a/lib/ui/views/ocr/ocr_viewmodel.dart +++ b/lib/ui/views/ocr/ocr_viewmodel.dart @@ -1,36 +1,19 @@ -import 'dart:io'; - -import 'package:flutter/painting.dart'; -import 'package:flutter/services.dart'; -import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:kana_kit/kana_kit.dart'; -import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart' as path_provider; import 'package:sagase/app/app.locator.dart'; -import 'package:sagase/datamodels/recognized_text_block.dart'; -import 'package:sagase/utils/constants.dart' as constants; import 'package:stacked/stacked.dart'; import 'package:stacked_services/stacked_services.dart'; -import 'package:flutter_exif_rotation/flutter_exif_rotation.dart'; class OcrViewModel extends BaseViewModel { final _navigationService = locator(); final _snackbarService = locator(); - final _kanaKit = const KanaKit(); - final ImagePicker _imagePicker = ImagePicker(); - final _textRecognizer = - TextRecognizer(script: TextRecognitionScript.japanese); - Uint8List? _currentImageBytes; - Uint8List? get currentImageBytes => _currentImageBytes; - late Size _imageSize; - Size get imageSize => _imageSize; + XFile? _image; + XFile? get image => _image; - List? _recognizedTextBlocks; - List? get recognizedTextBlocks => _recognizedTextBlocks; + List? _selectedText; + List? get selectedText => _selectedText; OcrViewModel(bool cameraStart) { if (cameraStart) { @@ -49,108 +32,51 @@ class OcrViewModel extends BaseViewModel { } Future _processImage(ImageSource imageSource) async { - _currentImageBytes = null; - _recognizedTextBlocks = null; + _image = null; + _selectedText = null; rebuildUi(); - XFile? image; - try { - image = await _imagePicker.pickImage(source: imageSource); + _image = await _imagePicker.pickImage(source: imageSource); } catch (_) { _snackbarService.showSnackbar( message: 'Failed to open camera or gallery', ); } - if (image == null) { - _navigationService.back(); - return; - } - - final ocrImageDir = path.join( - (await path_provider.getApplicationCacheDirectory()).path, - constants.ocrImagesDir, - ); - await Directory(ocrImageDir).create(); - - final imagePath = path.join(ocrImageDir, image.name); - try { - await File(image.path).rename(imagePath); - } catch (_) { - if (File(image.path).existsSync()) { - File(image.path).delete(); - } - _snackbarService.showSnackbar(message: 'Failed to open image'); - _navigationService.back(); - return; - } - - final rotatedImage = await FlutterExifRotation.rotateImage(path: imagePath); - - final inputImage = InputImage.fromFilePath(rotatedImage.path); - - _currentImageBytes = await rotatedImage.readAsBytes(); rebuildUi(); + } - if (inputImage.metadata != null) { - _imageSize = inputImage.metadata!.size; - } else { - final decodedImage = await decodeImageFromList(_currentImageBytes!); - _imageSize = Size( - decodedImage.width.toDouble(), - decodedImage.height.toDouble(), - ); - } - - final recognizedText = await _textRecognizer.processImage(inputImage); - - _recognizedTextBlocks = []; - for (final textBlock in recognizedText.blocks) { - if (_kanaKit.isRomaji(textBlock.text)) continue; - _recognizedTextBlocks!.add( - RecognizedTextBlock( - text: textBlock.text, - points: textBlock.cornerPoints, - ), - ); - } + void handleImageProcessed() { + _selectedText = []; + rebuildUi(); + } + void handleImageError() { + _image = null; + _snackbarService.showSnackbar(message: 'Failed to process image'); rebuildUi(); + } - await rotatedImage.delete(); + void handleTextSelected(String text) { + _selectedText?.add(text); + rebuildUi(); } void reorderList(int oldIndex, int newIndex) { if (oldIndex < newIndex) newIndex -= 1; - _recognizedTextBlocks!.insert( + _selectedText!.insert( newIndex, - _recognizedTextBlocks!.removeAt(oldIndex), + _selectedText!.removeAt(oldIndex), ); rebuildUi(); } - void toggleCheckBox(int index, bool value) { - _recognizedTextBlocks![index].selected = value; - rebuildUi(); - } - void analyzeSelectedText() { - if (_recognizedTextBlocks == null) return; - if (_recognizedTextBlocks!.length == 1) { - _navigationService.back(result: _recognizedTextBlocks![0].text); - return; - } - - List lines = []; - - for (final textBlock in _recognizedTextBlocks!) { - if (textBlock.selected) lines.add(textBlock.text); - } - - if (lines.isEmpty) return; + if (_selectedText == null) return; + if (_selectedText!.isEmpty) return; - _navigationService.back(result: lines.join('\n')); + _navigationService.back(result: _selectedText!.join('\n')); } } diff --git a/lib/ui/views/search/search_view.dart b/lib/ui/views/search/search_view.dart index 03a317c..536bfd8 100644 --- a/lib/ui/views/search/search_view.dart +++ b/lib/ui/views/search/search_view.dart @@ -16,6 +16,7 @@ import 'package:stacked_hooks/stacked_hooks.dart'; import 'search_viewmodel.dart'; import 'widgets/analysis_prompt.dart'; import 'widgets/hand_writing_input.dart'; +import 'widgets/ocr_widget.dart'; class SearchView extends StatelessWidget { const SearchView({super.key}); @@ -52,12 +53,18 @@ class _Body extends StackedHookView { viewModel.searchResult == null ? _SearchHistory(searchController) : const _SearchResults(), - if (viewModel.showHandWriting) + if (viewModel.inputMode == InputMode.handWriting) HandWritingInput( searchController: searchController, handWritingController: handWritingController, keyboardFocusNode: keyboardFocusNode, - ) + ), + if (viewModel.inputMode == InputMode.ocr) + OcrWidget( + searchController: searchController, + handWritingController: handWritingController, + keyboardFocusNode: keyboardFocusNode, + ), ], ), ); @@ -88,12 +95,23 @@ class _SearchTextField extends ViewModelWidget { focusNode: keyboardFocusNode, displayArrows: false, toolbarButtons: [ + // Open OCR and dismiss keyboard + (node) { + return IconButton( + onPressed: () { + node.unfocus(); + viewModel.setInputMode(InputMode.ocr); + handWritingFocusNode.requestFocus(); + }, + icon: const Icon(Icons.camera_alt), + ); + }, // Open hand writing and dismiss keyboard (node) { return IconButton( onPressed: () { node.unfocus(); - viewModel.toggleHandWriting(); + viewModel.setInputMode(InputMode.handWriting); handWritingFocusNode.requestFocus(); }, icon: const Icon(Icons.draw), @@ -117,15 +135,16 @@ class _SearchTextField extends ViewModelWidget { children: [ Expanded( child: TextField( - autofocus: viewModel.showHandWriting, - readOnly: viewModel.showHandWriting, + autofocus: viewModel.inputMode != InputMode.text, + readOnly: viewModel.inputMode != InputMode.text, showCursor: true, autocorrect: false, enableIMEPersonalizedLearning: false, maxLines: 1, - textInputAction: - viewModel.showHandWriting ? null : TextInputAction.done, - focusNode: viewModel.showHandWriting + textInputAction: viewModel.inputMode != InputMode.text + ? null + : TextInputAction.done, + focusNode: viewModel.inputMode != InputMode.text ? handWritingFocusNode : keyboardFocusNode, controller: searchController, @@ -213,7 +232,6 @@ class _SearchResults extends ViewModelWidget { ); } - // Show results return Expanded( child: ListView.separated( key: UniqueKey(), diff --git a/lib/ui/views/search/search_viewmodel.dart b/lib/ui/views/search/search_viewmodel.dart index 1f8a132..dd9ee7b 100644 --- a/lib/ui/views/search/search_viewmodel.dart +++ b/lib/ui/views/search/search_viewmodel.dart @@ -1,5 +1,7 @@ import 'package:async/async.dart'; +import 'package:camera/camera.dart'; import 'package:google_mlkit_digital_ink_recognition/google_mlkit_digital_ink_recognition.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:kana_kit/kana_kit.dart'; import 'package:sagase/app/app.dialogs.dart'; import 'package:sagase/app/app.locator.dart'; @@ -30,8 +32,8 @@ class SearchViewModel extends FutureViewModel { CancelableOperation<(List, bool)>? _searchOperation; - bool _showHandWriting = false; - bool get showHandWriting => _showHandWriting; + InputMode _inputMode = InputMode.text; + InputMode get inputMode => _inputMode; List _handWritingResult = []; List get handWritingResult => _handWritingResult; @@ -45,6 +47,12 @@ class SearchViewModel extends FutureViewModel { bool _promptAnalysis = false; bool get promptAnalysis => _promptAnalysis; + XFile? _image; + XFile? get image => _image; + + bool _ocrError = false; + bool get ocrError => _ocrError; + @override Future futureToRun() async { await loadSearchHistory(); @@ -132,22 +140,6 @@ class SearchViewModel extends FutureViewModel { return (results, promptAnalysis); } - void toggleHandWriting() { - if (!_digitalInkService.ready) { - if (!_snackbarService.isSnackbarOpen) { - _snackbarService.showSnackbar( - message: - 'Hand writing detection is setting up. Please try again later.', - ); - } - return; - } - _showHandWriting = !showHandWriting; - handWritingResult.clear(); - locator().setShowNavigationBar(!_showHandWriting); - notifyListeners(); - } - Future recognizeWriting(Ink ink) async { _handWritingResult = await _digitalInkService.recognizeWriting(ink); notifyListeners(); @@ -210,4 +202,60 @@ class SearchViewModel extends FutureViewModel { arguments: ProperNounViewArguments(properNoun: properNoun), ); } + + void setInputMode(InputMode mode) { + if (_inputMode == mode) return; + + if (mode == InputMode.handWriting) { + if (!_digitalInkService.ready) { + if (!_snackbarService.isSnackbarOpen) { + _snackbarService.showSnackbar( + message: + 'Hand writing detection is setting up. Please try again later.', + ); + } + return; + } + + handWritingResult.clear(); + } + + _inputMode = mode; + _image = null; + locator().setShowNavigationBar(mode == InputMode.text); + + rebuildUi(); + } + + Future handlePictureTaken(XFile image) async { + _image = image; + rebuildUi(); + } + + void handleImageProcessed() { + //TODO maybe don't need + // rebuildUi(); + } + + void handleImageError() { + _image = null; + _snackbarService.showSnackbar(message: 'Failed to process image'); + _ocrError = true; + rebuildUi(); + } + + void handleTextSelected(String text) { + searchOnChange(text); + } + + void resetImage() { + _image = null; + rebuildUi(); + } +} + +enum InputMode { + text, + handWriting, + ocr, } diff --git a/lib/ui/views/search/widgets/hand_writing_input.dart b/lib/ui/views/search/widgets/hand_writing_input.dart index e3a1a7e..f0880bb 100644 --- a/lib/ui/views/search/widgets/hand_writing_input.dart +++ b/lib/ui/views/search/widgets/hand_writing_input.dart @@ -74,18 +74,24 @@ class HandWritingInput extends ViewModelWidget { ), ), const VerticalDivider(width: 1, thickness: 1), + IconButton( + onPressed: () => viewModel.setInputMode(InputMode.ocr), + icon: const Icon(Icons.camera_alt), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), IconButton( onPressed: () { - viewModel.toggleHandWriting(); + viewModel.setInputMode(InputMode.text); keyboardFocusNode.requestFocus(); }, - icon: const Icon(Icons.text_fields), + icon: const Icon(Icons.keyboard), splashColor: Colors.transparent, highlightColor: Colors.transparent, ), IconButton( - onPressed: viewModel.toggleHandWriting, - icon: const Icon(Icons.keyboard_hide), + onPressed: () => viewModel.setInputMode(InputMode.text), + icon: const Icon(Icons.close), splashColor: Colors.transparent, highlightColor: Colors.transparent, ), diff --git a/lib/ui/views/search/widgets/ocr_widget.dart b/lib/ui/views/search/widgets/ocr_widget.dart new file mode 100644 index 0000000..afd7c36 --- /dev/null +++ b/lib/ui/views/search/widgets/ocr_widget.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:sagase/ui/widgets/camera_viewfinder.dart'; +import 'package:sagase/ui/widgets/hand_writing_canvas.dart'; +import 'package:sagase/ui/widgets/ocr_image.dart'; +import 'package:stacked/stacked.dart'; + +import '../search_viewmodel.dart'; + +class OcrWidget extends ViewModelWidget { + final TextEditingController searchController; + final HandWritingController handWritingController; + final FocusNode keyboardFocusNode; + + const OcrWidget({ + super.key, + required this.searchController, + required this.handWritingController, + required this.keyboardFocusNode, + }); + + @override + Widget build(BuildContext context, SearchViewModel viewModel) { + return Expanded( + child: Column( + children: [ + const Divider(height: 1, thickness: 1), + SizedBox( + height: 40, + child: Row( + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: viewModel.resetImage, + child: const Text('Retake photo'), + ), + ), + ), + IconButton( + onPressed: () => + viewModel.setInputMode(InputMode.handWriting), + icon: const Icon(Icons.draw), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + IconButton( + onPressed: () { + viewModel.setInputMode(InputMode.text); + keyboardFocusNode.requestFocus(); + }, + icon: const Icon(Icons.keyboard), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + IconButton( + onPressed: () => viewModel.setInputMode(InputMode.text), + icon: const Icon(Icons.close), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + ], + ), + ), + const Divider(height: 1, thickness: 1), + Expanded( + child: viewModel.image == null + ? CameraViewfinder(onPictureTaken: viewModel.handlePictureTaken) + : OcrImage( + key: ValueKey(viewModel.image!.path.hashCode), + image: viewModel.image!, + onImageProcessed: viewModel.handleImageProcessed, + onImageError: viewModel.handleImageError, + onTextSelected: viewModel.handleTextSelected, + locked: true, + singleSelection: true, + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/widgets/camera_viewfinder.dart b/lib/ui/widgets/camera_viewfinder.dart new file mode 100644 index 0000000..3b00af9 --- /dev/null +++ b/lib/ui/widgets/camera_viewfinder.dart @@ -0,0 +1,132 @@ +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; + +class CameraViewfinder extends StatefulWidget { + final void Function(XFile) onPictureTaken; + + const CameraViewfinder({super.key, required this.onPictureTaken}); + + @override + State createState() => _CameraViewfinderState(); +} + +class _CameraViewfinderState extends State + with WidgetsBindingObserver { + CameraController? _controller; + List? _cameras; + bool _cameraInitialized = false; + bool _cameraError = false; + + @override + void initState() { + super.initState(); + _initCamera(); + } + + Future _initCamera() async { + _cameras = await availableCameras(); + if (_cameras!.isEmpty) { + setState(() { + _cameraError = true; + }); + } + CameraDescription cameraToUse = _cameras!.first; + for (final camera in _cameras!) { + if (camera.lensDirection == CameraLensDirection.back) { + cameraToUse = camera; + break; + } + } + _controller = CameraController( + cameraToUse, + ResolutionPreset.max, + enableAudio: false, + ); + + _controller!.initialize().then((_) { + if (mounted) { + setState(() => _cameraInitialized = true); + } + }).catchError((Object e) { + setState(() { + _cameraError = true; + }); + }); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (_controller == null || !_controller!.value.isInitialized) { + return; + } + + if (state == AppLifecycleState.inactive) { + setState(() => _cameraInitialized = false); + _controller!.dispose(); + } else if (state == AppLifecycleState.resumed) { + _initCamera(); + } + } + + Future _takePhoto() async { + if (!_controller!.value.isInitialized) return; + final image = await _controller!.takePicture(); + widget.onPictureTaken(image); + } + + @override + Widget build(BuildContext context) { + if (_cameraError) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 4, + children: [ + Text( + 'Failed to start camera', + style: TextStyle(fontSize: 16), + ), + Text('Confirm camera permissions in system settings'), + ], + ), + ); + } + + if (_cameraInitialized) { + return Stack( + children: [ + SizedBox( + width: MediaQuery.of(context).size.width, + child: FittedBox( + clipBehavior: Clip.hardEdge, + fit: BoxFit.cover, + child: SizedBox( + width: MediaQuery.of(context).size.width, + child: CameraPreview(_controller!), + ), + ), + ), + Positioned( + bottom: MediaQuery.of(context).padding.bottom + 16, + left: 0, + right: 0, + child: Center( + child: FloatingActionButton( + onPressed: _takePhoto, + child: const Icon(Icons.camera_alt), + ), + ), + ), + ], + ); + } + + return const Center(child: CircularProgressIndicator()); + } +} diff --git a/lib/ui/widgets/ocr_image.dart b/lib/ui/widgets/ocr_image.dart new file mode 100644 index 0000000..1d7b42b --- /dev/null +++ b/lib/ui/widgets/ocr_image.dart @@ -0,0 +1,307 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_exif_rotation/flutter_exif_rotation.dart'; +import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart' as path_provider; +import 'package:sagase/datamodels/recognized_text_block.dart'; +import 'package:sagase/ui/painters/ocr_painter.dart'; +import 'package:sagase/utils/constants.dart' as constants; +import 'package:shimmer/shimmer.dart'; + +class OcrImage extends StatefulWidget { + final XFile image; + final void Function() onImageProcessed; + final void Function() onImageError; + final void Function(String) onTextSelected; + final bool locked; + final bool singleSelection; + + const OcrImage({ + super.key, + required this.image, + required this.onImageProcessed, + required this.onImageError, + required this.locked, + required this.onTextSelected, + required this.singleSelection, + }); + + @override + State createState() => _OcrImageState(); +} + +class _OcrImageState extends State { + final _textRecognizer = + TextRecognizer(script: TextRecognitionScript.japanese); + Uint8List? _currentImageBytes; + late Size _imageSize; + List? _recognizedTextBlocks; + + @override + void initState() { + super.initState(); + _loadImage(); + } + + Future _loadImage() async { + final ocrImageDir = path.join( + (await path_provider.getApplicationCacheDirectory()).path, + constants.ocrImagesDir, + ); + await Directory(ocrImageDir).create(); + + final imagePath = path.join(ocrImageDir, widget.image.name); + try { + await File(widget.image.path).rename(imagePath); + } catch (_) { + if (File(widget.image.path).existsSync()) { + File(widget.image.path).delete(); + } + + widget.onImageError(); + return; + } + + final rotatedImage = await FlutterExifRotation.rotateImage(path: imagePath); + + final inputImage = InputImage.fromFilePath(rotatedImage.path); + _currentImageBytes = await rotatedImage.readAsBytes(); + + if (inputImage.metadata != null) { + _imageSize = inputImage.metadata!.size; + } else { + final decodedImage = await decodeImageFromList(_currentImageBytes!); + _imageSize = Size( + decodedImage.width.toDouble(), + decodedImage.height.toDouble(), + ); + } + + setState(() {}); + + final recognizedText = await _textRecognizer.processImage(inputImage); + + _recognizedTextBlocks = []; + for (final textBlock in recognizedText.blocks) { + _recognizedTextBlocks!.add( + RecognizedTextBlock( + text: textBlock.text, + points: textBlock.cornerPoints, + ), + ); + } + + setState(() {}); + widget.onImageProcessed(); + + await rotatedImage.delete(); + } + + void handleSelect(RecognizedTextBlock textBlock) { + setState(() { + if (!textBlock.selected) { + widget.onTextSelected(textBlock.text); + } + + if (widget.singleSelection) { + for (final textBlock in _recognizedTextBlocks!) { + textBlock.selected = false; + } + } + + textBlock.selected = true; + _recognizedTextBlocks = List.from(_recognizedTextBlocks!); + }); + } + + @override + Widget build(BuildContext context) { + return _currentImageBytes == null + ? _OcrImageLoading() + : _OcrImage( + recognizedTextBlocks: _recognizedTextBlocks, + imageSize: _imageSize, + image: _currentImageBytes!, + locked: widget.locked, + onSelect: handleSelect, + ); + } +} + +class _OcrImageLoading extends StatelessWidget { + const _OcrImageLoading(); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Padding( + padding: const EdgeInsets.all(16), + child: Shimmer.fromColors( + baseColor: isDark ? const Color(0xFF3a3a3a) : Colors.grey.shade300, + highlightColor: isDark ? const Color(0xFF4a4a4a) : Colors.grey.shade100, + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + ), + ), + ); + } +} + +class _OcrImage extends StatelessWidget { + final List? recognizedTextBlocks; + final Size imageSize; + final Uint8List image; + final bool locked; + final void Function(RecognizedTextBlock) onSelect; + + const _OcrImage({ + this.recognizedTextBlocks, + required this.imageSize, + required this.image, + required this.locked, + required this.onSelect, + }); + + @override + Widget build(BuildContext context) { + if (locked) { + return LayoutBuilder( + builder: (context, constraints) { + return SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: FittedBox( + clipBehavior: Clip.hardEdge, + fit: BoxFit.cover, + child: CustomPaint( + foregroundPainter: recognizedTextBlocks == null + ? null + : OcrPainter( + recognizedTextBlocks!, + imageSize, + onSelect, + ), + child: Image.memory(image), + ), + ), + ); + }, + ); + } else { + // TODO + return LayoutBuilder( + builder: (context, constraints) { + return InteractiveViewer( + constrained: true, + child: CustomPaint( + foregroundPainter: recognizedTextBlocks == null + ? null + : OcrPainter( + recognizedTextBlocks!, + imageSize, + onSelect, + ), + child: Image.memory(image), + ), + ); + }, + ); + // return LayoutBuilder( + // builder: (context, constraints) { + // return InteractiveViewer( + // constrained: false, + // child: CustomPaint( + // foregroundPainter: recognizedTextBlocks == null + // ? null + // : OcrPainter( + // recognizedTextBlocks!, + // imageSize, + // ), + // // child: IgnorePointer( + // // child: FittedBox( + // // clipBehavior: Clip.hardEdge, + // // fit: BoxFit.contain, + // // child: Image.memory(image), + // // ), + // // ), + // // child: IgnorePointer( + // // child: SizedBox( + // // width: MediaQuery.of(context).size.width, + // // child: FittedBox( + // // clipBehavior: Clip.hardEdge, + // // fit: BoxFit.contain, + // // child: Image.memory(image), + // // ), + // // ), + // // ), + // child: IgnorePointer( + // child: SizedBox( + // width: constraints.maxWidth, + // child: Image.memory( + // image, + // // fit: BoxFit.fitWidth, + // fit: BoxFit.cover, + // ), + // ), + // ), + // ), + // ); + // }, + // ); + } + + // return CustomPaint( + // foregroundPainter: recognizedTextBlocks == null + // ? null + // : OcrPainter( + // recognizedTextBlocks!, + // imageSize, + // ), + // child: Container( + // decoration: BoxDecoration( + // image: DecorationImage( + // image: MemoryImage(image), + // fit: BoxFit.cover, + // ), + // ), + // ), + // ); + + // return Container( + // decoration: BoxDecoration( + // image: DecorationImage( + // image: MemoryImage(image), + // fit: BoxFit.cover, + // ), + // ), + // ); + + // return Center( + // child: GestureDetector( + // // onTap: () => viewModel.rebuildUi(), + // child: CustomPaint( + // foregroundPainter: recognizedTextBlocks == null + // ? null + // : OcrPainter( + // recognizedTextBlocks!, + // imageSize, + // ), + // child: IgnorePointer( + // child: Image.memory( + // image, + // fit: BoxFit.fitWidth, + // ), + // ), + // ), + // ), + // ); + } +} diff --git a/pubspec.lock b/pubspec.lock index ff32da9..390d243 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -158,6 +158,46 @@ packages: url: "https://pub.dev" source: hosted version: "8.11.1" + camera: + dependency: "direct main" + description: + name: camera + sha256: d6ec2cbdbe2fa8f5e0d07d8c06368fe4effa985a4a5ddade9cc58a8cd849557d + url: "https://pub.dev" + source: hosted + version: "0.11.2" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "58b8fe843a3c83fd1273c00cb35f5a8ae507f6cc9b2029bcf7e2abba499e28d8" + url: "https://pub.dev" + source: hosted + version: "0.6.19+1" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "180c1b368cf3485a18c46e219e3ad80271a779a6d66a749376e31244fd927cde" + url: "https://pub.dev" + source: hosted + version: "0.9.20+7" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "2f757024a48696ff4814a789b0bd90f5660c0fb25f393ab4564fb483327930e2" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "595f28c89d1fb62d77c73c633193755b781c6d2e0ebcd8dc25b763b514e6ba8f" + url: "https://pub.dev" + source: hosted + version: "0.3.5" characters: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8818205..ce088ac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -87,6 +87,7 @@ dependencies: ref: 'ddb5dff' security_scoped_resource: ^0.0.2 sanitize_filename: ^1.0.5 + camera: ^0.11.2 image_picker: ^1.1.2 flutter_exif_rotation: git: From 85f80ed76544b6f68ac343cd449cba2162595313 Mon Sep 17 00:00:00 2001 From: Moseco Date: Sat, 20 Sep 2025 09:23:11 +0900 Subject: [PATCH 03/10] update podfile --- ios/Podfile.lock | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 150f47b..285aef0 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,6 +1,8 @@ PODS: - app_links (0.0.2): - Flutter + - camera_avfoundation (0.0.1): + - Flutter - disk_space_plus (0.0.1): - Flutter - Firebase/CoreOnly (12.0.0): @@ -250,6 +252,7 @@ PODS: DEPENDENCIES: - app_links (from `.symlinks/plugins/app_links/ios`) + - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - disk_space_plus (from `.symlinks/plugins/disk_space_plus/ios`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) @@ -309,6 +312,8 @@ SPEC REPOS: EXTERNAL SOURCES: app_links: :path: ".symlinks/plugins/app_links/ios" + camera_avfoundation: + :path: ".symlinks/plugins/camera_avfoundation/ios" disk_space_plus: :path: ".symlinks/plugins/disk_space_plus/ios" firebase_analytics: @@ -356,6 +361,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 + camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436 disk_space_plus: a36391fb5f732dbdd29628b0bac5a1acbd43aaef Firebase: 800d487043c0557d9faed71477a38d9aafb08a41 firebase_analytics: cd56fc56f75c1df30a6ff5290cd56e230996a76d From 02f32a8da336f06b5221b9a212b49e0474507a1b Mon Sep 17 00:00:00 2001 From: Moseco Date: Sat, 20 Sep 2025 09:28:52 +0900 Subject: [PATCH 04/10] fix unlocked ocr image --- lib/ui/views/search/widgets/ocr_widget.dart | 5 +- lib/ui/widgets/ocr_image.dart | 99 ++------------------- 2 files changed, 13 insertions(+), 91 deletions(-) diff --git a/lib/ui/views/search/widgets/ocr_widget.dart b/lib/ui/views/search/widgets/ocr_widget.dart index afd7c36..839abce 100644 --- a/lib/ui/views/search/widgets/ocr_widget.dart +++ b/lib/ui/views/search/widgets/ocr_widget.dart @@ -71,7 +71,10 @@ class OcrWidget extends ViewModelWidget { image: viewModel.image!, onImageProcessed: viewModel.handleImageProcessed, onImageError: viewModel.handleImageError, - onTextSelected: viewModel.handleTextSelected, + onTextSelected: (text) { + searchController.text = text; + viewModel.handleTextSelected(text); + }, locked: true, singleSelection: true, ), diff --git a/lib/ui/widgets/ocr_image.dart b/lib/ui/widgets/ocr_image.dart index 1d7b42b..6ddfbe7 100644 --- a/lib/ui/widgets/ocr_image.dart +++ b/lib/ui/widgets/ocr_image.dart @@ -196,11 +196,16 @@ class _OcrImage extends StatelessWidget { }, ); } else { - // TODO return LayoutBuilder( builder: (context, constraints) { return InteractiveViewer( - constrained: true, + constrained: false, + child: SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: FittedBox( + clipBehavior: Clip.hardEdge, + fit: BoxFit.contain, child: CustomPaint( foregroundPainter: recognizedTextBlocks == null ? null @@ -210,98 +215,12 @@ class _OcrImage extends StatelessWidget { onSelect, ), child: Image.memory(image), + ), + ), ), ); }, ); - // return LayoutBuilder( - // builder: (context, constraints) { - // return InteractiveViewer( - // constrained: false, - // child: CustomPaint( - // foregroundPainter: recognizedTextBlocks == null - // ? null - // : OcrPainter( - // recognizedTextBlocks!, - // imageSize, - // ), - // // child: IgnorePointer( - // // child: FittedBox( - // // clipBehavior: Clip.hardEdge, - // // fit: BoxFit.contain, - // // child: Image.memory(image), - // // ), - // // ), - // // child: IgnorePointer( - // // child: SizedBox( - // // width: MediaQuery.of(context).size.width, - // // child: FittedBox( - // // clipBehavior: Clip.hardEdge, - // // fit: BoxFit.contain, - // // child: Image.memory(image), - // // ), - // // ), - // // ), - // child: IgnorePointer( - // child: SizedBox( - // width: constraints.maxWidth, - // child: Image.memory( - // image, - // // fit: BoxFit.fitWidth, - // fit: BoxFit.cover, - // ), - // ), - // ), - // ), - // ); - // }, - // ); } - - // return CustomPaint( - // foregroundPainter: recognizedTextBlocks == null - // ? null - // : OcrPainter( - // recognizedTextBlocks!, - // imageSize, - // ), - // child: Container( - // decoration: BoxDecoration( - // image: DecorationImage( - // image: MemoryImage(image), - // fit: BoxFit.cover, - // ), - // ), - // ), - // ); - - // return Container( - // decoration: BoxDecoration( - // image: DecorationImage( - // image: MemoryImage(image), - // fit: BoxFit.cover, - // ), - // ), - // ); - - // return Center( - // child: GestureDetector( - // // onTap: () => viewModel.rebuildUi(), - // child: CustomPaint( - // foregroundPainter: recognizedTextBlocks == null - // ? null - // : OcrPainter( - // recognizedTextBlocks!, - // imageSize, - // ), - // child: IgnorePointer( - // child: Image.memory( - // image, - // fit: BoxFit.fitWidth, - // ), - // ), - // ), - // ), - // ); } } From 3481e771bf7854d17d0b458f360c7e816d3fbc43 Mon Sep 17 00:00:00 2001 From: Hampus Hammarlund Date: Sun, 21 Sep 2025 11:53:26 +0900 Subject: [PATCH 05/10] change select text method and other small changes --- lib/ui/painters/ocr_painter.dart | 2 +- lib/ui/views/ocr/ocr_view.dart | 185 ++++++++++++------ lib/ui/views/ocr/ocr_viewmodel.dart | 51 ++--- lib/ui/views/search/search_view.dart | 2 +- lib/ui/views/search/search_viewmodel.dart | 7 +- .../search/widgets/hand_writing_input.dart | 2 +- lib/ui/views/search/widgets/ocr_widget.dart | 37 ++-- lib/ui/widgets/camera_viewfinder.dart | 1 + lib/ui/widgets/ocr_image.dart | 27 +-- 9 files changed, 199 insertions(+), 115 deletions(-) diff --git a/lib/ui/painters/ocr_painter.dart b/lib/ui/painters/ocr_painter.dart index 8447556..312633e 100644 --- a/lib/ui/painters/ocr_painter.dart +++ b/lib/ui/painters/ocr_painter.dart @@ -16,7 +16,7 @@ class OcrPainter extends CustomPainter { final unselectedPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 2 - ..color = Colors.blueGrey; + ..color = Colors.lightBlueAccent; final selectedPaint = Paint() ..style = PaintingStyle.stroke diff --git a/lib/ui/views/ocr/ocr_view.dart b/lib/ui/views/ocr/ocr_view.dart index e61fdfd..4754347 100644 --- a/lib/ui/views/ocr/ocr_view.dart +++ b/lib/ui/views/ocr/ocr_view.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:sagase/ui/widgets/list_item_loading.dart'; import 'package:sagase/ui/widgets/ocr_image.dart'; import 'package:shimmer/shimmer.dart'; import 'package:stacked/stacked.dart'; +import 'package:stacked_hooks/stacked_hooks.dart'; import 'ocr_viewmodel.dart'; @@ -15,11 +18,21 @@ class OcrView extends StackedView { OcrViewModel viewModelBuilder(context) => OcrViewModel(cameraStart); @override - Widget builder(BuildContext context, OcrViewModel viewModel, Widget? child) { + Widget builder(context, viewModel, child) => const _Body(); +} + +class _Body extends StackedHookView { + const _Body(); + + @override + Widget builder(BuildContext context, OcrViewModel viewModel) { + final controller = useTextEditingController(); + return Scaffold( appBar: AppBar( title: Text('Character Detection'), - actions: viewModel.image == null + actions: viewModel.state == OcrState.waiting || + viewModel.state == OcrState.loading ? null : [ IconButton( @@ -32,35 +45,47 @@ class OcrView extends StackedView { ), ], ), - body: Column( - children: [ - Expanded( - child: viewModel.image == null - ? _OcrImageLoading() - : OcrImage( - key: ValueKey(viewModel.image!.path.hashCode), - image: viewModel.image!, - onImageProcessed: viewModel.handleImageProcessed, - onImageError: viewModel.handleImageError, - onTextSelected: viewModel.handleTextSelected, - locked: false, - singleSelection: false, - ), - ), - const Divider(indent: 8, endIndent: 8), - Expanded( - child: viewModel.selectedText == null - ? Column( - children: [ - ListItemLoading(), - const SizedBox(height: 8), - ListItemLoading(), - ], - ) - : _SelectedText(), - ), - ], - ), + body: viewModel.state == OcrState.error + ? const _Error() + : Column( + children: [ + Expanded( + child: viewModel.image == null + ? _OcrImageLoading() + : OcrImage( + key: ValueKey(viewModel.image!.path.hashCode), + image: viewModel.image!, + onImageProcessed: viewModel.handleImageProcessed, + onImageError: viewModel.handleImageError, + onTextSelected: (text) { + controller.text = controller.text + text; + viewModel.handleTextSelected(); + }, + locked: false, + singleSelection: false, + ), + ), + const Divider(indent: 8, endIndent: 8), + Expanded( + child: switch (viewModel.state) { + OcrState.viewEmpty => const Center( + child: Text( + 'No text found in the image', + style: TextStyle(fontSize: 18), + ), + ), + OcrState.viewing => _SelectedText(controller), + _ => Column( + children: [ + ListItemLoading(), + const SizedBox(height: 8), + ListItemLoading(), + ], + ) + }, + ), + ], + ), ); } } @@ -89,44 +114,88 @@ class _OcrImageLoading extends StatelessWidget { } class _SelectedText extends ViewModelWidget { + final TextEditingController controller; + + const _SelectedText(this.controller); + @override Widget build(BuildContext context, OcrViewModel viewModel) { - if (viewModel.selectedText!.isEmpty) { - return Center(child: Text('Select text from the image')); - } - return Column( children: [ Expanded( - child: ReorderableListView.builder( - itemCount: viewModel.selectedText!.length, - onReorder: viewModel.reorderList, - itemBuilder: (context, index) { - final current = viewModel.selectedText![index]; - return ListTile( - key: ValueKey(current), - title: Text(current), - trailing: Icon(Icons.drag_indicator), - ); - }, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: controller, + autofocus: true, + maxLines: null, + style: const TextStyle(fontSize: 24), + decoration: const InputDecoration.collapsed( + hintText: 'Select text from the image...', + ), + maxLength: 1000, + inputFormatters: [LengthLimitingTextInputFormatter(1000)], + ), + ), ), ), - Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom, - ), - width: double.infinity, - color: Colors.deepPurple, - child: TextButton.icon( - icon: const Icon(Icons.text_snippet, color: Colors.white), - label: Text( - 'Analyze text', - style: TextStyle(color: Colors.white), + AnimatedCrossFade( + crossFadeState: controller.text.isEmpty + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 200), + firstChild: SizedBox.shrink(), + secondChild: Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + width: double.infinity, + color: Colors.deepPurple, + child: TextButton.icon( + icon: const Icon(Icons.text_snippet, color: Colors.white), + label: const Text( + 'Analyze', + style: TextStyle(color: Colors.white), + ), + onPressed: () => viewModel.analyzeText(controller.text), ), - onPressed: viewModel.analyzeSelectedText, ), ), ], ); } } + +class _Error extends ViewModelWidget { + const _Error(); + + @override + Widget build(BuildContext context, OcrViewModel viewModel) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Failed to analyze image', + style: TextStyle(fontSize: 18), + ), + const SizedBox(height: 8), + const Text('Please try again'), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => viewModel.openCamera(), + icon: const Icon(Icons.camera), + label: const Text('Take photo'), + ), + ElevatedButton.icon( + onPressed: () => viewModel.selectImage(), + icon: const Icon(Icons.photo), + label: const Text('Select photo'), + ), + ], + ), + ); + } +} diff --git a/lib/ui/views/ocr/ocr_viewmodel.dart b/lib/ui/views/ocr/ocr_viewmodel.dart index 0325faa..d899179 100644 --- a/lib/ui/views/ocr/ocr_viewmodel.dart +++ b/lib/ui/views/ocr/ocr_viewmodel.dart @@ -9,12 +9,12 @@ class OcrViewModel extends BaseViewModel { final ImagePicker _imagePicker = ImagePicker(); + OcrState _state = OcrState.waiting; + OcrState get state => _state; + XFile? _image; XFile? get image => _image; - List? _selectedText; - List? get selectedText => _selectedText; - OcrViewModel(bool cameraStart) { if (cameraStart) { openCamera(); @@ -33,7 +33,7 @@ class OcrViewModel extends BaseViewModel { Future _processImage(ImageSource imageSource) async { _image = null; - _selectedText = null; + _state = OcrState.waiting; rebuildUi(); try { @@ -44,39 +44,46 @@ class OcrViewModel extends BaseViewModel { ); } + if (image == null) { + _navigationService.back(); + return; + } + + _state = OcrState.loading; + rebuildUi(); } - void handleImageProcessed() { - _selectedText = []; + void handleImageProcessed(int length) { + if (length == 0) { + _state = OcrState.viewEmpty; + } else { + _state = OcrState.viewing; + } rebuildUi(); } void handleImageError() { _image = null; - _snackbarService.showSnackbar(message: 'Failed to process image'); + _state = OcrState.error; rebuildUi(); } - void handleTextSelected(String text) { - _selectedText?.add(text); + void handleTextSelected() { rebuildUi(); } - void reorderList(int oldIndex, int newIndex) { - if (oldIndex < newIndex) newIndex -= 1; + void analyzeText(String text) { + if (text.isEmpty) return; - _selectedText!.insert( - newIndex, - _selectedText!.removeAt(oldIndex), - ); - rebuildUi(); + _navigationService.back(result: text); } +} - void analyzeSelectedText() { - if (_selectedText == null) return; - if (_selectedText!.isEmpty) return; - - _navigationService.back(result: _selectedText!.join('\n')); - } +enum OcrState { + waiting, + loading, + viewing, + viewEmpty, + error, } diff --git a/lib/ui/views/search/search_view.dart b/lib/ui/views/search/search_view.dart index 536bfd8..e18bef3 100644 --- a/lib/ui/views/search/search_view.dart +++ b/lib/ui/views/search/search_view.dart @@ -121,7 +121,7 @@ class _SearchTextField extends ViewModelWidget { (node) { return IconButton( onPressed: node.unfocus, - icon: const Icon(Icons.keyboard_hide), + icon: const Icon(Icons.keyboard_arrow_down), ); }, ], diff --git a/lib/ui/views/search/search_viewmodel.dart b/lib/ui/views/search/search_viewmodel.dart index dd9ee7b..aef704b 100644 --- a/lib/ui/views/search/search_viewmodel.dart +++ b/lib/ui/views/search/search_viewmodel.dart @@ -222,6 +222,7 @@ class SearchViewModel extends FutureViewModel { _inputMode = mode; _image = null; + _ocrError = false; locator().setShowNavigationBar(mode == InputMode.text); rebuildUi(); @@ -232,11 +233,6 @@ class SearchViewModel extends FutureViewModel { rebuildUi(); } - void handleImageProcessed() { - //TODO maybe don't need - // rebuildUi(); - } - void handleImageError() { _image = null; _snackbarService.showSnackbar(message: 'Failed to process image'); @@ -250,6 +246,7 @@ class SearchViewModel extends FutureViewModel { void resetImage() { _image = null; + _ocrError = false; rebuildUi(); } } diff --git a/lib/ui/views/search/widgets/hand_writing_input.dart b/lib/ui/views/search/widgets/hand_writing_input.dart index f0880bb..e0bf95e 100644 --- a/lib/ui/views/search/widgets/hand_writing_input.dart +++ b/lib/ui/views/search/widgets/hand_writing_input.dart @@ -91,7 +91,7 @@ class HandWritingInput extends ViewModelWidget { ), IconButton( onPressed: () => viewModel.setInputMode(InputMode.text), - icon: const Icon(Icons.close), + icon: const Icon(Icons.keyboard_arrow_down), splashColor: Colors.transparent, highlightColor: Colors.transparent, ), diff --git a/lib/ui/views/search/widgets/ocr_widget.dart b/lib/ui/views/search/widgets/ocr_widget.dart index 839abce..f70be05 100644 --- a/lib/ui/views/search/widgets/ocr_widget.dart +++ b/lib/ui/views/search/widgets/ocr_widget.dart @@ -55,7 +55,7 @@ class OcrWidget extends ViewModelWidget { ), IconButton( onPressed: () => viewModel.setInputMode(InputMode.text), - icon: const Icon(Icons.close), + icon: const Icon(Icons.keyboard_arrow_down), splashColor: Colors.transparent, highlightColor: Colors.transparent, ), @@ -64,20 +64,27 @@ class OcrWidget extends ViewModelWidget { ), const Divider(height: 1, thickness: 1), Expanded( - child: viewModel.image == null - ? CameraViewfinder(onPictureTaken: viewModel.handlePictureTaken) - : OcrImage( - key: ValueKey(viewModel.image!.path.hashCode), - image: viewModel.image!, - onImageProcessed: viewModel.handleImageProcessed, - onImageError: viewModel.handleImageError, - onTextSelected: (text) { - searchController.text = text; - viewModel.handleTextSelected(text); - }, - locked: true, - singleSelection: true, - ), + child: viewModel.ocrError + ? Center( + child: Text( + 'Failed to process image', + style: TextStyle(fontSize: 18), + ), + ) + : viewModel.image == null + ? CameraViewfinder( + onPictureTaken: viewModel.handlePictureTaken) + : OcrImage( + key: ValueKey(viewModel.image!.path.hashCode), + image: viewModel.image!, + onImageError: viewModel.handleImageError, + onTextSelected: (text) { + searchController.text = text; + viewModel.handleTextSelected(text); + }, + locked: true, + singleSelection: true, + ), ), ], ), diff --git a/lib/ui/widgets/camera_viewfinder.dart b/lib/ui/widgets/camera_viewfinder.dart index 3b00af9..c678238 100644 --- a/lib/ui/widgets/camera_viewfinder.dart +++ b/lib/ui/widgets/camera_viewfinder.dart @@ -119,6 +119,7 @@ class _CameraViewfinderState extends State child: Center( child: FloatingActionButton( onPressed: _takePhoto, + backgroundColor: Colors.deepPurple, child: const Icon(Icons.camera_alt), ), ), diff --git a/lib/ui/widgets/ocr_image.dart b/lib/ui/widgets/ocr_image.dart index 6ddfbe7..4968542 100644 --- a/lib/ui/widgets/ocr_image.dart +++ b/lib/ui/widgets/ocr_image.dart @@ -14,7 +14,7 @@ import 'package:shimmer/shimmer.dart'; class OcrImage extends StatefulWidget { final XFile image; - final void Function() onImageProcessed; + final void Function(int)? onImageProcessed; final void Function() onImageError; final void Function(String) onTextSelected; final bool locked; @@ -23,7 +23,7 @@ class OcrImage extends StatefulWidget { const OcrImage({ super.key, required this.image, - required this.onImageProcessed, + this.onImageProcessed, required this.onImageError, required this.locked, required this.onTextSelected, @@ -96,7 +96,10 @@ class _OcrImageState extends State { } setState(() {}); - widget.onImageProcessed(); + + if (widget.onImageProcessed != null) { + widget.onImageProcessed!(_recognizedTextBlocks!.length); + } await rotatedImage.delete(); } @@ -206,15 +209,15 @@ class _OcrImage extends StatelessWidget { child: FittedBox( clipBehavior: Clip.hardEdge, fit: BoxFit.contain, - child: CustomPaint( - foregroundPainter: recognizedTextBlocks == null - ? null - : OcrPainter( - recognizedTextBlocks!, - imageSize, - onSelect, - ), - child: Image.memory(image), + child: CustomPaint( + foregroundPainter: recognizedTextBlocks == null + ? null + : OcrPainter( + recognizedTextBlocks!, + imageSize, + onSelect, + ), + child: Image.memory(image), ), ), ), From d8a642ebbd68cb1489af58f481b5c3465f191bf4 Mon Sep 17 00:00:00 2001 From: Moseco Date: Sun, 21 Sep 2025 15:22:30 +0900 Subject: [PATCH 06/10] fix hit test --- lib/ui/painters/ocr_painter.dart | 109 ++++++++----------------------- lib/ui/views/ocr/ocr_view.dart | 31 ++++++--- lib/ui/widgets/ocr_image.dart | 89 ++++++++++++++++++++----- 3 files changed, 122 insertions(+), 107 deletions(-) diff --git a/lib/ui/painters/ocr_painter.dart b/lib/ui/painters/ocr_painter.dart index 312633e..a5c6598 100644 --- a/lib/ui/painters/ocr_painter.dart +++ b/lib/ui/painters/ocr_painter.dart @@ -1,44 +1,44 @@ -import 'dart:math'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:sagase/datamodels/recognized_text_block.dart'; class OcrPainter extends CustomPainter { - final List recognizedTextBlocks; + final List? recognizedTextBlocks; final Size imageSize; - final void Function(RecognizedTextBlock) onSelect; - OcrPainter(this.recognizedTextBlocks, this.imageSize, this.onSelect); + OcrPainter(this.recognizedTextBlocks, this.imageSize); @override void paint(Canvas canvas, Size size) { - final unselectedPaint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 2 - ..color = Colors.lightBlueAccent; - - final selectedPaint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 2 - ..color = Colors.lightGreenAccent; - - for (final textBlock in recognizedTextBlocks) { - final List cornerPoints = []; - for (final point in textBlock.points) { - double x = point.x.toDouble() * size.width / imageSize.width; - double y = point.y.toDouble() * size.height / imageSize.height; - cornerPoints.add(Offset(x, y)); + if (recognizedTextBlocks != null) { + final unselectedPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = Colors.lightBlueAccent; + + final selectedPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = Colors.lightGreenAccent; + + for (final textBlock in recognizedTextBlocks!) { + final List cornerPoints = []; + for (final point in textBlock.points) { + double x = point.x.toDouble() * size.width / imageSize.width; + double y = point.y.toDouble() * size.height / imageSize.height; + cornerPoints.add(Offset(x, y)); + } + + textBlock.offsets = List.from(cornerPoints); + + cornerPoints.add(cornerPoints.first); + canvas.drawPoints( + PointMode.polygon, + cornerPoints, + textBlock.selected ? selectedPaint : unselectedPaint, + ); } - - textBlock.offsets = List.from(cornerPoints); - - cornerPoints.add(cornerPoints.first); - canvas.drawPoints( - PointMode.polygon, - cornerPoints, - textBlock.selected ? selectedPaint : unselectedPaint, - ); } } @@ -46,55 +46,4 @@ class OcrPainter extends CustomPainter { bool shouldRepaint(OcrPainter oldDelegate) { return oldDelegate.recognizedTextBlocks != recognizedTextBlocks; } - - @override - bool? hitTest(Offset position) { - for (final textBlock in recognizedTextBlocks) { - if (_pointInsideRectangle(position, textBlock.offsets)) { - onSelect(textBlock); - return true; - } - } - - return false; - } - - bool _pointInsideRectangle(Offset point, List rectCorners) { - double x1 = rectCorners[0].dx; - double x2 = rectCorners[1].dx; - double x3 = rectCorners[2].dx; - double x4 = rectCorners[3].dx; - - double y1 = rectCorners[0].dy; - double y2 = rectCorners[1].dy; - double y3 = rectCorners[2].dy; - double y4 = rectCorners[3].dy; - - double a1 = sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)); - double a2 = sqrt((x2 - x3) * (x2 - x3) + (y2 - y3) * (y2 - y3)); - double a3 = sqrt((x3 - x4) * (x3 - x4) + (y3 - y4) * (y3 - y4)); - double a4 = sqrt((x4 - x1) * (x4 - x1) + (y4 - y1) * (y4 - y1)); - - double b1 = sqrt( - (x1 - point.dx) * (x1 - point.dx) + (y1 - point.dy) * (y1 - point.dy)); - double b2 = sqrt( - (x2 - point.dx) * (x2 - point.dx) + (y2 - point.dy) * (y2 - point.dy)); - double b3 = sqrt( - (x3 - point.dx) * (x3 - point.dx) + (y3 - point.dy) * (y3 - point.dy)); - double b4 = sqrt( - (x4 - point.dx) * (x4 - point.dx) + (y4 - point.dy) * (y4 - point.dy)); - - double u1 = (a1 + b1 + b2) / 2; - double u2 = (a2 + b2 + b3) / 2; - double u3 = (a3 + b3 + b4) / 2; - double u4 = (a4 + b4 + b1) / 2; - - double area1 = sqrt(u1 * (u1 - a1) * (u1 - b1) * (u1 - b2)); - double area2 = sqrt(u2 * (u2 - a2) * (u2 - b2) * (u2 - b3)); - double area3 = sqrt(u3 * (u3 - a3) * (u3 - b3) * (u3 - b4)); - double area4 = sqrt(u4 * (u4 - a4) * (u4 - b4) * (u4 - b1)); - - double difference = 0.95 * (area1 + area2 + area3 + area4) - a1 * a2; - return difference < 1; - } } diff --git a/lib/ui/views/ocr/ocr_view.dart b/lib/ui/views/ocr/ocr_view.dart index 4754347..0ef4055 100644 --- a/lib/ui/views/ocr/ocr_view.dart +++ b/lib/ui/views/ocr/ocr_view.dart @@ -36,17 +36,23 @@ class _Body extends StackedHookView { ? null : [ IconButton( - onPressed: viewModel.openCamera, + onPressed: () { + viewModel.openCamera(); + controller.clear(); + }, icon: Icon(Icons.camera), ), IconButton( - onPressed: viewModel.selectImage, + onPressed: () { + viewModel.selectImage(); + controller.clear(); + }, icon: Icon(Icons.photo), ), ], ), body: viewModel.state == OcrState.error - ? const _Error() + ? _Error(controller) : Column( children: [ Expanded( @@ -129,7 +135,6 @@ class _SelectedText extends ViewModelWidget { padding: const EdgeInsets.all(8), child: TextField( controller: controller, - autofocus: true, maxLines: null, style: const TextStyle(fontSize: 24), decoration: const InputDecoration.collapsed( @@ -169,7 +174,9 @@ class _SelectedText extends ViewModelWidget { } class _Error extends ViewModelWidget { - const _Error(); + final TextEditingController controller; + + const _Error(this.controller); @override Widget build(BuildContext context, OcrViewModel viewModel) { @@ -185,14 +192,20 @@ class _Error extends ViewModelWidget { const Text('Please try again'), const SizedBox(height: 16), ElevatedButton.icon( - onPressed: () => viewModel.openCamera(), + onPressed: () { + viewModel.openCamera(); + controller.clear(); + }, icon: const Icon(Icons.camera), - label: const Text('Take photo'), + label: const Text('Open camera'), ), ElevatedButton.icon( - onPressed: () => viewModel.selectImage(), + onPressed: () { + viewModel.selectImage(); + controller.clear(); + }, icon: const Icon(Icons.photo), - label: const Text('Select photo'), + label: const Text('Pick from photos'), ), ], ), diff --git a/lib/ui/widgets/ocr_image.dart b/lib/ui/widgets/ocr_image.dart index 4968542..a2710bf 100644 --- a/lib/ui/widgets/ocr_image.dart +++ b/lib/ui/widgets/ocr_image.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math'; import 'dart:typed_data'; import 'package:camera/camera.dart'; @@ -184,15 +185,16 @@ class _OcrImage extends StatelessWidget { child: FittedBox( clipBehavior: Clip.hardEdge, fit: BoxFit.cover, - child: CustomPaint( - foregroundPainter: recognizedTextBlocks == null - ? null - : OcrPainter( - recognizedTextBlocks!, - imageSize, - onSelect, - ), - child: Image.memory(image), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: _handleTapUp, + child: CustomPaint( + foregroundPainter: OcrPainter( + recognizedTextBlocks!, + imageSize, + ), + child: Image.memory(image), + ), ), ), ); @@ -209,15 +211,16 @@ class _OcrImage extends StatelessWidget { child: FittedBox( clipBehavior: Clip.hardEdge, fit: BoxFit.contain, - child: CustomPaint( - foregroundPainter: recognizedTextBlocks == null - ? null - : OcrPainter( - recognizedTextBlocks!, - imageSize, - onSelect, - ), - child: Image.memory(image), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: _handleTapUp, + child: CustomPaint( + foregroundPainter: OcrPainter( + recognizedTextBlocks, + imageSize, + ), + child: Image.memory(image), + ), ), ), ), @@ -226,4 +229,54 @@ class _OcrImage extends StatelessWidget { ); } } + + void _handleTapUp(TapUpDetails details) { + if (recognizedTextBlocks != null) { + for (final textBlock in recognizedTextBlocks!) { + if (_pointInsideRectangle(details.localPosition, textBlock.offsets)) { + onSelect(textBlock); + break; + } + } + } + } + + bool _pointInsideRectangle(Offset point, List rectCorners) { + double x1 = rectCorners[0].dx; + double x2 = rectCorners[1].dx; + double x3 = rectCorners[2].dx; + double x4 = rectCorners[3].dx; + + double y1 = rectCorners[0].dy; + double y2 = rectCorners[1].dy; + double y3 = rectCorners[2].dy; + double y4 = rectCorners[3].dy; + + double a1 = sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)); + double a2 = sqrt((x2 - x3) * (x2 - x3) + (y2 - y3) * (y2 - y3)); + double a3 = sqrt((x3 - x4) * (x3 - x4) + (y3 - y4) * (y3 - y4)); + double a4 = sqrt((x4 - x1) * (x4 - x1) + (y4 - y1) * (y4 - y1)); + + double b1 = sqrt( + (x1 - point.dx) * (x1 - point.dx) + (y1 - point.dy) * (y1 - point.dy)); + double b2 = sqrt( + (x2 - point.dx) * (x2 - point.dx) + (y2 - point.dy) * (y2 - point.dy)); + double b3 = sqrt( + (x3 - point.dx) * (x3 - point.dx) + (y3 - point.dy) * (y3 - point.dy)); + double b4 = sqrt( + (x4 - point.dx) * (x4 - point.dx) + (y4 - point.dy) * (y4 - point.dy)); + + double u1 = (a1 + b1 + b2) / 2; + double u2 = (a2 + b2 + b3) / 2; + double u3 = (a3 + b3 + b4) / 2; + double u4 = (a4 + b4 + b1) / 2; + + double area1 = sqrt(u1 * (u1 - a1) * (u1 - b1) * (u1 - b2)); + double area2 = sqrt(u2 * (u2 - a2) * (u2 - b2) * (u2 - b3)); + double area3 = sqrt(u3 * (u3 - a3) * (u3 - b3) * (u3 - b4)); + double area4 = sqrt(u4 * (u4 - a4) * (u4 - b4) * (u4 - b1)); + + double difference = 0.95 * (area1 + area2 + area3 + area4) - a1 * a2; + return difference < 1; + } } From 1a63358e04e40cdfce195568800b5bf4c86456ee Mon Sep 17 00:00:00 2001 From: Moseco Date: Sun, 21 Sep 2025 15:39:39 +0900 Subject: [PATCH 07/10] add keyboard dismiss --- lib/ui/views/ocr/ocr_view.dart | 75 ++++++++++++++------------- lib/ui/widgets/camera_viewfinder.dart | 1 + 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/lib/ui/views/ocr/ocr_view.dart b/lib/ui/views/ocr/ocr_view.dart index 0ef4055..1570763 100644 --- a/lib/ui/views/ocr/ocr_view.dart +++ b/lib/ui/views/ocr/ocr_view.dart @@ -126,49 +126,52 @@ class _SelectedText extends ViewModelWidget { @override Widget build(BuildContext context, OcrViewModel viewModel) { - return Column( - children: [ - Expanded( - child: SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.all(8), - child: TextField( - controller: controller, - maxLines: null, - style: const TextStyle(fontSize: 24), - decoration: const InputDecoration.collapsed( - hintText: 'Select text from the image...', + return GestureDetector( + onVerticalDragStart: (_) => FocusScope.of(context).unfocus(), + child: Column( + children: [ + Expanded( + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: controller, + maxLines: null, + style: const TextStyle(fontSize: 24), + decoration: const InputDecoration.collapsed( + hintText: 'Select text from the image...', + ), + maxLength: 1000, + inputFormatters: [LengthLimitingTextInputFormatter(1000)], ), - maxLength: 1000, - inputFormatters: [LengthLimitingTextInputFormatter(1000)], ), ), ), - ), - AnimatedCrossFade( - crossFadeState: controller.text.isEmpty - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 200), - firstChild: SizedBox.shrink(), - secondChild: Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom, - ), - width: double.infinity, - color: Colors.deepPurple, - child: TextButton.icon( - icon: const Icon(Icons.text_snippet, color: Colors.white), - label: const Text( - 'Analyze', - style: TextStyle(color: Colors.white), + AnimatedCrossFade( + crossFadeState: controller.text.isEmpty + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 200), + firstChild: SizedBox.shrink(), + secondChild: Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + width: double.infinity, + color: Colors.deepPurple, + child: TextButton.icon( + icon: const Icon(Icons.text_snippet, color: Colors.white), + label: const Text( + 'Analyze', + style: TextStyle(color: Colors.white), + ), + onPressed: () => viewModel.analyzeText(controller.text), ), - onPressed: () => viewModel.analyzeText(controller.text), ), ), - ), - ], + ], + ), ); } } diff --git a/lib/ui/widgets/camera_viewfinder.dart b/lib/ui/widgets/camera_viewfinder.dart index c678238..29aeef4 100644 --- a/lib/ui/widgets/camera_viewfinder.dart +++ b/lib/ui/widgets/camera_viewfinder.dart @@ -119,6 +119,7 @@ class _CameraViewfinderState extends State child: Center( child: FloatingActionButton( onPressed: _takePhoto, + foregroundColor: Colors.white, backgroundColor: Colors.deepPurple, child: const Icon(Icons.camera_alt), ), From 1c4eb8af3a612179dd4fef12145ceaeb760ea8a9 Mon Sep 17 00:00:00 2001 From: Hampus Hammarlund Date: Sun, 21 Sep 2025 17:47:26 +0900 Subject: [PATCH 08/10] fix handling of new line characters --- lib/ui/views/search/search_view.dart | 7 ++++--- lib/ui/views/search/widgets/ocr_widget.dart | 1 + lib/ui/widgets/ocr_image.dart | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/ui/views/search/search_view.dart b/lib/ui/views/search/search_view.dart index e18bef3..aca9ff4 100644 --- a/lib/ui/views/search/search_view.dart +++ b/lib/ui/views/search/search_view.dart @@ -296,10 +296,11 @@ class _SearchHistory extends ViewModelWidget { onPressed: () async { final cdata = await Clipboard.getData(Clipboard.kTextPlain); if (cdata?.text != null) { - searchController.text = cdata!.text!; + final text = cdata!.text!.replaceAll('\n', ''); + searchController.text = text; searchController.selection = TextSelection.fromPosition( - TextPosition(offset: cdata.text!.length)); - viewModel.searchOnChange(cdata.text!); + TextPosition(offset: text.length)); + viewModel.searchOnChange(text); } }, ), diff --git a/lib/ui/views/search/widgets/ocr_widget.dart b/lib/ui/views/search/widgets/ocr_widget.dart index f70be05..a595816 100644 --- a/lib/ui/views/search/widgets/ocr_widget.dart +++ b/lib/ui/views/search/widgets/ocr_widget.dart @@ -79,6 +79,7 @@ class OcrWidget extends ViewModelWidget { image: viewModel.image!, onImageError: viewModel.handleImageError, onTextSelected: (text) { + text = text.replaceAll('\n', ''); searchController.text = text; viewModel.handleTextSelected(text); }, diff --git a/lib/ui/widgets/ocr_image.dart b/lib/ui/widgets/ocr_image.dart index a2710bf..bf9feb0 100644 --- a/lib/ui/widgets/ocr_image.dart +++ b/lib/ui/widgets/ocr_image.dart @@ -190,7 +190,7 @@ class _OcrImage extends StatelessWidget { onTapUp: _handleTapUp, child: CustomPaint( foregroundPainter: OcrPainter( - recognizedTextBlocks!, + recognizedTextBlocks, imageSize, ), child: Image.memory(image), From 57d4163c9fec7eedcd79af9dd31a3b36fad88d3f Mon Sep 17 00:00:00 2001 From: Hampus Hammarlund Date: Sun, 21 Sep 2025 18:27:40 +0900 Subject: [PATCH 09/10] change icon back to close --- lib/ui/views/search/search_view.dart | 2 +- lib/ui/views/search/widgets/hand_writing_input.dart | 2 +- lib/ui/views/search/widgets/ocr_widget.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/ui/views/search/search_view.dart b/lib/ui/views/search/search_view.dart index aca9ff4..e08a74a 100644 --- a/lib/ui/views/search/search_view.dart +++ b/lib/ui/views/search/search_view.dart @@ -121,7 +121,7 @@ class _SearchTextField extends ViewModelWidget { (node) { return IconButton( onPressed: node.unfocus, - icon: const Icon(Icons.keyboard_arrow_down), + icon: const Icon(Icons.close), ); }, ], diff --git a/lib/ui/views/search/widgets/hand_writing_input.dart b/lib/ui/views/search/widgets/hand_writing_input.dart index e0bf95e..f0880bb 100644 --- a/lib/ui/views/search/widgets/hand_writing_input.dart +++ b/lib/ui/views/search/widgets/hand_writing_input.dart @@ -91,7 +91,7 @@ class HandWritingInput extends ViewModelWidget { ), IconButton( onPressed: () => viewModel.setInputMode(InputMode.text), - icon: const Icon(Icons.keyboard_arrow_down), + icon: const Icon(Icons.close), splashColor: Colors.transparent, highlightColor: Colors.transparent, ), diff --git a/lib/ui/views/search/widgets/ocr_widget.dart b/lib/ui/views/search/widgets/ocr_widget.dart index a595816..b53e682 100644 --- a/lib/ui/views/search/widgets/ocr_widget.dart +++ b/lib/ui/views/search/widgets/ocr_widget.dart @@ -55,7 +55,7 @@ class OcrWidget extends ViewModelWidget { ), IconButton( onPressed: () => viewModel.setInputMode(InputMode.text), - icon: const Icon(Icons.keyboard_arrow_down), + icon: const Icon(Icons.close), splashColor: Colors.transparent, highlightColor: Colors.transparent, ), From ffd4cedb03108fcbaac2aceceae425fd338ac8ef Mon Sep 17 00:00:00 2001 From: Hampus Hammarlund Date: Sun, 21 Sep 2025 19:01:45 +0900 Subject: [PATCH 10/10] increase painter stroke width --- lib/ui/painters/ocr_painter.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ui/painters/ocr_painter.dart b/lib/ui/painters/ocr_painter.dart index a5c6598..71a8a59 100644 --- a/lib/ui/painters/ocr_painter.dart +++ b/lib/ui/painters/ocr_painter.dart @@ -14,12 +14,12 @@ class OcrPainter extends CustomPainter { if (recognizedTextBlocks != null) { final unselectedPaint = Paint() ..style = PaintingStyle.stroke - ..strokeWidth = 2 + ..strokeWidth = 4 ..color = Colors.lightBlueAccent; final selectedPaint = Paint() ..style = PaintingStyle.stroke - ..strokeWidth = 2 + ..strokeWidth = 4 ..color = Colors.lightGreenAccent; for (final textBlock in recognizedTextBlocks!) {