treewide: implement most of library logic

This commit is contained in:
2025-05-31 17:05:34 +02:00
parent b404267728
commit 3f55ae1413
20 changed files with 1475 additions and 82 deletions

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@@ -40,6 +40,8 @@
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${androidSdk}/libexec/android-sdk/build-tools/34.0.0/aapt2";
FLUTTER_SDK = "${flutter'}";
JAVA_HOME = "${jdk'}/lib/openjdk";
LIBSQLITE_PATH = "${pkgs.sqlite.out}/lib/libsqlite3.so";
JADB_PATH = "./assets/jadb.sqlite";
};
};
});

View File

@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:mugiten/models/history/history_entry.dart';
import 'package:ruby_text/ruby_text.dart';
import 'package:sqflite/sqflite.dart';
import 'package:jadb/search.dart';
import '../../models/library/library_list.dart';
import '../common/kanji_box.dart';
import '../common/loading.dart';
Future<void> showAddToLibraryDialog({
required BuildContext context,
required int? jmdictEntryId,
required String? kanji,
}) =>
showDialog(
context: context,
barrierDismissible: true,
builder: (_) => AddToLibraryDialog(
jmdictEntryId: jmdictEntryId,
kanji: kanji,
),
);
class AddToLibraryDialog extends StatefulWidget {
final int? jmdictEntryId;
final String? kanji;
const AddToLibraryDialog({
super.key,
required this.jmdictEntryId,
required this.kanji,
});
@override
State<AddToLibraryDialog> createState() => _AddToLibraryDialogState();
}
class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
Map<LibraryList, bool>? librariesContainEntry;
/// A lock to make sure that the local data and the database doesn't
/// get out of sync.
bool toggleLock = false;
@override
void initState() {
super.initState();
// TODO:
LibraryList.allListsContains(
jmdictEntryId: widget.jmdictEntryId,
kanji: widget.kanji,
).then((data) => setState(() => librariesContainEntry = data));
}
Future<void> toggleEntry({required LibraryList lib}) async {
if (toggleLock) return;
setState(() => toggleLock = true);
// TODO:
await lib.toggleEntry(
jmdictEntryId: widget.jmdictEntryId,
kanji: widget.kanji,
);
setState(() {
toggleLock = false;
librariesContainEntry![lib] = !librariesContainEntry![lib]!;
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add to library'),
contentPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 12),
content: Column(
children: [
ListTile(
title: Center(
child: widget.kanji != null
? Row(
children: [
const Expanded(child: SizedBox()),
KanjiBox.headline4(
context: context,
kanji: widget.kanji!,
),
const Expanded(child: SizedBox()),
],
)
: FutureBuilder(
future: GetIt.instance
.get<Database>()
.jadbGetWordById(widget.jmdictEntryId!),
builder: (context, snapshot) {
if (snapshot.hasError)
return ErrorWidget(snapshot.error!);
if (!snapshot.hasData) {
return const LoadingScreen();
}
final entry = snapshot.data!;
final japaneseWord = entry.japanese.firstOrNull;
assert(
japaneseWord != null,
'Japanese word should not be null',
);
if (japaneseWord == null) {
return const Text('???');
}
return RubySpanWidget(
RubyTextData(
japaneseWord.base,
ruby: japaneseWord.furigana,
),
);
},
),
),
),
const Divider(thickness: 3),
Expanded(
child: SizedBox(
width: double.maxFinite,
child: librariesContainEntry == null
? const LoadingScreen()
: ListView(
children: librariesContainEntry!.entries.map((e) {
final lib = e.key;
final checked = e.value;
return ListTile(
onTap: () => toggleEntry(lib: lib),
contentPadding:
const EdgeInsets.symmetric(vertical: 5),
title: Row(
children: [
Checkbox(
value: checked,
onChanged: (_) => toggleEntry(lib: lib),
),
Text(lib.name),
],
),
);
}).toList(),
),
),
),
],
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
);
}
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import '../../models/library/library_entry.dart';
import '../../models/library/library_list.dart';
import '../../routing/routes.dart';
import '../../settings.dart';
import '../common/kanji_box.dart';
class LibraryListEntryTile extends StatelessWidget {
final int? index;
final LibraryList library;
final LibraryEntry entry;
final void Function()? onDelete;
final void Function()? onUpdate;
const LibraryListEntryTile({
super.key,
required this.entry,
required this.library,
this.index,
this.onDelete,
this.onUpdate,
});
@override
Widget build(BuildContext context) {
return Slidable(
endActionPane: ActionPane(
motion: const ScrollMotion(),
children: [
SlidableAction(
backgroundColor: Colors.red,
icon: Icons.delete,
onPressed: (_) async {
await library.deleteEntry(
jmdictEntryId: entry.jmdictEntryId,
kanji: entry.kanji,
);
onDelete?.call();
},
),
],
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 10),
onTap: () async {
await Navigator.pushNamed(
context,
entry.kanji != null ? Routes.kanjiSearch : Routes.search,
arguments: entry.kanji ?? entry.jmdictEntryId,
);
onUpdate?.call();
},
title: Row(
children: [
if (index != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
(index! + 1).toString(),
style: Theme.of(context)
.textTheme
.titleMedium!
.merge(japaneseFont.textStyle),
),
),
entry.kanji != null
? KanjiBox.headline4(context: context, kanji: entry.kanji!)
: Expanded(child: Text(entry.jmdictEntryId.toString())),
],
),
),
);
}
}

View File

@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import '../../models/library/library_list.dart';
import '../../routing/routes.dart';
import '../common/loading.dart';
class LibraryListTile extends StatelessWidget {
final Widget? leading;
final LibraryList library;
final void Function()? onDelete;
final void Function()? onUpdate;
final bool isEditable;
const LibraryListTile({
super.key,
required this.library,
this.leading,
this.onDelete,
this.onUpdate,
this.isEditable = true,
});
@override
Widget build(BuildContext context) {
return Slidable(
endActionPane: ActionPane(
motion: const ScrollMotion(),
children: !isEditable
? []
: [
SlidableAction(
backgroundColor: Colors.blue,
icon: Icons.edit,
onPressed: (_) async {
// TODO: update name
onUpdate?.call();
},
),
SlidableAction(
backgroundColor: Colors.red,
icon: Icons.delete,
onPressed: (_) async {
await library.delete();
onDelete?.call();
},
),
],
),
child: ListTile(
leading: leading,
onTap: () => Navigator.pushNamed(
context,
Routes.library,
arguments: library,
),
title: Row(
children: [
Expanded(child: Text(library.name)),
FutureBuilder<int>(
future: library.length,
builder: (context, snapshot) {
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
if (!snapshot.hasData) return const LoadingScreen();
return Text('${snapshot.data} items');
},
),
],
),
),
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../../models/library/library_list.dart';
void Function() showNewLibraryDialog(context) => () async {
final String? listName = await showDialog<String>(
context: context,
barrierDismissible: true,
builder: (_) => const NewLibraryDialog(),
);
if (listName == null) return;
LibraryList.insert(listName);
};
class NewLibraryDialog extends StatefulWidget {
const NewLibraryDialog({super.key});
@override
State<NewLibraryDialog> createState() => _NewLibraryDialogState();
}
enum _NameState {
initial,
currentlyChecking,
invalid,
alreadyExists,
valid,
}
class _NewLibraryDialogState extends State<NewLibraryDialog> {
final controller = TextEditingController();
_NameState nameState = _NameState.initial;
Future<void> onNameUpdate(proposedListName) async {
setState(() => nameState = _NameState.currentlyChecking);
if (proposedListName == '') {
setState(() => nameState = _NameState.invalid);
return;
}
final nameAlreadyExists = await LibraryList.exists(proposedListName);
if (nameAlreadyExists) {
setState(() => nameState = _NameState.alreadyExists);
} else {
setState(() => nameState = _NameState.valid);
}
}
bool get errorStatus =>
nameState == _NameState.invalid || nameState == _NameState.alreadyExists;
String? get statusLabel => {
_NameState.invalid: 'Invalid Name',
_NameState.alreadyExists: 'Already Exists',
}[nameState];
bool get confirmButtonActive => nameState == _NameState.valid;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add new library'),
content: TextField(
decoration: InputDecoration(
hintText: 'Library name',
errorText: statusLabel,
),
controller: controller,
onChanged: onNameUpdate,
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
style: confirmButtonActive
? null
: ElevatedButton.styleFrom(foregroundColor: Colors.grey),
onPressed: confirmButtonActive
? () => Navigator.pop(context, controller.text)
: () {},
child: const Text('Add'),
),
],
);
}
}

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:jadb/models/word_search/word_search_result.dart';
import 'package:jadb/util/text_filtering.dart';
import 'package:mugiten/components/library/add_to_library_dialog.dart';
import 'package:mugiten/models/library/library_list.dart';
import './parts/common_badge.dart';
import './parts/header.dart';
@@ -84,12 +87,36 @@ class _SearchResultCardState extends State<SearchResultCard> {
Widget build(BuildContext context) {
final backgroundColor = Theme.of(context).scaffoldBackgroundColor;
return ExpansionTile(
collapsedBackgroundColor: backgroundColor,
backgroundColor: backgroundColor,
// onExpansionChanged: (b) async { },
title: _header,
children: [_body()],
return Slidable(
endActionPane: ActionPane(
motion: const ScrollMotion(),
children: [
SlidableAction(
backgroundColor: Colors.yellow,
icon: Icons.star,
onPressed: (_) => LibraryList.favourites.toggleEntry(
jmdictEntryId: widget.result.entryId,
kanji: null,
),
),
SlidableAction(
backgroundColor: Colors.blue,
icon: Icons.bookmark,
onPressed: (context) => showAddToLibraryDialog(
context: context,
jmdictEntryId: widget.result.entryId,
kanji: null,
),
),
],
),
child: ExpansionTile(
collapsedBackgroundColor: backgroundColor,
backgroundColor: backgroundColor,
// onExpansionChanged: (b) async { },
title: _header,
children: [_body()],
),
);
}
}

View File

@@ -25,9 +25,28 @@ Future<String> databasePath() async {
return join((await _databaseDir()).path, 'mugiten.sqlite');
}
/// Migrates the database from version [oldVersion] to [newVersion].
Future<void> migrate(Database db, int oldVersion, int newVersion) async {
log('Migrating database from v$oldVersion to v$newVersion...');
class DatabaseMigration {
final String path;
final String content;
const DatabaseMigration({
required this.path,
required this.content,
});
int get version {
final String fileName = basenameWithoutExtension(path);
return int.parse(fileName.split('_')[0]);
}
@override
String toString() {
return 'DatabaseMigration(path: $path, content: ${content.length} chars)';
}
}
Future<List<DatabaseMigration>> readMigrationsFromAssets() async {
log('Reading migrations from assets...');
final String assetManifest =
await rootBundle.loadString('AssetManifest.json');
@@ -41,21 +60,28 @@ Future<void> migrate(Database db, int oldVersion, int newVersion) async {
)
.toList();
migrations.sort();
log('Found ${migrations.length} migration files:');
for (final migration in migrations) {
log(' - $migration');
}
migrations.sort();
return Future.wait(
migrations.map(
(migration) async {
final content = await rootBundle.loadString(migration, cache: false);
return DatabaseMigration(path: migration, content: content);
},
),
);
}
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
/// Migrates the database from version [oldVersion] to [newVersion].
Future<void> migrate(Database db, List<DatabaseMigration> migrations) async {
for (final migration in migrations) {
log('Running migration ${migration.version} from ${migration.path}');
migration.content
.split(';')
.map(
(s) => s
@@ -69,6 +95,39 @@ Future<void> migrate(Database db, int oldVersion, int newVersion) async {
}
}
Future<Database> openAndMigrateDatabase(
String dbPath,
List<DatabaseMigration> migrations,
) async {
log('Opening database at $dbPath');
final Database database = await openDatabase(
dbPath,
version: 2,
readOnly: false,
onUpgrade: (db, oldVersion, newVersion) async {
log('Migrating database from v$oldVersion to v$newVersion...');
final migrationsToRun = migrations
.where((migration) =>
migration.version > oldVersion && migration.version <= newVersion)
.toList();
await migrate(db, migrationsToRun);
},
onConfigure: (db) async {
// Enable foreign key constraints
await db.execute('PRAGMA foreign_keys=ON');
},
onOpen: (db) async {
log('Verifying jadb tables...');
db.jadbVerifyTables();
log('jadb opened successfully');
},
);
return database;
}
/// Sets up the database, creating it if it does not exist.
Future<void> setupDatabase() async {
log('Setting up database...');
@@ -81,24 +140,8 @@ Future<void> setupDatabase() async {
log('jadb.sqlite extracted to $dbPath');
}
log('Opening database at $dbPath');
final Database database = await openDatabase(
dbPath,
version: 2,
readOnly: false,
onUpgrade: migrate,
onConfigure: (db) async {
// Enable foreign key constraints
await db.execute('PRAGMA foreign_keys=ON');
},
onOpen: (db) async {
log('Verifying jadb tables...');
db.jadbVerifyTables();
log('jadb opened successfully');
},
);
final List<DatabaseMigration> migrations = await readMigrationsFromAssets();
final Database database = await openAndMigrateDatabase(dbPath, migrations);
log('Registering database in GetIt...');
GetIt.instance.registerSingleton<Database>(database);

View File

@@ -0,0 +1,76 @@
// /// Can assume Android for time being
// Future<void> exportData(context) async {
// // setState(() => dataExportIsLoading = true);
// final path = (await getExternalStorageDirectory())!;
// final dbData = await exportDatabase(db);
// final file = File('${path.path}/jisho_data.json');
// file.createSync(recursive: true);
// await file.writeAsString(jsonEncode(dbData));
// setState(() => dataExportIsLoading = false);
// ScaffoldMessenger.of(context)
// .showSnackBar(SnackBar(content: Text('Data exported to ${file.path}')));
// }
// /// Can assume Android for time being
// Future<void> importData(context) async {
// // setState(() => dataImportIsLoading = true);
// final path = await FilePicker.platform.pickFiles(
// type: FileType.custom,
// allowedExtensions: ['json'],
// );
// final file = File(path!.files[0].path!);
// final List<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 List<dynamic>)
// // importedSearches = (((jsonDecode(await file.readAsString())
// // as Map<String, Object?>)['stores']! as List<Object?>)
// // .map((e) => e! as Map<String, Object?>)
// // .where((e) => e['name'] == 'search')
// // .first['values'] as List<dynamic>)
// .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);
// setState(() => dataImportIsLoading = false);
// showSnackbar(context, 'Data imported successfully');
// }

View File

@@ -0,0 +1,23 @@
abstract class LibraryListTableNames {
/// Attributes:
/// - name TEXT
/// - prevList TEXT
static const String libraryList = 'Mugiten_LibraryList';
/// Attributes:
/// - listName TEXT
/// - jmdictEntryId INTEGER
/// - kanji TEXT
/// - lastModified TIMESTAMP
/// - prevJmdictEntryId INTEGER
/// - prevEntryKanji TEXT
static const String libraryListEntry = 'Mugiten_LibraryListEntry';
///////////
// VIEWS //
///////////
/// Attributes:
/// - name TEXT
static const String libraryListOrdered = 'Mugiten_LibraryListOrdered';
}

View File

@@ -0,0 +1,67 @@
class LibraryEntry {
DateTime lastModified;
String? kanji;
int? jmdictEntryId;
LibraryEntry({
DateTime? lastModified,
this.kanji,
this.jmdictEntryId,
}) : lastModified = lastModified ?? DateTime.now(),
assert(
kanji != null || jmdictEntryId != null,
"Library entry can't be empty",
),
assert(
!(kanji != null && jmdictEntryId != null),
"Library entry can't have both kanji and jmdictEntryId",
);
LibraryEntry.fromJmdictId({
required int this.jmdictEntryId,
DateTime? lastModified,
}) : lastModified = lastModified ?? DateTime.now();
LibraryEntry.fromKanji({
required String this.kanji,
DateTime? lastModified,
}) : lastModified = lastModified ?? DateTime.now();
Map<String, Object?> toJson() => {
'kanji': kanji,
'jmdictEntryId': jmdictEntryId,
'lastModified': lastModified.millisecondsSinceEpoch,
};
factory LibraryEntry.fromJson(Map<String, Object?> json) {
assert(
(json.containsKey('kanji') && json['kanji'] != null) ||
(json.containsKey('jmdictEntryId') && json['jmdictEntryId'] != null),
"Library entry can't be empty",
);
assert(
json.containsKey('lastModified'),
"Library entry must have a lastModified timestamp",
);
if (json.containsKey('kanji') && json['kanji'] != null) {
return LibraryEntry.fromKanji(
kanji: json['kanji']! as String,
lastModified: DateTime.fromMillisecondsSinceEpoch(
json['lastModified']! as int,
),
);
} else {
return LibraryEntry.fromJmdictId(
jmdictEntryId: json['jmdictEntryId']! as int,
lastModified: DateTime.fromMillisecondsSinceEpoch(
json['lastModified']! as int,
),
);
}
}
// NOTE: this just happens to be the same as the logic in `fromJson`
factory LibraryEntry.fromDBMap(Map<String, Object?> dbObject) =>
LibraryEntry.fromJson(dbObject);
}

View File

@@ -0,0 +1,438 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:mugiten/database/library_list/table_names.dart';
import '../../database/database.dart';
import '../../database/database_errors.dart';
import 'library_entry.dart';
class LibraryList {
final String name;
const LibraryList._byName(this.name);
static const LibraryList favourites = LibraryList._byName('favourites');
/// Get all entries within the library, in their custom order
Future<List<LibraryEntry>> get entries async {
const columns = ['jmdictEntryId', 'kanji', 'lastModified'];
final query = await db().rawQuery(
'''
WITH RECURSIVE
"RecursionTable"(${columns.map((c) => '"$c"').join(', ')}) AS (
SELECT ${columns.map((c) => '"$c"').join(', ')}
FROM "${LibraryListTableNames.libraryListEntry}"
WHERE
"listName" = ?
AND "prevEntryJmdictEntryId" IS NULL
AND "prevEntryKanji" IS NULL
UNION ALL
SELECT ${columns.map((c) => '"R"."$c"').join(', ')}
FROM "${LibraryListTableNames.libraryListEntry}" AS "R", "RecursionTable"
WHERE "R"."listName" = ?
AND ("R"."prevEntryJmdictEntryId" = "RecursionTable"."jmdictEntryId"
OR "R"."prevEntryKanji" = "RecursionTable"."kanji")
)
SELECT ${columns.map((c) => '"$c"').join(', ')} FROM "RecursionTable";
''',
[name, name],
);
return query.map((e) => LibraryEntry.fromDBMap(e)).toList();
}
/// Get all existing libraries in their custom order.
static Future<List<LibraryList>> get allLibraries async {
final query = await db().query(LibraryListTableNames.libraryListOrdered);
return query
.map((lib) => LibraryList._byName(lib['name']! as String))
.toList();
}
/// Generates a map of all the libraries, with the value being
/// whether or not the specified entry is within the library.
static Future<Map<LibraryList, bool>> allListsContains({
required int? jmdictEntryId,
required String? kanji,
}) async {
if ((jmdictEntryId == null) == (kanji == null)) {
throw ArgumentError(
'Either jmdictEntryId or kanji must be provided, but not both.',
);
}
final query = await db().rawQuery(
'''
SELECT
*,
EXISTS(
SELECT * FROM "${LibraryListTableNames.libraryListEntry}"
WHERE "listName" = "name" AND ("jmdictEntryId" = ? OR "kanji" = ?)
) AS "exists"
FROM "${LibraryListTableNames.libraryListOrdered}"
''',
[
jmdictEntryId,
kanji,
],
);
return Map.fromEntries(
query.map(
(lib) => MapEntry(
LibraryList._byName(lib['name']! as String),
lib['exists']! as int == 1,
),
),
);
}
/// Whether a library contains a specific entry
Future<bool> contains({
required int? jmdictEntryId,
required String? kanji,
}) async {
if (jmdictEntryId == null && kanji == null) {
return false;
}
if (jmdictEntryId != null && kanji != null) {
throw ArgumentError(
'Either jmdictEntryId or kanji must be provided, but not both.',
);
}
final query = await db().rawQuery(
'''
SELECT EXISTS(
SELECT *
FROM "${LibraryListTableNames.libraryListEntry}"
WHERE "listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)
) AS "exists"
''',
[name, jmdictEntryId, kanji],
);
return query.first['exists']! as int == 1;
}
/// Whether a library contains a specific word entry
Future<bool> containsJmdictEntryId(int jmdictEntryId) => contains(
jmdictEntryId: jmdictEntryId,
kanji: null,
);
/// Whether a library contains a specific kanji entry
Future<bool> containsKanji(String kanji) => contains(
jmdictEntryId: null,
kanji: kanji,
);
/// Whether a library exists in the database
static Future<bool> exists(String libraryName) async {
final query = await db().rawQuery(
'''
SELECT EXISTS(
SELECT *
FROM "${LibraryListTableNames.libraryList}"
WHERE "name" = ?
) AS "exists"
''',
[libraryName],
);
return query.first['exists']! as int == 1;
}
static Future<int> libraryCount() async {
final query = await db().query(
LibraryListTableNames.libraryList,
columns: ['COUNT(*) AS count'],
);
return query.first['count']! as int;
}
/// The amount of items within this library.
Future<int> get length async {
final query = await db().query(
LibraryListTableNames.libraryListEntry,
columns: ['COUNT(*) AS count'],
where: 'listName = ?',
whereArgs: [name],
);
return query.first['count']! as int;
}
/// Swaps two entries within a list
/// Will throw an exception if the entry is already in the library
Future<void> insertEntry({
required int? jmdictEntryId,
required String? kanji,
int? position,
DateTime? lastModified,
}) async {
if ((jmdictEntryId == null) == (kanji == null)) {
throw ArgumentError(
'Either jmdictEntryId or kanji must be provided, but not both.',
);
}
// TODO: set up lastModified insertion
if (await contains(jmdictEntryId: jmdictEntryId, kanji: kanji)) {
throw DataAlreadyExistsError(
tableName: LibraryListTableNames.libraryListEntry,
illegalArguments: {
'jmdictEntryId': jmdictEntryId,
'kanji': kanji,
},
);
}
if (position != null) {
final len = await length;
if (0 > position || position > len) {
throw IndexError.withLength(
position,
len,
indexable: this,
name: 'position',
message:
'Data insertion position ($position) can not be between 0 and length ($len).',
);
} else if (position != len) {
log(
'Adding ${jmdictEntryId != null ? 'jmdict entry $jmdictEntryId' : 'kanji "$kanji"'} to library "$name" at position $position',
);
final b = db().batch();
final entries_ = await entries;
final prevEntry = entries_[position - 1];
final nextEntry = entries_[position];
b.insert(LibraryListTableNames.libraryListEntry, {
'listName': name,
'jmdictEntryId': jmdictEntryId,
'kanji': kanji,
'prevEntryJmdictEntryId': prevEntry.jmdictEntryId,
'prevEntryKanji': prevEntry.kanji,
});
b.update(
LibraryListTableNames.libraryListEntry,
{
'prevEntryJmdictEntryId': jmdictEntryId,
'prevEntryKanji': kanji,
},
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
whereArgs: [name, nextEntry.jmdictEntryId, nextEntry.kanji],
);
await b.commit();
return;
}
}
log(
'Adding ${jmdictEntryId != null ? 'jmdict entry $jmdictEntryId' : 'kanji "$kanji"'} to library "$name"',
);
final LibraryEntry? prevEntry = (await entries).lastOrNull;
await db().insert(LibraryListTableNames.libraryListEntry, {
'listName': name,
'jmdictEntryId': jmdictEntryId,
'kanji': kanji,
'prevEntryJmdictEntryId': prevEntry?.jmdictEntryId,
'prevEntryKanji': prevEntry?.kanji,
});
}
/// Deletes an entry within a list
/// Will throw an exception if the entry is not in the library
Future<void> deleteEntry({
required int? jmdictEntryId,
required String? kanji,
}) async {
if ((jmdictEntryId == null) == (kanji == null)) {
throw ArgumentError(
'Either jmdictEntryId or kanji must be provided, but not both.',
);
}
if (!await contains(jmdictEntryId: jmdictEntryId, kanji: kanji)) {
throw DataNotFoundError(
tableName: LibraryListTableNames.libraryListEntry,
illegalArguments: {
'jmdictEntryId': jmdictEntryId,
'kanji': kanji,
},
);
}
log(
'Deleting ${jmdictEntryId != null ? 'jmdict entry $jmdictEntryId' : 'kanji "$kanji"'} from library "$name"',
);
// TODO: these queries might be combined into one
final entryQuery = await db().query(
LibraryListTableNames.libraryListEntry,
columns: ['prevEntryJmdictEntryId', 'prevEntryKanji'],
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
whereArgs: [name, jmdictEntryId, kanji],
);
final nextEntryQuery = await db().query(
LibraryListTableNames.libraryListEntry,
where:
'"listName" = ? AND "(prevEntryJmdictEntryId" = ? OR "prevEntryKanji" = ?)',
whereArgs: [name, jmdictEntryId, kanji],
);
final prevEntryJmdictEntryId =
entryQuery.first['prevEntryJmdictEntryId'] as int?;
final prevEntryKanji = entryQuery.first['prevEntryKanji'] as String?;
final LibraryEntry? nextEntry =
nextEntryQuery.map((e) => LibraryEntry.fromDBMap(e)).firstOrNull;
final b = db().batch();
if (nextEntry != null) {
b.update(
LibraryListTableNames.libraryListEntry,
{
'prevEntryJmdictEntryId': prevEntryJmdictEntryId,
'prevEntryKanji': prevEntryKanji,
},
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
whereArgs: [name, nextEntry.jmdictEntryId, nextEntry.kanji],
);
}
b.delete(
LibraryListTableNames.libraryListEntry,
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
whereArgs: [name, jmdictEntryId, kanji],
);
b.commit();
}
/// Swaps two entries within a list
/// Will throw an error if both of the entries doesn't exist
Future<void> swapEntries({
required int? jmdictEntryId1,
required String? kanji1,
required int? jmdictEntryId2,
required String? kanji2,
}) async {
if ((jmdictEntryId1 == null) == (kanji1 == null) ||
(jmdictEntryId2 == null) == (kanji2 == null)) {
throw ArgumentError(
'Either jmdictEntryId or kanji must be provided for both entries, but not both.',
);
}
if (!await contains(jmdictEntryId: jmdictEntryId1, kanji: kanji1)) {
throw DataNotFoundError(
tableName: LibraryListTableNames.libraryListEntry,
illegalArguments: {
'jmdictEntryId': jmdictEntryId1,
'kanji': kanji1,
},
);
}
if (!await contains(jmdictEntryId: jmdictEntryId2, kanji: kanji2)) {
throw DataNotFoundError(
tableName: LibraryListTableNames.libraryListEntry,
illegalArguments: {
'jmdictEntryId': jmdictEntryId2,
'kanji': kanji2,
},
);
}
log(
'Swapping ${jmdictEntryId1 != null ? 'jmdict entry $jmdictEntryId1' : 'kanji "$kanji1"'} with ${jmdictEntryId2 != null ? 'jmdict entry $jmdictEntryId2' : 'kanji "$kanji2"'} in library "$name"',
);
// TODO: implement function.
throw UnimplementedError();
}
/// Toggle whether an entry is in the library or not.
/// If [overrideToggleOn] is given true or false, it will specifically insert or
/// delete the entry respectively. Else, it will figure out whether the entry
/// is in the library already automatically.
Future<bool> toggleEntry({
required int? jmdictEntryId,
required String? kanji,
bool? overrideToggleOn,
}) async {
if ((jmdictEntryId == null) == (kanji == null)) {
throw ArgumentError(
'Either jmdictEntryId or kanji must be provided, but not both.',
);
}
overrideToggleOn ??=
!(await contains(jmdictEntryId: jmdictEntryId, kanji: kanji));
if (overrideToggleOn) {
await insertEntry(
jmdictEntryId: jmdictEntryId,
kanji: kanji,
);
} else {
await deleteEntry(
jmdictEntryId: jmdictEntryId,
kanji: kanji,
);
}
return overrideToggleOn;
}
Future<void> deleteAllEntries() => db().delete(
LibraryListTableNames.libraryListEntry,
where: 'listName = ?',
whereArgs: [name],
);
/// Insert a new library list into the database
static Future<LibraryList> insert(String libraryName) async {
if (await exists(libraryName)) {
throw DataAlreadyExistsError(
tableName: LibraryListTableNames.libraryList,
illegalArguments: {
'libraryName': libraryName,
},
);
}
// This is ok, because "favourites" should always exist.
final prevList = (await allLibraries).last;
await db().insert(LibraryListTableNames.libraryList, {
'name': libraryName,
'prevList': prevList.name,
});
return LibraryList._byName(libraryName);
}
/// Delete this library from the database
Future<void> delete() async {
if (name == 'favourites') {
throw IllegalDeletionError(
tableName: LibraryListTableNames.libraryList,
illegalArguments: {'name': name},
);
}
await db().delete(
LibraryListTableNames.libraryList,
where: 'name = ?',
whereArgs: [name],
);
}
}

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:mugiten/models/library/library_list.dart';
import 'package:mugiten/screens/library/library_content_view.dart';
import 'package:mugiten/screens/search/kanji_search_result_page.dart';
import 'package:mugiten/screens/search/word_search_result_page.dart';
@@ -42,6 +44,12 @@ Route<Widget> generateRoute(RouteSettings settings) {
builder: (_) => KanjiRadicalSearch(prechosenRadical: prechosenRadical),
);
case Routes.library:
final library = args! as LibraryList;
return MaterialPageRoute(
builder: (_) => LibraryContentView(library: library),
);
case Routes.aboutLicenses:
return MaterialPageRoute(builder: (_) => const LicensesView());

View File

@@ -9,4 +9,5 @@ abstract class Routes {
static const String errorNotFound = '/error/404';
static const String errorNetwork = '/error/network';
static const String errorOther = '/error/other';
static const String library = '/library';
}

View File

@@ -6,8 +6,10 @@ import 'package:mugiten/screens/search/word_search_view.dart';
import '../bloc/theme/theme_bloc.dart';
import '../components/common/denshi_jisho_background.dart';
import '../components/library/new_library_dialog.dart';
import 'debug.dart';
import 'history.dart';
import 'library/library_view.dart';
import 'settings.dart';
class Home extends StatefulWidget {
@@ -20,25 +22,35 @@ class Home extends StatefulWidget {
class _HomeState extends State<Home> {
int pageNum = 0;
_Page get page => pages[pageNum];
@override
Widget build(BuildContext context) {
return BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, themeState) {
return Scaffold(
appBar: AppBar(
title: pages[pageNum].titleBar,
title: Text(page.titleBar),
centerTitle: true,
backgroundColor: AppTheme.mugitenWheat.background,
foregroundColor: AppTheme.mugitenWheat.foreground,
actions: page.actions,
),
body: DenshiJishoBackground(child: pages[pageNum].content),
body: DenshiJishoBackground(child: page.content),
bottomNavigationBar: BottomNavigationBar(
fixedColor: AppTheme.mugitenWheat.background,
currentIndex: pageNum,
onTap: (index) => setState(() {
pageNum = index;
}),
items: pages.map((p) => p.item).toList(),
items: pages
.map(
(p) => BottomNavigationBarItem(
label: p.titleBar,
icon: p.icon,
),
)
.toList(),
showSelectedLabels: false,
showUnselectedLabels: false,
unselectedItemColor: themeState.theme.menuGreyDark.background,
@@ -51,52 +63,40 @@ class _HomeState extends State<Home> {
List<_Page> get pages => [
const _Page(
content: WordSearchView(),
titleBar: Text('Search'),
item: BottomNavigationBarItem(
label: 'Search',
icon: Icon(Icons.search),
),
titleBar: 'Search',
icon: Icon(Icons.search),
),
const _Page(
content: KanjiSearchView(),
titleBar: Text('Kanji Search'),
item: BottomNavigationBarItem(
label: 'Kanji',
icon: Icon(Mdi.ideogramCjk, size: 30),
),
titleBar: 'Kanji Search',
icon: Icon(Mdi.ideogramCjk, size: 30),
),
const _Page(
content: HistoryView(),
titleBar: Text('History'),
item: BottomNavigationBarItem(
label: 'History',
icon: Icon(Icons.history),
),
titleBar: 'History',
icon: Icon(Icons.history),
),
_Page(
content: Container(),
titleBar: const Text('Saved'),
item: const BottomNavigationBarItem(
label: 'Saved',
icon: Icon(Icons.bookmark),
),
content: const LibraryView(),
titleBar: 'Library',
icon: const Icon(Icons.bookmark),
actions: [
IconButton(
onPressed: showNewLibraryDialog(context),
icon: const Icon(Icons.add),
)
],
),
const _Page(
content: SettingsView(),
titleBar: Text('Settings'),
item: BottomNavigationBarItem(
label: 'Settings',
icon: Icon(Icons.settings),
),
titleBar: 'Settings',
icon: Icon(Icons.settings),
),
if (kDebugMode) ...[
const _Page(
content: DebugView(),
titleBar: Text('Debug Page'),
item: BottomNavigationBarItem(
label: 'Debug',
icon: Icon(Icons.biotech),
),
titleBar: 'Debug Page',
icon: Icon(Icons.biotech),
)
],
];
@@ -104,12 +104,14 @@ class _HomeState extends State<Home> {
class _Page {
final Widget content;
final Widget titleBar;
final BottomNavigationBarItem item;
final String titleBar;
final Icon icon;
final List<Widget> actions;
const _Page({
required this.content,
required this.titleBar,
required this.item,
required this.icon,
this.actions = const [],
});
}

View File

@@ -0,0 +1,78 @@
import 'package:confirm_dialog/confirm_dialog.dart';
import 'package:flutter/material.dart';
import '../../components/common/loading.dart';
import '../../components/library/library_list_entry_tile.dart';
import '../../models/library/library_entry.dart';
import '../../models/library/library_list.dart';
class LibraryContentView extends StatefulWidget {
final LibraryList library;
const LibraryContentView({
super.key,
required this.library,
});
@override
State<LibraryContentView> createState() => _LibraryContentViewState();
}
class _LibraryContentViewState extends State<LibraryContentView> {
List<LibraryEntry>? entries;
Future<void> getEntriesFromDatabase() =>
widget.library.entries.then((es) => setState(() => entries = es));
@override
void initState() {
super.initState();
getEntriesFromDatabase();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.library.name),
actions: [
IconButton(
onPressed: () async {
final entryCount = await widget.library.length;
if (!mounted) return;
final bool userIsSure = await confirm(
context,
content: Text(
'Are you sure that you want to clear all $entryCount entries?',
),
);
if (!userIsSure) return;
await widget.library.deleteAllEntries();
await getEntriesFromDatabase();
},
icon: const Icon(Icons.delete),
),
],
),
body: entries == null
? const LoadingScreen()
: ListView.separated(
itemCount: entries!.length,
itemBuilder: (context, index) => LibraryListEntryTile(
index: index,
entry: entries![index],
library: widget.library,
onDelete: () => setState(() {
entries!.removeAt(index);
}),
onUpdate: () => getEntriesFromDatabase(),
),
separatorBuilder: (context, index) => const Divider(
height: 0,
indent: 10,
endIndent: 10,
),
),
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../../components/common/loading.dart';
import '../../components/library/library_list_tile.dart';
import '../../models/library/library_list.dart';
class LibraryView extends StatefulWidget {
const LibraryView({super.key});
@override
State<LibraryView> createState() => _LibraryViewState();
}
class _LibraryViewState extends State<LibraryView> {
List<LibraryList>? libraries;
Future<void> getEntriesFromDatabase() =>
LibraryList.allLibraries.then((libs) => setState(() => libraries = libs));
@override
void initState() {
super.initState();
getEntriesFromDatabase();
}
@override
Widget build(BuildContext context) {
if (libraries == null) return const LoadingScreen();
return Column(
children: [
LibraryListTile(
library: LibraryList.favourites,
leading: const Icon(Icons.star),
onDelete: getEntriesFromDatabase,
onUpdate: getEntriesFromDatabase,
isEditable: false,
),
Expanded(
child: ListView(
children: libraries!
// Skip favourites
.skip(1)
.map(
(e) => LibraryListTile(
library: e,
onDelete: getEntriesFromDatabase,
onUpdate: getEntriesFromDatabase,
),
)
.toList(),
),
),
],
);
}
}

View File

@@ -0,0 +1,63 @@
CREATE TABLE "Mugiten_LibraryList" (
"name" TEXT PRIMARY KEY NOT NULL,
"prevList" TEXT UNIQUE
-- This is true for all lists except the first one in the order.
-- FOREIGN KEY ("prevList") REFERENCES "Mugiten_LibraryList"("name"),
-- The list can't link to itself
CHECK("prevList" != "name"),
-- 'favourites' should always be the first list
CHECK (NOT (("name" = 'favourites') <> ("prevList" IS NULL)))
);
-- This entry should always exist
INSERT INTO "Mugiten_LibraryList"("name") VALUES ('favourites');
-- Useful for the view below
CREATE INDEX "Mugiten_LibraryList_byPrevList" ON "Mugiten_LibraryList"("prevList");
-- A view that sorts the LibraryLists in their custom order.
CREATE VIEW "Mugiten_LibraryList_Ordered" AS
WITH RECURSIVE "RecursionTable"("name") AS (
SELECT "name"
FROM "Mugiten_LibraryList" AS "I"
WHERE "I"."prevList" IS NULL
UNION ALL
SELECT "R"."name"
FROM "Mugiten_LibraryList" AS "R"
JOIN "RecursionTable" ON
("R"."prevList" = "RecursionTable"."name")
)
SELECT * FROM "RecursionTable";
CREATE TABLE "Mugiten_LibraryListEntry" (
"listName" TEXT NOT NULL REFERENCES "Mugiten_LibraryList"("name") ON DELETE CASCADE,
"jmdictEntryId" INTEGER REFERENCES "JMdict_Entry"("entryId") ON DELETE CASCADE,
"kanji" CHAR(1) REFERENCES "KANJIDIC_Character"("literal") ON DELETE CASCADE,
-- Defaults to unix timestamp in milliseconds
"lastModified" INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
"prevEntryJmdictEntryId" INTEGER,
"prevEntryKanji" CHAR(1),
PRIMARY KEY ("listName", "jmdictEntryId", "kanji"),
-- This is true for all other entries than the first one in the list.
-- FOREIGN KEY ("listName", "prevEntryJmdictEntryId", "prevEntryKanji")
-- REFERENCES "Mugiten_LibraryListEntry"("listName", "jmdictEntryId", "kanji"),
-- Two entries can not appear directly after the same entry
UNIQUE("listName", "prevEntryJmdictEntryId", "prevEntryKanji"),
-- The entry can't link to itself
CHECK(NOT ("prevEntryJmdictEntryId" == "jmdictEntryId" AND "prevEntryKanji" == "kanji")),
-- Only one of the fields must be non-null
CHECK (("jmdictEntryId" IS NOT NULL) <> ("kanji" IS NOT NULL))
);
-- Useful when doing the recursive ordering statement
CREATE INDEX "Mugiten_LibraryListEntry_byListNameAndPrevEntry"
ON "Mugiten_LibraryListEntry"("listName", "prevEntryJmdictEntryId", "prevEntryKanji");

View File

@@ -157,10 +157,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: "77f8e81d22d2a07d0dee2c62e1dda71dc1da73bf43bb2d45af09727406167964"
sha256: ef9908739bdd9c476353d6adff72e88fd00c625f5b959ae23f7567bd5137db0a
url: "https://pub.dev"
source: hosted
version: "10.1.9"
version: "10.2.0"
fixnum:
dependency: transitive
description:
@@ -226,10 +226,10 @@ packages:
dependency: "direct main"
description:
name: flutter_svg
sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1
sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845
url: "https://pub.dev"
source: hosted
version: "2.1.0"
version: "2.2.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -277,7 +277,7 @@ packages:
description:
path: "."
ref: HEAD
resolved-ref: bd0fee1b2de564d6f8d9f979fb836ddd56de67a9
resolved-ref: "8ec9771222a5a7696beba192ec4019951d716563"
url: "https://git.pvv.ntnu.no/oysteikt/jadb.git"
source: git
version: "1.0.0"
@@ -634,10 +634,10 @@ packages:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: "7986c26234c0a5cf4fd83ff4ee39d4195b1f47cdb50a949ec7987ede4dcbdc2a"
sha256: e07232b998755fe795655c56d1f5426e0190c9c435e1752d39e7b1cd33699c71
url: "https://pub.dev"
source: hosted
version: "0.5.33"
version: "0.5.34"
stack_trace:
dependency: transitive
description:
@@ -778,10 +778,10 @@ packages:
dependency: transitive
description:
name: vector_graphics
sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de"
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
url: "https://pub.dev"
source: hosted
version: "1.1.18"
version: "1.1.19"
vector_graphics_codec:
dependency: transitive
description:

View File

@@ -0,0 +1,108 @@
import 'dart:ffi';
import 'dart:io';
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:mugiten/database/database.dart';
import 'package:mugiten/models/library/library_list.dart';
import 'package:sqflite/sqflite.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqlite3/open.dart';
Future<Database> createDatabaseCopy({
required String libsqlitePath,
required String jadbPath,
}) async {
final jadbFile = File(jadbPath);
if (!jadbFile.existsSync()) {
throw Exception("JADB_PATH does not exist: $jadbPath");
}
// Make a copy of jadbPath
final random_suffix =
Random().nextInt((pow(2, 32) - 1) as int).toRadixString(16);
final jadbCopyPath =
jadbFile.parent.uri.resolve("jadb_copy_$random_suffix.sqlite").path;
await jadbFile.copy(jadbCopyPath);
print("Using database copy: $jadbCopyPath");
// Initialize FFI
sqfliteFfiInit();
databaseFactory = await createDatabaseFactoryFfi(
ffiInit: () =>
open.overrideForAll(() => DynamicLibrary.open(libsqlitePath)),
);
WidgetsFlutterBinding.ensureInitialized();
return await openAndMigrateDatabase(
jadbCopyPath,
await readMigrationsFromAssets(),
);
}
Future<void> insertTestData(Database db) async {
final libraryList1 = await LibraryList.insert("Test Library 1");
await libraryList1.insertEntry(
jmdictEntryId: null,
kanji: "",
);
await libraryList1.insertEntry(
jmdictEntryId: null,
kanji: "",
);
}
void main() {
late final String libsqlitePath;
late final String jadbPath;
late final Database database;
setUpAll(() {
if (!Platform.environment.containsKey("LIBSQLITE_PATH")) {
throw Exception("LIBSQLITE_PATH environment variable is not set.");
}
if (!Platform.environment.containsKey("JADB_PATH")) {
throw Exception("JADB_PATH environment variable is not set.");
}
libsqlitePath = File(Platform.environment["LIBSQLITE_PATH"]!)
.resolveSymbolicLinksSync();
jadbPath =
File(Platform.environment["JADB_PATH"]!).resolveSymbolicLinksSync();
});
// Setup sqflite_common_ffi for flutter test
setUp(() async {
database = await createDatabaseCopy(
libsqlitePath: libsqlitePath,
jadbPath: jadbPath,
);
GetIt.instance.registerSingleton<Database>(database);
await insertTestData(database);
});
tearDown(() async {
await database.close();
GetIt.instance.unregister<Database>();
final jadbCopyPath = database.path;
if (File(jadbCopyPath).existsSync()) {
await File(jadbCopyPath).delete();
}
});
test('Database is open', () async {
expect(database.isOpen, isTrue);
});
}