Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -356,6 +361,7 @@ EXTERNAL SOURCES:

SPEC CHECKSUMS:
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436
disk_space_plus: a36391fb5f732dbdd29628b0bac5a1acbd43aaef
Firebase: 800d487043c0557d9faed71477a38d9aafb08a41
firebase_analytics: cd56fc56f75c1df30a6ff5290cd56e230996a76d
Expand Down
49 changes: 49 additions & 0 deletions lib/ui/painters/ocr_painter.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:sagase/datamodels/recognized_text_block.dart';

class OcrPainter extends CustomPainter {
final List<RecognizedTextBlock>? recognizedTextBlocks;
final Size imageSize;

OcrPainter(this.recognizedTextBlocks, this.imageSize);

@override
void paint(Canvas canvas, Size size) {
if (recognizedTextBlocks != null) {
final unselectedPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 4
..color = Colors.lightBlueAccent;

final selectedPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 4
..color = Colors.lightGreenAccent;

for (final textBlock in recognizedTextBlocks!) {
final List<Offset> 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,
);
}
}
}

@override
bool shouldRepaint(OcrPainter oldDelegate) {
return oldDelegate.recognizedTextBlocks != recognizedTextBlocks;
}
}
245 changes: 143 additions & 102 deletions lib/ui/views/ocr/ocr_view.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
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';
import 'painters/text_detector_painter.dart';

class OcrView extends StackedView<OcrViewModel> {
final bool cameraStart;
Expand All @@ -15,44 +18,80 @@ class OcrView extends StackedView<OcrViewModel> {
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<OcrViewModel> {
const _Body();

@override
Widget builder(BuildContext context, OcrViewModel viewModel) {
final controller = useTextEditingController();

return Scaffold(
appBar: AppBar(
title: Text('Character Detection'),
actions: viewModel.currentImageBytes == null
actions: viewModel.state == OcrState.waiting ||
viewModel.state == OcrState.loading
? 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: Column(
children: [
Expanded(
child: viewModel.currentImageBytes == null
? _OcrImageLoading()
: _OcrImage(),
),
const Divider(indent: 8, endIndent: 8),
Expanded(
child: viewModel.recognizedTextBlocks == null
? Column(
children: [
ListItemLoading(),
const SizedBox(height: 8),
ListItemLoading(),
],
)
: _SelectedText(),
),
],
),
body: viewModel.state == OcrState.error
? _Error(controller)
: 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(),
],
)
},
),
],
),
);
}
}
Expand Down Expand Up @@ -80,97 +119,99 @@ class _OcrImageLoading extends StatelessWidget {
}
}

class _OcrImage extends ViewModelWidget<OcrViewModel> {
class _SelectedText extends ViewModelWidget<OcrViewModel> {
final TextEditingController controller;

const _SelectedText(this.controller);

@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,
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...',
),
child: IgnorePointer(
child: Image.memory(viewModel.currentImageBytes!),
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),
),
onPressed: () => viewModel.analyzeText(controller.text),
),
),
),
],
),
);
}
}

class _SelectedText extends ViewModelWidget<OcrViewModel> {
class _Error extends ViewModelWidget<OcrViewModel> {
final TextEditingController controller;

const _Error(this.controller);

@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),
),
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Failed to analyze image',
style: TextStyle(fontSize: 18),
),
),
);
} 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),
);
},
);
}

return Column(
children: [
Expanded(child: textSection),
Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
const SizedBox(height: 8),
const Text('Please try again'),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
viewModel.openCamera();
controller.clear();
},
icon: const Icon(Icons.camera),
label: const Text('Open camera'),
),
width: double.infinity,
color: Colors.deepPurple,
child: TextButton.icon(
icon: const Icon(Icons.text_snippet, color: Colors.white),
label: Text(
viewModel.recognizedTextBlocks!.length == 1
? 'Analyze text'
: 'Analyze selected text',
style: TextStyle(color: Colors.white),
),
onPressed: viewModel.analyzeSelectedText,
ElevatedButton.icon(
onPressed: () {
viewModel.selectImage();
controller.clear();
},
icon: const Icon(Icons.photo),
label: const Text('Pick from photos'),
),
),
],
],
),
);
}
}
Loading