diff --git a/assets/images/dbpedia.png b/assets/images/dbpedia.png new file mode 100644 index 0000000..31c4a00 Binary files /dev/null and b/assets/images/dbpedia.png differ diff --git a/lib/components/history/kanji_box.dart b/lib/components/history/kanji_box.dart index 66d8382..4c67f80 100644 --- a/lib/components/history/kanji_box.dart +++ b/lib/components/history/kanji_box.dart @@ -19,18 +19,17 @@ class KanjiBox extends StatelessWidget { final colors = state.theme.menuGreyLight; return Container( padding: const EdgeInsets.all(5), + alignment: Alignment.center, decoration: BoxDecoration( color: colors.background, borderRadius: BorderRadius.circular(10.0), ), - child: Center( - child: FittedBox( - child: Text( - kanji, - style: TextStyle( - color: colors.foreground, - fontSize: 25, - ), + child: FittedBox( + child: Text( + kanji, + style: TextStyle( + color: colors.foreground, + fontSize: 25, ), ), ), diff --git a/lib/components/search/language_selector.dart b/lib/components/search/language_selector.dart index 9b44f30..6aa70a6 100644 --- a/lib/components/search/language_selector.dart +++ b/lib/components/search/language_selector.dart @@ -33,8 +33,9 @@ class _LanguageSelectorState extends State { Widget _languageOption(String language) => Container( + alignment: Alignment.center, padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 20.0), - child: Center(child: Text(language)), + child: Text(language), ); @override diff --git a/lib/components/search/search_result_body.dart b/lib/components/search/search_result_body.dart index d00c770..e85ea7e 100644 --- a/lib/components/search/search_result_body.dart +++ b/lib/components/search/search_result_body.dart @@ -14,7 +14,9 @@ class SearchResultsBody extends StatelessWidget { @override Widget build(BuildContext context) { return ListView( - children: results.map((result) => SearchResultCard(result: result)).toList(), + children: [ + for (final result in results) SearchResultCard(result: result) + ], ); } } diff --git a/lib/components/search/search_results_body/parts/audio_player.dart b/lib/components/search/search_results_body/parts/audio_player.dart new file mode 100644 index 0000000..df95d14 --- /dev/null +++ b/lib/components/search/search_results_body/parts/audio_player.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart' as ja; +import 'package:unofficial_jisho_api/api.dart'; + +import '../../../../bloc/theme/theme_bloc.dart'; + +class AudioPlayer extends StatefulWidget { + final AudioFile audio; + + const AudioPlayer({ + Key? key, + required this.audio, + }) : super(key: key); + + @override + _AudioPlayerState createState() => _AudioPlayerState(); +} + +class _AudioPlayerState extends State { + final ja.AudioPlayer player = ja.AudioPlayer(); + + double _calculateRelativePlayerPosition(Duration? position) { + if (position != null && player.duration != null) + return position.inMilliseconds / player.duration!.inMilliseconds; + return 0; + } + + bool _isPlaying(ja.PlayerState? state) => state != null && state.playing; + + @override + void initState() { + player.setUrl(widget.audio.uri); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (_, state) { + final ColorSet colors = state.theme.menuGreyLight; + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(100), + color: colors.background, + ), + child: Row( + children: [ + IconButton( + onPressed: () => player.play().then((_) { + player.stop(); + player.seek(Duration.zero); + }), + iconSize: 30, + icon: StreamBuilder( + stream: player.playerStateStream, + builder: (_, snapshot) => Icon( + _isPlaying(snapshot.data) ? Icons.stop : Icons.play_arrow, + ), + ), + ), + Expanded( + child: StreamBuilder( + stream: player.positionStream, + builder: (_, snapshot) => LinearProgressIndicator( + backgroundColor: colors.foreground, + value: _calculateRelativePlayerPosition(snapshot.data), + ), + ), + ), + + IconButton(icon: const Icon(Icons.volume_up), onPressed: () {}), + ], + ), + ); + }, + ); + } +} diff --git a/lib/components/search/search_results_body/parts/badge.dart b/lib/components/search/search_results_body/parts/badge.dart index 457fc01..fcbc98c 100644 --- a/lib/components/search/search_results_body/parts/badge.dart +++ b/lib/components/search/search_results_body/parts/badge.dart @@ -4,8 +4,11 @@ class Badge extends StatelessWidget { final Widget? child; final Color color; - const Badge({this.child, required this.color, Key? key,}) : super(key: key); - + const Badge({ + Key? key, + this.child, + required this.color, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -18,10 +21,7 @@ class Badge extends StatelessWidget { shape: BoxShape.circle, color: color, ), - child: FittedBox( - child: Center( - child: child, - ), - ), - ); } + child: FittedBox(child: child), + ); + } } diff --git a/lib/components/search/search_results_body/parts/jlpt_badge.dart b/lib/components/search/search_results_body/parts/jlpt_badge.dart index 77e97c5..b15fcdb 100644 --- a/lib/components/search/search_results_body/parts/jlpt_badge.dart +++ b/lib/components/search/search_results_body/parts/jlpt_badge.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import './badge.dart'; class JLPTBadge extends StatelessWidget { - final String jlptLevel; + final String? jlptLevel; const JLPTBadge({ required this.jlptLevel, @@ -10,12 +10,12 @@ class JLPTBadge extends StatelessWidget { }) : super(key: key); String get formattedJlptLevel => - jlptLevel.isNotEmpty ? jlptLevel.substring(5).toUpperCase() : ''; + jlptLevel != null ? jlptLevel!.substring(5).toUpperCase() : ''; @override Widget build(BuildContext context) { return Badge( - color: jlptLevel.isNotEmpty ? Colors.blue : Colors.transparent, + color: jlptLevel != null ? Colors.blue : Colors.transparent, child: Text( formattedJlptLevel, style: const TextStyle(color: Colors.white), diff --git a/lib/components/search/search_results_body/parts/kanji.dart b/lib/components/search/search_results_body/parts/kanji.dart new file mode 100644 index 0000000..876c7e9 --- /dev/null +++ b/lib/components/search/search_results_body/parts/kanji.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +import '../../../../bloc/theme/theme_bloc.dart'; +import '../../../../routing/routes.dart'; + +class KanjiRow extends StatelessWidget { + final List kanji; + final double fontSize; + const KanjiRow({ + Key? key, + required this.kanji, + this.fontSize = 20, + }) : super(key: key); + + Widget _kanjiBox(String kanji) => UnconstrainedBox( + child: IntrinsicHeight( + child: AspectRatio( + aspectRatio: 1, + child: BlocBuilder( + builder: (context, state) { + final colors = state.theme.menuGreyLight; + return Container( + padding: const EdgeInsets.all(10), + alignment: Alignment.center, + decoration: BoxDecoration( + color: colors.background, + borderRadius: BorderRadius.circular(10), + ), + child: FittedBox( + child: Text( + kanji, + style: TextStyle( + color: colors.foreground, + fontSize: fontSize, + ), + ), + ), + ); + }, + ), + ), + ), + ); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Kanji:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 5), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + for (final k in kanji) + InkWell( + onTap: () => Navigator.pushNamed( + context, + Routes.kanjiSearch, + arguments: k, + ), + child: _kanjiBox(k), + ) + ], + ), + ], + ); + } +} diff --git a/lib/components/search/search_results_body/parts/kanji_kana_box.dart b/lib/components/search/search_results_body/parts/kanji_kana_box.dart new file mode 100644 index 0000000..2aa0b43 --- /dev/null +++ b/lib/components/search/search_results_body/parts/kanji_kana_box.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:unofficial_jisho_api/api.dart'; + +import '../../../../models/themes/theme.dart'; +import '../../../../services/romaji_transliteration.dart'; +import '../../../../settings.dart'; + +class KanjiKanaBox extends StatelessWidget { + final JishoJapaneseWord word; + final bool showRomajiBelow; + final ColorSet colors; + final bool autoTransliterateRomaji; + final bool centerFurigana; + final double? furiganaFontsize; + final double? kanjiFontsize; + final EdgeInsets margin; + final EdgeInsets padding; + + const KanjiKanaBox({ + Key? key, + required this.word, + this.showRomajiBelow = false, + this.colors = LightTheme.defaultMenuGreyNormal, + this.autoTransliterateRomaji = true, + this.centerFurigana = true, + this.furiganaFontsize, + this.kanjiFontsize, + this.margin = const EdgeInsets.symmetric( + horizontal: 5.0, + vertical: 5.0, + ), + this.padding = const EdgeInsets.all(5.0), + }) : super(key: key); + + bool get hasFurigana => word.reading != null; + + String get kana => '${word.reading ?? ""}${word.word ?? ""}' + .replaceAll(RegExp(r'\p{Script=Hani}', unicode: true), ''); + + @override + Widget build(BuildContext context) { + final String? wordReading = word.reading == null + ? null + : (romajiEnabled && autoTransliterateRomaji + ? transliterateKanaToLatin(word.reading!) + : word.reading!); + + final fFontsize = furiganaFontsize ?? + ((kanjiFontsize != null) ? 0.8 * kanjiFontsize! : null); + + return Container( + margin: margin, + padding: padding, + color: colors.background, + child: DefaultTextStyle.merge( + child: Column( + crossAxisAlignment: centerFurigana + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + // See header.dart for more details about this logic + hasFurigana + ? Text( + wordReading!, + style: TextStyle( + fontSize: fFontsize, + color: colors.foreground, + ), + ) + : Text( + 'あ', + style: TextStyle( + color: Colors.transparent, + fontSize: fFontsize, + ), + ), + + DefaultTextStyle.merge( + child: hasFurigana + ? Text(word.word!) + : Text(wordReading ?? word.word!), + style: TextStyle(fontSize: kanjiFontsize), + ), + if (romajiEnabled && showRomajiBelow) + Text( + transliterateKanaToLatin(kana), + ) + ], + ), + style: TextStyle(color: colors.foreground), + ), + ); + } +} diff --git a/lib/components/search/search_results_body/parts/links.dart b/lib/components/search/search_results_body/parts/links.dart new file mode 100644 index 0000000..a50bd06 --- /dev/null +++ b/lib/components/search/search_results_body/parts/links.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:unofficial_jisho_api/api.dart'; +import 'package:url_launcher/url_launcher.dart'; + +Future _launch(String url) async { + if (await canLaunch(url)) { + launch(url); + } else { + debugPrint('Could not open url: $url'); + } +} + +final BoxDecoration _iconStyle = BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.all(Radius.circular(10)), + border: Border.all(), +); + +Widget _wiki({ + required String link, + required bool isJapanese, +}) => + Container( + margin: const EdgeInsets.only(right: 10), + child: Stack( + alignment: Alignment.bottomRight, + children: [ + Container( + decoration: _iconStyle, + margin: EdgeInsets.fromLTRB(0, 0, 10, isJapanese ? 12 : 10), + child: IconButton( + onPressed: () => _launch(link), + icon: SvgPicture.asset('assets/images/wikipedia.svg'), + ), + ), + Container( + padding: EdgeInsets.all(isJapanese ? 10 : 8), + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all(), + ), + child: Text( + isJapanese ? 'J' : 'E', + style: const TextStyle( + color: Colors.black, + fontFamily: 'serif', + ), + ), + ), + ], + ), + ); + +Widget _dbpedia(String link) => Container( + decoration: _iconStyle, + child: IconButton( + onPressed: () => _launch(link), + icon: Image.asset( + 'assets/images/dbpedia.png', + ), + ), + ); + +final Map _patterns = { + RegExp(r'^Read “.+” on English Wikipedia$'): (l) => + _wiki(link: l, isJapanese: false), + RegExp(r'^Read “.+” on Japanese Wikipedia$'): (l) => + _wiki(link: l, isJapanese: true), + // DBpedia comes through attribution. + // RegExp(r'^Read “.+” on DBpedia$'): _dbpedia, +}; + +class Links extends StatelessWidget { + final List links; + final JishoAttribution attribution; + + const Links({ + Key? key, + required this.links, + required this.attribution, + }) : super(key: key); + + List get _body { + if (links.isEmpty) return []; + + // Copy sense.links so that it doesn't need to be modified. + final List newLinks = List.from(links); + final List newStringLinks = [for (final l in newLinks) l.url]; + + final Map matches = {}; + for (int i = 0; i < newLinks.length; i++) + for (final RegExp p in _patterns.keys) + if (p.hasMatch(newLinks[i].text)) matches[p] = i; + + final List icons = [ + ...[ + for (final match in matches.entries) + _patterns[match.key]!(newStringLinks[match.value]) + ], + if (attribution.dbpedia != null) _dbpedia(attribution.dbpedia!) + ]; + + (matches.values.toList()..sort()).reversed.forEach(newLinks.removeAt); + + final List otherLinks = [ + for (final link in newLinks) ...[ + InkWell( + onTap: () => _launch(link.url), + child: Text( + link.text, + style: const TextStyle(color: Colors.blue), + ), + ) + ] + ]; + + return [ + const Text('Links:', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 5), + Row(crossAxisAlignment: CrossAxisAlignment.start, children: icons), + const SizedBox(height: 5), + if (otherLinks.isNotEmpty) ...otherLinks, + ]; + } + + @override + Widget build(BuildContext context) => + Column(crossAxisAlignment: CrossAxisAlignment.start, children: _body); +} diff --git a/lib/components/search/search_results_body/parts/notes.dart b/lib/components/search/search_results_body/parts/notes.dart new file mode 100644 index 0000000..d274bfb --- /dev/null +++ b/lib/components/search/search_results_body/parts/notes.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +class Notes extends StatelessWidget { + final List notes; + const Notes({Key? key, required this.notes}) : super(key: key); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Notes:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(notes.join(', ')), + ], + ); +} diff --git a/lib/components/search/search_results_body/parts/other_forms.dart b/lib/components/search/search_results_body/parts/other_forms.dart index 81c00bc..525b3e4 100644 --- a/lib/components/search/search_results_body/parts/other_forms.dart +++ b/lib/components/search/search_results_body/parts/other_forms.dart @@ -2,8 +2,7 @@ import 'package:flutter/material.dart'; import 'package:unofficial_jisho_api/api.dart'; import '../../../../bloc/theme/theme_bloc.dart'; -import '../../../../services/romaji_transliteration.dart'; -import '../../../../settings.dart'; +import 'kanji_kana_box.dart'; class OtherForms extends StatelessWidget { final List forms; @@ -11,68 +10,28 @@ class OtherForms extends StatelessWidget { const OtherForms({required this.forms, Key? key}) : super(key: key); @override - Widget build(BuildContext context) { - return Column( - children: forms.isNotEmpty - ? [ - const Text( - 'Other Forms', - style: TextStyle(fontWeight: FontWeight.bold), - ), - Row( - children: forms.map((form) => _KanaBox(form)).toList(), - ), - ] - : [], - ); - } -} - -class _KanaBox extends StatelessWidget { - final JishoJapaneseWord word; - - const _KanaBox(this.word); - - bool get hasFurigana => word.word != null; - - @override - Widget build(BuildContext context) { - final _menuColors = - BlocProvider.of(context).state.theme.menuGreyLight; - - final String? wordReading = word.reading == null - ? null - : (romajiEnabled - ? transliterateKanaToLatin(word.reading!) - : word.reading!); - - return Container( - margin: const EdgeInsets.symmetric( - horizontal: 5.0, - vertical: 5.0, - ), - padding: const EdgeInsets.all(5.0), - decoration: BoxDecoration( - color: _menuColors.background, - boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.5), - spreadRadius: 1, - blurRadius: 0.5, - offset: const Offset(1, 1), - ), - ], - ), - child: DefaultTextStyle.merge( - child: Column( - children: [ - // See header.dart for more details about this logic - hasFurigana ? Text(wordReading ?? '') : const Text(''), - hasFurigana ? Text(word.word!) : Text(wordReading ?? word.word!), - ], - ), - style: TextStyle(color: _menuColors.foreground), - ), - ); - } + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: forms.isNotEmpty + ? [ + const Text( + 'Other Forms:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Wrap( + children: [ + for (final form in forms) + BlocBuilder( + builder: (context, state) { + return KanjiKanaBox( + word: form, + colors: state.theme.menuGreyLight, + ); + }, + ), + ], + ), + ] + : [], + ); } diff --git a/lib/components/search/search_results_body/parts/sense/antonyms.dart b/lib/components/search/search_results_body/parts/sense/antonyms.dart new file mode 100644 index 0000000..07b8edb --- /dev/null +++ b/lib/components/search/search_results_body/parts/sense/antonyms.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import '../../../../../models/themes/theme.dart'; +import '../../../../../routing/routes.dart'; +import 'search_chip.dart'; + +class Antonyms extends StatelessWidget { + final List antonyms; + final ColorSet colors; + + const Antonyms({ + Key? key, + required this.antonyms, + this.colors = const ColorSet( + foreground: Colors.white, + background: Colors.blue, + ), + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Antonyms:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 5), + Wrap( + spacing: 5, + runSpacing: 5, + children: [ + for (final antonym in antonyms) + InkWell( + onTap: () => Navigator.pushNamed( + context, + Routes.search, + arguments: antonym, + ), + child: SearchChip( + text: antonym, + colors: colors, + ), + ), + ], + ) + ], + ); + } +} diff --git a/lib/components/search/search_results_body/parts/sense/definition_abstract.dart b/lib/components/search/search_results_body/parts/sense/definition_abstract.dart new file mode 100644 index 0000000..96be835 --- /dev/null +++ b/lib/components/search/search_results_body/parts/sense/definition_abstract.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class DefinitionAbstract extends StatelessWidget { + final String text; + final Color? color; + + const DefinitionAbstract({ + Key? key, + required this.text, + this.color, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: TextStyle(color: color), + ); + } +} diff --git a/lib/components/search/search_results_body/parts/sense/english_definitions.dart b/lib/components/search/search_results_body/parts/sense/english_definitions.dart new file mode 100644 index 0000000..d5f3d21 --- /dev/null +++ b/lib/components/search/search_results_body/parts/sense/english_definitions.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import '../../../../../bloc/theme/theme_bloc.dart'; +import 'search_chip.dart'; + +class EnglishDefinitions extends StatelessWidget { + final List englishDefinitions; + final ColorSet colors; + + const EnglishDefinitions({ + Key? key, + required this.englishDefinitions, + this.colors = LightTheme.defaultMenuGreyNormal, + }) : super(key: key); + + @override + Widget build(BuildContext context) => Wrap( + runSpacing: 10.0, + spacing: 5, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final def in englishDefinitions) + SearchChip(text: def, colors: colors) + ], + ); +} diff --git a/lib/components/search/search_results_body/parts/sense/search_chip.dart b/lib/components/search/search_results_body/parts/sense/search_chip.dart new file mode 100644 index 0000000..637d627 --- /dev/null +++ b/lib/components/search/search_results_body/parts/sense/search_chip.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import '../../../../../models/themes/theme.dart'; + +class SearchChip extends StatelessWidget { + final String text; + final ColorSet colors; + + const SearchChip({ + Key? key, + required this.text, + this.colors = LightTheme.defaultMenuGreyNormal, + }) : super(key: key); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colors.background, + borderRadius: BorderRadius.circular(10.0), + ), + child: Text( + text, + style: TextStyle(color: colors.foreground), + ), + ); +} diff --git a/lib/components/search/search_results_body/parts/sense/sense.dart b/lib/components/search/search_results_body/parts/sense/sense.dart new file mode 100644 index 0000000..f2a36ff --- /dev/null +++ b/lib/components/search/search_results_body/parts/sense/sense.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:unofficial_jisho_api/api.dart'; + +import '../../../../../bloc/theme/theme_bloc.dart'; +import 'antonyms.dart'; +import 'definition_abstract.dart'; +import 'english_definitions.dart'; +import 'sentences.dart'; +import 'supplemental_info.dart'; + +class Sense extends StatelessWidget { + final int index; + final JishoWordSense sense; + final PhraseScrapeMeaning? meaning; + + const Sense({ + Key? key, + required this.index, + required this.sense, + this.meaning, + }) : super(key: key); + + // TODO: This assumes that there is only one antonym. However, the + // antonym system is made with the case of multiple antonyms + // in mind. + List _removeAntonyms(List supplementalInfo) { + for (int i = 0; i < supplementalInfo.length; i++) { + if (RegExp(r'^Antonym: .*$').hasMatch(supplementalInfo[i])) { + supplementalInfo.removeAt(i); + break; + } + } + return supplementalInfo; + } + + List? get _supplementalWithoutAntonyms => meaning == null + ? null + : _removeAntonyms(List.from(meaning!.supplemental)); + + bool get hasSupplementalInfo => + sense.info.isNotEmpty || + sense.source.isNotEmpty || + sense.tags.isNotEmpty || + (_supplementalWithoutAntonyms?.isNotEmpty ?? false); + + @override + Widget build(BuildContext context) => BlocBuilder( + builder: (context, state) => Container( + margin: const EdgeInsets.symmetric(vertical: 5), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: state.theme.menuGreyLight.background, + borderRadius: BorderRadius.circular(10.0), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${index + 1}. ${sense.partsOfSpeech.join(', ')}', + style: const TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.left, + ), + EnglishDefinitions( + englishDefinitions: sense.englishDefinitions, + colors: state.theme.menuGreyNormal, + ), + if (hasSupplementalInfo) + SupplementalInfo( + sense: sense, + supplementalInfo: _supplementalWithoutAntonyms, + ), + if (meaning?.definitionAbstract != null) + DefinitionAbstract( + text: meaning!.definitionAbstract!, + color: state.theme.foreground, + ), + if (sense.antonyms.isNotEmpty) Antonyms(antonyms: sense.antonyms), + if (meaning != null && meaning!.sentences.isNotEmpty) + Sentences(sentences: meaning!.sentences) + ] + .map( + (e) => Container( + margin: const EdgeInsets.symmetric(vertical: 5), + child: e, + ), + ) + .toList(), + ), + ), + ); +} diff --git a/lib/components/search/search_results_body/parts/sense/sentences.dart b/lib/components/search/search_results_body/parts/sense/sentences.dart new file mode 100644 index 0000000..9711d9f --- /dev/null +++ b/lib/components/search/search_results_body/parts/sense/sentences.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:unofficial_jisho_api/api.dart'; + +import '../../../../../models/themes/theme.dart'; +import '../kanji_kana_box.dart'; + +class Sentences extends StatelessWidget { + final List sentences; + final ColorSet colors; + + const Sentences({ + Key? key, + required this.sentences, + this.colors = LightTheme.defaultMenuGreyNormal, + }) : super(key: key); + + Widget _sentence(PhraseScrapeSentence sentence) => Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colors.background, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + runSpacing: 10, + children: sentence.pieces + .map( + (p) => JishoJapaneseWord( + word: p.unlifted, + reading: p.lifted, + ), + ) + .map( + (word) => KanjiKanaBox( + word: word, + showRomajiBelow: true, + margin: EdgeInsets.zero, + padding: EdgeInsets.zero, + centerFurigana: false, + autoTransliterateRomaji: false, + kanjiFontsize: 15, + furiganaFontsize: 12, + colors: colors, + ), + ) + .toList(), + ), + Divider( + height: 20, + color: Colors.grey[400], + thickness: 3, + ), + Text( + sentence.english, + style: TextStyle(color: colors.foreground), + ), + ], + ), + ); + + @override + Widget build(BuildContext context) => + Column(children: [for (final s in sentences) _sentence(s)]); +} diff --git a/lib/components/search/search_results_body/parts/sense/supplemental_info.dart b/lib/components/search/search_results_body/parts/sense/supplemental_info.dart new file mode 100644 index 0000000..a52f886 --- /dev/null +++ b/lib/components/search/search_results_body/parts/sense/supplemental_info.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:unofficial_jisho_api/api.dart'; + +class SupplementalInfo extends StatelessWidget { + final JishoWordSense sense; + final List? supplementalInfo; + final Color? color; + + const SupplementalInfo({ + Key? key, + required this.sense, + this.supplementalInfo, + this.color, + }) : super(key: key); + + Widget _info(JishoWordSense sense) { + final List restrictions = List.from(sense.restrictions); + if (restrictions.isNotEmpty) + restrictions[0] = 'Only applies to ${restrictions[0]}'; + + final List combinedInfo = sense.tags + sense.info + restrictions; + + return Text( + combinedInfo.join(', '), + style: TextStyle(color: color), + ); + } + + List get _body { + if (supplementalInfo != null) return [Text(supplementalInfo!.join(', '))]; + + return [ + if (sense.source.isNotEmpty) + Text('From ${sense.source[0].language} ${sense.source[0].word}'), + if (sense.tags.isNotEmpty || + sense.restrictions.isNotEmpty || + sense.info.isNotEmpty) + _info(sense), + ]; + } + + @override + Widget build(BuildContext context) => DefaultTextStyle.merge( + child: Column(children: _body), + style: TextStyle(color: color), + ); +} diff --git a/lib/components/search/search_results_body/parts/senses.dart b/lib/components/search/search_results_body/parts/senses.dart index 1bd0ccc..7177948 100644 --- a/lib/components/search/search_results_body/parts/senses.dart +++ b/lib/components/search/search_results_body/parts/senses.dart @@ -1,62 +1,33 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:unofficial_jisho_api/parser.dart'; +import 'package:unofficial_jisho_api/api.dart'; + +import 'sense/sense.dart'; class Senses extends StatelessWidget { final List senses; + final List? extraData; const Senses({ required this.senses, + this.extraData, Key? key, }) : super(key: key); - @override - Widget build(BuildContext context) { - final List senseWidgets = - senses.asMap().entries.map((e) => _Sense(e.key, e.value)).toList(); - - return Column( - children: senseWidgets, - ); - } -} - -class _Sense extends StatelessWidget { - final int index; - final JishoWordSense sense; - - const _Sense(this.index, this.sense); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Row( - children: [ - Text( - '${index + 1}. ', - style: const TextStyle(color: Colors.grey), + List get _senseWidgets => [ + for (int i = 0; i < senses.length; i++) + Sense( + index: i, + sense: senses[i], + meaning: extraData?.firstWhereOrNull( + (m) => m.definition == senses[i].englishDefinitions.join('; '), ), - Text( - sense.partsOfSpeech.join(', '), - style: const TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.left, - ), - ], - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 20), - margin: const EdgeInsets.fromLTRB(0, 5, 0, 15), - child: Row( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: - sense.englishDefinitions.map((def) => Text(def)).toList(), - ), - ], ), - ), - ], - ); - } + ]; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _senseWidgets, + ); } diff --git a/lib/components/search/search_results_body/parts/wikipedia_attribute.dart b/lib/components/search/search_results_body/parts/wikipedia_attribute.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/components/search/search_results_body/search_card.dart b/lib/components/search/search_results_body/search_card.dart index d4c8129..e41ebce 100644 --- a/lib/components/search/search_results_body/search_card.dart +++ b/lib/components/search/search_results_body/search_card.dart @@ -7,8 +7,13 @@ import './parts/jlpt_badge.dart'; import './parts/other_forms.dart'; import './parts/senses.dart'; import './parts/wanikani_badge.dart'; +import '../../../settings.dart'; +import 'parts/audio_player.dart'; +import 'parts/kanji.dart'; +import 'parts/links.dart'; +import 'parts/notes.dart'; -class SearchResultCard extends StatelessWidget { +class SearchResultCard extends StatefulWidget { final JishoResult result; late final JishoJapaneseWord mainWord; late final List otherForms; @@ -21,46 +26,127 @@ class SearchResultCard extends StatelessWidget { super(key: key); @override - Widget build(BuildContext context) { - final backgroundColor = Theme.of(context).scaffoldBackgroundColor; - return ExpansionTile( - collapsedBackgroundColor: backgroundColor, - backgroundColor: backgroundColor, - title: IntrinsicWidth( + _SearchResultCardState createState() => _SearchResultCardState(); +} + +class _SearchResultCardState extends State { + PhrasePageScrapeResultData? extraData; + + Future _scrape(JishoResult result) => + (!(result.japanese[0].word == null && result.japanese[0].reading == null)) + ? scrapeForPhrase( + widget.result.japanese[0].word ?? + widget.result.japanese[0].reading!, + ) + : Future(() => null); + + List get links => + [for (final sense in widget.result.senses) ...sense.links]; + + bool get hasAttribution => + widget.result.attribution.jmdict || + widget.result.attribution.jmnedict || + (widget.result.attribution.dbpedia != null); + + String? get jlptLevel { + if (widget.result.jlpt.isEmpty) return null; + final jlpt = List.from(widget.result.jlpt); + jlpt.sort(); + return jlpt.last; + } + + List get kanji => RegExp(r'(\p{Script=Hani})', unicode: true) + .allMatches( + widget.result.japanese + .map((w) => '${w.word ?? ""}${w.reading ?? ""}') + .join(), + ) + .map((match) => match.group(0)!) + .toSet() + .toList(); + + Widget get _header => IntrinsicWidth( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - JapaneseHeader(word: mainWord), + JapaneseHeader(word: widget.mainWord), Row( children: [ WKBadge( - level: result.tags.firstWhere( + level: widget.result.tags.firstWhere( (tag) => tag.contains('wanikani'), orElse: () => '', ), ), - JLPTBadge( - jlptLevel: result.jlpt.isNotEmpty ? result.jlpt[0] : '', - ), - CommonBadge(isCommon: result.isCommon ?? false) + JLPTBadge(jlptLevel: jlptLevel), + CommonBadge(isCommon: widget.result.isCommon ?? false) ], ) ], ), - ), - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 30), - child: Column( - children: [ - Senses(senses: result.senses), - OtherForms(forms: otherForms), - // Text(result.toJson().toString()), - // Text(result.attribution.toJson().toString()), - // Text(result.japanese.map((e) => e.toJson().toString()).toList().toString()), + ); + + static const _margin = SizedBox(height: 20); + + List _withMargin(Widget w) => [_margin, w]; + + Widget _body({PhrasePageScrapeResultData? extendedData}) => Container( + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (extendedData != null && extendedData.audio.isNotEmpty) ...[ + // TODO: There's usually multiple mimetypes in the data. + // If one mimetype fails, the app should try to use another one. + AudioPlayer(audio: extendedData.audio.first), + const SizedBox(height: 10), ], - ), - ) + Senses( + senses: widget.result.senses, + extraData: extendedData?.meanings, + ), + if (widget.otherForms.isNotEmpty) + ..._withMargin(OtherForms(forms: widget.otherForms)), + if (extendedData != null && extendedData.notes.isNotEmpty) + ..._withMargin(Notes(notes: extendedData.notes)), + if (kanji.isNotEmpty) ..._withMargin(KanjiRow(kanji: kanji)), + if (links.isNotEmpty || hasAttribution) + ..._withMargin( + Links( + links: links, + attribution: widget.result.attribution, + ), + ) + ], + ), + ); + + @override + Widget build(BuildContext context) { + final backgroundColor = Theme.of(context).scaffoldBackgroundColor; + + return ExpansionTile( + collapsedBackgroundColor: backgroundColor, + backgroundColor: backgroundColor, + onExpansionChanged: (b) async { + if (extensiveSearchEnabled && extraData == null) { + final data = await _scrape(widget.result); + setState(() { + extraData = (data != null && data.found) ? data.data : null; + }); + } + }, + title: _header, + children: [ + if (extensiveSearchEnabled && extraData == null) + const Padding( + padding: EdgeInsets.symmetric(vertical: 10), + child: Center(child: CircularProgressIndicator()), + ) + else if (extraData != null) + _body(extendedData: extraData) + else + _body() ], ); } diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index e69ad44..f702f88 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -60,6 +60,17 @@ class _SettingsViewState extends State { switchValue: romajiEnabled, switchActiveColor: AppTheme.jishoGreen.background, ), + SettingsTile.switchTile( + title: 'Extensive search', + onToggle: (b) { + setState(() => extensiveSearchEnabled = b); + }, + switchValue: extensiveSearchEnabled, + switchActiveColor: AppTheme.jishoGreen.background, + subtitle: + 'Gathers extra data when searching for words, at the expense of having to wait for extra word details', + subtitleMaxLines: 3, + ), ], ), SettingsSection( diff --git a/lib/settings.dart b/lib/settings.dart index c33b52b..9623499 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -5,6 +5,7 @@ final SharedPreferences _prefs = GetIt.instance.get(); const Map _defaults = { 'romajiEnabled': false, + 'extensiveSearch': true, 'darkThemeEnabled': false, 'autoThemeEnabled': false, }; @@ -13,9 +14,11 @@ bool _getSettingOrDefault(String settingName) => _prefs.getBool(settingName) ?? _defaults[settingName]; bool get romajiEnabled => _getSettingOrDefault('romajiEnabled'); +bool get extensiveSearchEnabled => _getSettingOrDefault('extensiveSearch'); bool get darkThemeEnabled => _getSettingOrDefault('darkThemeEnabled'); bool get autoThemeEnabled => _getSettingOrDefault('autoThemeEnabled'); set romajiEnabled(b) => _prefs.setBool('romajiEnabled', b); +set extensiveSearchEnabled(b) => _prefs.setBool('extensiveSearch', b); set darkThemeEnabled(b) => _prefs.setBool('darkThemeEnabled', b); set autoThemeEnabled(b) => _prefs.setBool('autoThemeEnabled', b); diff --git a/pubspec.lock b/pubspec.lock index 71aeb2d..9ab5a3e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -43,6 +43,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.8.2" + audio_session: + dependency: transitive + description: + name: audio_session + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.6+1" bloc: dependency: transitive description: @@ -156,7 +163,7 @@ packages: source: hosted version: "4.1.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection url: "https://pub.dartlang.org" @@ -272,6 +279,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" flutter_test: dependency: "direct dev" description: flutter @@ -373,6 +387,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.4.0" + just_audio: + dependency: "direct main" + description: + name: just_audio + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.18" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.2" logging: dependency: transitive description: @@ -429,6 +464,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.0" + path_drawing: + dependency: transitive + description: + name: path_drawing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + path_parsing: + dependency: transitive + description: + name: path_parsing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" path_provider: dependency: "direct main" description: @@ -534,6 +583,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + rxdart: + dependency: transitive + description: + name: rxdart + url: "https://pub.dartlang.org" + source: hosted + version: "0.27.3" sembast: dependency: "direct main" description: @@ -763,6 +819,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.2" + uuid: + dependency: transitive + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.5" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 19891d4..1dda187 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,14 +7,17 @@ environment: dependencies: animated_size_and_fade: ^3.0.0 + collection: ^1.15.0 confirm_dialog: ^1.0.0 division: ^0.9.0 flutter: sdk: flutter flutter_bloc: ^8.0.0 flutter_slidable: ^1.1.0 + flutter_svg: ^1.0.2 get_it: ^7.2.0 http: ^0.13.4 + just_audio: ^0.9.18 mdi: ^5.0.0-nullsafety.0 path: ^1.8.0 path_provider: ^2.0.2 @@ -41,7 +44,7 @@ flutter: uses-material-design: true assets: - - assets/images/denshi_jisho_background_overlay.png + - assets/images/ - assets/images/logo/ # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg