Compare commits
23 Commits
v0.3.1
...
linked-lis
| Author | SHA1 | Date | |
|---|---|---|---|
|
c6f6d33fbb
|
|||
|
f5e06776d0
|
|||
|
c6d97c73a9
|
|||
|
61c8d27bb9
|
|||
|
ee4289030b
|
|||
|
ce78bed1a6
|
|||
|
072f855c13
|
|||
|
38af074e6f
|
|||
|
29387397da
|
|||
|
6e99792125
|
|||
|
0b844f0342
|
|||
|
7f4e230713
|
|||
|
7bb948fe19
|
|||
|
87ce1b9228
|
|||
|
a0b4834e1c
|
|||
|
6c0342ac6c
|
|||
|
ae0789b133
|
|||
|
a46ef5f3ea
|
|||
|
c2194bb120
|
|||
|
c43f4366f1
|
|||
|
671bee0f26
|
|||
|
cf536bdbbd
|
|||
|
4cf1e751a8
|
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
22
docs/changelog/v0.4.0.md
Normal 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.
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../bloc/theme/theme_bloc.dart';
|
||||
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../bloc/theme/theme_bloc.dart';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../bloc/theme/theme_bloc.dart';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../bloc/theme/theme_bloc.dart';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
109
lib/database/linked_list.dart
Normal file
109
lib/database/linked_list.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
435
lib/models/history_entry.dart
Normal file
435
lib/models/history_entry.dart
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
748
lib/models/library_list.dart
Normal file
748
lib/models/library_list.dart
Normal 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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
46
pubspec.lock
46
pubspec.lock
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: "字",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user