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),
|
||||
);
|
||||
|
||||
case '/kanjiSearch/draw':
|
||||
return MaterialPageRoute(builder: (_) => const KanjiDrawingSearch());
|
||||
|
||||
default:
|
||||
return MaterialPageRoute(
|
||||
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(
|
||||
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