mirror of
https://github.com/h7x4/Jisho-Study-Tool.git
synced 2024-12-21 13:37:29 +01:00
Add kanji handdrawing feature
This commit is contained in:
parent
24601a300d
commit
37ee031693
@ -23,6 +23,9 @@ Route<Widget> generateRoute(RouteSettings settings) {
|
|||||||
builder: (_) => KanjiResultPage(kanjiSearchTerm: searchTerm),
|
builder: (_) => KanjiResultPage(kanjiSearchTerm: searchTerm),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case '/kanjiSearch/draw':
|
||||||
|
return MaterialPageRoute(builder: (_) => const KanjiDrawingSearch());
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return MaterialPageRoute(
|
return MaterialPageRoute(
|
||||||
builder: (_) => const Text('ERROR: this route does not exist'),
|
builder: (_) => const Text('ERROR: this route does not exist'),
|
||||||
|
100
lib/services/handwriting.dart
Normal file
100
lib/services/handwriting.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
222
lib/view/components/drawing_board/drawing_board.dart
Normal file
222
lib/view/components/drawing_board/drawing_board.dart
Normal 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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -27,7 +27,7 @@ class KanjiSearchOptionsBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
_IconButton(
|
_IconButton(
|
||||||
icon: const Icon(Icons.mode),
|
icon: const Icon(Icons.mode),
|
||||||
onPressed: () {},
|
onPressed: () => Navigator.pushNamed(context, '/kanjiSearch/draw'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user