diff --git a/lib/models/history/search.dart b/lib/models/history/search.dart index 332ebcf..6f95ae5 100644 --- a/lib/models/history/search.dart +++ b/lib/models/history/search.dart @@ -86,3 +86,35 @@ Future addSearchToDatabase({ .toJson(), ); } + +List mergeSearches(List a, List b) { + final List result = [...a]; + + for (final Search search in b) { + late final Iterable matchingEntry; + if (search.isKanji) { + matchingEntry = + result.where((e) => e.kanjiQuery?.kanji == search.kanjiQuery!.kanji); + } else { + matchingEntry = + result.where((e) => e.wordQuery?.query == search.wordQuery!.query); + } + + if (matchingEntry.isEmpty) { + result.add(search); + continue; + } + + final timestamps = [...matchingEntry.first.timestamps]; + matchingEntry.first.timestamps.clear(); + matchingEntry.first.timestamps.addAll( + (timestamps + ..addAll(search.timestamps) + ..sort()) + .toSet() + .toList(), + ); + } + + return result; +} diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 29e6852..edae4f4 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -1,13 +1,22 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:confirm_dialog/confirm_dialog.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_settings_ui/flutter_settings_ui.dart'; import 'package:mdi/mdi.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sembast/sembast_io.dart'; +import 'package:sembast/utils/sembast_import_export.dart'; import '../bloc/theme/theme_bloc.dart'; import '../components/common/denshi_jisho_background.dart'; import '../models/history/search.dart'; import '../routing/routes.dart'; +import '../services/database.dart'; import '../services/open_webpage.dart'; +import '../services/snackbar.dart'; import '../settings.dart'; class SettingsView extends StatefulWidget { @@ -19,6 +28,8 @@ class SettingsView extends StatefulWidget { class _SettingsViewState extends State { final Database db = GetIt.instance.get(); + bool dataExportIsLoading = false; + bool dataImportIsLoading = false; Future clearHistory(context) async { final bool userIsSure = await confirm(context); @@ -40,6 +51,92 @@ class _SettingsViewState extends State { setState(() => autoThemeEnabled = b); } + Future changeFont(context) async { + final int? i = await _chooseFromList( + list: [for (final font in JapaneseFont.values) font.name], + chosen: japaneseFont.index, + )(context); + if (i != null) + setState(() { + japaneseFont = JapaneseFont.values[i]; + }); + } + + /// Can assume Android for time being + Future exportData(context) async { + setState(() => dataExportIsLoading = true); + + final path = (await getExternalStorageDirectory())!; + final dbData = await exportDatabase(db); + final file = File('${path.path}/jisho_data.json'); + file.createSync(recursive: true); + await file.writeAsString(jsonEncode(dbData)); + + setState(() => dataExportIsLoading = false); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Data exported to ${file.path}'))); + } + + /// Can assume Android for time being + Future importData(context) async { + setState(() => dataImportIsLoading = true); + + final path = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + ); + final file = File(path!.files[0].path!); + + final List prevSearches = (await Search.store.find(db)) + .map((e) => Search.fromJson(e.value! as Map)) + .toList(); + late final List importedSearches; + try { + importedSearches = ((((jsonDecode(await file.readAsString()) + as Map)['stores']! as List) + .map((e) => e as Map) + .where((e) => e['name'] == 'search') + .first)['values'] as List) + .map((item) => Search.fromJson(item)) + .toList(); + } catch (e) { + debugPrint(e.toString()); + showSnackbar( + context, + "Couldn't read file. Did you choose the right one?", + ); + return; + } + + final List mergedSearches = + mergeSearches(prevSearches, importedSearches); + + // print(mergedSearches); + + await GetIt.instance.get().close(); + GetIt.instance.unregister(); + + final importedDb = await importDatabase( + { + 'sembast_export': 1, + 'version': 1, + 'stores': [ + { + 'name': 'search', + 'keys': [for (var i = 1; i <= mergedSearches.length; i++) i], + 'values': mergedSearches.map((e) => e.toJson()).toList(), + } + ] + }, + databaseFactoryIo, + await databasePath(), + ); + GetIt.instance.registerSingleton(importedDb); + + setState(() => dataImportIsLoading = false); + showSnackbar(context, 'Data imported successfully'); + } + Future Function(BuildContext) _chooseFromList({ required List list, int? chosen, @@ -89,9 +186,7 @@ class _SettingsViewState extends State { SettingsTile.switchTile( title: 'Use romaji', leading: const Icon(Mdi.alphabetical), - onToggle: (b) { - setState(() => romajiEnabled = b); - }, + onToggle: (b) => setState(() => romajiEnabled = b), switchValue: romajiEnabled, theme: theme, switchActiveColor: AppTheme.jishoGreen.background, @@ -99,9 +194,7 @@ class _SettingsViewState extends State { SettingsTile.switchTile( title: 'Extensive search', leading: const Icon(Icons.downloading), - onToggle: (b) { - setState(() => extensiveSearchEnabled = b); - }, + onToggle: (b) => setState(() => extensiveSearchEnabled = b), switchValue: extensiveSearchEnabled, theme: theme, switchActiveColor: AppTheme.jishoGreen.background, @@ -114,18 +207,7 @@ class _SettingsViewState extends State { SettingsTile( title: 'Japanese font', leading: const Icon(Icons.format_size), - onPressed: (context) async { - final int? i = await _chooseFromList( - list: [ - for (final font in JapaneseFont.values) font.name - ], - chosen: japaneseFont.index, - )(context); - if (i != null) - setState(() { - japaneseFont = JapaneseFont.values[i]; - }); - }, + onPressed: changeFont, theme: theme, trailing: Text(japaneseFont.name), // subtitle: @@ -134,6 +216,7 @@ class _SettingsViewState extends State { ), ], ), + SettingsSection( title: 'Theme', titleTextStyle: _titleTextStyle, @@ -161,6 +244,7 @@ class _SettingsViewState extends State { ), ], ), + // TODO: This will be left commented until caching is implemented // SettingsSection( // title: 'Cache', @@ -196,14 +280,31 @@ class _SettingsViewState extends State { // ), // ], // ), + SettingsSection( title: 'Data', titleTextStyle: _titleTextStyle, tiles: [ + SettingsTile( + leading: const Icon(Icons.file_upload), + title: 'Import Data', + onPressed: importData, + enabled: Platform.isAndroid, + subtitle: + Platform.isAndroid ? null : 'Not available on iOS yet', + subtitleWidget: dataImportIsLoading + ? const LinearProgressIndicator() + : null, + ), SettingsTile( leading: const Icon(Icons.file_download), title: 'Export Data', - enabled: false, + enabled: Platform.isAndroid, + subtitle: + Platform.isAndroid ? null : 'Not available on iOS yet', + subtitleWidget: dataExportIsLoading + ? const LinearProgressIndicator() + : null, ), SettingsTile( leading: const Icon(Icons.delete), @@ -220,6 +321,7 @@ class _SettingsViewState extends State { ) ], ), + SettingsSection( title: 'Info', titleTextStyle: _titleTextStyle, diff --git a/lib/services/database.dart b/lib/services/database.dart index a1cf55c..3e093be 100644 --- a/lib/services/database.dart +++ b/lib/services/database.dart @@ -6,10 +6,14 @@ import 'package:path_provider/path_provider.dart'; import 'package:sembast/sembast.dart'; import 'package:sembast/sembast_io.dart'; -Future setupDatabase() async { +Future databasePath() async { final Directory appDocDir = await getApplicationDocumentsDirectory(); if (!appDocDir.existsSync()) appDocDir.createSync(recursive: true); + return join(appDocDir.path, 'sembast.db'); +} + +Future setupDatabase() async { final Database database = - await databaseFactoryIo.openDatabase(join(appDocDir.path, 'sembast.db')); + await databaseFactoryIo.openDatabase(await databasePath()); GetIt.instance.registerSingleton(database); } diff --git a/lib/services/snackbar.dart b/lib/services/snackbar.dart new file mode 100644 index 0000000..d53f63f --- /dev/null +++ b/lib/services/snackbar.dart @@ -0,0 +1,4 @@ +import 'package:flutter/material.dart'; + +void showSnackbar(BuildContext context, String text) => + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); diff --git a/pubspec.lock b/pubspec.lock index 0f790d8..d2cf37b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "33.0.0" + version: "31.0.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "3.1.0" + version: "2.8.0" animated_size_and_fade: dependency: "direct main" description: @@ -28,7 +28,7 @@ packages: name: archive url: "https://pub.dartlang.org" source: hosted - version: "3.1.8" + version: "3.3.0" args: dependency: transitive description: @@ -56,7 +56,7 @@ packages: name: bloc url: "https://pub.dartlang.org" source: hosted - version: "8.0.2" + version: "8.0.3" boolean_selector: dependency: transitive description: @@ -70,7 +70,7 @@ packages: name: build url: "https://pub.dartlang.org" source: hosted - version: "2.2.1" + version: "2.3.0" build_config: dependency: transitive description: @@ -84,7 +84,7 @@ packages: name: build_daemon url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.1.0" build_resolvers: dependency: transitive description: @@ -98,7 +98,7 @@ packages: name: build_runner url: "https://pub.dartlang.org" source: hosted - version: "2.1.7" + version: "2.1.10" build_runner_core: dependency: transitive description: @@ -119,7 +119,7 @@ packages: name: built_value url: "https://pub.dartlang.org" source: hosted - version: "8.1.3" + version: "8.2.3" characters: dependency: transitive description: @@ -175,7 +175,7 @@ packages: name: confirm_dialog url: "https://pub.dartlang.org" source: hosted - version: "1.0.0" + version: "1.0.1" convert: dependency: transitive description: @@ -183,13 +183,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1" + coverage: + dependency: transitive + description: + name: coverage + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" crypto: dependency: transitive description: name: crypto url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.0.2" csslib: dependency: transitive description: @@ -232,6 +239,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.1.2" + file_picker: + dependency: "direct main" + description: + name: file_picker + url: "https://pub.dartlang.org" + source: hosted + version: "4.5.1" fixnum: dependency: transitive description: @@ -264,7 +278,14 @@ packages: name: flutter_native_splash url: "https://pub.dartlang.org" source: hosted - version: "1.3.3" + version: "2.1.6" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" flutter_settings_ui: dependency: "direct main" description: @@ -351,7 +372,7 @@ packages: name: http_multi_server url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.2.0" http_parser: dependency: transitive description: @@ -365,7 +386,7 @@ packages: name: image url: "https://pub.dartlang.org" source: hosted - version: "3.1.0" + version: "3.1.3" io: dependency: transitive description: @@ -386,28 +407,35 @@ packages: name: json_annotation url: "https://pub.dartlang.org" source: hosted - version: "4.4.0" + version: "4.5.0" just_audio: dependency: "direct main" description: name: just_audio url: "https://pub.dartlang.org" source: hosted - version: "0.9.18" + version: "0.9.21" just_audio_platform_interface: dependency: transitive description: name: just_audio_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "4.0.0" + version: "4.1.0" just_audio_web: dependency: transitive description: name: just_audio_web url: "https://pub.dartlang.org" source: hosted - version: "0.4.3" + version: "0.4.7" + lint: + dependency: transitive + description: + name: lint + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.2" logging: dependency: transitive description: @@ -422,6 +450,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.12.11" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" mdi: dependency: "direct main" description: @@ -442,7 +477,7 @@ packages: name: mime url: "https://pub.dartlang.org" source: hosted - version: "1.0.1" + version: "1.0.2" nested: dependency: transitive description: @@ -450,6 +485,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" package_config: dependency: transitive description: @@ -484,21 +526,21 @@ packages: name: path_provider url: "https://pub.dartlang.org" source: hosted - version: "2.0.8" + version: "2.0.9" path_provider_android: dependency: transitive description: name: path_provider_android url: "https://pub.dartlang.org" source: hosted - version: "2.0.11" + version: "2.0.13" path_provider_ios: dependency: transitive description: name: path_provider_ios url: "https://pub.dartlang.org" source: hosted - version: "2.0.7" + version: "2.0.8" path_provider_linux: dependency: transitive description: @@ -575,7 +617,7 @@ packages: name: pub_semver url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" pubspec_parse: dependency: transitive description: @@ -596,42 +638,84 @@ packages: name: sembast url: "https://pub.dartlang.org" source: hosted - version: "3.1.1+1" + version: "3.2.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.4" + share_plus_linux: + dependency: transitive + description: + name: share_plus_linux + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + share_plus_macos: + dependency: transitive + description: + name: share_plus_macos + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + share_plus_web: + dependency: transitive + description: + name: share_plus_web + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + share_plus_windows: + dependency: transitive + description: + name: share_plus_windows + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" shared_preferences: dependency: "direct main" description: name: shared_preferences url: "https://pub.dartlang.org" source: hosted - version: "2.0.12" + version: "2.0.13" shared_preferences_android: dependency: transitive description: name: shared_preferences_android url: "https://pub.dartlang.org" source: hosted - version: "2.0.10" + version: "2.0.11" shared_preferences_ios: dependency: transitive description: name: shared_preferences_ios url: "https://pub.dartlang.org" source: hosted - version: "2.0.9" + version: "2.1.0" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux url: "https://pub.dartlang.org" source: hosted - version: "2.0.4" + version: "2.1.0" shared_preferences_macos: dependency: transitive description: name: shared_preferences_macos url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.0.3" shared_preferences_platform_interface: dependency: transitive description: @@ -652,14 +736,28 @@ packages: name: shared_preferences_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.4" + version: "2.1.0" shelf: dependency: transitive description: name: shelf url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.3.0" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + shelf_static: + dependency: transitive + description: + name: shelf_static + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" shelf_web_socket: dependency: transitive description: @@ -679,6 +777,20 @@ packages: description: flutter source: sdk version: "0.0.99" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + source_maps: + dependency: transitive + description: + name: source_maps + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.10" source_span: dependency: transitive description: @@ -720,7 +832,7 @@ packages: name: synchronized url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "3.0.0+2" term_glyph: dependency: transitive description: @@ -728,13 +840,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + test: + dependency: "direct main" + description: + name: test + url: "https://pub.dartlang.org" + source: hosted + version: "1.19.5" test_api: dependency: transitive description: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.4.3" + version: "0.4.8" + test_core: + dependency: transitive + description: + name: test_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.9" timing: dependency: transitive description: @@ -769,35 +895,35 @@ packages: name: url_launcher url: "https://pub.dartlang.org" source: hosted - version: "6.0.18" + version: "6.1.0" url_launcher_android: dependency: transitive description: name: url_launcher_android url: "https://pub.dartlang.org" source: hosted - version: "6.0.14" + version: "6.0.16" url_launcher_ios: dependency: transitive description: name: url_launcher_ios url: "https://pub.dartlang.org" source: hosted - version: "6.0.14" + version: "6.0.15" url_launcher_linux: dependency: transitive description: name: url_launcher_linux url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "3.0.0" url_launcher_macos: dependency: transitive description: name: url_launcher_macos url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "3.0.0" url_launcher_platform_interface: dependency: transitive description: @@ -811,21 +937,21 @@ packages: name: url_launcher_web url: "https://pub.dartlang.org" source: hosted - version: "2.0.6" + version: "2.0.9" url_launcher_windows: dependency: transitive description: name: url_launcher_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "3.0.0" uuid: dependency: transitive description: name: uuid url: "https://pub.dartlang.org" source: hosted - version: "3.0.5" + version: "3.0.6" vector_math: dependency: transitive description: @@ -833,6 +959,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.1" + vm_service: + dependency: transitive + description: + name: vm_service + url: "https://pub.dartlang.org" + source: hosted + version: "7.5.0" watcher: dependency: transitive description: @@ -846,21 +979,28 @@ packages: name: web_socket_channel url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.2.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" win32: dependency: transitive description: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "2.3.3" + version: "2.5.2" xdg_directories: dependency: transitive description: name: xdg_directories url: "https://pub.dartlang.org" source: hosted - version: "0.2.0" + version: "0.2.0+1" xml: dependency: transitive description: @@ -876,5 +1016,5 @@ packages: source: hosted version: "3.1.0" sdks: - dart: ">=2.15.1 <3.0.0" - flutter: ">=2.5.0" + dart: ">=2.16.0 <3.0.0" + flutter: ">=2.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index 44074f1..0309083 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,9 +10,11 @@ dependencies: collection: ^1.15.0 confirm_dialog: ^1.0.0 division: ^0.9.0 + file_picker: ^4.5.1 flutter: sdk: flutter flutter_bloc: ^8.0.0 + flutter_settings_ui: ^2.0.1 flutter_slidable: ^1.1.0 flutter_svg: ^1.0.2 get_it: ^7.2.0 @@ -22,7 +24,8 @@ dependencies: path: ^1.8.0 path_provider: ^2.0.2 sembast: ^3.1.1 - flutter_settings_ui: ^2.0.1 + share_plus: ^4.0.4 + test: ^1.19.5 shared_preferences: ^2.0.6 signature: ^5.0.0 unofficial_jisho_api: ^2.0.4 @@ -32,8 +35,8 @@ dev_dependencies: build_runner: ^2.0.6 flutter_test: sdk: flutter - flutter_native_splash: ^1.2.0 - flutter_launcher_icons: "^0.9.1" + flutter_native_splash: ^2.1.6 + flutter_launcher_icons: "^0.9.2" flutter_icons: android: "launcher_icon" diff --git a/test/models/history_test.dart b/test/models/history_test.dart new file mode 100644 index 0000000..953c51c --- /dev/null +++ b/test/models/history_test.dart @@ -0,0 +1,34 @@ +import 'package:jisho_study_tool/models/history/kanji_query.dart'; +import 'package:jisho_study_tool/models/history/search.dart'; +import 'package:jisho_study_tool/models/history/word_query.dart'; +import 'package:test/test.dart'; + +void main() { + group('Search', () { + final List searches = [ + Search.fromKanjiQuery(kanjiQuery: KanjiQuery(kanji: '何')), + Search.fromWordQuery(wordQuery: WordQuery(query: 'テスト')), + Search.fromJson({'timestamps':[1648658269960],'lastTimestamp':1648658269960,'wordQuery':null,'kanjiQuery':{'kanji':'日'}}), + Search.fromJson({'timestamps':[1648674967535],'lastTimestamp':1648674967535,'wordQuery':{'query':'黙る'},'kanjiQuery':null}), + Search.fromJson({'timestamps':[1649079907766],'lastTimestamp':1649079907766,'wordQuery':{'query':'seal'},'kanjiQuery':null}), + Search.fromJson({'timestamps':[1649082072981],'lastTimestamp':1649082072981,'wordQuery':{'query':'感涙屋'},'kanjiQuery':null}), + Search.fromJson({'timestamps':[1644951726777,1644951732749],'lastTimestamp':1644951732749,'wordQuery':{'query':'呑める'},'kanjiQuery':null}), + ]; + test("mergeSearches with empty lists doesn't add data", () { + final List merged1 = mergeSearches(searches, []); + final List merged2 = mergeSearches([], searches); + for (int i = 0; i < searches.length; i++) { + expect(merged1[i], searches[i]); + expect(merged2[i], searches[i]); + } + }); + + test("mergeSearches with the same list doesn't add data", () { + final List merged = mergeSearches(searches, searches); + for (int i = 0; i < searches.length; i++) { + expect(merged[i], searches[i]); + } + expect(mergeSearches(searches, searches), searches); + }); + }); +}