treewide: implement most of library logic
This commit is contained in:
3
devtools_options.yaml
Normal file
3
devtools_options.yaml
Normal 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:
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
164
lib/components/library/add_to_library_dialog.dart
Normal file
164
lib/components/library/add_to_library_dialog.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/components/library/library_list_entry_tile.dart
Normal file
76
lib/components/library/library_list_entry_tile.dart
Normal 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())),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
73
lib/components/library/library_list_tile.dart
Normal file
73
lib/components/library/library_list_tile.dart
Normal 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');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/components/library/new_library_dialog.dart
Normal file
85
lib/components/library/new_library_dialog.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
76
lib/database/history/export.dart
Normal file
76
lib/database/history/export.dart
Normal 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');
|
||||
// }
|
||||
23
lib/database/library_list/table_names.dart
Normal file
23
lib/database/library_list/table_names.dart
Normal 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';
|
||||
}
|
||||
67
lib/models/library/library_entry.dart
Normal file
67
lib/models/library/library_entry.dart
Normal 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);
|
||||
}
|
||||
438
lib/models/library/library_list.dart
Normal file
438
lib/models/library/library_list.dart
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 [],
|
||||
});
|
||||
}
|
||||
|
||||
78
lib/screens/library/library_content_view.dart
Normal file
78
lib/screens/library/library_content_view.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lib/screens/library/library_view.dart
Normal file
56
lib/screens/library/library_view.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
63
migrations/0002_library_list.sql
Normal file
63
migrations/0002_library_list.sql
Normal 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");
|
||||
18
pubspec.lock
18
pubspec.lock
@@ -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:
|
||||
|
||||
108
test/models/library_test.dart
Normal file
108
test/models/library_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user