diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 59c866d..d80b0c9 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,5 +1,9 @@ + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9c405d5..418ee90 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,11 @@ - + android:icon="@mipmap/launcher_icon" + android:requestLegacyExternalStorage="true" + > 0)) +); + +CREATE INDEX "JST_SavedListEntry_byListName" ON "JST_SavedListEntry"("listName"); + +-- CREATE VIEW "JST_SavedListEntry_sortedByLists" AS +-- WITH RECURSIVE "JST_SavedListEntry_sorted"("next") AS ( +-- -- Initial SELECT +-- UNION ALL +-- SELECT * FROM "" +-- -- Recursive Select +-- ) +-- SELECT * FROM "JST_SavedListEntry_sorted"; + +CREATE TABLE "JST_HistoryEntry" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT +); + +CREATE TABLE "JST_HistoryEntryKanji" ( + "entryId" INTEGER NOT NULL REFERENCES "JST_HistoryEntry"("id") ON DELETE CASCADE, + "kanji" CHAR(1) NOT NULL, + PRIMARY KEY ("entryId", "kanji") +); + +CREATE TABLE "JST_HistoryEntryWord" ( + "entryId" INTEGER NOT NULL REFERENCES "JST_HistoryEntry"("id") ON DELETE CASCADE, + "searchword" TEXT NOT NULL, + "language" CHAR(1) CHECK ("language" IN ("e", "j")), + PRIMARY KEY ("entryId", "searchword") +); + +CREATE TABLE "JST_HistoryEntryTimestamp" ( + "entryId" INTEGER NOT NULL REFERENCES "JST_HistoryEntry"("id") ON DELETE CASCADE, + -- Here, I'm using INTEGER insted of TIMESTAMP or DATETIME, because it seems to be + -- the easiest way to deal with global and local timeconversion between dart and + -- SQLite. + "timestamp" INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY ("entryId", "timestamp") +); + +CREATE INDEX "JST_HistoryEntryTimestamp_byTimestamp" ON "JST_HistoryEntryTimestamp"("timestamp"); + +CREATE VIEW "JST_HistoryEntry_orderedByTimestamp" AS +SELECT * FROM "JST_HistoryEntryTimestamp" +LEFT JOIN "JST_HistoryEntryWord" USING ("entryId") +LEFT JOIN "JST_HistoryEntryKanji" USING ("entryId") +GROUP BY "entryId" +ORDER BY MAX("timestamp") DESC; \ No newline at end of file diff --git a/lib/components/history/search_item.dart b/lib/components/history/history_entry_item.dart similarity index 61% rename from lib/components/history/search_item.dart rename to lib/components/history/history_entry_item.dart index 2862bc4..74d23dc 100644 --- a/lib/components/history/search_item.dart +++ b/lib/components/history/history_entry_item.dart @@ -1,51 +1,64 @@ import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; -import '../../models/history/search.dart'; +import '../../models/history/history_entry.dart'; import '../../routing/routes.dart'; import '../../services/datetime.dart'; +import '../../services/snackbar.dart'; import '../../settings.dart'; +import '../common/loading.dart'; import 'kanji_box.dart'; -class SearchItem extends StatelessWidget { - final Search search; - // final Widget search; +class HistoryEntryItem extends StatelessWidget { + final HistoryEntry entry; final int objectKey; final void Function()? onDelete; final void Function()? onFavourite; - const SearchItem({ - required this.search, + const HistoryEntryItem({ + required this.entry, required this.objectKey, this.onDelete, this.onFavourite, Key? key, }) : super(key: key); - Widget get _child => (search.isKanji) - ? KanjiBox(kanji: search.kanjiQuery!.kanji) - : Text(search.wordQuery!.query); + Widget get _child => (entry.isKanji) + ? KanjiBox(kanji: entry.kanji!) + : Text(entry.word!); - void Function() _onTap(context) => search.isKanji + void Function() _onTap(context) => entry.isKanji ? () => Navigator.pushNamed( context, Routes.kanjiSearch, - arguments: search.kanjiQuery!.kanji, + arguments: entry.kanji, ) : () => Navigator.pushNamed( context, Routes.search, - arguments: search.wordQuery!.query, + arguments: entry.word, ); MaterialPageRoute get timestamps => MaterialPageRoute( builder: (context) => Scaffold( appBar: AppBar(title: const Text('Last searched')), - body: ListView( - children: [ - for (final ts in search.timestamps.reversed) - ListTile(title: Text('${formatDate(ts)} ${formatTime(ts)}')) - ], + body: FutureBuilder>( + future: entry.timestamps, + builder: (context, snapshot) { + // TODO: provide proper error handling + if (snapshot.hasError) + return ErrorWidget(snapshot.error!); + if (!snapshot.hasData) return const LoadingScreen(); + return ListView( + children: snapshot.data! + .map( + (ts) => ListTile( + title: Text('${formatDate(ts)} ${formatTime(ts)}'), + ), + ) + .toList(), + ); + }, ), ), ); @@ -60,18 +73,15 @@ class SearchItem extends StatelessWidget { backgroundColor: Colors.yellow, icon: Icons.star, onPressed: (_) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('TODO: implement favourites')), - ); + showSnackbar(context, 'TODO: implement favourites'); onFavourite?.call(); }, ), SlidableAction( backgroundColor: Colors.red, icon: Icons.delete, - onPressed: (_) { - final Database db = GetIt.instance.get(); - Search.store.record(objectKey).delete(db); + onPressed: (_) async { + await entry.delete(); onDelete?.call(); }, ), @@ -93,7 +103,7 @@ class SearchItem extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text(formatTime(search.timestamp)), + child: Text(formatTime(entry.lastTimestamp)), ), DefaultTextStyle.merge( style: japaneseFont.textStyle, diff --git a/lib/components/history/word_search_item.dart b/lib/components/history/word_search_item.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/data/archive_format.dart b/lib/data/archive_format.dart new file mode 100644 index 0000000..8eacfa5 --- /dev/null +++ b/lib/data/archive_format.dart @@ -0,0 +1,20 @@ +import 'dart:io'; + +// Example file Structure: +// jisho_data_22.01.01_1 +// - history.json +// - saved/ +// - lista.json +// - listb.json + +extension ArchiveFormat on Directory { + // TODO: make the export dir dependent on date + Directory get exportDirectory { + final dir = Directory(uri.resolve('export').path); + dir.createSync(recursive: true); + return dir; + } + + File get historyFile => File(uri.resolve('history.json').path); + Directory get savedLists => Directory(uri.resolve('savedLists').path); +} diff --git a/lib/data/database.dart b/lib/data/database.dart new file mode 100644 index 0000000..ce5076b --- /dev/null +++ b/lib/data/database.dart @@ -0,0 +1,127 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:get_it/get_it.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; + +export 'package:sqflite/sqlite_api.dart'; + +Database db() => GetIt.instance.get(); + +Future _databaseDir() async { + final Directory appDocDir = await getApplicationDocumentsDirectory(); + if (!appDocDir.existsSync()) appDocDir.createSync(recursive: true); + return appDocDir; +} + +Future databasePath() async { + return join((await _databaseDir()).path, 'jisho.sqlite'); +} + +Future migrate(Database db, int oldVersion, int newVersion) async { + final String assetManifest = + await rootBundle.loadString('AssetManifest.json'); + + final List migrations = + (jsonDecode(assetManifest) as Map) + .keys + .where( + (assetPath) => + assetPath.contains(RegExp(r'migrations\/\d{4}.*\.sql')), + ) + .toList(); + + migrations.sort(); + + for (int i = oldVersion + 1; i <= newVersion; i++) { + log( + 'Migrating database from v$i to v${i + 1} with File(${migrations[i - 1]})', + ); + final migrationContent = await rootBundle.loadString(migrations[i - 1], cache: false); + + migrationContent + .split(';') + .map( + (s) => s + .split('\n') + .where((l) => !l.startsWith(RegExp(r'\s*--'))) + .join('\n') + .trim(), + ) + .where((s) => s != '') + .forEach(db.execute); + } +} + +Future setupDatabase() async { + databaseFactory.debugSetLogLevel(sqfliteLogLevelSql); + final Database database = await openDatabase( + await databasePath(), + version: 1, + onCreate: (db, version) => migrate(db, 0, version), + onUpgrade: migrate, + onOpen: (db) => Future.wait([ + db.execute('PRAGMA foreign_keys=ON') + ]), + ); + GetIt.instance.registerSingleton(database); +} + +Future resetDatabase() async { + await db().close(); + File(await databasePath()).deleteSync(); + GetIt.instance.unregister(); + await setupDatabase(); +} + +class TableNames { + /// Attributes: + /// - id INTEGER + static const String historyEntry = 'JST_HistoryEntry'; + + /// Attributes: + /// - entryId INTEGER + /// - kanji CHAR(1) + static const String historyEntryKanji = 'JST_HistoryEntryKanji'; + + /// Attributes: + /// - entryId INTEGER + /// - timestamp INTEGER + static const String historyEntryTimestamp = 'JST_HistoryEntryTimestamp'; + + /// Attributes: + /// - entryId INTEGER + /// - searchword TEXT + /// - language CHAR(1)? + static const String historyEntryWord = 'JST_HistoryEntryWord'; + + /// Attributes: + /// - name TEXT + /// - nextList TEXT + static const String savedList = 'JST_SavedList'; + + /// Attributes: + /// - listName TEXT + /// - entryText TEXT + /// - isKanji BOOLEAN + /// - lastModified TIMESTAMP + /// - nextEntry TEXT + static const String savedListEntry = 'JST_SavedListEntry'; + + /////////// + // VIEWS // + /////////// + + /// Attributes: + /// - entryId INTEGER + /// - timestamp INTEGER + /// - searchword TEXT? + /// - kanji CHAR(1)? + /// - language CHAR(1)? + static const String historyEntryOrderedByTimestamp = + 'JST_HistoryEntry_orderedByTimestamp'; +} diff --git a/lib/data/export.dart b/lib/data/export.dart new file mode 100644 index 0000000..bca452b --- /dev/null +++ b/lib/data/export.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +import '../models/history/history_entry.dart'; +import 'database.dart'; + +Future exportDirectory() async { + final basedir = (await getExternalStorageDirectory())!; + // TODO: fix path + final dir = Directory(basedir.uri.resolve('export').path); + dir.createSync(recursive: true); + return dir; +} + +/// Returns the path to which the data was saved. +Future exportData() async { + final dir = await exportDirectory(); + final savedDir = Directory.fromUri(dir.uri.resolve('saved')); + savedDir.createSync(); + + await Future.wait([ + exportHistoryTo(dir), + exportSavedListsTo(savedDir), + ]); + return dir.path; +} + +Future exportHistoryTo(Directory dir) async { + final file = File(dir.uri.resolve('history.json').path); + file.createSync(); + final query = await db().query(TableNames.historyEntryOrderedByTimestamp); + final List entries = + query.map((e) => HistoryEntry.fromDBMap(e)).toList(); + final List> jsonEntries = + await Future.wait(entries.map((he) async => he.toJson())); + file.writeAsStringSync(jsonEncode(jsonEntries)); +} + +Future exportSavedListsTo(Directory dir) async { + // TODO: + // final query = db().query(TableNames.savedList); + print('TODO: implement exportSavedLists'); +} diff --git a/lib/data/import.dart b/lib/data/import.dart new file mode 100644 index 0000000..201cf38 --- /dev/null +++ b/lib/data/import.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../models/history/history_entry.dart'; +import 'archive_format.dart'; + +Future importData(Directory dir) async { + await Future.wait([ + importHistoryFrom(dir.historyFile), + importSavedListsFrom(dir.savedLists), + ]); +} + +Future importHistoryFrom(File file) async { + final String content = file.readAsStringSync(); + await HistoryEntry.insertJsonEntries( + (jsonDecode(content) as List) + .map((h) => h as Map) + .toList(), + ); +} + +Future importSavedListsFrom(Directory savedListsDir) async { + print('TODO: Implement importSavedLists'); +} diff --git a/lib/main.dart b/lib/main.dart index 819e432..3e2ccf3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'bloc/theme/theme_bloc.dart'; +import 'data/database.dart'; import 'routing/router.dart'; -import 'services/database.dart'; import 'services/licenses.dart'; import 'services/preferences.dart'; import 'settings.dart'; diff --git a/lib/models/history/history_entry.dart b/lib/models/history/history_entry.dart new file mode 100644 index 0000000..96cd53f --- /dev/null +++ b/lib/models/history/history_entry.dart @@ -0,0 +1,358 @@ +import 'dart:math'; + +import 'package:get_it/get_it.dart'; + +import '../../data/database.dart'; + +export 'package:get_it/get_it.dart'; + +class HistoryEntry { + int id; + final String? kanji; + final String? word; + final DateTime lastTimestamp; + + /// Whether this item is a kanji search or a word search + bool get isKanji => word == null; + + HistoryEntry.withKanji({ + required this.id, + required this.kanji, + required this.lastTimestamp, + }) : word = null; + + HistoryEntry.withWord({ + required this.id, + required this.word, + required this.lastTimestamp, + }) : kanji = null; + + /// Reconstruct a HistoryEntry object with data from the database + /// This is specifically intended for the historyEntryOrderedByTimestamp + /// view, but it can also be used with custom searches as long as it + /// contains the following attributes: + /// + /// - entryId + /// - timestamp + /// - searchword? + /// - kanji? + factory HistoryEntry.fromDBMap(Map dbObject) => + dbObject['searchword'] != null + ? HistoryEntry.withWord( + id: dbObject['entryId']! as int, + word: dbObject['searchword']! as String, + lastTimestamp: DateTime.fromMillisecondsSinceEpoch( + dbObject['timestamp']! as int, + ), + ) + : HistoryEntry.withKanji( + id: dbObject['entryId']! as int, + kanji: dbObject['kanji']! as String, + lastTimestamp: DateTime.fromMillisecondsSinceEpoch( + dbObject['timestamp']! as int, + ), + ); + + // TODO: There is a lot in common with + // insertKanji, + // insertWord, + // insertJsonEntry, + // insertJsonEntries, + // The commonalities should be factored into a helper function + + /// Insert a kanji history entry into the database. + /// If it already exists, only a timestamp will be added + static Future insertKanji({ + required String kanji, + }) => + db().transaction((txn) async { + final DateTime timestamp = DateTime.now(); + late final int id; + + final existingEntry = await txn.query( + TableNames.historyEntryKanji, + where: 'kanji = ?', + whereArgs: [kanji], + ); + + if (existingEntry.isNotEmpty) { + // Retrieve entry record id, and add a timestamp. + id = existingEntry.first['entryId']! as int; + await txn.insert(TableNames.historyEntryTimestamp, { + 'entryId': id, + 'timestamp': timestamp.millisecondsSinceEpoch, + }); + } else { + // Create new record, and add a timestamp. + id = await txn.insert( + TableNames.historyEntry, + {}, + nullColumnHack: 'id', + ); + final Batch b = txn.batch(); + + b.insert(TableNames.historyEntryTimestamp, { + 'entryId': id, + 'timestamp': timestamp.millisecondsSinceEpoch, + }); + b.insert(TableNames.historyEntryKanji, { + 'entryId': id, + 'kanji': kanji, + }); + await b.commit(); + } + + return HistoryEntry.withKanji( + id: id, + kanji: kanji, + lastTimestamp: timestamp, + ); + }); + + /// Insert a word history entry into the database. + /// If it already exists, only a timestamp will be added + static Future insertWord({ + required String word, + String? language, + }) => + db().transaction((txn) async { + final DateTime timestamp = DateTime.now(); + late final int id; + + final existingEntry = await txn.query( + TableNames.historyEntryWord, + where: 'searchword = ?', + whereArgs: [word], + ); + + if (existingEntry.isNotEmpty) { + // Retrieve entry record id, and add a timestamp. + id = existingEntry.first['entryId']! as int; + await txn.insert(TableNames.historyEntryTimestamp, { + 'entryId': id, + 'timestamp': timestamp.millisecondsSinceEpoch, + }); + } else { + id = await txn.insert( + TableNames.historyEntry, + {}, + nullColumnHack: 'id', + ); + final Batch b = txn.batch(); + + b.insert(TableNames.historyEntryTimestamp, { + 'entryId': id, + 'timestamp': timestamp.millisecondsSinceEpoch, + }); + b.insert(TableNames.historyEntryWord, { + 'entryId': id, + 'searchword': word, + 'language': { + null: null, + 'japanese': 'j', + 'english': 'e', + }[language] + }); + await b.commit(); + } + + return HistoryEntry.withWord( + id: id, + word: word, + lastTimestamp: timestamp, + ); + }); + + /// All recorded timestamps for this specific HistoryEntry + /// sorted in descending order. + Future> get timestamps async => GetIt.instance + .get() + .query( + TableNames.historyEntryTimestamp, + where: 'entryId = ?', + whereArgs: [id], + orderBy: 'timestamp DESC', + ) + .then( + (timestamps) => timestamps + .map( + (t) => DateTime.fromMillisecondsSinceEpoch( + t['timestamp']! as int, + ), + ) + .toList(), + ); + + /// Export to json for archival reasons + /// Combined with [insertJsonEntry], this makes up functionality for exporting + /// and importing data from the app. + Future> toJson() async => { + 'word': word, + 'kanji': kanji, + 'timestamps': + (await timestamps).map((ts) => ts.millisecondsSinceEpoch).toList() + }; + + /// Insert archived json entry into database if it doesn't exist there already. + /// Combined with [toJson], this makes up functionality for exporting and + /// importing data from the app. + static Future insertJsonEntry( + Map json, + ) async => + db().transaction((txn) async { + final b = txn.batch(); + final bool isKanji = json['word'] == null; + final existingEntry = isKanji + ? await txn.query( + TableNames.historyEntryKanji, + where: 'kanji = ?', + whereArgs: [json['kanji']! as String], + ) + : await txn.query( + TableNames.historyEntryWord, + where: 'searchword = ?', + whereArgs: [json['word']! as String], + ); + + late final int id; + if (existingEntry.isEmpty) { + id = await txn.insert( + TableNames.historyEntry, + {}, + nullColumnHack: 'id', + ); + if (isKanji) { + b.insert(TableNames.historyEntryKanji, { + 'entryId': id, + 'kanji': json['kanji']! as String, + }); + } else { + b.insert(TableNames.historyEntryWord, { + 'entryId': id, + 'searchword': json['word']! as String, + }); + } + } else { + id = existingEntry.first['entryId']! as int; + } + final List timestamps = + (json['timestamps']! as List).map((ts) => ts as int).toList(); + for (final timestamp in timestamps) { + b.insert( + TableNames.historyEntryTimestamp, + { + 'entryId': id, + 'timestamp': timestamp, + }, + conflictAlgorithm: ConflictAlgorithm.ignore, + ); + } + + await b.commit(); + + return isKanji + ? HistoryEntry.withKanji( + id: id, + kanji: json['kanji']! as String, + lastTimestamp: + DateTime.fromMillisecondsSinceEpoch(timestamps.reduce(max)), + ) + : HistoryEntry.withWord( + id: id, + word: json['word']! as String, + lastTimestamp: + DateTime.fromMillisecondsSinceEpoch(timestamps.reduce(max)), + ); + }); + + /// An efficient implementation of [insertJsonEntry] for multiple + /// entries. + /// + /// This assumes that there are no duplicates within the elements + /// in the json. + static Future> insertJsonEntries( + List> json, + ) => + db().transaction((txn) async { + final b = txn.batch(); + final List entries = []; + for (final jsonObject in json) { + final bool isKanji = jsonObject['word'] == null; + final existingEntry = isKanji + ? await txn.query( + TableNames.historyEntryKanji, + where: 'kanji = ?', + whereArgs: [jsonObject['kanji']! as String], + ) + : await txn.query( + TableNames.historyEntryWord, + where: 'searchword = ?', + whereArgs: [jsonObject['word']! as String], + ); + + late final int id; + if (existingEntry.isEmpty) { + id = await txn.insert( + TableNames.historyEntry, + {}, + nullColumnHack: 'id', + ); + if (isKanji) { + b.insert(TableNames.historyEntryKanji, { + 'entryId': id, + 'kanji': jsonObject['kanji']! as String, + }); + } else { + b.insert(TableNames.historyEntryWord, { + 'entryId': id, + 'searchword': jsonObject['word']! as String, + }); + } + } else { + id = existingEntry.first['entryId']! as int; + } + final List timestamps = (jsonObject['timestamps']! as List) + .map((ts) => ts as int) + .toList(); + for (final timestamp in timestamps) { + b.insert( + TableNames.historyEntryTimestamp, + { + 'entryId': id, + 'timestamp': timestamp, + }, + conflictAlgorithm: ConflictAlgorithm.ignore, + ); + } + + entries.add( + isKanji + ? HistoryEntry.withKanji( + id: id, + kanji: jsonObject['kanji']! as String, + lastTimestamp: DateTime.fromMillisecondsSinceEpoch( + timestamps.reduce(max), + ), + ) + : HistoryEntry.withWord( + id: id, + word: jsonObject['word']! as String, + lastTimestamp: DateTime.fromMillisecondsSinceEpoch( + timestamps.reduce(max), + ), + ), + ); + } + + await b.commit(); + return entries; + }); + + static Future> get fromDB async => + (await db().query(TableNames.historyEntryOrderedByTimestamp)) + .map((e) => HistoryEntry.fromDBMap(e)) + .toList(); + + Future delete() => + db().delete(TableNames.historyEntry, where: 'id = ?', whereArgs: [id]); +} diff --git a/lib/models/history/kanji_query.dart b/lib/models/history/kanji_query.dart deleted file mode 100644 index 56d2c00..0000000 --- a/lib/models/history/kanji_query.dart +++ /dev/null @@ -1,12 +0,0 @@ -class KanjiQuery { - final String kanji; - - KanjiQuery({ - required this.kanji, - }); - - Map toJson() => {'kanji': kanji}; - - factory KanjiQuery.fromJson(Map json) => - KanjiQuery(kanji: json['kanji'] as String); -} diff --git a/lib/models/history/search.dart b/lib/models/history/search.dart deleted file mode 100644 index 6f95ae5..0000000 --- a/lib/models/history/search.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:get_it/get_it.dart'; -import 'package:sembast/sembast.dart'; - -import './kanji_query.dart'; -import './word_query.dart'; - -export 'package:get_it/get_it.dart'; -export 'package:sembast/sembast.dart'; - -class Search { - final WordQuery? wordQuery; - final KanjiQuery? kanjiQuery; - final List timestamps; - - Search.fromKanjiQuery({ - required KanjiQuery this.kanjiQuery, - List? timestamps, - }) : wordQuery = null, - timestamps = timestamps ?? [DateTime.now()]; - - Search.fromWordQuery({ - required WordQuery this.wordQuery, - List? timestamps, - }) : kanjiQuery = null, - timestamps = timestamps ?? [DateTime.now()]; - - bool get isKanji => wordQuery == null; - - DateTime get timestamp => timestamps.last; - - Map toJson() => { - 'timestamps': [for (final ts in timestamps) ts.millisecondsSinceEpoch], - 'lastTimestamp': timestamps.last.millisecondsSinceEpoch, - 'wordQuery': wordQuery?.toJson(), - 'kanjiQuery': kanjiQuery?.toJson(), - }; - - factory Search.fromJson(Map json) { - final List timestamps = [ - for (final ts in json['timestamps'] as List) - DateTime.fromMillisecondsSinceEpoch(ts as int) - ]; - - return json['wordQuery'] != null - ? Search.fromWordQuery( - wordQuery: WordQuery.fromJson(json['wordQuery']), - timestamps: timestamps, - ) - : Search.fromKanjiQuery( - kanjiQuery: KanjiQuery.fromJson(json['kanjiQuery']), - timestamps: timestamps, - ); - } - - static StoreRef get store => intMapStoreFactory.store('search'); -} - -Future addSearchToDatabase({ - required String searchTerm, - required bool isKanji, -}) async { - final DateTime now = DateTime.now(); - final db = GetIt.instance.get(); - final Filter filter = Filter.equals( - isKanji ? 'kanjiQuery.kanji' : 'wordQuery.query', - searchTerm, - ); - - final RecordSnapshot? previousSearch = - await Search.store.findFirst(db, finder: Finder(filter: filter)); - - if (previousSearch != null) { - final search = - Search.fromJson(previousSearch.value! as Map); - search.timestamps.add(now); - Search.store.record(previousSearch.key).put(db, search.toJson()); - return; - } - - Search.store.add( - db, - isKanji - ? Search.fromKanjiQuery(kanjiQuery: KanjiQuery(kanji: searchTerm)) - .toJson() - : Search.fromWordQuery(wordQuery: WordQuery(query: searchTerm)) - .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/models/history/word_query.dart b/lib/models/history/word_query.dart deleted file mode 100644 index 8192da2..0000000 --- a/lib/models/history/word_query.dart +++ /dev/null @@ -1,16 +0,0 @@ - -class WordQuery { - final String query; - - // TODO: Link query with results that the user clicks onto. - // final List chosenResults; - - WordQuery({ - required this.query, - }); - - Map toJson() => {'query': query}; - - factory WordQuery.fromJson(Map json) => - WordQuery(query: json['query'] as String); -} diff --git a/lib/models/history/word_result.dart b/lib/models/history/word_result.dart deleted file mode 100644 index a7c23b2..0000000 --- a/lib/models/history/word_result.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'word_query.dart'; - -class WordResult { - final DateTime timestamp; - final String word; - final WordQuery searchString; - - WordResult({ - required this.timestamp, - required this.word, - required this.searchString, - }); -} diff --git a/lib/models/storage/common.dart b/lib/models/storage/common.dart deleted file mode 100644 index 6aeeb7c..0000000 --- a/lib/models/storage/common.dart +++ /dev/null @@ -1,22 +0,0 @@ -// import 'package:objectbox/objectbox.dart'; -// import 'package:unofficial_jisho_api/api.dart' as jisho; - -// TODO: Rewrite for sembast - -// @Entity() -// class ExampleSentencePiece { -// int id; -// String? lifted; -// String unlifted; - -// ExampleSentencePiece({ -// this.id = 0, -// required this.lifted, -// required this.unlifted, -// }); - -// ExampleSentencePiece.fromJishoObject(jisho.ExampleSentencePiece object) -// : id = 0, -// lifted = object.lifted, -// unlifted = object.unlifted; -// } diff --git a/lib/models/storage/example.dart b/lib/models/storage/example.dart deleted file mode 100644 index 58e7d2c..0000000 --- a/lib/models/storage/example.dart +++ /dev/null @@ -1,58 +0,0 @@ -// import 'package:objectbox/objectbox.dart'; -// import 'package:unofficial_jisho_api/api.dart' as jisho; - -// import 'common.dart'; - -// TODO: Rewrite for sembast - -// @Entity() -// class ExampleResultData { -// int id; -// String kanji; -// String kana; -// String english; -// List pieces; - -// ExampleResultData({ -// this.id = 0, -// required this.kanji, -// required this.kana, -// required this.english, -// required this.pieces, -// }); - -// ExampleResultData.fromJishoObject(jisho.ExampleResultData object) -// : id = 0, -// kanji = object.kanji, -// kana = object.kana, -// english = object.english, -// pieces = object.pieces -// .map((p) => ExampleSentencePiece.fromJishoObject(p)) -// .toList(); -// } - -// @Entity() -// class ExampleResults { -// int id; -// String query; -// bool found; -// String uri; -// List results; - -// ExampleResults({ -// this.id = 0, -// required this.query, -// required this.found, -// required this.uri, -// required this.results, -// }); - -// ExampleResults.fromJishoObject(jisho.ExampleResults object) -// : id = 0, -// query = object.query, -// found = object.found, -// uri = object.uri, -// results = object.results -// .map((r) => ExampleResultData.fromJishoObject(r)) -// .toList(); -// } diff --git a/lib/models/storage/kanji_result.dart b/lib/models/storage/kanji_result.dart deleted file mode 100644 index 28f324b..0000000 --- a/lib/models/storage/kanji_result.dart +++ /dev/null @@ -1,129 +0,0 @@ -// import 'package:objectbox/objectbox.dart'; -// import 'package:unofficial_jisho_api/api.dart' as jisho; - -// TODO: Rewrite for sembast - -// @Entity() -// class YomiExample { -// int id; -// String example; -// String reading; -// String meaning; - -// YomiExample({ -// this.id = 0, -// required this.example, -// required this.reading, -// required this.meaning, -// }); - -// YomiExample.fromJishoObject(jisho.YomiExample object) -// : id = 0, -// example = object.example, -// reading = object.reading, -// meaning = object.meaning; -// } - -// @Entity() -// class Radical { -// int id = 0; -// String symbol; -// List forms; -// String meaning; - -// Radical({ -// this.id = 0, -// required this.symbol, -// required this.forms, -// required this.meaning, -// }); - -// Radical.fromJishoObject(jisho.Radical object) -// : symbol = object.symbol, -// forms = object.forms, -// meaning = object.meaning; -// } - -// @Entity() -// class KanjiResult { -// int id = 0; -// String query; -// bool found; -// KanjiResultData? data; - -// KanjiResult({ -// this.id = 0, -// required this.query, -// required this.found, -// required this.data, -// }); - -// KanjiResult.fromJishoObject(jisho.KanjiResult object) -// : query = object.query, -// found = object.found, -// data = (object.data == null) -// ? null -// : KanjiResultData.fromJishoObject(object.data!); -// } - -// @Entity() -// class KanjiResultData { -// int id = 0; -// String? taughtIn; -// String? jlptLevel; -// int? newspaperFrequencyRank; -// int strokeCount; -// String meaning; -// List kunyomi; -// List onyomi; -// List kunyomiExamples; -// List onyomiExamples; -// Radical? radical; -// List parts; -// String strokeOrderDiagramUri; -// String strokeOrderSvgUri; -// String strokeOrderGifUri; -// String uri; - -// KanjiResultData({ -// this.id = 0, -// required this.taughtIn, -// required this.jlptLevel, -// required this.newspaperFrequencyRank, -// required this.strokeCount, -// required this.meaning, -// required this.kunyomi, -// required this.onyomi, -// required this.kunyomiExamples, -// required this.onyomiExamples, -// required this.radical, -// required this.parts, -// required this.strokeOrderDiagramUri, -// required this.strokeOrderSvgUri, -// required this.strokeOrderGifUri, -// required this.uri, -// }); - -// KanjiResultData.fromJishoObject(jisho.KanjiResultData object) -// : taughtIn = object.taughtIn, -// jlptLevel = object.jlptLevel, -// newspaperFrequencyRank = object.newspaperFrequencyRank, -// strokeCount = object.strokeCount, -// meaning = object.meaning, -// kunyomi = object.kunyomi, -// onyomi = object.onyomi, -// kunyomiExamples = object.kunyomiExamples -// .map((k) => YomiExample.fromJishoObject(k)) -// .toList(), -// onyomiExamples = object.onyomiExamples -// .map((o) => YomiExample.fromJishoObject(o)) -// .toList(), -// radical = (object.radical == null) -// ? null -// : Radical.fromJishoObject(object.radical!), -// parts = object.parts, -// strokeOrderDiagramUri = object.strokeOrderDiagramUri, -// strokeOrderSvgUri = object.strokeOrderSvgUri, -// strokeOrderGifUri = object.strokeOrderGifUri, -// uri = object.uri; -// } diff --git a/lib/models/storage/scrape_result.dart b/lib/models/storage/scrape_result.dart deleted file mode 100644 index c02eae7..0000000 --- a/lib/models/storage/scrape_result.dart +++ /dev/null @@ -1,155 +0,0 @@ -// import 'package:objectbox/objectbox.dart'; -// import 'package:unofficial_jisho_api/api.dart' as jisho; - -// import 'common.dart'; - -// TODO: Rewrite for sembast - -// @Entity() -// class PhraseScrapeSentence { -// int id; -// String english; -// String japanese; -// List pieces; - -// PhraseScrapeSentence({ -// this.id = 0, -// required this.english, -// required this.japanese, -// required this.pieces, -// }); - -// PhraseScrapeSentence.fromJishoObject(jisho.PhraseScrapeSentence object) -// : id = 0, -// english = object.english, -// japanese = object.japanese, -// pieces = object.pieces -// .map((p) => ExampleSentencePiece.fromJishoObject(p)) -// .toList(); -// } - -// @Entity() -// class PhraseScrapeMeaning { -// int id; -// List seeAlsoTerms; -// List sentences; -// String definition; -// List supplemental; -// String? definitionAbstract; -// List tags; - -// PhraseScrapeMeaning({ -// this.id = 0, -// required this.seeAlsoTerms, -// required this.sentences, -// required this.definition, -// required this.supplemental, -// required this.definitionAbstract, -// required this.tags, -// }); - -// PhraseScrapeMeaning.fromJishoObject(jisho.PhraseScrapeMeaning object) -// : id = 0, -// seeAlsoTerms = object.seeAlsoTerms, -// sentences = object.sentences -// .map((s) => PhraseScrapeSentence.fromJishoObject(s)) -// .toList(), -// definition = object.definition, -// supplemental = object.supplemental, -// definitionAbstract = object.definitionAbstract, -// tags = object.tags; -// } - -// @Entity() -// class KanjiKanaPair { -// int id; -// String kanji; -// String? kana; - -// KanjiKanaPair({ -// this.id = 0, -// required this.kanji, -// required this.kana, -// }); - -// KanjiKanaPair.fromJishoObject(jisho.KanjiKanaPair object) -// : id = 0, -// kanji = object.kanji, -// kana = object.kana; -// } - -// @Entity() -// class PhrasePageScrapeResult { -// int id; -// bool found; -// String query; -// PhrasePageScrapeResultData? data; - -// PhrasePageScrapeResult({ -// this.id = 0, -// required this.found, -// required this.query, -// required this.data, -// }); - -// PhrasePageScrapeResult.fromJishoObject(jisho.PhrasePageScrapeResult object) -// : id = 0, -// found = object.found, -// query = object.query, -// data = (object.data == null) -// ? null -// : PhrasePageScrapeResultData.fromJishoObject(object.data!); -// } - -// @Entity() -// class AudioFile { -// int id; -// String uri; -// String mimetype; - -// AudioFile({ -// this.id = 0, -// required this.uri, -// required this.mimetype, -// }); - -// AudioFile.fromJishoObject(jisho.AudioFile object) -// : id = 0, -// uri = object.uri, -// mimetype = object.mimetype; -// } - -// @Entity() -// class PhrasePageScrapeResultData { -// int id; -// String uri; -// List tags; -// List meanings; -// List otherForms; -// List audio; -// List notes; - -// PhrasePageScrapeResultData({ -// this.id = 0, -// required this.uri, -// required this.tags, -// required this.meanings, -// required this.otherForms, -// required this.audio, -// required this.notes, -// }); - -// PhrasePageScrapeResultData.fromJishoObject( -// jisho.PhrasePageScrapeResultData object, -// ) : id = 0, -// uri = object.uri, -// tags = object.tags, -// meanings = object.meanings -// .map((m) => PhraseScrapeMeaning.fromJishoObject(m)) -// .toList(), -// otherForms = object.otherForms -// .map((f) => KanjiKanaPair.fromJishoObject(f)) -// .toList(), -// audio = object.audio.map((a) => AudioFile.fromJishoObject(a)).toList(), -// notes = object.notes; -// } diff --git a/lib/models/storage/search_result.dart b/lib/models/storage/search_result.dart deleted file mode 100644 index 3f6738b..0000000 --- a/lib/models/storage/search_result.dart +++ /dev/null @@ -1,195 +0,0 @@ -// import 'package:objectbox/objectbox.dart'; -// import 'package:unofficial_jisho_api/api.dart' as jisho; - -// TODO: Rewrite for sembast - -// @Entity() -// class SearchResult { -// int id; -// final JishoResultMeta meta; -// final ToMany data; - -// SearchResult({ -// this.id = 0, -// required this.meta, -// required this.data, -// }); - -// SearchResult.fromJishoObject(final jisho.JishoAPIResult object) -// : id = 0, -// meta = JishoResultMeta.fromJishoObject(object.meta), -// data = ToMany() -// ..addAll( -// object.data?.map((r) => JishoResult.fromJishoObject(r)) ?? -// [], -// ); -// } - -// @Entity() -// class JishoResultMeta { -// int id; -// int status; - -// JishoResultMeta({ -// this.id = 0, -// required this.status, -// }); - -// JishoResultMeta.fromJishoObject(final jisho.JishoResultMeta object) -// : id = 0, -// status = object.status; -// } - -// @Entity() -// class JishoResult { -// int id; -// JishoAttribution attribution; -// bool? is_common; -// List japanese; -// List jlpt; -// List senses; -// String slug; -// List tags; - -// JishoResult({ -// this.id = 0, -// required this.attribution, -// required this.is_common, -// required this.japanese, -// required this.jlpt, -// required this.senses, -// required this.slug, -// required this.tags, -// }); - -// JishoResult.fromJishoObject(final jisho.JishoResult object) -// : id = 0, -// attribution = JishoAttribution.fromJishoObject(object.attribution), -// is_common = object.isCommon, -// japanese = object.japanese -// .map((j) => JishoJapaneseWord.fromJishoObject(j)) -// .toList(), -// jlpt = object.jlpt, -// senses = object.senses -// .map((s) => JishoWordSense.fromJishoObject(s)) -// .toList(), -// slug = object.slug, -// tags = object.tags; -// } - -// @Entity() -// class JishoAttribution { -// int id; -// String? dbpedia; -// bool jmdict; -// bool jmnedict; - -// JishoAttribution({ -// this.id = 0, -// required this.dbpedia, -// required this.jmdict, -// required this.jmnedict, -// }); - -// JishoAttribution.fromJishoObject(final jisho.JishoAttribution object) -// : id = 0, -// dbpedia = object.dbpedia, -// jmdict = object.jmdict, -// jmnedict = object.jmnedict; -// } - -// @Entity() -// class JishoJapaneseWord { -// int id; -// String? reading; -// String? word; - -// JishoJapaneseWord({ -// this.id = 0, -// required this.reading, -// required this.word, -// }); - -// JishoJapaneseWord.fromJishoObject(final jisho.JishoJapaneseWord object) -// : id = 0, -// reading = object.reading, -// word = object.word; -// } - -// @Entity() -// class JishoWordSense { -// int id; -// List antonyms; -// List english_definitions; -// List info; -// List links; -// List parts_of_speech; -// List restrictions; -// List see_also; -// List source; -// List tags; - -// JishoWordSense({ -// this.id = 0, -// required this.antonyms, -// required this.english_definitions, -// required this.info, -// required this.links, -// required this.parts_of_speech, -// required this.restrictions, -// required this.see_also, -// required this.source, -// required this.tags, -// }); - -// JishoWordSense.fromJishoObject(final jisho.JishoWordSense object) -// : id = 0, -// antonyms = object.antonyms, -// english_definitions = object.englishDefinitions, -// info = object.info, -// links = -// object.links.map((l) => JishoSenseLink.fromJishoObject(l)).toList(), -// parts_of_speech = object.partsOfSpeech, -// restrictions = object.restrictions, -// see_also = object.seeAlso, -// source = object.source -// .map((s) => JishoWordSource.fromJishoObject(s)) -// .toList(), -// tags = object.tags; -// } - -// @Entity() -// class JishoWordSource { -// int id; -// String language; -// String? word; - -// JishoWordSource({ -// this.id = 0, -// required this.language, -// required this.word, -// }); - -// JishoWordSource.fromJishoObject(final jisho.JishoWordSource object) -// : id = 0, -// language = object.language, -// word = object.word; -// } - -// @Entity() -// class JishoSenseLink { -// int id; -// String text; -// String url; - -// JishoSenseLink({ -// this.id = 0, -// required this.text, -// required this.url, -// }); - -// JishoSenseLink.fromJishoObject(final jisho.JishoSenseLink object) -// : id = 0, -// text = object.text, -// url = object.url; -// } diff --git a/lib/screens/history.dart b/lib/screens/history.dart index 4afbb2c..70825aa 100644 --- a/lib/screens/history.dart +++ b/lib/screens/history.dart @@ -3,37 +3,24 @@ import 'package:flutter/material.dart'; import '../components/common/loading.dart'; import '../components/common/opaque_box.dart'; import '../components/history/date_divider.dart'; -import '../components/history/search_item.dart'; -import '../models/history/search.dart'; +import '../components/history/history_entry_item.dart'; +import '../models/history/history_entry.dart'; import '../services/datetime.dart'; class HistoryView extends StatelessWidget { const HistoryView({Key? key}) : super(key: key); - Stream> get searchStream => Search.store - .query(finder: Finder(sortOrders: [SortOrder('lastTimestamp', false)])) - .onSnapshots(_db) - .map( - (snapshot) => Map.fromEntries( - snapshot.where((snap) => snap.value != null).map( - (snap) => MapEntry( - snap.key, - Search.fromJson(snap.value! as Map), - ), - ), - ), - ); - - Database get _db => GetIt.instance.get(); - @override Widget build(BuildContext context) { - return StreamBuilder>( - stream: searchStream, + // TODO: Use infinite scroll pagination + return FutureBuilder>( + future: HistoryEntry.fromDB, builder: (context, snapshot) { + // TODO: provide proper error handling + if (snapshot.hasError) return ErrorWidget(snapshot.error!); if (!snapshot.hasData) return const LoadingScreen(); - final Map data = snapshot.data!; + final Map data = snapshot.data!.asMap(); if (data.isEmpty) return const Center( child: Text('The history is empty.\nTry searching for something!'), @@ -52,25 +39,25 @@ class HistoryView extends StatelessWidget { } Widget Function(BuildContext, int) historyEntrySeparatorWithData( - List data, + List data, ) => (context, index) { - final Search search = data[index]; - final DateTime searchDate = search.timestamp; + final HistoryEntry search = data[index]; + final DateTime searchDate = search.lastTimestamp; - if (index == 0 || !dateIsEqual(data[index - 1].timestamp, searchDate)) + if (index == 0 || !dateIsEqual(data[index - 1].lastTimestamp, searchDate)) return TextDivider(text: formatDate(roundToDay(searchDate))); return const Divider(height: 0); }; Widget Function(BuildContext, int) historyEntryWithData( - Map data, + Map data, ) => (context, index) => (index == 0) ? const SizedBox.shrink() - : SearchItem( - search: data.values.toList()[index - 1], + : HistoryEntryItem( + entry: data.values.toList()[index - 1], objectKey: data.keys.toList()[index - 1], onDelete: () => build(context), ); diff --git a/lib/screens/search/result_page.dart b/lib/screens/search/result_page.dart index f9cbc3c..3bb8939 100644 --- a/lib/screens/search/result_page.dart +++ b/lib/screens/search/result_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import '../../components/common/loading.dart'; import '../../components/kanji/kanji_result_body.dart'; import '../../components/search/search_result_body.dart'; -import '../../models/history/search.dart'; +import '../../models/history/history_entry.dart'; import '../../services/jisho_api/jisho_search.dart'; import '../../services/jisho_api/kanji_search.dart'; @@ -33,14 +33,16 @@ class _ResultPageState extends State { ? fetchKanji(widget.searchTerm) : fetchJishoResults(widget.searchTerm), builder: (context, snapshot) { - if (!snapshot.hasData) return const LoadingScreen(); + // TODO: provide proper error handling if (snapshot.hasError) return ErrorWidget(snapshot.error!); + if (!snapshot.hasData) return const LoadingScreen(); if (!addedToDatabase) { - addSearchToDatabase( - searchTerm: widget.searchTerm, - isKanji: widget.isKanji, - ); + if (widget.isKanji) { + HistoryEntry.insertKanji(kanji: widget.searchTerm); + } else { + HistoryEntry.insertWord(word: widget.searchTerm); + } addedToDatabase = true; } diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index edae4f4..cdfb1bf 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:io'; import 'package:confirm_dialog/confirm_dialog.dart'; @@ -6,15 +5,13 @@ 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 '../data/database.dart'; +import '../data/export.dart'; +import '../data/import.dart'; import '../routing/routes.dart'; -import '../services/database.dart'; import '../services/open_webpage.dart'; import '../services/snackbar.dart'; import '../settings.dart'; @@ -27,22 +24,39 @@ 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); + final historyCount = (await db().query( + TableNames.historyEntry, + columns: ['COUNT(*) AS count'], + ))[0]['count']! as int; - if (userIsSure) { - await Search.store.delete(db); - } + final bool userIsSure = await confirm( + context, + content: Text( + 'Are you sure that you want to delete $historyCount entries?', + ), + ); + if (!userIsSure) return; + + await db().delete(TableNames.historyEntry); + showSnackbar(context, 'Cleared history'); + } + + Future clearAll(context) async { + final bool userIsSure = await confirm(context); + if (!userIsSure) return; + + await resetDatabase(); + showSnackbar(context, 'Cleared everything'); } // ignore: avoid_positional_boolean_parameters void toggleAutoTheme(bool b) { final bool newThemeIsDark = b - ? WidgetsBinding.instance!.window.platformBrightness == Brightness.dark + ? WidgetsBinding.instance.window.platformBrightness == Brightness.dark : darkThemeEnabled; BlocProvider.of(context) @@ -63,75 +77,19 @@ class _SettingsViewState extends State { } /// Can assume Android for time being - Future exportData(context) async { + Future exportHandler(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)); - + final path = await exportData(); setState(() => dataExportIsLoading = false); - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('Data exported to ${file.path}'))); + showSnackbar(context, 'Data exported to $path'); } /// Can assume Android for time being - Future importData(context) async { + Future importHandler(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); + final path = await FilePicker.platform.getDirectoryPath(); + await importData(Directory(path!)); setState(() => dataImportIsLoading = false); showSnackbar(context, 'Data imported successfully'); @@ -288,7 +246,7 @@ class _SettingsViewState extends State { SettingsTile( leading: const Icon(Icons.file_upload), title: 'Import Data', - onPressed: importData, + onPressed: importHandler, enabled: Platform.isAndroid, subtitle: Platform.isAndroid ? null : 'Not available on iOS yet', @@ -299,6 +257,7 @@ class _SettingsViewState extends State { SettingsTile( leading: const Icon(Icons.file_download), title: 'Export Data', + onPressed: exportHandler, enabled: Platform.isAndroid, subtitle: Platform.isAndroid ? null : 'Not available on iOS yet', @@ -318,7 +277,13 @@ class _SettingsViewState extends State { onPressed: (c) {}, titleTextStyle: const TextStyle(color: Colors.red), enabled: false, - ) + ), + SettingsTile( + leading: const Icon(Icons.delete), + title: 'Clear Everything', + onPressed: clearAll, + titleTextStyle: const TextStyle(color: Colors.red), + ), ], ), diff --git a/lib/services/database.dart b/lib/services/database.dart deleted file mode 100644 index 3e093be..0000000 --- a/lib/services/database.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'dart:io'; - -import 'package:get_it/get_it.dart'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:sembast/sembast.dart'; -import 'package:sembast/sembast_io.dart'; - -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(await databasePath()); - GetIt.instance.registerSingleton(database); -} diff --git a/pubspec.lock b/pubspec.lock index d2cf37b..43e2a86 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "31.0.0" + version: "40.0.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "2.8.0" + version: "4.1.0" animated_size_and_fade: dependency: "direct main" description: @@ -35,7 +35,7 @@ packages: name: args url: "https://pub.dartlang.org" source: hosted - version: "2.3.0" + version: "2.3.1" async: dependency: transitive description: @@ -49,7 +49,7 @@ packages: name: audio_session url: "https://pub.dartlang.org" source: hosted - version: "0.1.6+1" + version: "0.1.7" bloc: dependency: transitive description: @@ -91,14 +91,14 @@ packages: name: build_resolvers url: "https://pub.dartlang.org" source: hosted - version: "2.0.6" + version: "2.0.9" build_runner: dependency: "direct dev" description: name: build_runner url: "https://pub.dartlang.org" source: hosted - version: "2.1.10" + version: "2.1.11" build_runner_core: dependency: transitive description: @@ -119,7 +119,7 @@ packages: name: built_value url: "https://pub.dartlang.org" source: hosted - version: "8.2.3" + version: "8.3.2" characters: dependency: transitive description: @@ -141,13 +141,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" - cli_util: - dependency: transitive - description: - name: cli_util - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.5" clock: dependency: transitive description: @@ -168,7 +161,7 @@ packages: name: collection url: "https://pub.dartlang.org" source: hosted - version: "1.15.0" + version: "1.16.0" confirm_dialog: dependency: "direct main" description: @@ -182,14 +175,14 @@ packages: name: convert url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.0.2" coverage: dependency: transitive description: name: coverage url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.3.2" crypto: dependency: transitive description: @@ -203,14 +196,14 @@ packages: name: csslib url: "https://pub.dartlang.org" source: hosted - version: "0.17.1" + version: "0.17.2" dart_style: dependency: transitive description: name: dart_style url: "https://pub.dartlang.org" source: hosted - version: "2.2.1" + version: "2.2.3" division: dependency: "direct main" description: @@ -218,20 +211,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.9.0" + equatable: + dependency: transitive + description: + name: equatable + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" fake_async: dependency: transitive description: name: fake_async url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.3.0" ffi: dependency: transitive description: name: ffi url: "https://pub.dartlang.org" source: hosted - version: "1.1.2" + version: "1.2.1" file: dependency: transitive description: @@ -245,14 +245,14 @@ packages: name: file_picker url: "https://pub.dartlang.org" source: hosted - version: "4.5.1" + version: "4.6.1" fixnum: dependency: transitive description: name: fixnum url: "https://pub.dartlang.org" source: hosted - version: "1.0.0" + version: "1.0.1" flutter: dependency: "direct main" description: flutter @@ -278,14 +278,14 @@ packages: name: flutter_native_splash url: "https://pub.dartlang.org" source: hosted - version: "2.1.6" + version: "2.2.2" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle url: "https://pub.dartlang.org" source: hosted - version: "2.0.5" + version: "2.0.6" flutter_settings_ui: dependency: "direct main" description: @@ -299,14 +299,14 @@ packages: name: flutter_slidable url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.3.0" flutter_svg: dependency: "direct main" description: name: flutter_svg url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -323,7 +323,7 @@ packages: name: frontend_server_client url: "https://pub.dartlang.org" source: hosted - version: "2.1.2" + version: "2.1.3" get_it: dependency: "direct main" description: @@ -379,14 +379,14 @@ packages: name: http_parser url: "https://pub.dartlang.org" source: hosted - version: "4.0.0" + version: "4.0.1" image: dependency: transitive description: name: image url: "https://pub.dartlang.org" source: hosted - version: "3.1.3" + version: "3.2.0" io: dependency: transitive description: @@ -400,7 +400,7 @@ packages: name: js url: "https://pub.dartlang.org" source: hosted - version: "0.6.3" + version: "0.6.4" json_annotation: dependency: transitive description: @@ -414,7 +414,7 @@ packages: name: just_audio url: "https://pub.dartlang.org" source: hosted - version: "0.9.21" + version: "0.9.24" just_audio_platform_interface: dependency: transitive description: @@ -456,7 +456,7 @@ packages: name: material_color_utilities url: "https://pub.dartlang.org" source: hosted - version: "0.1.3" + version: "0.1.4" mdi: dependency: "direct main" description: @@ -505,7 +505,7 @@ packages: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.8.0" + version: "1.8.1" path_drawing: dependency: transitive description: @@ -526,56 +526,56 @@ packages: name: path_provider url: "https://pub.dartlang.org" source: hosted - version: "2.0.9" + version: "2.0.10" path_provider_android: dependency: transitive description: name: path_provider_android url: "https://pub.dartlang.org" source: hosted - version: "2.0.13" + version: "2.0.14" path_provider_ios: dependency: transitive description: name: path_provider_ios url: "https://pub.dartlang.org" source: hosted - version: "2.0.8" + version: "2.0.9" path_provider_linux: dependency: transitive description: name: path_provider_linux url: "https://pub.dartlang.org" source: hosted - version: "2.1.5" + version: "2.1.7" path_provider_macos: dependency: transitive description: name: path_provider_macos url: "https://pub.dartlang.org" source: hosted - version: "2.0.5" + version: "2.0.6" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.3" + version: "2.0.4" path_provider_windows: dependency: transitive description: name: path_provider_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.5" + version: "2.0.7" petitparser: dependency: transitive description: name: petitparser url: "https://pub.dartlang.org" source: hosted - version: "4.4.0" + version: "5.0.0" platform: dependency: transitive description: @@ -610,7 +610,7 @@ packages: name: provider url: "https://pub.dartlang.org" source: hosted - version: "6.0.2" + version: "6.0.3" pub_semver: dependency: transitive description: @@ -631,21 +631,14 @@ packages: name: rxdart url: "https://pub.dartlang.org" source: hosted - version: "0.27.3" - sembast: - dependency: "direct main" - description: - name: sembast - url: "https://pub.dartlang.org" - source: hosted - version: "3.2.0" + version: "0.27.4" share_plus: dependency: "direct main" description: name: share_plus url: "https://pub.dartlang.org" source: hosted - version: "4.0.4" + version: "4.0.5" share_plus_linux: dependency: transitive description: @@ -659,63 +652,63 @@ packages: name: share_plus_macos url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "3.0.1" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "3.0.2" + version: "3.0.3" share_plus_web: dependency: transitive description: name: share_plus_web url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "3.0.1" share_plus_windows: dependency: transitive description: name: share_plus_windows url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "3.0.1" shared_preferences: dependency: "direct main" description: name: shared_preferences url: "https://pub.dartlang.org" source: hosted - version: "2.0.13" + version: "2.0.15" shared_preferences_android: dependency: transitive description: name: shared_preferences_android url: "https://pub.dartlang.org" source: hosted - version: "2.0.11" + version: "2.0.12" shared_preferences_ios: dependency: transitive description: name: shared_preferences_ios url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" shared_preferences_macos: dependency: transitive description: name: shared_preferences_macos url: "https://pub.dartlang.org" source: hosted - version: "2.0.3" + version: "2.0.4" shared_preferences_platform_interface: dependency: transitive description: @@ -729,14 +722,14 @@ packages: name: shared_preferences_web url: "https://pub.dartlang.org" source: hosted - version: "2.0.3" + version: "2.0.4" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" shelf: dependency: transitive description: @@ -771,7 +764,7 @@ packages: name: signature url: "https://pub.dartlang.org" source: hosted - version: "5.0.0" + version: "5.0.1" sky_engine: dependency: transitive description: flutter @@ -797,7 +790,35 @@ packages: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.8.1" + version: "1.8.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2+1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1+1" + sqflite_common_ffi: + dependency: "direct main" + description: + name: sqflite_common_ffi + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + sqlite3: + dependency: transitive + description: + name: sqlite3 + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.1" stack_trace: dependency: transitive description: @@ -846,21 +867,21 @@ packages: name: test url: "https://pub.dartlang.org" source: hosted - version: "1.19.5" + version: "1.21.1" test_api: dependency: transitive description: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.4.8" + version: "0.4.9" test_core: dependency: transitive description: name: test_core url: "https://pub.dartlang.org" source: hosted - version: "0.4.9" + version: "0.4.13" timing: dependency: transitive description: @@ -874,7 +895,7 @@ packages: name: typed_data url: "https://pub.dartlang.org" source: hosted - version: "1.3.0" + version: "1.3.1" universal_io: dependency: transitive description: @@ -888,42 +909,42 @@ packages: name: unofficial_jisho_api url: "https://pub.dartlang.org" source: hosted - version: "2.0.4" + version: "3.0.0" url_launcher: dependency: "direct main" description: name: url_launcher url: "https://pub.dartlang.org" source: hosted - version: "6.1.0" + version: "6.1.2" url_launcher_android: dependency: transitive description: name: url_launcher_android url: "https://pub.dartlang.org" source: hosted - version: "6.0.16" + version: "6.0.17" url_launcher_ios: dependency: transitive description: name: url_launcher_ios url: "https://pub.dartlang.org" source: hosted - version: "6.0.15" + version: "6.0.17" url_launcher_linux: dependency: transitive description: name: url_launcher_linux url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "3.0.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "3.0.1" url_launcher_platform_interface: dependency: transitive description: @@ -937,14 +958,14 @@ packages: name: url_launcher_web url: "https://pub.dartlang.org" source: hosted - version: "2.0.9" + version: "2.0.11" url_launcher_windows: dependency: transitive description: name: url_launcher_windows url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "3.0.1" uuid: dependency: transitive description: @@ -958,14 +979,14 @@ packages: name: vector_math url: "https://pub.dartlang.org" source: hosted - version: "2.1.1" + version: "2.1.2" vm_service: dependency: transitive description: name: vm_service url: "https://pub.dartlang.org" source: hosted - version: "7.5.0" + version: "8.3.0" watcher: dependency: transitive description: @@ -986,14 +1007,14 @@ packages: name: webkit_inspection_protocol url: "https://pub.dartlang.org" source: hosted - version: "1.0.0" + version: "1.1.0" win32: dependency: transitive description: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "2.5.2" + version: "2.6.1" xdg_directories: dependency: transitive description: @@ -1007,14 +1028,14 @@ packages: name: xml url: "https://pub.dartlang.org" source: hosted - version: "5.3.1" + version: "6.1.0" yaml: dependency: transitive description: name: yaml url: "https://pub.dartlang.org" source: hosted - version: "3.1.0" + version: "3.1.1" sdks: - dart: ">=2.16.0 <3.0.0" - flutter: ">=2.10.0" + dart: ">=2.17.0 <3.0.0" + flutter: ">=2.11.0-0.1.pre" diff --git a/pubspec.yaml b/pubspec.yaml index 1ce7037..d302ac0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,11 +23,12 @@ dependencies: mdi: ^5.0.0-nullsafety.0 path: ^1.8.0 path_provider: ^2.0.2 - sembast: ^3.1.1 share_plus: ^4.0.4 - test: ^1.19.5 shared_preferences: ^2.0.6 signature: ^5.0.0 + sqflite: ^2.0.2 + sqflite_common_ffi: ^2.1.1 + test: ^1.19.5 unofficial_jisho_api: ^3.0.0 url_launcher: ^6.0.9 @@ -53,6 +54,7 @@ flutter: - assets/images/components/ - assets/images/links/ - assets/images/logo/ + - assets/migrations/ - assets/licenses/ diff --git a/test/models/history_test.dart b/test/models/history_test.dart index 953c51c..7818a47 100644 --- a/test/models/history_test.dart +++ b/test/models/history_test.dart @@ -1,34 +1,6 @@ -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:jisho_study_tool/models/history/history_entry.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); - }); - }); + group('Search', () {}); }