From a1ac79821f5cbea1968f07b64698549c4264dc46 Mon Sep 17 00:00:00 2001 From: Hampus Hammarlund Date: Wed, 4 Feb 2026 22:12:54 +0900 Subject: [PATCH 1/2] feat: add option to add vocab identified with text analysis to a dictionary list --- lib/app/app.dart | 2 + lib/services/dictionary_service.dart | 8 +++ .../add_to_my_list_bottom_sheet.dart | 54 +++++++++++++++++++ .../text_analysis/text_analysis_view.dart | 6 ++- .../text_analysis_viewmodel.dart | 28 +++++++++- pubspec.lock | 4 +- pubspec.yaml | 2 +- 7 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 lib/ui/bottom_sheets/add_to_my_list_bottom_sheet.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 1e49978..354db4b 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -4,6 +4,7 @@ import 'package:sagase/services/firebase_service.dart'; import 'package:sagase/services/dictionary_service.dart'; import 'package:sagase/services/mecab_service.dart'; import 'package:sagase/services/shared_preferences_service.dart'; +import 'package:sagase/ui/bottom_sheets/add_to_my_list_bottom_sheet.dart'; import 'package:sagase/ui/bottom_sheets/assign_lists_bottom_sheet.dart'; import 'package:sagase/ui/bottom_sheets/assign_my_lists_bottom_sheet.dart'; import 'package:sagase/ui/bottom_sheets/select_dictionary_item_bottom_sheet.dart'; @@ -107,6 +108,7 @@ import 'package:stacked_themes/stacked_themes.dart'; LazySingleton(classType: SearchViewModel), ], bottomsheets: [ + StackedBottomsheet(classType: AddToMyListBottomSheet), StackedBottomsheet(classType: AssignMyListsBottomSheet), StackedBottomsheet(classType: AssignListsBottomSheet), StackedBottomsheet(classType: StrokeOrderBottomSheet), diff --git a/lib/services/dictionary_service.dart b/lib/services/dictionary_service.dart index 60426a4..d511de8 100644 --- a/lib/services/dictionary_service.dart +++ b/lib/services/dictionary_service.dart @@ -328,6 +328,14 @@ class DictionaryService { .addDictionaryItem(dictionaryList, dictionaryItem); } + Future addManyToMyDictionaryList( + MyDictionaryList dictionaryList, + List dictionaryItems, + ) async { + await _database.myDictionaryListsDao + .addDictionaryItems(dictionaryList, dictionaryItems); + } + Future removeFromMyDictionaryList( MyDictionaryList dictionaryList, DictionaryItem dictionaryItem, diff --git a/lib/ui/bottom_sheets/add_to_my_list_bottom_sheet.dart b/lib/ui/bottom_sheets/add_to_my_list_bottom_sheet.dart new file mode 100644 index 0000000..b72cae4 --- /dev/null +++ b/lib/ui/bottom_sheets/add_to_my_list_bottom_sheet.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:sagase/ui/bottom_sheets/base_bottom_sheet.dart'; +import 'package:stacked_services/stacked_services.dart'; + +class AddToMyListBottomSheet extends StatelessWidget { + final SheetRequest request; + final Function(SheetResponse) completer; + + const AddToMyListBottomSheet({ + required this.request, + required this.completer, + super.key, + }); + + @override + Widget build(BuildContext context) { + return BaseBottomSheet( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 8), + child: Text( + 'Add vocab to list', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: const Text( + 'Identified vocab will be added to the selected list.\nWords with multiple options will be skipped.', + textAlign: TextAlign.center, + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: request.data.length, + itemBuilder: (context, index) => ListTile( + title: Text(request.data[index].name), + onTap: () => + completer(SheetResponse(data: request.data[index])), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/views/text_analysis/text_analysis_view.dart b/lib/ui/views/text_analysis/text_analysis_view.dart index 07b4a9f..927b5bd 100644 --- a/lib/ui/views/text_analysis/text_analysis_view.dart +++ b/lib/ui/views/text_analysis/text_analysis_view.dart @@ -71,8 +71,10 @@ class _Body extends StackedHookView { icon: const Icon(Icons.edit), ), IconButton( - onPressed: viewModel.copyText, - icon: const Icon(Icons.copy), + onPressed: viewModel.analysisFailed + ? null + : viewModel.addToDictionaryList, + icon: const Icon(Icons.playlist_add), ), ], }, diff --git a/lib/ui/views/text_analysis/text_analysis_viewmodel.dart b/lib/ui/views/text_analysis/text_analysis_viewmodel.dart index 24bdb21..9fed492 100644 --- a/lib/ui/views/text_analysis/text_analysis_viewmodel.dart +++ b/lib/ui/views/text_analysis/text_analysis_viewmodel.dart @@ -87,8 +87,32 @@ class TextAnalysisViewModel extends FutureViewModel { rebuildUi(); } - void copyText() { - Clipboard.setData(ClipboardData(text: _text)); + Future addToDictionaryList() async { + final myDictionaryLists = + await _dictionaryService.getAllMyDictionaryLists(); + + final response = await _bottomSheetService.showCustomSheet( + variant: BottomSheetType.addToMyListBottom, + data: myDictionaryLists, + ); + + if (response?.data == null) return; + + final List itemsToAdd = []; + for (var token in tokens!) { + if (token.associatedDictionaryItems != null && + token.associatedDictionaryItems!.length == 1 && + token.associatedDictionaryItems!.first is Vocab) { + itemsToAdd.add(token.associatedDictionaryItems!.first); + } + } + + await _dictionaryService.addManyToMyDictionaryList( + response!.data! as MyDictionaryList, + itemsToAdd, + ); + + _snackbarService.showSnackbar(message: 'Vocab added to list'); } void openAssociatedDictionaryItem(JapaneseTextToken token) { diff --git a/pubspec.lock b/pubspec.lock index fad5ab8..dbccea5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1244,8 +1244,8 @@ packages: dependency: "direct main" description: path: "." - ref: "6e44fb2" - resolved-ref: "6e44fb245bb5892e64026bc7424eb53ba7cd532c" + ref: "3df24c3" + resolved-ref: "3df24c3d43db9ff7adcc16d6b1a2d75bd242ba9e" url: "https://github.com/Moseco/sagase_dictionary" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 9c60130..49fc9ca 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,7 +20,7 @@ dependencies: sagase_dictionary: git: url: https://github.com/Moseco/sagase_dictionary - ref: '6e44fb2' + ref: '3df24c3' # State management stacked: ^3.5.0 From 92b41ba0790d20d21a3cf669bf56b995c01ac33d Mon Sep 17 00:00:00 2001 From: Hampus Hammarlund Date: Thu, 5 Feb 2026 11:34:19 +0900 Subject: [PATCH 2/2] feat: split text by line break during text analysis --- .../text_analysis_viewmodel.dart | 51 +++-- .../widgets/text_analysis_viewing.dart | 201 ++++++++++-------- 2 files changed, 140 insertions(+), 112 deletions(-) diff --git a/lib/ui/views/text_analysis/text_analysis_viewmodel.dart b/lib/ui/views/text_analysis/text_analysis_viewmodel.dart index 9fed492..3fb7b8a 100644 --- a/lib/ui/views/text_analysis/text_analysis_viewmodel.dart +++ b/lib/ui/views/text_analysis/text_analysis_viewmodel.dart @@ -26,7 +26,7 @@ class TextAnalysisViewModel extends FutureViewModel { bool _addToHistory; - List? tokens; + List>? tokens; bool _analysisFailed = true; bool get analysisFailed => _analysisFailed; @@ -54,22 +54,31 @@ class TextAnalysisViewModel extends FutureViewModel { _analysisFailed = true; - tokens = _mecabService.parseText(_text); + final lines = _text.split('\n'); + tokens = []; - for (var token in tokens!) { - if (token.pos == PartOfSpeech.nounProper && - _sharedPreferencesService.getProperNounsEnabled()) { - token.associatedDictionaryItems = - await _dictionaryService.getProperNounByJapaneseTextToken(token); - } + for (var line in lines) { + String internalTrimmed = line.trim(); + if (internalTrimmed.isEmpty) continue; - if (token.associatedDictionaryItems == null || - token.associatedDictionaryItems!.isEmpty) { - token.associatedDictionaryItems = - await _dictionaryService.getVocabByJapaneseTextToken(token); - } - if (token.associatedDictionaryItems!.isNotEmpty) { - _analysisFailed = false; + final lineTokens = _mecabService.parseText(internalTrimmed); + tokens!.add(lineTokens); + + for (var token in lineTokens) { + if (token.pos == PartOfSpeech.nounProper && + _sharedPreferencesService.getProperNounsEnabled()) { + token.associatedDictionaryItems = + await _dictionaryService.getProperNounByJapaneseTextToken(token); + } + + if (token.associatedDictionaryItems == null || + token.associatedDictionaryItems!.isEmpty) { + token.associatedDictionaryItems = + await _dictionaryService.getVocabByJapaneseTextToken(token); + } + if (token.associatedDictionaryItems!.isNotEmpty) { + _analysisFailed = false; + } } } @@ -99,11 +108,13 @@ class TextAnalysisViewModel extends FutureViewModel { if (response?.data == null) return; final List itemsToAdd = []; - for (var token in tokens!) { - if (token.associatedDictionaryItems != null && - token.associatedDictionaryItems!.length == 1 && - token.associatedDictionaryItems!.first is Vocab) { - itemsToAdd.add(token.associatedDictionaryItems!.first); + for (var lineTokens in tokens!) { + for (var token in lineTokens) { + if (token.associatedDictionaryItems != null && + token.associatedDictionaryItems!.length == 1 && + token.associatedDictionaryItems!.first is Vocab) { + itemsToAdd.add(token.associatedDictionaryItems!.first); + } } } diff --git a/lib/ui/views/text_analysis/widgets/text_analysis_viewing.dart b/lib/ui/views/text_analysis/widgets/text_analysis_viewing.dart index 2636cf6..f6a74f3 100644 --- a/lib/ui/views/text_analysis/widgets/text_analysis_viewing.dart +++ b/lib/ui/views/text_analysis/widgets/text_analysis_viewing.dart @@ -12,110 +12,128 @@ class TextAnalysisViewing extends ViewModelWidget { @override Widget build(BuildContext context, TextAnalysisViewModel viewModel) { - List textChildren = []; + List lineWidgets = []; List associatedVocabChildren = []; - for (var token in viewModel.tokens!) { - List data = []; - // Create writing buffer to be used in case of multiple associated vocab - final writing = StringBuffer(); - // Add main pairs - for (var rubyPair in token.rubyTextPairs) { - writing.write(rubyPair.writing); - data.add( - RubyTextData( - rubyPair.writing, - ruby: rubyPair.reading, - ), - ); - } - // Add any trailing pairs - if (token.trailing != null) { - for (var trailing in token.trailing!) { - for (var rubyPair in trailing.rubyTextPairs) { - writing.write(rubyPair.writing); - data.add( - RubyTextData( - rubyPair.writing, - ruby: rubyPair.reading, - ), - ); + for (var lineTokens in viewModel.tokens!) { + List textChildren = []; + + for (var token in lineTokens) { + List data = []; + + // Create writing buffer to be used in case of multiple associated vocab + final writing = StringBuffer(); + // Add main pairs + for (var rubyPair in token.rubyTextPairs) { + writing.write(rubyPair.writing); + data.add( + RubyTextData( + rubyPair.writing, + ruby: rubyPair.reading, + ), + ); + } + // Add any trailing pairs + if (token.trailing != null) { + for (var trailing in token.trailing!) { + for (var rubyPair in trailing.rubyTextPairs) { + writing.write(rubyPair.writing); + data.add( + RubyTextData( + rubyPair.writing, + ruby: rubyPair.reading, + ), + ); + } } } - } - textChildren.add( - GestureDetector( - onTap: () => viewModel.openAssociatedDictionaryItem(token), - onLongPress: () => viewModel.copyToken(token), - child: Container( - decoration: BoxDecoration( - border: token.associatedDictionaryItems!.isNotEmpty - ? Border( - bottom: BorderSide( - color: Theme.of(context).textTheme.bodyMedium!.color!, - ), - ) - : null, - ), - child: RubyText( - data, - style: const TextStyle( - fontSize: 24, - letterSpacing: 0, - height: 1.1, + textChildren.add( + GestureDetector( + onTap: () => viewModel.openAssociatedDictionaryItem(token), + onLongPress: () => viewModel.copyToken(token), + child: Container( + decoration: BoxDecoration( + border: token.associatedDictionaryItems!.isNotEmpty + ? Border( + bottom: BorderSide( + color: Theme.of(context).textTheme.bodyMedium!.color!, + ), + ) + : null, + ), + child: RubyText( + data, + style: const TextStyle( + fontSize: 24, + letterSpacing: 0, + height: 1.1, + ), + rubyStyle: const TextStyle(height: 1.2), ), - rubyStyle: const TextStyle(height: 1.2), ), ), - ), - ); + ); - if (token.associatedDictionaryItems != null && - token.associatedDictionaryItems!.isNotEmpty) { - if (token.associatedDictionaryItems!.length == 1) { - if (token.associatedDictionaryItems![0] is Vocab) { - associatedVocabChildren.add( - VocabListItem( - vocab: token.associatedDictionaryItems![0] as Vocab, - onPressed: () => viewModel.openAssociatedDictionaryItem(token), - ), - ); + if (token.associatedDictionaryItems != null && + token.associatedDictionaryItems!.isNotEmpty) { + if (token.associatedDictionaryItems!.length == 1) { + if (token.associatedDictionaryItems![0] is Vocab) { + associatedVocabChildren.add( + VocabListItem( + vocab: token.associatedDictionaryItems![0] as Vocab, + onPressed: () => + viewModel.openAssociatedDictionaryItem(token), + ), + ); + } else { + associatedVocabChildren.add( + ProperNounListItem( + properNoun: token.associatedDictionaryItems![0] as ProperNoun, + onPressed: () => + viewModel.openAssociatedDictionaryItem(token), + ), + ); + } } else { + // Multiple dictionary item associatedVocabChildren.add( - ProperNounListItem( - properNoun: token.associatedDictionaryItems![0] as ProperNoun, - onPressed: () => viewModel.openAssociatedDictionaryItem(token), + InkWell( + onTap: () => viewModel.openAssociatedDictionaryItem(token), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Multiple options for $writing', + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + const Text( + 'Tap to view', + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ], + ), + ), ), ); } - } else { - // Multiple dictionary item - associatedVocabChildren.add( - InkWell( - onTap: () => viewModel.openAssociatedDictionaryItem(token), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Multiple options for $writing', - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - const Text( - 'Tap to view', - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ], - ), - ), - ), - ); } } + + if (textChildren.isNotEmpty) { + lineWidgets.add( + Wrap( + crossAxisAlignment: WrapCrossAlignment.end, + spacing: 6, + children: textChildren, + ), + ); + } } final padding = MediaQuery.of(context).padding; @@ -133,10 +151,9 @@ class TextAnalysisViewing extends ViewModelWidget { padding: const EdgeInsets.all(8), child: SizedBox( width: double.infinity, - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.end, - spacing: 6, - children: textChildren, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: lineWidgets, ), ), ),