Migrate history to SQLite + more

- remove all sembast code
- setup database migration system
- setup data import export system
- remove sembast object tests
- make everything ready for implementing "saved lists" feature
migrate-to-sqlite
Oystein Kristoffer Tveit 2022-06-05 02:41:11 +02:00
parent cad62f2b8b
commit d2a3de4823
27 changed files with 858 additions and 993 deletions

View File

@ -1,5 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="app.jishostudytool.jisho_study_tool">
<application
android:requestLegacyExternalStorage="true"
>
</application>
<!-- Flutter needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->

View File

@ -1,9 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="app.jishostudytool.jisho_study_tool">
<application
<application
android:label="Jisho Study Tool"
android:name="${applicationName}"
android:icon="@mipmap/launcher_icon">
android:icon="@mipmap/launcher_icon"
android:requestLegacyExternalStorage="true"
>
<activity
android:name=".MainActivity"
android:exported="true"

View File

@ -0,0 +1,64 @@
CREATE TABLE "JST_SavedList" (
"name" TEXT PRIMARY KEY NOT NULL,
"nextList" TEXT REFERENCES "JST_SavedList"("name")
);
CREATE INDEX "JST_SavedList_byNextList" ON "JST_SavedList"("nextList");
CREATE TABLE "JST_SavedListEntry" (
"listName" TEXT NOT NULL REFERENCES "JST_SavedList"("name") ON DELETE CASCADE,
"entryText" TEXT NOT NULL,
"isKanji" BOOLEAN NOT NULL DEFAULT 0,
"lastModified" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"nextEntry" TEXT NOT NULL,
PRIMARY KEY ("listName", "entryText", "isKanji"),
FOREIGN KEY ("listName", "nextEntry") REFERENCES "JST_SavedListEntry"("listName", "entryText"),
CHECK ((NOT "isKanji") OR ("nextEntry" <> 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;

View File

@ -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<List<DateTime>>(
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<Database>();
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,

View File

@ -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);
}

127
lib/data/database.dart Normal file
View File

@ -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<Database>();
Future<Directory> _databaseDir() async {
final Directory appDocDir = await getApplicationDocumentsDirectory();
if (!appDocDir.existsSync()) appDocDir.createSync(recursive: true);
return appDocDir;
}
Future<String> databasePath() async {
return join((await _databaseDir()).path, 'jisho.sqlite');
}
Future<void> migrate(Database db, int oldVersion, int newVersion) async {
final String assetManifest =
await rootBundle.loadString('AssetManifest.json');
final List<String> migrations =
(jsonDecode(assetManifest) as Map<String, Object?>)
.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<void> 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>(database);
}
Future<void> resetDatabase() async {
await db().close();
File(await databasePath()).deleteSync();
GetIt.instance.unregister<Database>();
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';
}

45
lib/data/export.dart Normal file
View File

@ -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<Directory> 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<String> 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<void> exportHistoryTo(Directory dir) async {
final file = File(dir.uri.resolve('history.json').path);
file.createSync();
final query = await db().query(TableNames.historyEntryOrderedByTimestamp);
final List<HistoryEntry> entries =
query.map((e) => HistoryEntry.fromDBMap(e)).toList();
final List<Map<String, Object?>> jsonEntries =
await Future.wait(entries.map((he) async => he.toJson()));
file.writeAsStringSync(jsonEncode(jsonEntries));
}
Future<void> exportSavedListsTo(Directory dir) async {
// TODO:
// final query = db().query(TableNames.savedList);
print('TODO: implement exportSavedLists');
}

25
lib/data/import.dart Normal file
View File

@ -0,0 +1,25 @@
import 'dart:convert';
import 'dart:io';
import '../models/history/history_entry.dart';
import 'archive_format.dart';
Future<void> importData(Directory dir) async {
await Future.wait([
importHistoryFrom(dir.historyFile),
importSavedListsFrom(dir.savedLists),
]);
}
Future<void> importHistoryFrom(File file) async {
final String content = file.readAsStringSync();
await HistoryEntry.insertJsonEntries(
(jsonDecode(content) as List)
.map((h) => h as Map<String, Object?>)
.toList(),
);
}
Future<void> importSavedListsFrom(Directory savedListsDir) async {
print('TODO: Implement importSavedLists');
}

View File

@ -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';

View File

@ -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<String, Object?> 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<HistoryEntry> 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<HistoryEntry> 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<List<DateTime>> get timestamps async => GetIt.instance
.get<Database>()
.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<Map<String, Object?>> 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<HistoryEntry> insertJsonEntry(
Map<String, Object?> 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<int> 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<List<HistoryEntry>> insertJsonEntries(
List<Map<String, Object?>> json,
) =>
db().transaction((txn) async {
final b = txn.batch();
final List<HistoryEntry> 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<int> 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<List<HistoryEntry>> get fromDB async =>
(await db().query(TableNames.historyEntryOrderedByTimestamp))
.map((e) => HistoryEntry.fromDBMap(e))
.toList();
Future<void> delete() =>
db().delete(TableNames.historyEntry, where: 'id = ?', whereArgs: [id]);
}

View File

@ -1,12 +0,0 @@
class KanjiQuery {
final String kanji;
KanjiQuery({
required this.kanji,
});
Map<String, Object?> toJson() => {'kanji': kanji};
factory KanjiQuery.fromJson(Map<String, dynamic> json) =>
KanjiQuery(kanji: json['kanji'] as String);
}

View File

@ -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<DateTime> timestamps;
Search.fromKanjiQuery({
required KanjiQuery this.kanjiQuery,
List<DateTime>? timestamps,
}) : wordQuery = null,
timestamps = timestamps ?? [DateTime.now()];
Search.fromWordQuery({
required WordQuery this.wordQuery,
List<DateTime>? timestamps,
}) : kanjiQuery = null,
timestamps = timestamps ?? [DateTime.now()];
bool get isKanji => wordQuery == null;
DateTime get timestamp => timestamps.last;
Map<String, Object?> toJson() => {
'timestamps': [for (final ts in timestamps) ts.millisecondsSinceEpoch],
'lastTimestamp': timestamps.last.millisecondsSinceEpoch,
'wordQuery': wordQuery?.toJson(),
'kanjiQuery': kanjiQuery?.toJson(),
};
factory Search.fromJson(Map<String, dynamic> json) {
final List<DateTime> timestamps = [
for (final ts in json['timestamps'] as List<dynamic>)
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<int, Object?> get store => intMapStoreFactory.store('search');
}
Future<void> addSearchToDatabase({
required String searchTerm,
required bool isKanji,
}) async {
final DateTime now = DateTime.now();
final db = GetIt.instance.get<Database>();
final Filter filter = Filter.equals(
isKanji ? 'kanjiQuery.kanji' : 'wordQuery.query',
searchTerm,
);
final RecordSnapshot<int, Object?>? previousSearch =
await Search.store.findFirst(db, finder: Finder(filter: filter));
if (previousSearch != null) {
final search =
Search.fromJson(previousSearch.value! as Map<String, Object?>);
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<Search> mergeSearches(List<Search> a, List<Search> b) {
final List<Search> result = [...a];
for (final Search search in b) {
late final Iterable<Search> 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;
}

View File

@ -1,16 +0,0 @@
class WordQuery {
final String query;
// TODO: Link query with results that the user clicks onto.
// final List<WordResult> chosenResults;
WordQuery({
required this.query,
});
Map<String, Object?> toJson() => {'query': query};
factory WordQuery.fromJson(Map<String, dynamic> json) =>
WordQuery(query: json['query'] as String);
}

View File

@ -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,
});
}

View File

@ -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;
// }

View File

@ -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<ExampleSentencePiece> 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<ExampleResultData> 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();
// }

View File

@ -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<String> 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<String> kunyomi;
// List<String> onyomi;
// List<YomiExample> kunyomiExamples;
// List<YomiExample> onyomiExamples;
// Radical? radical;
// List<String> 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;
// }

View File

@ -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<ExampleSentencePiece> 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<String> seeAlsoTerms;
// List<PhraseScrapeSentence> sentences;
// String definition;
// List<String> supplemental;
// String? definitionAbstract;
// List<String> 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<String> tags;
// List<PhraseScrapeMeaning> meanings;
// List<KanjiKanaPair> otherForms;
// List<AudioFile> audio;
// List<String> 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;
// }

View File

@ -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<JishoResult> 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<JishoResult>()
// ..addAll(
// object.data?.map((r) => JishoResult.fromJishoObject(r)) ??
// <JishoResult>[],
// );
// }
// @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<JishoJapaneseWord> japanese;
// List<String> jlpt;
// List<JishoWordSense> senses;
// String slug;
// List<String> 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<String> antonyms;
// List<String> english_definitions;
// List<String> info;
// List<JishoSenseLink> links;
// List<String> parts_of_speech;
// List<String> restrictions;
// List<String> see_also;
// List<JishoWordSource> source;
// List<String> 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;
// }

View File

@ -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<Map<int, Search>> 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<String, Object?>),
),
),
),
);
Database get _db => GetIt.instance.get<Database>();
@override
Widget build(BuildContext context) {
return StreamBuilder<Map<int, Search>>(
stream: searchStream,
// TODO: Use infinite scroll pagination
return FutureBuilder<List<HistoryEntry>>(
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<int, Search> data = snapshot.data!;
final Map<int, HistoryEntry> 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<Search> data,
List<HistoryEntry> 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<int, Search> data,
Map<int, HistoryEntry> 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),
);

View File

@ -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<ResultPage> {
? 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;
}

View File

@ -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<SettingsView> {
final Database db = GetIt.instance.get<Database>();
bool dataExportIsLoading = false;
bool dataImportIsLoading = false;
Future<void> 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<void> 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<ThemeBloc>(context)
@ -63,75 +77,19 @@ class _SettingsViewState extends State<SettingsView> {
}
/// Can assume Android for time being
Future<void> exportData(context) async {
Future<void> 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<void> importData(context) async {
Future<void> 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<Search> prevSearches = (await Search.store.find(db))
.map((e) => Search.fromJson(e.value! as Map<String, Object?>))
.toList();
late final List<Search> importedSearches;
try {
importedSearches = ((((jsonDecode(await file.readAsString())
as Map<String, Object?>)['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<Search> mergedSearches =
mergeSearches(prevSearches, importedSearches);
// print(mergedSearches);
await GetIt.instance.get<Database>().close();
GetIt.instance.unregister<Database>();
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<Database>(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<SettingsView> {
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<SettingsView> {
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<SettingsView> {
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),
),
],
),

View File

@ -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<String> databasePath() async {
final Directory appDocDir = await getApplicationDocumentsDirectory();
if (!appDocDir.existsSync()) appDocDir.createSync(recursive: true);
return join(appDocDir.path, 'sembast.db');
}
Future<void> setupDatabase() async {
final Database database =
await databaseFactoryIo.openDatabase(await databasePath());
GetIt.instance.registerSingleton<Database>(database);
}

View File

@ -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"

View File

@ -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/

View File

@ -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<Search> 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<Search> merged1 = mergeSearches(searches, []);
final List<Search> 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<Search> merged = mergeSearches(searches, searches);
for (int i = 0; i < searches.length; i++) {
expect(merged[i], searches[i]);
}
expect(mergeSearches(searches, searches), searches);
});
});
group('Search', () {});
}