diff --git a/lib/router.dart b/lib/router.dart index 5b9b5ce..04f4901 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -23,6 +23,9 @@ Route 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'), diff --git a/lib/services/handwriting.dart b/lib/services/handwriting.dart new file mode 100644 index 0000000..b6052a8 --- /dev/null +++ b/lib/services/handwriting.dart @@ -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> 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> 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 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> 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: { + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: jsonEncode(this), + ); + + final List json = jsonDecode(response.body); + // TODO: add a more detailed error. + if (response.statusCode != 200 || json[0] != 'SUCCESS') throw Error(); + + return (((json[1] as List)[0] as List)[1] + as List) + .map((e) => e as String) + .toList(); + } +} diff --git a/lib/view/components/drawing_board/drawing_board.dart b/lib/view/components/drawing_board/drawing_board.dart new file mode 100644 index 0000000..d136aa1 --- /dev/null +++ b/lib/view/components/drawing_board/drawing_board.dart @@ -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 { + List suggestions = []; + + final List> strokes = []; + final List> undoQueue = []; + + GlobalKey signatureW = GlobalKey(); + GlobalKey suggestionBarW = GlobalKey(); + + static const double fontSize = 30; + static const double suggestionCirclePadding = 13; + + late ColorSet panelColor = + BlocProvider.of(context).state.theme.menuGreyLight; + late ColorSet barColor = + BlocProvider.of(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 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 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(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( + listener: (context, state) => setState(() { + panelColor = state.theme.menuGreyLight; + barColor = state.theme.menuGreyDark; + }), + child: Column( + children: [ + suggestionBar(), + drawingPanel(), + ], + ), + ); + } +} diff --git a/lib/view/components/kanji/kanji_search_body/kanji_search_options_bar.dart b/lib/view/components/kanji/kanji_search_body/kanji_search_options_bar.dart index 23aad34..6014d35 100644 --- a/lib/view/components/kanji/kanji_search_body/kanji_search_options_bar.dart +++ b/lib/view/components/kanji/kanji_search_body/kanji_search_options_bar.dart @@ -27,7 +27,7 @@ class KanjiSearchOptionsBar extends StatelessWidget { ), _IconButton( icon: const Icon(Icons.mode), - onPressed: () {}, + onPressed: () => Navigator.pushNamed(context, '/kanjiSearch/draw'), ), ], ), diff --git a/lib/view/screens/search/search_mechanisms/drawing.dart b/lib/view/screens/search/search_mechanisms/drawing.dart index e69de29..f4008fc 100644 --- a/lib/view/screens/search/search_mechanisms/drawing.dart +++ b/lib/view/screens/search/search_mechanisms/drawing.dart @@ -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, + ), + ), + ], + ), + ); + } +}