Add kanji handdrawing feature

This commit is contained in:
Oystein Kristoffer Tveit 2022-01-14 16:10:35 +01:00
parent 24601a300d
commit 37ee031693
5 changed files with 352 additions and 1 deletions

View File

@ -23,6 +23,9 @@ Route<Widget> generateRoute(RouteSettings settings) {
builder: (_) => KanjiResultPage(kanjiSearchTerm: searchTerm),
);
case '/kanjiSearch/draw':
return MaterialPageRoute(builder: (_) => const KanjiDrawingSearch());
default:
return MaterialPageRoute(
builder: (_) => const Text('ERROR: this route does not exist'),

View File

@ -0,0 +1,100 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:signature/signature.dart';
class TimedPoint {
final DateTime time;
final Point point;
const TimedPoint({
required this.time,
required this.point,
});
}
class HandwritingRequest {
final double appVersion;
final String apiLevel;
String? device;
final int inputType;
final String options;
final int writingAreaWidth;
final int writingAreaHeight;
final String preContext;
final int maxNumResults;
final int maxCompletions;
final String language;
final List<List<TimedPoint>> ink;
HandwritingRequest({
this.appVersion = 0.4,
this.apiLevel = '537.36',
this.device,
this.inputType = 0,
this.options = 'enable_pre_space',
required this.writingAreaWidth,
required this.writingAreaHeight,
this.preContext = '',
this.maxNumResults = 10,
this.maxCompletions = 0,
this.language = 'ja',
required this.ink,
});
List<List<dynamic>> get formattedInk => ink
.map(
(stroke) => [
stroke.map((tp) => tp.point.offset.dx).toList(),
stroke.map((tp) => tp.point.offset.dy).toList(),
stroke
.map((tp) => tp.time.difference(stroke.first.time).inMilliseconds)
.toList(),
],
)
.toList();
Map<String, Object?> toJson() => {
'app_version': appVersion,
'api_level': apiLevel,
'device': device,
'input_type': inputType,
'options': options,
'requests': [
{
'writing_guide': {
'writing_area_width': writingAreaWidth,
'writing_area_height': writingAreaHeight
},
'pre_context': preContext,
'max_num_results': maxNumResults,
'max_completions': maxCompletions,
'language': language,
'ink': formattedInk,
}
]
};
Future<List<String>> fetch() async {
device ??= HttpClient().userAgent;
final response = await http.post(
Uri.parse(
'https://inputtools.google.com/request?itc=ja-t-i0-handwrit&app=translate',
),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(this),
);
final List<dynamic> json = jsonDecode(response.body);
// TODO: add a more detailed error.
if (response.statusCode != 200 || json[0] != 'SUCCESS') throw Error();
return (((json[1] as List<dynamic>)[0] as List<dynamic>)[1]
as List<dynamic>)
.map((e) => e as String)
.toList();
}
}

View File

@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import 'package:signature/signature.dart';
import '../../../bloc/theme/theme_bloc.dart';
import '../../../services/handwriting.dart';
class DrawingBoard extends StatefulWidget {
final Function(String)? onSuggestionChosen;
final bool onlyOneCharacterSuggestions;
final bool allowKanji;
final bool allowHiragana;
final bool allowKatakana;
final bool allowOther;
const DrawingBoard({
this.onSuggestionChosen,
this.onlyOneCharacterSuggestions = false,
this.allowKanji = true,
this.allowHiragana = false,
this.allowKatakana = false,
this.allowOther = false,
Key? key,
}) : super(key: key);
@override
_DrawingBoardState createState() => _DrawingBoardState();
}
class _DrawingBoardState extends State<DrawingBoard> {
List<String> suggestions = [];
final List<List<TimedPoint>> strokes = [];
final List<List<TimedPoint>> undoQueue = [];
GlobalKey signatureW = GlobalKey();
GlobalKey suggestionBarW = GlobalKey();
static const double fontSize = 30;
static const double suggestionCirclePadding = 13;
late ColorSet panelColor =
BlocProvider.of<ThemeBloc>(context).state.theme.menuGreyLight;
late ColorSet barColor =
BlocProvider.of<ThemeBloc>(context).state.theme.menuGreyNormal;
late final SignatureController controller = SignatureController(
penColor: panelColor.foreground,
onDrawStart: () {
strokes.add([]);
undoQueue.clear();
},
onDrawMove: () => strokes.last
.add(TimedPoint(time: DateTime.now(), point: controller.points.last)),
onDrawEnd: () => updateSuggestions(),
);
Future<void> updateSuggestions() async {
if (strokes.isEmpty) return setState(() => suggestions.clear());
final newSuggestions = await HandwritingRequest(
writingAreaHeight: signatureW.currentContext!.size!.width.toInt(),
writingAreaWidth: signatureW.currentContext!.size!.width.toInt(),
ink: strokes,
).fetch();
setState(() {
suggestions = newSuggestions;
});
}
List<String> get filteredSuggestions {
const kanjiR = r'\p{Script=Hani}';
const hiraganaR = r'\p{Script=Hiragana}';
const katakanaR = r'\p{Script=Katakana}';
const otherR = '[^$kanjiR$hiraganaR$katakanaR]';
final x = widget.allowKanji ? kanjiR : '';
final y = widget.allowHiragana ? hiraganaR : '';
final z = widget.allowKatakana ? katakanaR : '';
late final RegExp combinedRegex;
if ((widget.allowKanji || widget.allowHiragana || widget.allowKatakana) &&
widget.allowOther) {
combinedRegex = RegExp('^(?:[$x$y$z]|$otherR)+\$', unicode: true);
} else if (widget.allowOther) {
combinedRegex = RegExp('^$otherR+\$', unicode: true);
} else {
combinedRegex = RegExp('^[$x$y$z]+\$', unicode: true);
}
return suggestions
.where((s) => combinedRegex.hasMatch(s))
.where((s) => !widget.onlyOneCharacterSuggestions || s.length == 1)
.toList();
}
Widget kanjiChip(String kanji) => InkWell(
onTap: () => widget.onSuggestionChosen?.call(kanji),
child: Container(
height: fontSize + 2 * suggestionCirclePadding,
width: fontSize + 2 * suggestionCirclePadding,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: BlocProvider.of<ThemeBloc>(context)
.state
.theme
.menuGreyLight
.background,
),
child: Center(
child: Text(
kanji,
style: const TextStyle(fontSize: fontSize),
),
),
),
);
Widget suggestionBar() {
const padding = EdgeInsets.symmetric(horizontal: 10, vertical: 5);
return Container(
key: suggestionBarW,
color: barColor.background,
alignment: Alignment.center,
padding: padding,
// TODO: calculate dynamically
constraints: BoxConstraints(
minHeight: 8 +
suggestionCirclePadding * 2 +
fontSize +
(2 * 4) +
padding.vertical,
),
child: Wrap(
spacing: 20,
runSpacing: 5,
children: filteredSuggestions.map((s) => kanjiChip(s)).toList(),
),
);
}
Widget buttonRow() => Container(
color: panelColor.background,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => setState(() {
controller.clear();
strokes.clear();
suggestions.clear();
}),
icon: const Icon(Icons.delete),
),
IconButton(
onPressed: () {
if (strokes.isNotEmpty) {
undoQueue.add(strokes.removeLast());
controller.undo();
updateSuggestions();
}
},
icon: const Icon(Icons.undo),
),
IconButton(
onPressed: () {
if (undoQueue.isNotEmpty) {
strokes.add(undoQueue.removeLast());
controller.redo();
updateSuggestions();
}
},
icon: const Icon(Icons.redo),
),
if (!widget.onlyOneCharacterSuggestions)
IconButton(
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('TODO: implement scrolling page feature!'),
),
),
icon: const Icon(Icons.arrow_forward),
),
],
),
);
Widget drawingPanel() => AspectRatio(
aspectRatio: 1.2,
child: Stack(
alignment: Alignment.bottomRight,
children: [
ClipRect(
child: Signature(
key: signatureW,
controller: controller,
backgroundColor: panelColor.background,
),
),
buttonRow(),
],
),
);
@override
Widget build(BuildContext context) {
return BlocListener<ThemeBloc, ThemeState>(
listener: (context, state) => setState(() {
panelColor = state.theme.menuGreyLight;
barColor = state.theme.menuGreyDark;
}),
child: Column(
children: [
suggestionBar(),
drawingPanel(),
],
),
);
}
}

View File

@ -27,7 +27,7 @@ class KanjiSearchOptionsBar extends StatelessWidget {
),
_IconButton(
icon: const Icon(Icons.mode),
onPressed: () {},
onPressed: () => Navigator.pushNamed(context, '/kanjiSearch/draw'),
),
],
),

View File

@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import '../../../components/drawing_board/drawing_board.dart';
class KanjiDrawingSearch extends StatelessWidget {
const KanjiDrawingSearch({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Draw a kanji')),
body: Column(
children: [
Expanded(child: Column()),
DrawingBoard(
onlyOneCharacterSuggestions: true,
onSuggestionChosen: (suggestion) => Navigator.popAndPushNamed(
context,
'/kanjiSearch',
arguments: suggestion,
),
),
],
),
);
}
}