Compare commits

...

23 Commits

Author SHA1 Message Date
c6f6d33fbb WIP 2025-07-15 03:26:53 +02:00
f5e06776d0 library_list: use joined data from libraryListGetListEntries instead of FutureBuilders 2025-07-15 03:18:32 +02:00
c6d97c73a9 Rewrite database models as class extensions + data classes 2025-07-15 03:12:55 +02:00
61c8d27bb9 android: make debug app a separate instance 2025-07-15 02:05:57 +02:00
ee4289030b models/history: remove dead code 2025-07-14 19:39:10 +02:00
ce78bed1a6 word_search: add history count bubble in appbar 2025-07-14 19:02:57 +02:00
072f855c13 initialization: remove use of GetIt 2025-07-14 18:20:04 +02:00
38af074e6f history: add infinite scroll 2025-07-14 01:06:01 +02:00
29387397da treewide: remove remaining uses of db() 2025-07-14 00:01:01 +02:00
6e99792125 history: remove all uses of db() in model methods 2025-07-13 23:56:38 +02:00
0b844f0342 library_list: remove all uses of db() in model methods 2025-07-13 22:41:46 +02:00
7f4e230713 library_list: add basic support for pagination 2025-07-13 22:24:25 +02:00
7bb948fe19 treewide: format 2025-07-13 22:11:03 +02:00
87ce1b9228 treewide: remove exports and implicit imports 2025-07-13 22:06:46 +02:00
a0b4834e1c word_search: fix pagination and search result count 2025-07-13 21:07:37 +02:00
6c0342ac6c Add infinite scroll for word search results 2025-07-13 19:41:00 +02:00
ae0789b133 Add changelogs for v0.4.0 and bump version number in pubspec.yaml 2025-07-13 17:33:47 +02:00
a46ef5f3ea history: add assertions for entry constructors 2025-07-13 17:26:05 +02:00
c2194bb120 word_search: trim word before search 2025-07-13 17:25:46 +02:00
c43f4366f1 kanji_result: don't allow search for on:/kun: labels 2025-07-13 17:07:37 +02:00
671bee0f26 history: fix date separators 2025-07-13 16:46:31 +02:00
cf536bdbbd disable horizontal rotation 2025-07-13 16:30:08 +02:00
4cf1e751a8 word_search: display full language lame, omit phrase if null 2025-07-13 16:23:07 +02:00
57 changed files with 1802 additions and 1309 deletions

View File

@@ -28,6 +28,8 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
resValue "string", "app_name", "Mugiten"
}
buildTypes {
@@ -36,6 +38,12 @@ android {
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.debug
}
debug {
applicationIdSuffix ".debug"
versionNameSuffix "-debug"
resValue "string", "app_name", "Mugiten Debug"
}
}
}

View File

@@ -1,4 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="@string/app_name"
/>
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.

View File

@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="Mugiten"
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity

22
docs/changelog/v0.4.0.md Normal file
View File

@@ -0,0 +1,22 @@
# v0.4.0
(TODO: add description)
## New features ✨
- Added infinite scroll for word search results.
- Added infinite scroll for history view.
- Added a bubble in the top bar of word search results to indicate how many times it has been searched before.
## Changes 🔧
- Display proper language names for "From language" references.
- Lock rotation to portrait mode for the entire application.
- Don't allow users to search for whitespace only queries.
## Bugfixes 🐞
- Fix a bug where some "From language" references would display 'null' as the phrase.
- Remove duplicate date separators in the history page.
- Don't allow users to search for the `On:` and `Kun:` labels on the kanji result page.
- Strip leading and trailing whitespace from search queries.

View File

@@ -5,9 +5,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../models/themes/theme.dart';
import '../../settings.dart';
export 'package:flutter_bloc/flutter_bloc.dart';
export 'package:mugiten/models/themes/theme.dart';
part 'theme_event.dart';
part 'theme_state.dart';

View File

@@ -1,6 +1,7 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/theme/theme_bloc.dart';
import '../../settings.dart';

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart' hide Ink;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:google_mlkit_digital_ink_recognition/google_mlkit_digital_ink_recognition.dart';
import 'package:jadb/search.dart';
import 'package:mugiten/models/themes/theme.dart';
import 'package:signature/signature.dart';
import 'package:sqflite/sqflite.dart';

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/theme/theme_bloc.dart';

View File

@@ -1,15 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:get_it/get_it.dart';
import 'package:mugiten/bloc/theme/theme_bloc.dart';
import 'package:mugiten/components/search/search_results_body/parts/circle_badge.dart';
import 'package:mugiten/models/history_entry.dart';
import 'package:sqflite/sqlite_api.dart';
import '../../models/history/history_entry.dart';
import '../../routing/routes.dart';
import '../../services/datetime.dart';
import '../../settings.dart';
import '../common/kanji_box.dart';
import '../common/loading.dart';
class HistoryEntryTile extends StatelessWidget {
final HistoryEntry entry;
@@ -40,22 +41,14 @@ class HistoryEntryTile extends StatelessWidget {
MaterialPageRoute get timestamps => MaterialPageRoute(
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('Last searched')),
body: FutureBuilder<List<DateTime>>(
future: entry.timestamps,
builder: (context, snapshot) {
// TODO: provide proper error handling
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
if (!snapshot.hasData) return const LoadingScreen();
return ListView(
children: snapshot.data!
.map(
(ts) => ListTile(
title: Text('${formatDate(ts)} ${formatTime(ts)}'),
),
)
.toList(),
);
},
body: ListView(
children: entry.timestamps
.map(
(ts) => ListTile(
title: Text('${formatDate(ts)} ${formatTime(ts)}'),
),
)
.toList(),
),
),
);
@@ -70,7 +63,7 @@ class HistoryEntryTile extends StatelessWidget {
backgroundColor: Colors.red,
icon: Icons.delete,
onPressed: (_) async {
await entry.delete();
await GetIt.instance.get<Database>().historyEntryDelete(entry.id);
onDelete?.call();
},
),
@@ -103,7 +96,7 @@ class HistoryEntryTile extends StatelessWidget {
)
: Expanded(child: Text(entry.word!))),
if (entry.isKanji) Expanded(child: SizedBox.shrink()),
if ((entry.timestampCount ?? 0) > 1)
if (entry.timestampCount > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: BlocBuilder<ThemeBloc, ThemeState>(

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:jadb/util/romaji_transliteration.dart';
import 'package:mugiten/models/themes/theme.dart';
import '../../../bloc/theme/theme_bloc.dart';
import '../../../routing/routes.dart';

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../bloc/theme/theme_bloc.dart';
import '../../../settings.dart';

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../bloc/theme/theme_bloc.dart';
import '../../../settings.dart';

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../bloc/theme/theme_bloc.dart';

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../bloc/theme/theme_bloc.dart';
import '../../../routing/routes.dart';

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../bloc/theme/theme_bloc.dart';

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../bloc/theme/theme_bloc.dart';

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:jadb/util/romaji_transliteration.dart';
import 'package:mugiten/models/themes/theme.dart';
import '../../../bloc/theme/theme_bloc.dart';
import '../../../routing/routes.dart';
@@ -54,11 +56,13 @@ class YomiChips extends StatelessWidget {
required BuildContext context,
required String yomi,
required ColorSet colors,
bool searchable = true,
TextStyle? extraTextStyle,
}) =>
InkWell(
onTap: () =>
Navigator.pushNamed(context, Routes.search, arguments: yomi),
onTap: searchable
? () => Navigator.pushNamed(context, Routes.search, arguments: yomi)
: null,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 5),
padding: const EdgeInsets.symmetric(
@@ -99,6 +103,7 @@ class YomiChips extends StatelessWidget {
yomiCard(
context: context,
yomi: type == YomiType.kunyomi ? 'Kun:' : 'On:',
searchable: false,
colors: ColorSet(
foreground: type.getColors(context).background,
background: Colors.transparent,

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../bloc/theme/theme_bloc.dart';
import '../../../routing/routes.dart';

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../bloc/theme/theme_bloc.dart';
import '../../../routing/routes.dart';

View File

@@ -1,12 +1,10 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:jadb/search.dart';
import 'package:mugiten/models/history/history_entry.dart';
import 'package:mugiten/models/library_list.dart';
import 'package:ruby_text/ruby_text.dart';
import 'package:sqflite/sqflite.dart';
import '../../models/library/library_list.dart';
import '../common/kanji_box.dart';
import '../common/loading.dart';
Future<void> showAddToLibraryDialog({
@@ -38,7 +36,7 @@ class AddToLibraryDialog extends StatefulWidget {
}
class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
Map<LibraryList, bool>? librariesContainEntry;
Map<String, bool>? librariesContainEntry;
/// A lock to make sure that the local data and the database doesn't
/// get out of sync.
@@ -48,25 +46,30 @@ class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
void initState() {
super.initState();
LibraryList.allListsContains(
jmdictEntryId: widget.jmdictEntryId,
kanji: widget.kanji,
).then((data) => setState(() => librariesContainEntry = data));
GetIt.instance
.get<Database>()
.libraryListAllListsContain(
jmdictEntryId: widget.jmdictEntryId,
kanji: widget.kanji,
)
.then((data) => setState(() => librariesContainEntry = data));
}
Future<void> toggleEntry({required LibraryList lib}) async {
Future<void> toggleEntry(String libraryName) async {
if (toggleLock) return;
setState(() => toggleLock = true);
await lib.toggleEntry(
jmdictEntryId: widget.jmdictEntryId,
kanji: widget.kanji,
);
await GetIt.instance.get<Database>().libraryListToggleEntry(
libraryName,
jmdictEntryId: widget.jmdictEntryId,
kanji: widget.kanji,
);
setState(() {
toggleLock = false;
librariesContainEntry![lib] = !librariesContainEntry![lib]!;
librariesContainEntry![libraryName] =
!librariesContainEntry![libraryName]!;
});
}
@@ -132,19 +135,19 @@ class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
? const LoadingScreen()
: ListView(
children: librariesContainEntry!.entries.map((e) {
final lib = e.key;
final libraryName = e.key;
final checked = e.value;
return ListTile(
onTap: () => toggleEntry(lib: lib),
onTap: () => toggleEntry(libraryName),
contentPadding:
const EdgeInsets.symmetric(vertical: 5),
title: Row(
children: [
Checkbox(
value: checked,
onChanged: (_) => toggleEntry(lib: lib),
onChanged: (_) => toggleEntry(libraryName),
),
Text(lib.name),
Text(libraryName),
],
),
);

View File

@@ -1,12 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:get_it/get_it.dart';
import 'package:jadb/search.dart';
import 'package:mugiten/components/search/search_results_body/search_card.dart';
import 'package:mugiten/models/library_list.dart';
import 'package:sqflite/sqlite_api.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';
@@ -14,7 +12,7 @@ import '../common/kanji_box.dart';
class LibraryListEntryTile extends StatelessWidget {
final int? index;
final LibraryList library;
final LibraryEntry entry;
final LibraryListEntry entry;
final void Function()? onDelete;
final void Function()? onUpdate;
@@ -31,7 +29,7 @@ class LibraryListEntryTile extends StatelessWidget {
Widget build(BuildContext context) {
return entry.kanji != null
? _kanjiTile(context, index, entry.kanji!)
: _jmdictEntryTile(context, index, entry.jmdictEntryId!);
: _jmdictEntryTile(context, index, entry);
}
Widget _index(BuildContext context, int index) {
@@ -52,10 +50,11 @@ class LibraryListEntryTile extends StatelessWidget {
backgroundColor: Colors.red,
icon: Icons.delete,
onPressed: (_) async {
await library.deleteEntry(
jmdictEntryId: entry.jmdictEntryId,
kanji: entry.kanji,
);
await GetIt.instance.get<Database>().libraryListDeleteEntry(
library.name,
jmdictEntryId: entry.jmdictEntryId,
kanji: entry.kanji,
);
onDelete?.call();
},
);
@@ -85,44 +84,15 @@ class LibraryListEntryTile extends StatelessWidget {
);
}
Widget _jmdictEntryTile(BuildContext context, int? index, int jmdictEntryId) {
return FutureBuilder(
future: GetIt.instance.get<Database>().jadbGetWordById(jmdictEntryId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return ListTile(
leading: (index != null) ? _index(context, index) : null,
title: const Expanded(
child: Text(
'...',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
);
}
if (!snapshot.hasData || snapshot.data == null) {
return ListTile(
leading: (index != null) ? _index(context, index) : null,
title: const Expanded(
child: Text(
'<Not found>',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
);
}
final entry = snapshot.data!;
final result = SearchResultCard(
result: entry,
leading: index != null ? _index(context, index) : null,
slidableActions: [_deleteAction()],
);
return result;
},
Widget _jmdictEntryTile(
BuildContext context,
int? index,
LibraryListEntry entry,
) {
return SearchResultCard(
result: entry.wordSearchResult!,
leading: index != null ? _index(context, index) : null,
slidableActions: [_deleteAction()],
);
}
}

View File

@@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:get_it/get_it.dart';
import 'package:mugiten/models/library_list.dart';
import 'package:sqflite/sqlite_api.dart';
import '../../models/library/library_list.dart';
import '../../routing/routes.dart';
import '../common/loading.dart';
class LibraryListTile extends StatelessWidget {
final Widget? leading;
@@ -37,11 +38,14 @@ class LibraryListTile extends StatelessWidget {
onUpdate?.call();
},
),
// TODO: ask for confirmation before deleting
SlidableAction(
backgroundColor: Colors.red,
icon: Icons.delete,
onPressed: (_) async {
await library.delete();
await GetIt.instance
.get<Database>()
.libraryListDeleteList(library.name);
onDelete?.call();
},
),
@@ -57,14 +61,7 @@ class LibraryListTile extends StatelessWidget {
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');
},
),
Text('${library.totalCount} items'),
],
),
),

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../../models/library/library_list.dart';
import 'package:get_it/get_it.dart';
import 'package:mugiten/models/library_list.dart';
import 'package:sqflite/sqlite_api.dart';
void Function() showNewLibraryDialog(context) => () async {
final String? listName = await showDialog<String>(
@@ -7,8 +9,10 @@ void Function() showNewLibraryDialog(context) => () async {
barrierDismissible: true,
builder: (_) => const NewLibraryDialog(),
);
if (listName == null) return;
LibraryList.insert(listName);
await GetIt.instance.get<Database>().libraryListInsertList(listName);
};
class NewLibraryDialog extends StatefulWidget {
@@ -37,7 +41,9 @@ class _NewLibraryDialogState extends State<NewLibraryDialog> {
return;
}
final nameAlreadyExists = await LibraryList.exists(proposedListName);
final nameAlreadyExists = await GetIt.instance
.get<Database>()
.libraryListExists(proposedListName);
if (nameAlreadyExists) {
setState(() => nameState = _NameState.alreadyExists);
} else {

View File

@@ -43,8 +43,9 @@ class GlobalSearchBar extends StatelessWidget {
color: AppTheme.mugitenWheat.background,
child: IconButton(
onPressed: () {
if (textController.text.isNotEmpty) {
_search(context, textController.text);
final text = textController.text.trim();
if (text.isNotEmpty) {
_search(context, text);
}
},
icon: const Icon(
@@ -67,9 +68,10 @@ class GlobalSearchBar extends StatelessWidget {
if (result != null && result.isNotEmpty) {
if (textController.selection.isValid) {
final pos = textController.selection.baseOffset;
textController.text = textController.text.substring(0, pos) +
result +
textController.text.substring(pos);
textController.text =
textController.text.substring(0, pos) +
result +
textController.text.substring(pos);
textController.selection = TextSelection.fromPosition(
TextPosition(offset: pos + result.length),
);

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../bloc/theme/theme_bloc.dart';
import '../../../../routing/routes.dart';

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:jadb/models/word_search/word_search_ruby.dart';
import '../../../../bloc/theme/theme_bloc.dart';

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import '../../../../../bloc/theme/theme_bloc.dart';
import 'package:mugiten/models/themes/theme.dart';
import 'search_chip.dart';
class EnglishDefinitions extends StatelessWidget {

View File

@@ -1,10 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:jadb/models/word_search/word_search_sense.dart';
import 'package:sealed_languages/sealed_languages.dart';
import '../../../../../bloc/theme/theme_bloc.dart';
import 'antonyms.dart';
import 'english_definitions.dart';
final Map<String, String> languageNameMap = {
...{
for (final lang in NaturalLanguage.list) lang.code: lang.name,
for (final lang in NaturalLanguage.list)
if (lang.bibliographicCode != null) lang.bibliographicCode!: lang.name,
}
};
class Sense extends StatelessWidget {
final int index;
final WordSearchSense sense;
@@ -26,9 +36,16 @@ class Sense extends StatelessWidget {
...sense.restrictedToKanji.map((e) => 'Restricted to $e'),
...sense.fields.map((e) => 'Field: ${_capitalize(e.description)}'),
...sense.misc.map((e) => e.description),
...sense.languageSource
.where((e) => !e.fullyDescribesSense)
.map((e) => 'From ${e.language}, "${e.phrase}"'),
...sense.languageSource.map((e) {
final languageName =
languageNameMap[e.language.toUpperCase()] ?? e.language;
if (e.phrase != null) {
return 'From $languageName, "${e.phrase}"';
} else {
return 'From $languageName';
}
}),
...sense.seeAlso.map((e) => 'See also: ${e.baseWord}'),
...sense.dialects.map((e) => '${_capitalize(e.description)} dialect'),
];

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:get_it/get_it.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 'package:mugiten/models/library_list.dart';
import 'package:sqflite/sqlite_api.dart';
import './parts/common_badge.dart';
import './parts/header.dart';
@@ -99,10 +101,12 @@ class _SearchResultCardState extends State<SearchResultCard> {
SlidableAction(
backgroundColor: Colors.yellow,
icon: Icons.star,
onPressed: (_) => LibraryList.favourites.toggleEntry(
jmdictEntryId: widget.result.entryId,
kanji: null,
),
onPressed: (_) =>
GetIt.instance.get<Database>().libraryListToggleEntry(
"favourites",
jmdictEntryId: widget.result.entryId,
kanji: null,
),
),
SlidableAction(
backgroundColor: Colors.blue,

View File

@@ -10,12 +10,8 @@ import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
export 'package:sqflite/sqlite_api.dart';
const int expectedDatabaseVersion = 2;
Database db() => GetIt.instance.get<Database>();
/// Returns the directory where mugiten's database file is stored.
Future<Directory> _databaseDir() async {
final Directory appDocDir = await getApplicationDocumentsDirectory();
@@ -216,7 +212,7 @@ Future<void> setupDatabase() async {
/// Resets the database by closing it, deleting the file, and setting it up again.
Future<void> resetDatabase() async {
log('Closing database...');
await db().close();
await GetIt.instance.get<Database>().close();
log('Deleting mugiten.sqlite file...');
File(await databasePath()).deleteSync();

View File

@@ -0,0 +1,109 @@
import 'package:sqflite/sqflite.dart';
class LinkedListSQLExpressionGenerator<T, IDType> {
final String tableName;
final String currentItemColumnName;
final String prevItemColumnName;
final bool multilist;
final String? multilistIdentifierColumnName;
const LinkedListSQLExpressionGenerator({
required this.tableName,
required this.currentItemColumnName,
required this.prevItemColumnName,
required this.multilist,
required this.multilistIdentifierColumnName,
}) : assert(
!multilist || multilistIdentifierColumnName != null,
"Multilist expressions need to specify their list identifier column name",
);
Future<void> insertItem(
DatabaseExecutor db,
T item, {
int? position,
String? list,
}) async {
assert(
!multilist || list != null,
"`list` must be specified for multilist tables",
);
throw UnimplementedError();
}
Future<void> moveItemByPosition(
DatabaseExecutor db,
int from,
int to, {
String? list,
}) async {
assert(
!multilist || list != null,
"`list` must be specified for multilist tables",
);
throw UnimplementedError();
}
Future<void> moveItemById(
DatabaseExecutor db,
IDType id,
int to, {
String? list,
}) async {
assert(
!multilist || list != null,
"`list` must be specified for multilist tables",
);
throw UnimplementedError();
}
Future<bool> deleteItemByPosition(
DatabaseExecutor db,
int position, {
String? list,
}) async {
assert(
!multilist || list != null,
"`list` must be specified for multilist tables",
);
throw UnimplementedError();
}
Future<bool> deleteItemById(
DatabaseExecutor db,
IDType id, {
String? list,
}) async {
assert(
!multilist || list != null,
"`list` must be specified for multilist tables",
);
throw UnimplementedError();
}
/// Verify that the linked list is consistent. i.e.
/// - There are no dangling items.
/// - There are no two items with common parents.
Future<bool> verify(
DatabaseExecutor db, {
String? list,
}) async {
throw UnimplementedError();
}
/// This function tries to fix an inconsistent linked list by:
/// - Finds dangling items and appends them to the end of the list in the order they were found
/// - Finds items with common parents and straightens them out in the order they were found (as well as their tails)
Future<bool> autofix(
DatabaseExecutor db,
) async {
if (multilist) {
throw UnimplementedError("autofix is undefined for multilist tables");
}
throw UnimplementedError();
}
}

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mugiten/screens/initialization.dart';
import 'package:mugiten/services/initialization/initialization_logic.dart';
@@ -41,6 +43,10 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
}
@override

View File

@@ -1,374 +0,0 @@
import 'dart:math';
import 'package:get_it/get_it.dart';
import 'package:mugiten/database/history/table_names.dart';
import '../../database/database.dart';
class HistoryEntry {
int id;
final String? kanji;
final String? word;
final DateTime lastTimestamp;
final int? timestampCount;
/// Whether this item is a kanji search or a word search
bool get isKanji => word == null;
HistoryEntry.withKanji({
required this.id,
required this.kanji,
required this.lastTimestamp,
this.timestampCount,
}) : word = null;
HistoryEntry.withWord({
required this.id,
required this.word,
required this.lastTimestamp,
this.timestampCount,
}) : kanji = null;
/// Reconstruct a HistoryEntry object with data from the database
/// This is specifically intended for the historyEntryOrderedByTimestamp
/// view, but it can also be used with custom searches as long as it
/// contains the following attributes:
///
/// - entryId
/// - timestamp
/// - word?
/// - kanji?
factory HistoryEntry.fromDBMap(Map<String, Object?> dbObject) =>
dbObject['word'] != null
? HistoryEntry.withWord(
id: dbObject['entryId']! as int,
word: dbObject['word']! as String,
lastTimestamp: DateTime.fromMillisecondsSinceEpoch(
dbObject['timestamp']! as int,
),
timestampCount: dbObject.containsKey('timestampCount')
? dbObject['timestampCount']! as int
: null,
)
: HistoryEntry.withKanji(
id: dbObject['entryId']! as int,
kanji: dbObject['kanji']! as String,
lastTimestamp: DateTime.fromMillisecondsSinceEpoch(
dbObject['timestamp']! as int,
),
timestampCount: dbObject.containsKey('timestampCount')
? dbObject['timestampCount']! as int
: null,
);
// TODO: There is a lot in common with
// insertKanji,
// insertWord,
// insertJsonEntry,
// insertJsonEntries,
// The commonalities should be factored into a helper function
/// Insert a kanji history entry into the database.
/// If it already exists, only a timestamp will be added
static Future<HistoryEntry> insertKanji({
required String kanji,
}) =>
db().transaction((txn) async {
final DateTime timestamp = DateTime.now();
late final int id;
final existingEntry = await txn.query(
HistoryTableNames.historyEntryKanji,
where: 'kanji = ?',
whereArgs: [kanji],
);
if (existingEntry.isNotEmpty) {
// Retrieve entry record id, and add a timestamp.
id = existingEntry.first['entryId']! as int;
await txn.insert(HistoryTableNames.historyEntryTimestamp, {
'entryId': id,
'timestamp': timestamp.millisecondsSinceEpoch,
});
} else {
// Create new record, and add a timestamp.
id = await txn.insert(
HistoryTableNames.historyEntry,
{},
nullColumnHack: 'id',
);
final Batch b = txn.batch();
b.insert(HistoryTableNames.historyEntryTimestamp, {
'entryId': id,
'timestamp': timestamp.millisecondsSinceEpoch,
});
b.insert(HistoryTableNames.historyEntryKanji, {
'entryId': id,
'kanji': kanji,
});
await b.commit();
}
return HistoryEntry.withKanji(
id: id,
kanji: kanji,
lastTimestamp: timestamp,
);
});
/// Insert a word history entry into the database.
/// If it already exists, only a timestamp will be added
static Future<HistoryEntry> insertWord({
required String word,
String? language,
}) =>
db().transaction((txn) async {
final DateTime timestamp = DateTime.now();
late final int id;
final existingEntry = await txn.query(
HistoryTableNames.historyEntryWord,
where: 'word = ?',
whereArgs: [word],
);
if (existingEntry.isNotEmpty) {
// Retrieve entry record id, and add a timestamp.
id = existingEntry.first['entryId']! as int;
await txn.insert(HistoryTableNames.historyEntryTimestamp, {
'entryId': id,
'timestamp': timestamp.millisecondsSinceEpoch,
});
} else {
id = await txn.insert(
HistoryTableNames.historyEntry,
{},
nullColumnHack: 'id',
);
final Batch b = txn.batch();
b.insert(HistoryTableNames.historyEntryTimestamp, {
'entryId': id,
'timestamp': timestamp.millisecondsSinceEpoch,
});
b.insert(HistoryTableNames.historyEntryWord, {
'entryId': id,
'word': word,
'language': {
null: null,
'japanese': 'j',
'english': 'e',
}[language]
});
await b.commit();
}
return HistoryEntry.withWord(
id: id,
word: word,
lastTimestamp: timestamp,
);
});
/// All recorded timestamps for this specific HistoryEntry
/// sorted in descending order.
Future<List<DateTime>> get timestamps async => GetIt.instance
.get<Database>()
.query(
HistoryTableNames.historyEntryTimestamp,
where: 'entryId = ?',
whereArgs: [id],
orderBy: 'timestamp DESC',
)
.then(
(timestamps) => timestamps
.map(
(t) => DateTime.fromMillisecondsSinceEpoch(
t['timestamp']! as int,
),
)
.toList(),
);
/// Export to json for archival reasons
/// Combined with [insertJsonEntry], this makes up functionality for exporting
/// and importing data from the app.
Future<Map<String, Object?>> toJson() async => {
'word': word,
'kanji': kanji,
'timestamps':
(await timestamps).map((ts) => ts.millisecondsSinceEpoch).toList()
};
/// Insert archived json entry into database if it doesn't exist there already.
/// Combined with [toJson], this makes up functionality for exporting and
/// importing data from the app.
static Future<HistoryEntry> insertJsonEntry(
Map<String, Object?> json,
) async =>
db().transaction((txn) async {
final b = txn.batch();
final bool isKanji = json['word'] == null;
final existingEntry = isKanji
? await txn.query(
HistoryTableNames.historyEntryKanji,
where: 'kanji = ?',
whereArgs: [json['kanji']! as String],
)
: await txn.query(
HistoryTableNames.historyEntryWord,
where: 'word = ?',
whereArgs: [json['word']! as String],
);
late final int id;
if (existingEntry.isEmpty) {
id = await txn.insert(
HistoryTableNames.historyEntry,
{},
nullColumnHack: 'id',
);
if (isKanji) {
b.insert(HistoryTableNames.historyEntryKanji, {
'entryId': id,
'kanji': json['kanji']! as String,
});
} else {
b.insert(HistoryTableNames.historyEntryWord, {
'entryId': id,
'word': json['word']! as String,
});
}
} else {
id = existingEntry.first['entryId']! as int;
}
final List<int> timestamps =
(json['timestamps']! as List).map((ts) => ts as int).toList();
for (final timestamp in timestamps) {
b.insert(
HistoryTableNames.historyEntryTimestamp,
{
'entryId': id,
'timestamp': timestamp,
},
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
await b.commit();
return isKanji
? HistoryEntry.withKanji(
id: id,
kanji: json['kanji']! as String,
lastTimestamp:
DateTime.fromMillisecondsSinceEpoch(timestamps.reduce(max)),
)
: HistoryEntry.withWord(
id: id,
word: json['word']! as String,
lastTimestamp:
DateTime.fromMillisecondsSinceEpoch(timestamps.reduce(max)),
);
});
/// An efficient implementation of [insertJsonEntry] for multiple
/// entries.
///
/// This assumes that there are no duplicates within the elements
/// in the json.
static Future<List<HistoryEntry>> insertJsonEntries(
List<Map<String, Object?>> json,
) =>
db().transaction((txn) async {
final b = txn.batch();
final List<HistoryEntry> entries = [];
for (final jsonObject in json) {
final bool isKanji = jsonObject['word'] == null;
final existingEntry = isKanji
? await txn.query(
HistoryTableNames.historyEntryKanji,
where: 'kanji = ?',
whereArgs: [jsonObject['kanji']! as String],
)
: await txn.query(
HistoryTableNames.historyEntryWord,
where: 'word = ?',
whereArgs: [jsonObject['word']! as String],
);
late final int id;
if (existingEntry.isEmpty) {
id = await txn.insert(
HistoryTableNames.historyEntry,
{},
nullColumnHack: 'id',
);
if (isKanji) {
b.insert(HistoryTableNames.historyEntryKanji, {
'entryId': id,
'kanji': jsonObject['kanji']! as String,
});
} else {
b.insert(HistoryTableNames.historyEntryWord, {
'entryId': id,
'word': jsonObject['word']! as String,
});
}
} else {
id = existingEntry.first['entryId']! as int;
}
final List<int> timestamps = (jsonObject['timestamps']! as List)
.map((ts) => ts as int)
.toList();
for (final timestamp in timestamps) {
b.insert(
HistoryTableNames.historyEntryTimestamp,
{
'entryId': id,
'timestamp': timestamp,
},
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
entries.add(
isKanji
? HistoryEntry.withKanji(
id: id,
kanji: jsonObject['kanji']! as String,
lastTimestamp: DateTime.fromMillisecondsSinceEpoch(
timestamps.reduce(max),
),
)
: HistoryEntry.withWord(
id: id,
word: jsonObject['word']! as String,
lastTimestamp: DateTime.fromMillisecondsSinceEpoch(
timestamps.reduce(max),
),
),
);
}
await b.commit();
return entries;
});
static Future<int> amountOfEntries() async {
final query = await db().query(
HistoryTableNames.historyEntry,
columns: ['COUNT(*) AS count'],
);
return query.first['count']! as int;
}
static Future<List<HistoryEntry>> get fromDB async =>
(await db().query(HistoryTableNames.historyEntryOrderedByTimestamp))
.map((e) => HistoryEntry.fromDBMap(e))
.toList();
Future<void> delete() => db()
.delete(HistoryTableNames.historyEntry, where: 'id = ?', whereArgs: [id]);
}

View File

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

View File

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

View File

@@ -1,13 +0,0 @@
import 'word_query.dart';
class WordResult {
final DateTime timestamp;
final String word;
final WordQuery searchString;
WordResult({
required this.timestamp,
required this.word,
required this.searchString,
});
}

View File

@@ -0,0 +1,435 @@
import 'package:jadb/models/kanji_search/kanji_search_result.dart';
import 'package:jadb/models/word_search/word_search_result.dart';
import 'package:jadb/search.dart';
import 'package:mugiten/database/history/table_names.dart';
import 'package:sqflite/sqlite_api.dart';
extension HistoryEntryExt on DatabaseExecutor {
// Query
Future<HistoryEntry?> historyEntryGetWord(
String word,
// bool includeSearchResult = false,
) async {
assert(word.isNotEmpty, 'Word must not be empty');
final result = await query(
HistoryTableNames.historyEntryWord,
where: 'word = ?',
whereArgs: [word],
);
if (result.isEmpty) {
return null;
}
final entryId = result.first['entryId']! as int;
final language = result.first['language'] as String?;
final List<DateTime> timestamps = (await query(
HistoryTableNames.historyEntryTimestamp,
where: 'entryId = ?',
whereArgs: [entryId],
orderBy: 'timestamp DESC',
))
.map((e) => DateTime.fromMillisecondsSinceEpoch(e['timestamp']! as int))
.toList();
// TODO: join with search result(s) if matching exactly one, or single search result
return HistoryEntry(
id: entryId,
timestamps: timestamps,
word: word,
language: language,
wordSearchResult: null,
);
}
Future<HistoryEntry?> historyEntryGetKanji(
String kanji, {
bool includeSearchResult = false,
}) async {
assert(kanji.runes.length == 1, 'Kanji must be a single character');
final result = await query(
HistoryTableNames.historyEntryKanji,
where: 'kanji = ?',
whereArgs: [kanji],
);
if (result.isEmpty) {
return null;
}
final entryId = result.first['entryId']! as int;
final List<DateTime> timestamps = (await query(
HistoryTableNames.historyEntryTimestamp,
where: 'entryId = ?',
whereArgs: [entryId],
orderBy: 'timestamp DESC',
))
.map((e) => DateTime.fromMillisecondsSinceEpoch(e['timestamp']! as int))
.toList();
KanjiSearchResult? kanjiSearchResult =
includeSearchResult ? await jadbSearchKanji(kanji) : null;
return HistoryEntry(
id: entryId,
timestamps: timestamps,
kanji: kanji,
kanjiSearchResult: kanjiSearchResult,
);
}
Future<List<HistoryEntry>> historyEntryGetAll({
int? page,
int? pageSize,
// TODO: implement join against jadb
// bool includeSearchResult = false,
}) async {
assert(page == null || page >= 0);
assert(pageSize == null || pageSize > 0);
assert(
pageSize != null || page == null,
'pageSize must be provided if page is provided',
);
final result = await rawQuery(
'''
SELECT
*,
GROUP_CONCAT("${HistoryTableNames.historyEntryTimestamp}"."timestamp") AS "timestamps"
FROM "${HistoryTableNames.historyEntryOrderedByTimestamp}"
LEFT JOIN "${HistoryTableNames.historyEntryTimestamp}" USING ("entryId")
GROUP BY "${HistoryTableNames.historyEntryOrderedByTimestamp}"."entryId"
ORDER BY "${HistoryTableNames.historyEntryOrderedByTimestamp}"."timestamp" DESC
${pageSize != null ? 'LIMIT ?' : ''}
${page != null ? 'OFFSET ?' : ''}
''',
[
if (pageSize != null) pageSize,
if (page != null) page * pageSize!,
],
);
final List<HistoryEntry> entries = result.map((e) {
final timestamps = (e['timestamps'] as String)
.split(',')
.map((ts) => DateTime.fromMillisecondsSinceEpoch(int.parse(ts)))
.toList();
if (e['kanji'] != null) {
return HistoryEntry(
id: e['entryId']! as int,
timestamps: timestamps,
kanji: e['kanji'] as String,
);
} else {
return HistoryEntry(
id: e['entryId']! as int,
timestamps: timestamps,
word: e['word'] as String,
);
}
}).toList();
return entries;
}
Future<int> historyEntryAmount({
/// Whether to ignore duplicate searches
bool unique = true,
}) async {
late final int count;
if (unique) {
final result = await query(
HistoryTableNames.historyEntry,
columns: ['COUNT(*) AS count'],
);
count = result.firstOrNull?['count'] as int? ?? 0;
} else {
final result = await rawQuery(
'''
SELECT COUNT(*) AS count
FROM "${HistoryTableNames.historyEntryTimestamp}"
''',
);
count = result.firstOrNull?['count'] as int? ?? 0;
}
return count;
}
// Modification
Future<void> historyEntryInsertKanji(String kanji) async {
final DateTime timestamp = DateTime.now();
final existingEntry = await query(
HistoryTableNames.historyEntryKanji,
where: 'kanji = ?',
whereArgs: [kanji],
);
late final int id;
if (existingEntry.isNotEmpty) {
// Retrieve entry record id, and add a timestamp.
id = existingEntry.first['entryId']! as int;
} else {
id = await insert(
HistoryTableNames.historyEntry,
{},
nullColumnHack: 'id',
);
await insert(
HistoryTableNames.historyEntryKanji,
{
'entryId': id,
'kanji': kanji,
},
);
}
await insert(
HistoryTableNames.historyEntryTimestamp,
{
'entryId': id,
'timestamp': timestamp.millisecondsSinceEpoch,
},
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
Future<void> historyEntryInsertWord(String word, {String? language}) async {
final DateTime timestamp = DateTime.now();
final existingEntry = await query(
HistoryTableNames.historyEntryWord,
where: 'word = ?',
whereArgs: [word],
);
late final int id;
if (existingEntry.isNotEmpty) {
// Retrieve entry record id, and add a timestamp.
id = existingEntry.first['entryId']! as int;
} else {
id = await insert(
HistoryTableNames.historyEntry,
{},
nullColumnHack: 'id',
);
await insert(
HistoryTableNames.historyEntryWord,
{
'entryId': id,
'word': word,
// TODO: use an enum?
'language': {
null: null,
'japanese': 'j',
'english': 'e',
}[language]
},
);
}
await insert(
HistoryTableNames.historyEntryTimestamp,
{
'entryId': id,
'timestamp': timestamp.millisecondsSinceEpoch,
},
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
Future<bool> historyEntryDelete(int entryId) async {
await delete(
HistoryTableNames.historyEntryTimestamp,
where: 'entryId = ?',
whereArgs: [entryId],
);
await delete(
HistoryTableNames.historyEntryKanji,
where: 'entryId = ?',
whereArgs: [entryId],
);
await delete(
HistoryTableNames.historyEntryWord,
where: 'entryId = ?',
whereArgs: [entryId],
);
final result = await delete(
HistoryTableNames.historyEntry,
where: 'id = ?',
whereArgs: [entryId],
);
return result > 0;
}
Future<bool> historyEntryDeleteTimestamp(
int entryId,
DateTime timestamp,
) async {
final timestampCount = await query(
HistoryTableNames.historyEntryTimestamp,
columns: ['COUNT(*) AS count'],
where: 'entryId = ?',
whereArgs: [entryId],
);
if (timestampCount.isEmpty) {
return false;
}
final result = await delete(
HistoryTableNames.historyEntryTimestamp,
where: 'entryId = ? AND timestamp = ?',
whereArgs: [
entryId,
timestamp.millisecondsSinceEpoch,
],
);
if (result == 0) {
return false; // No timestamp was deleted
}
// If this is the last timestamp, delete the entry
if (timestampCount.isEmpty || timestampCount.first['count']! as int <= 1) {
return await historyEntryDelete(entryId);
}
return true;
}
Future<void> historyEntryInsertManyFromJson(
List<Map<String, Object?>> json,
) async {
final b = batch();
for (final jsonObject in json) {
final bool isKanji = jsonObject['word'] == null;
final existingEntry = isKanji
? await query(
HistoryTableNames.historyEntryKanji,
where: 'kanji = ?',
whereArgs: [jsonObject['kanji']! as String],
)
: await query(
HistoryTableNames.historyEntryWord,
where: 'word = ?',
whereArgs: [jsonObject['word']! as String],
);
late final int id;
if (existingEntry.isEmpty) {
id = await insert(
HistoryTableNames.historyEntry,
{},
nullColumnHack: 'id',
);
if (isKanji) {
b.insert(HistoryTableNames.historyEntryKanji, {
'entryId': id,
'kanji': jsonObject['kanji']! as String,
});
} else {
b.insert(HistoryTableNames.historyEntryWord, {
'entryId': id,
'word': jsonObject['word']! as String,
});
}
} else {
id = existingEntry.first['entryId']! as int;
}
final List<int> timestamps =
(jsonObject['timestamps']! as List).map((ts) => ts as int).toList();
for (final timestamp in timestamps) {
b.insert(
HistoryTableNames.historyEntryTimestamp,
{
'entryId': id,
'timestamp': timestamp,
},
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
}
await b.commit();
}
}
class HistoryEntry {
final int id;
final List<DateTime> timestamps;
final String? word;
final String? language;
final WordSearchResult? wordSearchResult;
final String? kanji;
final KanjiSearchResult? kanjiSearchResult;
HistoryEntry({
required this.id,
required this.timestamps,
this.word,
this.language,
this.wordSearchResult,
this.kanji,
this.kanjiSearchResult,
}) : assert(
(word != null && kanji == null) || (word == null && kanji != null),
'HistoryEntry must have either a word or a kanji, but not both',
),
assert(
(language == null || word != null),
'If language is provided, word must not be null',
),
assert(
(kanjiSearchResult == null || kanji != null),
'If kanjiSearchResult is provided, kanji must not be null',
),
assert(
(wordSearchResult == null || word != null),
'If wordSearchResult is provided, word must not be null',
),
assert(
kanji == null || kanji.runes.length == 1,
'Kanji must be a single character',
),
// TODO: This has not always been the case, so we should add a migration
// or something to clean up the data.
// assert(
// word == null || word == word.trim(),
// 'Word must not contain leading or trailing whitespace',
// ),
assert(
timestamps.isNotEmpty,
'Timestamps must not be empty',
);
bool get isKanji => word == null;
int get timestampCount => timestamps.length;
DateTime get lastTimestamp => timestamps.isNotEmpty
? timestamps.reduce((a, b) => a.isAfter(b) ? a : b)
: DateTime.fromMillisecondsSinceEpoch(0);
Map<String, Object?> toJson() {
return {
'word': word,
'kanji': kanji,
'timestamps': timestamps.map((ts) => ts.millisecondsSinceEpoch).toList(),
};
}
}

View File

@@ -1,67 +0,0 @@
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

@@ -1,542 +0,0 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:get_it/get_it.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>> entries(DatabaseExecutor db) 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(db());
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(db())).lastOrNull;
await db().insert(LibraryListTableNames.libraryListEntry, {
'listName': name,
'jmdictEntryId': jmdictEntryId,
'kanji': kanji,
'prevEntryJmdictEntryId': prevEntry?.jmdictEntryId,
'prevEntryKanji': prevEntry?.kanji,
});
}
Future<void> insertJsonEntries(
List<Map<String, Object?>> jsonEntries,
) async {
List<LibraryEntry> entries =
jsonEntries.map((e) => LibraryEntry.fromJson(e)).toList();
// TODO: batch
for (final entry in entries) {
if (entry.kanji != null) {
await insertEntry(
kanji: entry.kanji,
jmdictEntryId: null,
position: null,
lastModified: entry.lastModified,
);
} else if (entry.jmdictEntryId != null) {
await insertEntry(
jmdictEntryId: entry.jmdictEntryId,
kanji: null,
position: null,
lastModified: entry.lastModified,
);
}
}
}
/// 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().transaction((txn) async {
await txn.delete(
LibraryListTableNames.libraryListEntry,
where: 'listName = ?',
whereArgs: [name],
);
final String prevName = await txn
.query(
LibraryListTableNames.libraryList,
columns: ['prevList'],
where: 'name = ?',
whereArgs: [name],
)
.then((rows) => rows.first['prevList']! as String);
final String? nextName = await txn
.query(
LibraryListTableNames.libraryList,
columns: ['name'],
where: 'prevList = ?',
whereArgs: [name],
)
.then((rows) => rows.firstOrNull?['name'] as String?);
await txn.delete(
LibraryListTableNames.libraryList,
where: 'name = ?',
whereArgs: [name],
);
if (nextName != null) {
await txn.update(
LibraryListTableNames.libraryList,
{'prevList': prevName},
where: 'name = ?',
whereArgs: [nextName],
);
}
if (!await verifyLibrariesLinkedList(txn)) {
print(
'Library list "$name" has a broken linked list after deletion, rolling back');
txn.execute('ROLLBACK');
}
});
}
Future<bool> verifyLibrariesLinkedList(
DatabaseExecutor db,
) async {
final int allItemsCount = await db.query(
LibraryListTableNames.libraryList,
columns: ['COUNT(*) AS count'],
).then((rows) => rows.first['count']! as int);
final int distinctPrevListCount = await db.query(
LibraryListTableNames.libraryList,
columns: ['COUNT(DISTINCT prevList) AS count'],
).then((rows) => (rows.first['count']! as int) + 1);
final int recursiveCount = await db.query(
LibraryListTableNames.libraryListOrdered,
columns: ['COUNT(*) AS count'],
).then((rows) => rows.first['count']! as int);
if (allItemsCount != distinctPrevListCount) {
log(
'Library list "$name" has a mismatch between all items count ($allItemsCount) and distinct prevList count ($distinctPrevListCount).',
);
return false;
}
if (recursiveCount != allItemsCount) {
log(
'Library list "$name" has a mismatch between recursive count ($recursiveCount) and all items count ($allItemsCount).',
);
return false;
}
return true;
}
}

View File

@@ -0,0 +1,748 @@
import 'package:collection/collection.dart';
import 'package:jadb/models/kanji_search/kanji_search_result.dart';
import 'package:jadb/models/word_search/word_search_result.dart';
import 'package:jadb/search.dart';
import 'package:mugiten/database/library_list/table_names.dart';
import 'package:sqflite/sqlite_api.dart';
extension LibraryListExt on DatabaseExecutor {
// Query
Future<List<LibraryList>> libraryListGetLists({
int? page,
int? pageSize,
}) async {
final result = await rawQuery(
'''
SELECT
"name",
(
SELECT COUNT(*)
FROM "${LibraryListTableNames.libraryListEntry}"
WHERE "listName" = "name"
) AS "count"
FROM "${LibraryListTableNames.libraryListOrdered}"
${pageSize != null ? 'LIMIT ?' : ''}
${page != null ? 'OFFSET ?' : ''}
''',
[
if (pageSize != null) pageSize,
if (page != null) page * pageSize!,
],
);
// COUNT(*) AS "count"
// LEFT JOIN "${LibraryListTableNames.libraryListEntry}"
return result
.map((row) => LibraryList(
name: row['name'] as String,
totalCount: row['count'] as int? ?? 0,
))
.toList();
}
Future<LibraryList?> libraryListGetList(String listName) async {
assert(listName.isNotEmpty, 'Library list name must not be empty.');
final result = await rawQuery(
'''
SELECT
"name",
(
SELECT COUNT(*)
FROM "${LibraryListTableNames.libraryListEntry}"
WHERE "listName" = "name"
) AS "count"
FROM "${LibraryListTableNames.libraryListOrdered}"
WHERE "name" = ?
''',
[listName],
);
if (result.isEmpty) {
return null;
}
return LibraryList(
name: result.first['name'] as String,
totalCount: result.first['count'] as int? ?? 0,
);
}
Future<LibraryListPage?> libraryListGetListEntries(
String listName, {
int? page,
int? pageSize,
bool includeSearchResult = false,
}) async {
assert(listName.isNotEmpty, 'Library list name must not be empty.');
assert(
page == null || page >= 0,
'Page must be null or a non-negative integer.',
);
assert(
pageSize == null || pageSize > 0,
'Page size must be null or a positive integer.',
);
assert(
page == null || pageSize != null,
'If page is provided, pageSize must also be provided.',
);
final list = await libraryListGetList(listName);
if (list == null) {
return null;
}
final entries = await rawQuery(
'''
WITH RECURSIVE
"RecursionTable"(
"jmdictEntryId",
"kanji",
"lastModified"
) AS (
SELECT
"jmdictEntryId",
"kanji",
"lastModified"
FROM "${LibraryListTableNames.libraryListEntry}"
WHERE
"listName" = ?
AND "prevEntryJmdictEntryId" IS NULL
AND "prevEntryKanji" IS NULL
UNION ALL
SELECT
"R"."jmdictEntryId",
"R"."kanji",
"R"."lastModified"
FROM "${LibraryListTableNames.libraryListEntry}" AS "R", "RecursionTable"
WHERE
"R"."listName" = ?
AND ("R"."prevEntryJmdictEntryId" = "RecursionTable"."jmdictEntryId"
OR "R"."prevEntryKanji" = "RecursionTable"."kanji")
)
SELECT
"jmdictEntryId",
"kanji",
"lastModified"
FROM "RecursionTable"
${pageSize != null ? 'LIMIT ?' : ''}
${page != null ? 'OFFSET ?' : ''}
''',
[
listName,
listName,
if (pageSize != null) pageSize,
if (page != null) page * pageSize!,
],
);
Map<int, WordSearchResult>? wordResults;
Map<String, KanjiSearchResult>? kanjiResults;
if (includeSearchResult) {
final wordResultJmdictIds = entries
.where((e) => e['jmdictEntryId'] != null)
.map((e) => e['jmdictEntryId'] as int)
.toSet();
wordResults = await jadbGetManyWordsByIds(wordResultJmdictIds);
final kanjiResultKanjis = entries
.where((e) => e['kanji'] != null)
.map((e) => e['kanji'] as String)
.toSet();
kanjiResults = await jadbGetManyKanji(kanjiResultKanjis);
}
final result = entries.map((entry) {
if (entry['jmdictEntryId'] != null) {
return LibraryListEntry.fromJmdictId(
jmdictEntryId: entry['jmdictEntryId'] as int,
wordSearchResult: wordResults?[entry['jmdictEntryId'] as int],
lastModified: DateTime.fromMillisecondsSinceEpoch(
entry['lastModified'] as int,
),
);
} else if (entry['kanji'] != null) {
return LibraryListEntry.fromKanji(
kanji: entry['kanji'] as String,
kanjiSearchResult: kanjiResults?[entry['kanji'] as String],
lastModified: DateTime.fromMillisecondsSinceEpoch(
entry['lastModified'] as int,
),
);
} else {
throw ArgumentError(
'Library list entry must have either jmdictEntryId or kanji.',
);
}
}).toList();
return LibraryListPage(
name: listName,
totalCount: list.totalCount,
entries: result,
);
}
Future<Map<String, bool>> libraryListAllListsContain({
int? jmdictEntryId,
String? kanji,
}) async {
final result = await rawQuery(
'''
SELECT
"name",
EXISTS(
SELECT * FROM "${LibraryListTableNames.libraryListEntry}"
WHERE "listName" = "name"
AND ("jmdictEntryId" = ? OR "kanji" = ?)
) AS "exists"
FROM "${LibraryListTableNames.libraryListOrdered}"
''',
[
jmdictEntryId,
kanji,
],
);
return {
for (final row in result)
row['name'] as String: (row['exists'] as int) == 1,
};
}
Future<bool> libraryListListContains(
String listName, {
int? jmdictEntryId,
String? kanji,
}) async {
assert(listName.isNotEmpty, 'Library list name must not be empty.');
final result = await rawQuery(
'''
SELECT EXISTS(
SELECT * FROM "${LibraryListTableNames.libraryListEntry}"
WHERE "listName" = ?
AND ("jmdictEntryId" = ? OR "kanji" = ?)
) AS "exists"
''',
[
listName,
jmdictEntryId,
kanji,
],
);
return (result.firstOrNull?['exists'] as int? ?? 0) == 1;
}
Future<int> libraryListAmount() async {
final result = await query(
LibraryListTableNames.libraryList,
columns: ['COUNT(*) AS count'],
);
return result.firstOrNull?['count'] as int? ?? 0;
}
Future<bool> libraryListExists(String listName) async {
assert(listName.isNotEmpty, 'Library list name must not be empty.');
final result = await rawQuery(
'''
SELECT EXISTS(
SELECT * FROM "${LibraryListTableNames.libraryList}"
WHERE "name" = ?
) AS "exists"
''',
[listName],
);
return (result.firstOrNull?['exists'] as int? ?? 0) == 1;
}
// Modification
/// Inserts a new library list into the database.
Future<bool> libraryListInsertList(
String listName, {
bool existsOk = true,
}) async {
assert(listName.isNotEmpty, 'Library list name must not be empty.');
if (!existsOk && await libraryListExists(listName)) {
return false;
}
// // This is ok, because "favourites" should always exist.
final prevList = (await libraryListGetLists()).last;
await insert(
LibraryListTableNames.libraryList,
{
'name': listName,
'prevList': prevList.name,
},
);
return true;
}
/// Deletes a library list by its name.
Future<bool> libraryListDeleteList(
String listName, {
bool notEmptyOk = true,
bool doesNotExistOk = false,
}) async {
assert(listName.isNotEmpty, 'Library list name must not be empty.');
assert(listName != "favourites", 'Cannot delete the "favourites" list.');
if (!doesNotExistOk && !(await libraryListExists(listName))) {
return false;
}
if (!notEmptyOk &&
((await libraryListGetList(listName))?.totalCount ?? 0) > 0) {
return false;
}
final result = await delete(
LibraryListTableNames.libraryList,
where: '"name" = ?',
whereArgs: [listName],
);
return doesNotExistOk || result > 0;
}
/// Deletes all entries in a library list.
Future<bool> libraryListDeleteAllEntries(
String listName, {
bool doesNotExistOk = false,
}) async {
assert(listName.isNotEmpty, 'Library list name must not be empty.');
if (!doesNotExistOk && !(await libraryListExists(listName))) {
return false;
}
final result = await delete(
LibraryListTableNames.libraryListEntry,
where: '"listName" = ?',
whereArgs: [listName],
);
return doesNotExistOk || result > 0;
}
/// Appends an entry into the library list, optionally at a specific position.
///
/// This function returns false if the position is out of bounds,
/// if the list does not exist, or if the entry is already a part of the list.
Future<bool> libraryListInsertEntry(
String listName, {
int? jmdictEntryId,
String? kanji,
int? position,
}) async {
assert(listName.isNotEmpty, 'Library list name must not be empty.');
assert(
(jmdictEntryId == null) != (kanji == null),
'Either jmdictEntryId or kanji must be provided, but not both.',
);
// TODO: set up lastModified insertion
if (!await libraryListExists(listName)) {
return false;
}
if (await libraryListListContains(
listName,
jmdictEntryId: jmdictEntryId,
kanji: kanji,
)) {
return false;
}
if (position != null) {
final len = (await libraryListGetList(listName))!.totalCount;
if (0 > position || position > len) {
return false;
} else if (position != len) {
// TODO: use a transaction instead of a batch
final b = batch();
final entries_ = (await libraryListGetListEntries(listName))!.entries;
// TODO: create a query to get entries at exact positions.
final prevEntry = entries_[position - 1];
final nextEntry = entries_[position];
b.insert(LibraryListTableNames.libraryListEntry, {
'listName': listName,
'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: [listName, nextEntry.jmdictEntryId, nextEntry.kanji],
);
await b.commit();
return true;
}
}
final LibraryListEntry? prevEntry =
(await libraryListGetListEntries(listName))!.entries.lastOrNull;
await insert(
LibraryListTableNames.libraryListEntry,
{
'listName': listName,
'jmdictEntryId': jmdictEntryId,
'kanji': kanji,
'prevEntryJmdictEntryId': prevEntry?.jmdictEntryId,
'prevEntryKanji': prevEntry?.kanji,
},
);
return true;
}
/// Deletes an entry at a specific position in the library list.
///
/// This function returns false if the list does not exist,
/// or if the entry is not already a part of the list.
Future<bool> libraryListDeleteEntry(
String listName, {
int? jmdictEntryId,
String? kanji,
}) async {
assert(listName.isNotEmpty, 'Library list name must not be empty.');
assert(
(jmdictEntryId == null) != (kanji == null),
'Either jmdictEntryId or kanji must be provided, but not both.',
);
if (!await libraryListExists(listName)) {
return false;
}
if (!await libraryListListContains(
listName,
jmdictEntryId: jmdictEntryId,
kanji: kanji,
)) {
return false;
}
// TODO: these queries might be combined into one
final entryQuery = await query(
LibraryListTableNames.libraryListEntry,
columns: ['prevEntryJmdictEntryId', 'prevEntryKanji'],
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
whereArgs: [listName, jmdictEntryId, kanji],
);
final nextEntryQuery = await query(
LibraryListTableNames.libraryListEntry,
where:
'"listName" = ? AND ("prevEntryJmdictEntryId" = ? OR "prevEntryKanji" = ?)',
whereArgs: [listName, jmdictEntryId, kanji],
);
final prevEntryJmdictEntryId =
entryQuery.first['prevEntryJmdictEntryId'] as int?;
final prevEntryKanji = entryQuery.first['prevEntryKanji'] as String?;
final LibraryListEntry? nextEntry =
nextEntryQuery.map((e) => LibraryListEntry.fromDBMap(e)).firstOrNull;
// TODO: use a transaction instead of a batch
final b = batch();
if (nextEntry != null) {
b.update(
LibraryListTableNames.libraryListEntry,
{
'prevEntryJmdictEntryId': prevEntryJmdictEntryId,
'prevEntryKanji': prevEntryKanji,
},
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
whereArgs: [listName, nextEntry.jmdictEntryId, nextEntry.kanji],
);
}
b.delete(
LibraryListTableNames.libraryListEntry,
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
whereArgs: [listName, jmdictEntryId, kanji],
);
b.commit();
return true;
}
/// Deletes an entry at a specific position in the library list.
///
/// This function returns false if the position is out of bounds,
/// or if the list does not exist.
///
/// Avoid using this function if possible, as it has a time complexity of O(n),
/// in contrast to `libraryListDeleteEntry` which has a time complexity of whatever
/// SQLite uses for its indices.
Future<bool> libraryListDeleteEntryByPosition(
String listName,
int position,
) async {
assert(listName.isNotEmpty, 'Library list name must not be empty.');
assert(position >= 0, 'Position must be a non-negative integer.');
if (!await libraryListExists(listName)) {
return false;
}
final entries = (await libraryListGetListEntries(
listName,
page: 0,
pageSize: position + 1,
))
?.entries;
if (entries == null || position >= entries.length) {
return false;
}
final entry = entries[position];
final result = await libraryListDeleteEntry(
listName,
jmdictEntryId: entry.jmdictEntryId,
kanji: entry.kanji,
);
return result;
}
/// Reorders an entry within the library list.
///
/// This function returns false if the position is out of bounds,
/// if the list does not exist, or if the entry is not already a part of the list.
Future<bool> libraryListMoveEntry(
String listName,
int newPosition, {
int? jmdictEntryId,
String? kanji,
}) async {
assert(listName.isNotEmpty, 'Library list name must not be empty.');
throw UnimplementedError();
}
/// Appends an entry to the library list if it's not there already,
/// or removes it if it is. Returns whether the entry is now in the list.
Future<bool> libraryListToggleEntry(
String listName, {
int? jmdictEntryId,
String? kanji,
bool? overrideToggleOn,
}) async {
assert(listName.isNotEmpty, 'Library list name must not be empty.');
if ((jmdictEntryId == null) == (kanji == null)) {
throw ArgumentError(
'Either jmdictEntryId or kanji must be provided, but not both.',
);
}
final shouldToggleOn = overrideToggleOn ??
!(await libraryListListContains(
listName,
jmdictEntryId: jmdictEntryId,
kanji: kanji,
));
if (shouldToggleOn) {
final result = await libraryListInsertEntry(
listName,
jmdictEntryId: jmdictEntryId,
kanji: kanji,
);
assert(
result,
'Failed to insert entry into library list "$listName".',
);
} else {
final result = await libraryListDeleteEntry(
listName,
jmdictEntryId: jmdictEntryId,
kanji: kanji,
);
assert(
result,
'Failed to delete entry from library list "$listName".',
);
}
return shouldToggleOn;
}
/// Verifies the linked list structure of the list of library lists.
Future<bool> libraryListVerifyLists() async {
throw UnimplementedError();
}
/// Verifies the linked list structure of a single library list.
Future<bool> libraryListVerifyList(String listName) async {
assert(listName.isNotEmpty, 'Library list name must not be empty.');
throw UnimplementedError();
}
// Future<void> libraryListInsertJsonEntries(
// List<Map<String, Object?>> jsonEntries,
// ) async {
// throw UnimplementedError();
// }
Future<void> libraryListInsertJsonEntriesForSingleList(
String listName,
List<Map<String, Object?>> jsonEntries,
) async {
List<LibraryListEntry> entries =
jsonEntries.map((e) => LibraryListEntry.fromJson(e)).toList();
// TODO: batch
for (final entry in entries) {
await libraryListInsertEntry(
listName,
kanji: entry.kanji,
jmdictEntryId: entry.jmdictEntryId,
);
}
}
}
class LibraryList {
final String name;
final int totalCount;
const LibraryList({
required this.name,
required this.totalCount,
});
}
class LibraryListPage {
final String name;
final int totalCount;
final List<LibraryListEntry> entries;
const LibraryListPage({
required this.name,
required this.totalCount,
required this.entries,
});
}
class LibraryListEntry {
DateTime lastModified;
final int? jmdictEntryId;
final WordSearchResult? wordSearchResult;
final String? kanji;
final KanjiSearchResult? kanjiSearchResult;
LibraryListEntry({
DateTime? lastModified,
this.wordSearchResult,
this.jmdictEntryId,
this.kanji,
this.kanjiSearchResult,
}) : 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",
),
assert(
kanjiSearchResult?.kanji == kanji,
"KanjiSearchResult's kanji must match the kanji in LibraryListEntry",
),
assert(
wordSearchResult?.entryId == jmdictEntryId,
"WordSearchResult's jmdictEntryId must match the jmdictEntryId in LibraryListEntry",
);
LibraryListEntry.fromJmdictId({
required int this.jmdictEntryId,
this.wordSearchResult,
DateTime? lastModified,
}) : lastModified = lastModified ?? DateTime.now(),
kanji = null,
kanjiSearchResult = null;
LibraryListEntry.fromKanji({
required String this.kanji,
this.kanjiSearchResult,
DateTime? lastModified,
}) : lastModified = lastModified ?? DateTime.now(),
jmdictEntryId = null,
wordSearchResult = null;
Map<String, Object?> toJson() => {
'kanji': kanji,
'jmdictEntryId': jmdictEntryId,
'lastModified': lastModified.millisecondsSinceEpoch,
};
factory LibraryListEntry.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 LibraryListEntry.fromKanji(
kanji: json['kanji']! as String,
lastModified: DateTime.fromMillisecondsSinceEpoch(
json['lastModified']! as int,
),
);
} else {
return LibraryListEntry.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 LibraryListEntry.fromDBMap(Map<String, Object?> dbObject) =>
LibraryListEntry.fromJson(dbObject);
}

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:mugiten/models/library/library_list.dart';
import 'package:mugiten/models/library_list.dart';
import 'package:mugiten/screens/info/changelog.dart';
import 'package:mugiten/screens/library/library_content_view.dart';
import 'package:mugiten/screens/search/kanji_search_result_page.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:mugiten/database/database.dart';
import 'package:sqflite/sqlite_api.dart';
class DebugView extends StatelessWidget {
const DebugView({super.key});

View File

@@ -1,88 +1,144 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:sqflite/sqlite_api.dart';
import '../components/common/loading.dart';
import '../components/common/opaque_box.dart';
import '../components/history/date_divider.dart';
import '../components/history/history_entry_tile.dart';
import '../models/history/history_entry.dart';
import '../models/history_entry.dart';
import '../services/datetime.dart';
class HistoryView extends StatelessWidget {
const int pageSize = 50;
const int invisibleItemsThreshold = 25;
class HistoryView extends StatefulWidget {
const HistoryView({super.key});
@override
State<HistoryView> createState() => _HistoryViewState();
}
class _HistoryViewState extends State<HistoryView> {
late final _pagingController = PagingController<int, HistoryEntry?>(
getNextPageKey: (state) =>
state.lastPageIsEmpty ? null : state.nextIntPageKey,
fetchPage: (pageKey) async {
List<HistoryEntry?> result =
await GetIt.instance.get<Database>().historyEntryGetAll(
page: pageKey - 1,
pageSize: pageSize,
);
// Insert a null entry at the start in order to prepend a separator to the first actual entry.
if (pageKey == 1) {
result = [null, ...result];
}
return result;
},
);
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// TODO: Use infinite scroll pagination
return FutureBuilder<List<HistoryEntry>>(
future: HistoryEntry.fromDB,
GetIt.instance.get<Database>().historyEntryGetAll(
page: 0,
pageSize: pageSize,
);
return FutureBuilder<int>(
future: GetIt.instance.get<Database>().historyEntryAmount(),
builder: (context, snapshot) {
// TODO: provide proper error handling
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
if (!snapshot.hasData) return const LoadingScreen();
final Map<int, HistoryEntry> data = snapshot.data!.asMap();
if (data.isEmpty) {
return const Center(
child: Text('The history is empty.\nTry searching for something!'),
);
}
final int amountOfEntries = snapshot.data!;
return OpaqueBox(
child: ListView.separated(
itemCount: data.length + 2,
itemBuilder: historyEntryWithData(data),
separatorBuilder:
historyEntrySeparatorWithData(data.values.toList()),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Center(
child: Text(
'$amountOfEntries distinct searches made',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
),
),
Expanded(
child: PagingListener(
controller: _pagingController,
builder: (context, state, fetchNextPage) =>
PagedListView<int, HistoryEntry?>.separated(
state: state,
fetchNextPage: fetchNextPage,
separatorBuilder: (context, index) {
if (index == 0) {
final firstItemDate =
_pagingController.items![1]!.lastTimestamp;
return _dateDivider(firstItemDate);
}
final data = _pagingController.items!;
final HistoryEntry search = data[index]!;
// Previous in the sense of time, but it is the next item in the list.
final HistoryEntry? previousSearch =
data.length >= index + 1 ? data[index + 1] : null;
if (previousSearch != null &&
!dateIsEqual(
search.lastTimestamp,
previousSearch.lastTimestamp,
)) {
return _dateDivider(previousSearch.lastTimestamp);
}
return _divider();
},
builderDelegate: PagedChildBuilderDelegate<HistoryEntry?>(
invisibleItemsThreshold: invisibleItemsThreshold,
itemBuilder: (context, entry, index) => index == 0
? SizedBox.shrink()
: HistoryEntryTile(
entry: entry!,
objectKey: entry.id,
onDelete: () => _pagingController.refresh(),
),
noItemsFoundIndicatorBuilder: (context) => const Center(
child: Text(
'The history is empty.\nTry searching for something!',
),
),
),
),
),
),
],
),
);
},
);
}
Widget Function(BuildContext, int) historyEntrySeparatorWithData(
List<HistoryEntry> data,
) =>
(context, index) {
final HistoryEntry search = data[index];
final DateTime searchDate = search.lastTimestamp;
Widget _dateDivider(DateTime date) =>
TextDivider(text: formatDate(roundToDay(date)));
if (index != 1 &&
(index == 0 ||
!dateIsEqual(data[index - 2].lastTimestamp, searchDate))) {
return TextDivider(text: formatDate(roundToDay(searchDate)));
}
return const Divider(
height: 0,
indent: 10,
endIndent: 10,
);
};
Widget Function(BuildContext, int) historyEntryWithData(
Map<int, HistoryEntry> data,
) {
return (context, index) {
return switch (index) {
0 => const SizedBox.shrink(),
1 => Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Center(
child: Text(
'${data.length} distinct searches made',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
),
),
int i => HistoryEntryTile(
entry: data.values.toList()[i - 2],
objectKey: data.keys.toList()[i - 2],
onDelete: () => build(context),
),
};
};
}
Widget _divider() => const Divider(
height: 0,
indent: 10,
endIndent: 10,
);
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mdi/mdi.dart';
import 'package:mugiten/models/themes/theme.dart';
import 'package:mugiten/screens/search/kanji_search_view.dart';
import 'package:mugiten/screens/search/word_search_view.dart';
import 'package:mugiten/services/snackbar.dart';

View File

@@ -103,7 +103,8 @@ class ChangelogView extends StatelessWidget {
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 20.0),
padding:
const EdgeInsets.symmetric(horizontal: 20.0, vertical: 20.0),
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 100),

View File

@@ -1,11 +1,11 @@
import 'package:confirm_dialog/confirm_dialog.dart';
import 'package:flutter/material.dart';
import 'package:mugiten/database/database.dart';
import 'package:get_it/get_it.dart';
import 'package:mugiten/models/library_list.dart';
import 'package:sqflite/sqlite_api.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;
@@ -19,10 +19,15 @@ class LibraryContentView extends StatefulWidget {
}
class _LibraryContentViewState extends State<LibraryContentView> {
List<LibraryEntry>? entries;
List<LibraryListEntry>? entries;
Future<void> getEntriesFromDatabase() =>
widget.library.entries(db()).then((es) => setState(() => entries = es));
Future<void> getEntriesFromDatabase() => GetIt.instance
.get<Database>()
.libraryListGetListEntries(
widget.library.name,
includeSearchResult: true,
)
.then((entries_) => setState(() => entries = entries_?.entries));
@override
void initState() {
@@ -38,7 +43,7 @@ class _LibraryContentViewState extends State<LibraryContentView> {
actions: [
IconButton(
onPressed: () async {
final entryCount = await widget.library.length;
final entryCount = widget.library.totalCount;
if (!context.mounted) return;
final bool userIsSure = await confirm(
context,
@@ -48,7 +53,10 @@ class _LibraryContentViewState extends State<LibraryContentView> {
);
if (!userIsSure) return;
await widget.library.deleteAllEntries();
await GetIt.instance
.get<Database>()
.libraryListDeleteAllEntries(widget.library.name);
await getEntriesFromDatabase();
},
icon: const Icon(Icons.delete),

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:mugiten/models/library_list.dart';
import 'package:sqflite/sqlite_api.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});
@@ -14,8 +16,10 @@ class LibraryView extends StatefulWidget {
class _LibraryViewState extends State<LibraryView> {
List<LibraryList>? libraries;
Future<void> getEntriesFromDatabase() =>
LibraryList.allLibraries.then((libs) => setState(() => libraries = libs));
Future<void> getEntriesFromDatabase() => GetIt.instance
.get<Database>()
.libraryListGetLists()
.then((libs) => setState(() => libraries = libs));
@override
void initState() {
@@ -29,7 +33,7 @@ class _LibraryViewState extends State<LibraryView> {
return Column(
children: [
LibraryListTile(
library: LibraryList.favourites,
library: libraries!.first,
leading: const Icon(Icons.star),
onDelete: getEntriesFromDatabase,
onUpdate: getEntriesFromDatabase,

View File

@@ -4,13 +4,12 @@ import 'package:jadb/models/kanji_search/kanji_search_result.dart';
import 'package:jadb/search.dart';
import 'package:mdi/mdi.dart';
import 'package:mugiten/components/library/add_to_library_dialog.dart';
import 'package:mugiten/models/history/history_entry.dart';
import 'package:mugiten/models/library/library_list.dart';
import 'package:mugiten/models/history_entry.dart';
import 'package:mugiten/models/library_list.dart';
import 'package:mugiten/services/snackbar.dart';
import 'package:mugiten/settings.dart';
import 'package:sqflite/sqflite.dart';
// import './kanji_result_body/examples.dart';
import '../../components/kanji/kanji_result_body/grade.dart';
import '../../components/kanji/kanji_result_body/header.dart';
import '../../components/kanji/kanji_result_body/jlpt_level.dart';
@@ -114,8 +113,10 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
icon: const Icon(Icons.star),
color: isFavourite ? Colors.yellow : null,
onPressed: () {
LibraryList.favourites
.toggleEntry(
GetIt.instance
.get<Database>()
.libraryListToggleEntry(
"favourites",
jmdictEntryId: null,
kanji: result.kanji,
)
@@ -164,9 +165,21 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
@override
void initState() {
super.initState();
LibraryList.favourites
.containsKanji(widget.kanji)
GetIt.instance
.get<Database>()
.libraryListListContains(
"favourites",
kanji: widget.kanji,
)
.then((value) => setState(() => isFavourite = value));
if (!incognitoModeEnabled && !addedToDatabase) {
GetIt.instance
.get<Database>()
.historyEntryInsertKanji(widget.kanji)
.then((_) => setState(() => addedToDatabase = true));
}
}
@override
@@ -180,11 +193,6 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
}
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
if (!incognitoModeEnabled && !addedToDatabase) {
HistoryEntry.insertKanji(kanji: widget.kanji);
addedToDatabase = true;
}
return _body(snapshot.data!);
},
);

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:jadb/const_data/radicals.dart';
import 'package:jadb/search.dart';
import 'package:mugiten/models/themes/theme.dart';
import 'package:sqflite/sqflite.dart';
import '../../../../bloc/theme/theme_bloc.dart';

View File

@@ -1,15 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:jadb/models/word_search/word_search_result.dart';
import 'package:jadb/search.dart' show JaDBConnection;
import 'package:mdi/mdi.dart';
import 'package:mugiten/models/history/history_entry.dart';
import 'package:mugiten/bloc/theme/theme_bloc.dart';
import 'package:mugiten/components/search/search_results_body/parts/circle_badge.dart';
import 'package:mugiten/models/history_entry.dart';
import 'package:mugiten/services/snackbar.dart';
import 'package:mugiten/settings.dart';
import 'package:sqflite/sqflite.dart';
import '../../components/search/search_results_body/search_card.dart';
const int pageSize = 50;
const int invisibleItemsThreshold = 25;
class WordSearchResultPage extends StatefulWidget {
final String searchTerm;
@@ -23,9 +30,56 @@ class WordSearchResultPage extends StatefulWidget {
}
class _WordSearchResultPageState extends State<WordSearchResultPage> {
final List<WordSearchResult> results = [];
bool addedToDatabase = false;
HistoryEntry? historyEntry;
late final _pagingController = PagingController<int, WordSearchResult>(
getNextPageKey: (state) =>
state.lastPageIsEmpty ? null : state.nextIntPageKey,
fetchPage: (pageKey) => GetIt.instance
.get<Database>()
.jadbSearchWord(
widget.searchTerm,
page: pageKey - 1,
pageSize: pageSize,
)
.then((v) => v ?? <WordSearchResult>[]),
);
@override
void initState() {
super.initState();
if (!incognitoModeEnabled && !addedToDatabase) {
GetIt.instance
.get<Database>()
.historyEntryInsertWord(widget.searchTerm)
.then((_) => GetIt.instance
.get<Database>()
.historyEntryGetWord(widget.searchTerm))
.then(
(entry) => setState(() {
addedToDatabase = true;
historyEntry = entry;
}),
);
} else {
GetIt.instance
.get<Database>()
.historyEntryGetWord(widget.searchTerm)
.then(
(entry) => setState(() {
historyEntry = entry;
}),
);
}
}
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -39,47 +93,57 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
onPressed: () =>
showSnackbar(context, 'History tracking is disabled'),
),
if (historyEntry != null && historyEntry!.timestampCount > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, themeState) => CircleBadge(
color: themeState.theme.menuGreyNormal.background,
child: Text('${historyEntry!.timestampCount}'),
),
),
),
],
),
body: FutureBuilder(
future: (() async {
final jadbConnection = GetIt.instance.get<Database>();
final results = await Future.wait([
jadbConnection
.jadbSearchWordCount(widget.searchTerm)
.then((v) => v ?? 0),
jadbConnection
.jadbSearchWord(widget.searchTerm)
.then((v) => v ?? <WordSearchResult>[]),
]);
return (results[0] as int, results[1] as List<WordSearchResult>);
})(),
future: GetIt.instance
.get<Database>()
.jadbSearchWordCount(widget.searchTerm)
.then((v) => v ?? 0),
builder: (context, snapshot) {
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
if (!incognitoModeEnabled && !addedToDatabase) {
HistoryEntry.insertWord(word: widget.searchTerm);
addedToDatabase = true;
}
final searchCount = snapshot.data!;
return ListView(
return Column(
children: [
Center(
child: Text(
'Found ${snapshot.data!.$1} results for "${widget.searchTerm}"',
'Found $searchCount results for "${widget.searchTerm}"',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
),
for (final result in snapshot.data!.$2)
SearchResultCard(result: result)
Expanded(
child: PagingListener(
controller: _pagingController,
builder: (context, state, fetchNextPage) =>
PagedListView<int, WordSearchResult>(
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate(
invisibleItemsThreshold: invisibleItemsThreshold,
itemBuilder: (context, item, index) =>
SearchResultCard(result: item),
),
),
),
),
],
);
},

View File

@@ -1,23 +1,23 @@
import 'dart:io';
import 'dart:developer';
import 'package:confirm_dialog/confirm_dialog.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
import 'package:get_it/get_it.dart';
import 'package:mdi/mdi.dart';
import 'package:mugiten/bloc/theme/theme_bloc.dart';
import 'package:mugiten/components/common/denshi_jisho_background.dart';
import 'package:mugiten/database/database.dart';
import 'package:mugiten/database/history/table_names.dart';
import 'package:mugiten/main.dart';
import 'package:mugiten/models/history/history_entry.dart';
import 'package:mugiten/models/library/library_list.dart';
import 'package:mugiten/models/history_entry.dart';
import 'package:mugiten/models/themes/theme.dart';
import 'package:mugiten/routing/routes.dart';
import 'package:mugiten/services/data_export_import.dart';
import 'package:mugiten/services/snackbar.dart';
import 'package:mugiten/settings.dart';
import 'package:sqflite/sqlite_api.dart';
import 'package:url_launcher/url_launcher.dart';
class SettingsView extends StatefulWidget {
@@ -33,7 +33,8 @@ class _SettingsViewState extends State<SettingsView> {
bool dataImportIsLoading = false;
Future<void> clearHistory(context) async {
final historyCount = await HistoryEntry.amountOfEntries();
final historyCount =
await GetIt.instance.get<Database>().historyEntryAmount();
if (!context.mounted) return;

View File

@@ -3,10 +3,9 @@ import 'dart:core';
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:mugiten/database/history/table_names.dart';
import 'package:mugiten/database/library_list/table_names.dart';
import 'package:mugiten/models/history/history_entry.dart';
import 'package:mugiten/models/library/library_list.dart';
import 'package:mugiten/models/history_entry.dart';
import 'package:mugiten/models/library_list.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
// Example file Structure:
@@ -83,12 +82,12 @@ Future<File> exportData(DatabaseExecutor db) async {
return zipFile;
}
Future<void> importData(DatabaseExecutor db, File zipFile) async {
Future<void> importData(Database db, File zipFile) async {
final dir = await unpackZipToTempDir(zipFile.path);
await Future.wait([
importHistoryFrom(dir.historyFile),
importLibraryListsFrom(dir.libraryDir),
importHistoryFrom(db, dir.historyFile),
importLibraryListsFrom(db, dir.libraryDir),
]);
dir.deleteSync(recursive: true);
@@ -105,29 +104,19 @@ Future<void> exportHistoryTo(
final file = dir.historyFile;
file.createSync();
final query =
await db.query(HistoryTableNames.historyEntryOrderedByTimestamp);
final List<HistoryEntry> entries =
query.map((e) => HistoryEntry.fromDBMap(e)).toList();
/// TODO: This creates a ton of sql statements. Ideally, the whole export
/// should be done in only one query.
///
/// On second thought, is that even possible? It's a doubly nested list structure.
final List<Map<String, Object?>> jsonEntries = await Future.wait(
entries.map((historyEntry) async => historyEntry.toJson()));
final List<Map<String, Object?>> jsonEntries =
(await db.historyEntryGetAll()).map((e) => e.toJson()).toList();
file.writeAsStringSync(jsonEncode(jsonEntries));
}
Future<void> importHistoryFrom(File file) async {
Future<void> importHistoryFrom(Database db, File file) async {
final String content = file.readAsStringSync();
final List<Map<String, Object?>> json = (jsonDecode(content) as List)
.map((h) => h as Map<String, Object?>)
.toList();
// log('Importing ${json.length} entries from ${file.path}');
await HistoryEntry.insertJsonEntries(json);
await db.transaction((txn) => txn.historyEntryInsertManyFromJson(json));
}
///////////////////
@@ -157,7 +146,9 @@ Future<void> exportLibraryListTo(
final file = File(dir.uri.resolve('$libraryName.json').toFilePath());
await file.create();
final entries = (await LibraryList.byName(libraryName).entries(db))
// TODO: properly null check
final entries = (await db.libraryListGetListEntries(libraryName))!
.entries
.map((e) => e.toJson())
.toList();
@@ -165,7 +156,8 @@ Future<void> exportLibraryListTo(
}
// TODO: how do we handle lists that already exist? There seems to be no good way to merge them?
Future<void> importLibraryListsFrom(Directory libraryListsDir) async {
Future<void> importLibraryListsFrom(
DatabaseExecutor db, Directory libraryListsDir) async {
for (final file in libraryListsDir.listSync()) {
if (file is! File) continue;
@@ -174,8 +166,8 @@ Future<void> importLibraryListsFrom(Directory libraryListsDir) async {
final libraryName =
file.uri.pathSegments.last.replaceFirst(RegExp(r'\.json$'), '');
if (await LibraryList.exists(libraryName)) {
if ((await LibraryList.byName(libraryName).length) > 0) {
if (await db.libraryListExists(libraryName)) {
if ((await db.libraryListGetList(libraryName))!.totalCount > 0) {
print(
'Library list "$libraryName" already exists and is not empty. Skipping import.');
continue;
@@ -184,7 +176,7 @@ Future<void> importLibraryListsFrom(Directory libraryListsDir) async {
'Importing entries from file ${file.path}.');
}
} else {
LibraryList.insert(libraryName);
await db.libraryListInsertList(libraryName);
}
final content = await file.readAsString();
@@ -192,7 +184,9 @@ Future<void> importLibraryListsFrom(Directory libraryListsDir) async {
.map((e) => e as Map<String, Object?>)
.toList();
final libraryList = LibraryList.byName(libraryName);
await libraryList.insertJsonEntries(jsonEntries);
await db.libraryListInsertJsonEntriesForSingleList(
libraryName,
jsonEntries,
);
}
}

View File

@@ -1,7 +1,6 @@
import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:google_mlkit_digital_ink_recognition/google_mlkit_digital_ink_recognition.dart';
import 'package:mugiten/database/database.dart'
show
@@ -15,7 +14,6 @@ import 'package:mugiten/database/database.dart'
import 'package:mugiten/services/data_export_import.dart';
import 'package:mugiten/services/initialization/initialization_status.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
class InitializationCubit extends Cubit<InitializationStatus> {
final bool deleteDatabase;
@@ -50,9 +48,7 @@ class InitializationCubit extends Cubit<InitializationStatus> {
final tempDir = await getTemporaryDirectory();
final database = await openDatabaseWithoutMigrations(dbPath);
GetIt.instance.registerSingleton<Database>(database);
final dataDump = await exportData(database);
GetIt.instance.unregister<Database>();
await database.close();
@@ -77,9 +73,7 @@ class InitializationCubit extends Cubit<InitializationStatus> {
if (databaseAlreadyExists) {
emit(RestoreUserData(total: 2, progress: 1));
GetIt.instance.registerSingleton<Database>(database);
await importData(database, tmpdirDataDump!);
GetIt.instance.unregister<Database>();
emit(RestoreUserData(total: 2, progress: 2));
}

View File

@@ -51,8 +51,7 @@ const Map<String, dynamic> _defaults = {
bool _getSettingOrDefault(String settingName) =>
_prefs.getBool(settingName) ?? _defaults[settingName];
bool get incognitoModeEnabled =>
_getSettingOrDefault('incognitoModeEnabled');
bool get incognitoModeEnabled => _getSettingOrDefault('incognitoModeEnabled');
bool get romajiEnabled => _getSettingOrDefault('romajiEnabled');
bool get darkThemeEnabled => _getSettingOrDefault('darkThemeEnabled');
bool get autoThemeEnabled => _getSettingOrDefault('autoThemeEnabled');

View File

@@ -230,6 +230,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
flutter_staggered_grid_view:
dependency: transitive
description:
name: flutter_staggered_grid_view
sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_svg:
dependency: "direct main"
description:
@@ -296,15 +304,31 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.4"
infinite_scroll_pagination:
dependency: "direct main"
description:
name: infinite_scroll_pagination
sha256: "9b8f95362928a1de835658835194b435c40f2bfc386f6545c1fd706203df0cd4"
url: "https://pub.dev"
source: hosted
version: "5.1.0"
jadb:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: "1868c6fb41c781bb9c02cde172b39ab630f28421"
resolved-ref: "0a3387e77a0fa3f789e91f3dce00221466f19c98"
url: "https://git.pvv.ntnu.no/oysteikt/jadb.git"
source: git
version: "1.0.0"
l10n_languages:
dependency: transitive
description:
name: l10n_languages
sha256: "324e115dbc4d20e9bb504930e843848c233648ecbba40da59af4d6a0fc24bcfa"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
leak_tracker:
dependency: transitive
description:
@@ -521,6 +545,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
sealed_languages:
dependency: "direct main"
description:
name: sealed_languages
sha256: "07dc4f463eed36a208dcf251a26cdb301ecad1d3f2a178081a78524145fd60d4"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
share_plus:
dependency: "direct main"
description:
@@ -606,6 +638,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
sliver_tools:
dependency: transitive
description:
name: sliver_tools
sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6
url: "https://pub.dev"
source: hosted
version: "0.2.12"
source_span:
dependency: transitive
description:
@@ -754,10 +794,10 @@ packages:
dependency: "direct main"
description:
name: url_launcher
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.1"
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:

View File

@@ -1,7 +1,7 @@
name: mugiten
description: "A new Flutter project."
publish_to: "none" # Remove this line if you wish to publish to pub.dev
version: 0.3.1+1
version: 0.4.0+1
environment:
sdk: ^3.6.2
@@ -40,6 +40,8 @@ dependencies:
package_info_plus: ^8.3.0
flutter_markdown_plus: ^1.0.3
markdown: ^7.3.0
sealed_languages: ^2.1.0
infinite_scroll_pagination: ^5.1.0
dev_dependencies:
flutter_test:

View File

@@ -6,7 +6,7 @@ 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:mugiten/models/library_list.dart';
import 'package:sqflite/sqflite.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqlite3/open.dart';
@@ -46,13 +46,17 @@ Future<Database> createDatabaseCopy({
}
Future<void> insertTestData(Database db) async {
final libraryList1 = await LibraryList.insert("Test Library 1");
final libraryList1 = await db.libraryListInsertList("Test Library 1");
assert(libraryList1 == true);
await libraryList1.insertEntry(
await db.libraryListInsertEntry(
"Test Library 1",
jmdictEntryId: null,
kanji: "",
);
await libraryList1.insertEntry(
await db.libraryListInsertEntry(
"Test Library 1",
jmdictEntryId: null,
kanji: "",
);