Compare commits
48 Commits
datasource
...
interative
| Author | SHA1 | Date | |
|---|---|---|---|
|
493f072dcb
|
|||
|
3b10ac1f06
|
|||
|
0f50750e24
|
|||
|
9c354f98e6
|
|||
|
c3eebb1609
|
|||
|
239c9d59d1
|
|||
|
4476ae2106
|
|||
|
3c1d25499e
|
|||
|
8ce6850eca
|
|||
|
c3896bc4bc
|
|||
|
add267e4d0
|
|||
|
b24328c9a8
|
|||
|
e23a91c82d
|
|||
|
cc3f095fb1
|
|||
|
07fb050e0c
|
|||
|
3e18a90b2d
|
|||
|
028507a61c
|
|||
|
088d79d4d3
|
|||
|
c8e6d93f54
|
|||
|
81adfa19b3
|
|||
|
916c469f72
|
|||
|
5cc918ad84
|
|||
|
ae3aad9014
|
|||
|
452127b323
|
|||
|
414f0368da
|
|||
|
a021702119
|
|||
|
442ee25dd8
|
|||
|
39416351e3
|
|||
|
c259c3ae17
|
|||
|
5b135f5161
|
|||
|
6d66419c8c
|
|||
|
3fcaa90b72
|
|||
|
b1d4f5838e
|
|||
|
c0a7c76ea5
|
|||
|
d0fba3a523
|
|||
|
0f3fcde918
|
|||
|
87c55af0e3
|
|||
|
7c8911e031
|
|||
|
e6d90a0300
|
|||
|
e8f80b6999
|
|||
|
7c3536c06e
|
|||
|
cc97b29cc6
|
|||
|
37a2bac05f
|
|||
|
7b1ef7e740
|
|||
|
c290a45806
|
|||
|
c4ee9c3e04
|
|||
|
7127f2e3ae
|
|||
|
386e5d6e2c
|
@@ -10,6 +10,11 @@
|
||||
include:
|
||||
- package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- 'build/**'
|
||||
- '.direnv/**'
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
@@ -22,20 +27,64 @@ linter:
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
always_declare_return_types: true
|
||||
annotate_redeclares: true
|
||||
avoid_print: false
|
||||
avoid_setters_without_getters: true
|
||||
avoid_slow_async_io: true
|
||||
directives_ordering: true
|
||||
eol_at_end_of_file: true
|
||||
prefer_const_declarations: true
|
||||
prefer_contains: true
|
||||
prefer_final_fields: true
|
||||
prefer_final_locals: true
|
||||
prefer_single_quotes: true
|
||||
use_key_in_widget_constructors: true
|
||||
use_null_aware_elements: true
|
||||
- always_declare_return_types
|
||||
- always_use_package_imports
|
||||
- annotate_overrides
|
||||
- annotate_redeclares
|
||||
- avoid_annotating_with_dynamic
|
||||
- avoid_bool_literals_in_conditional_expressions
|
||||
- avoid_empty_else
|
||||
- avoid_null_checks_in_equality_operators
|
||||
- avoid_renaming_method_parameters
|
||||
- avoid_returning_null_for_void
|
||||
- avoid_setters_without_getters
|
||||
- avoid_slow_async_io
|
||||
- avoid_unnecessary_containers
|
||||
- await_only_futures
|
||||
- cancel_subscriptions
|
||||
- cascade_invocations
|
||||
- comment_references
|
||||
- directives_ordering
|
||||
- discarded_futures
|
||||
- empty_constructor_bodies
|
||||
- empty_statements
|
||||
- eol_at_end_of_file
|
||||
- exhaustive_cases
|
||||
- invalid_case_patterns
|
||||
- only_throw_errors
|
||||
- prefer_asserts_in_initializer_lists
|
||||
- prefer_asserts_with_message
|
||||
- prefer_const_constructors
|
||||
- prefer_const_constructors_in_immutables
|
||||
- prefer_const_declarations
|
||||
- prefer_const_literals_to_create_immutables
|
||||
- prefer_contains
|
||||
- prefer_final_fields
|
||||
- prefer_final_in_for_each
|
||||
- prefer_final_locals
|
||||
- prefer_final_parameters
|
||||
- prefer_single_quotes
|
||||
- require_trailing_commas
|
||||
- simplify_variable_pattern
|
||||
- sized_box_for_whitespace
|
||||
- sized_box_shrink_expand
|
||||
- test_types_in_equals
|
||||
- unnecessary_async
|
||||
- unnecessary_breaks
|
||||
- unnecessary_getters_setters
|
||||
- unnecessary_ignore
|
||||
- unnecessary_lambdas
|
||||
- unnecessary_late
|
||||
- unnecessary_null_checks
|
||||
- unnecessary_parenthesis
|
||||
- unrelated_type_equality_checks
|
||||
- use_enums
|
||||
- use_is_even_rather_than_modulo
|
||||
- use_key_in_widget_constructors
|
||||
- use_named_constants
|
||||
- use_null_aware_elements
|
||||
- valid_regexps
|
||||
- var_with_no_type_annotation
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -20,7 +20,6 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "wtf.nani.mugiten"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
android:label="@string/app_name"
|
||||
/>
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<!-- 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.
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
28
docs/changelog/v0.6.0 - 2026-02-25.md
Normal file
28
docs/changelog/v0.6.0 - 2026-02-25.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# v0.6.0 - 2026-02-25
|
||||
|
||||
The "offline" update, making the last essential piece of the dictionary available offline.
|
||||
|
||||
## New features ✨
|
||||
|
||||
- 🔥 Local rendering of kanji stroke order diagrams. This makes them available offline, and significantly improves framerate, sharpness and loading time.
|
||||
- Fetch example words for kanji results. This has been a feature before, but it was removed during the switch to offline data. Unlike the previous implementation, it does not tell you whether the examples are using onyomi or kunyomi. But this might come later.
|
||||
- Allow users to copy the content of search results, library lists and search history entries to the clipboard by using long press.
|
||||
- Added a menu for renaming library lists.
|
||||
- Added a page with an overview of datasources used in the app.
|
||||
|
||||
## Changes 🔧
|
||||
|
||||
- Revise settings menu descriptions.
|
||||
- Move enabled radicals to the top of the list in the radical kanji search menu.
|
||||
- Add more fonts. Note that this change might reset your current font selection.
|
||||
|
||||
## Bugfixes 🐞
|
||||
|
||||
- Fix a bug where the radical kanji search would remove all buttons when searching for certain radicals.
|
||||
- Fix a bug where the app would behave weirdly when the history was empty.
|
||||
|
||||
## Other 📝
|
||||
|
||||
- Updated flutter: `3.35` -> `3.41`
|
||||
- Updated dictionary data
|
||||
- Some much needed cleanup of theme handling. If there are any weird theme related bugs, please report them.
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1771369470,
|
||||
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
||||
"lastModified": 1774386573,
|
||||
"narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
||||
"rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -34,9 +34,11 @@
|
||||
in pkgs.mkShell {
|
||||
packages = [
|
||||
flutter'
|
||||
pkgs.sqlite-interactive
|
||||
androidPkgs.androidsdk
|
||||
jdk'
|
||||
|
||||
pkgs.sqlite-interactive
|
||||
pkgs.sqldiff
|
||||
];
|
||||
env = {
|
||||
ANDROID_SDK_ROOT = "${androidPkgs.androidsdk}/libexec/android-sdk";
|
||||
|
||||
66
lib/components/common/async_text_form_field.dart
Normal file
66
lib/components/common/async_text_form_field.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'package:async/async.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AsyncTextFormField extends StatefulWidget {
|
||||
final Future<String?> Function(String?) asyncValidator;
|
||||
final TextEditingController controller;
|
||||
final String? labelText;
|
||||
final String? hintText;
|
||||
|
||||
const AsyncTextFormField({
|
||||
super.key,
|
||||
required this.asyncValidator,
|
||||
required this.controller,
|
||||
this.labelText,
|
||||
this.hintText,
|
||||
});
|
||||
|
||||
@override
|
||||
AsyncTextFormFieldState createState() => AsyncTextFormFieldState();
|
||||
}
|
||||
|
||||
class AsyncTextFormFieldState extends State<AsyncTextFormField> {
|
||||
String? errorText;
|
||||
CancelableOperation? currentValidation;
|
||||
|
||||
Future<void> validate(final String text) async {
|
||||
currentValidation?.cancel();
|
||||
setState(() {
|
||||
errorText = null;
|
||||
currentValidation = CancelableOperation.fromFuture(
|
||||
widget.asyncValidator(text).then((final newErrorText) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
errorText = newErrorText;
|
||||
currentValidation = null;
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
print(
|
||||
'Building AsyncTextFormField with errorText: $errorText and currentValidation: $currentValidation',
|
||||
);
|
||||
return TextFormField(
|
||||
key: widget.key,
|
||||
controller: widget.controller,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.labelText,
|
||||
hintText: widget.hintText,
|
||||
errorText: errorText,
|
||||
// TODO: add a small timer, so that this doesn't flicker if the user is typing quickly and the validation is fast
|
||||
suffixIcon: currentValidation != null
|
||||
? const CircularProgressIndicator(strokeWidth: 2)
|
||||
: errorText != null
|
||||
? const Icon(Icons.error, color: Colors.red)
|
||||
: const Icon(Icons.check, color: Colors.green),
|
||||
),
|
||||
onChanged: validate,
|
||||
forceErrorText: errorText,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ class DenshiJishoBackground extends StatelessWidget {
|
||||
const DenshiJishoBackground({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
import '../../settings.dart';
|
||||
|
||||
/// The ratio is defined as 'the amount of space the text should take'
|
||||
/// divided by 'the amount of space the padding should take'.
|
||||
///
|
||||
@@ -60,21 +59,21 @@ class KanjiBox extends StatelessWidget {
|
||||
);
|
||||
|
||||
const factory KanjiBox.withFontSizeAndPadding({
|
||||
required String kanji,
|
||||
required double fontSize,
|
||||
required double padding,
|
||||
Color? foreground,
|
||||
Color? background,
|
||||
double borderRadius,
|
||||
required final String kanji,
|
||||
required final double fontSize,
|
||||
required final double padding,
|
||||
final Color? foreground,
|
||||
final Color? background,
|
||||
final double borderRadius,
|
||||
}) = KanjiBox._;
|
||||
|
||||
factory KanjiBox.withFontSize({
|
||||
required String kanji,
|
||||
required double fontSize,
|
||||
double ratio = defaultRatio,
|
||||
Color? foreground,
|
||||
Color? background,
|
||||
double borderRadius = defaultBorderRadius,
|
||||
required final String kanji,
|
||||
required final double fontSize,
|
||||
final double ratio = defaultRatio,
|
||||
final Color? foreground,
|
||||
final Color? background,
|
||||
final double borderRadius = defaultBorderRadius,
|
||||
}) => KanjiBox._(
|
||||
kanji: kanji,
|
||||
fontSize: fontSize,
|
||||
@@ -85,12 +84,12 @@ class KanjiBox extends StatelessWidget {
|
||||
);
|
||||
|
||||
factory KanjiBox.withPadding({
|
||||
required String kanji,
|
||||
double ratio = defaultRatio,
|
||||
required double padding,
|
||||
Color? foreground,
|
||||
Color? background,
|
||||
double borderRadius = defaultBorderRadius,
|
||||
required final String kanji,
|
||||
final double ratio = defaultRatio,
|
||||
required final double padding,
|
||||
final Color? foreground,
|
||||
final Color? background,
|
||||
final double borderRadius = defaultBorderRadius,
|
||||
}) => KanjiBox._(
|
||||
kanji: kanji,
|
||||
fontSize: ratio * padding,
|
||||
@@ -101,11 +100,11 @@ class KanjiBox extends StatelessWidget {
|
||||
);
|
||||
|
||||
factory KanjiBox.expanded({
|
||||
required String kanji,
|
||||
double ratio = defaultRatio,
|
||||
Color? foreground,
|
||||
Color? background,
|
||||
double borderRadius = defaultBorderRadius,
|
||||
required final String kanji,
|
||||
final double ratio = defaultRatio,
|
||||
final Color? foreground,
|
||||
final Color? background,
|
||||
final double borderRadius = defaultBorderRadius,
|
||||
}) => KanjiBox._(
|
||||
kanji: kanji,
|
||||
contentPaddingRatio: ratio,
|
||||
@@ -116,12 +115,12 @@ class KanjiBox extends StatelessWidget {
|
||||
|
||||
/// A shortcut
|
||||
factory KanjiBox.headline4({
|
||||
required BuildContext context,
|
||||
required String kanji,
|
||||
double ratio = defaultRatio,
|
||||
Color? foreground,
|
||||
Color? background,
|
||||
double borderRadius = defaultBorderRadius,
|
||||
required final BuildContext context,
|
||||
required final String kanji,
|
||||
final double ratio = defaultRatio,
|
||||
final Color? foreground,
|
||||
final Color? background,
|
||||
final double borderRadius = defaultBorderRadius,
|
||||
}) => KanjiBox.withFontSize(
|
||||
kanji: kanji,
|
||||
fontSize: Theme.of(context).textTheme.displaySmall!.fontSize!,
|
||||
@@ -132,7 +131,7 @@ class KanjiBox extends StatelessWidget {
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final calculatedForeground =
|
||||
foreground ??
|
||||
Theme.of(
|
||||
@@ -145,7 +144,7 @@ class KanjiBox extends StatelessWidget {
|
||||
).extension<MenuGreyLightThemeExtension>()!.backgroundColor;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
builder: (final context, final constraints) {
|
||||
final sizeConstraint = min(constraints.maxHeight, constraints.maxWidth);
|
||||
final calculatedFontSize = fontSize ?? sizeConstraint * fontSizeFactor;
|
||||
final calculatedPadding =
|
||||
@@ -163,11 +162,11 @@ class KanjiBox extends StatelessWidget {
|
||||
child: FittedBox(
|
||||
child: Text(
|
||||
kanji,
|
||||
textScaler: TextScaler.linear(1),
|
||||
textScaler: TextScaler.noScaling,
|
||||
style: TextStyle(
|
||||
color: calculatedForeground,
|
||||
fontSize: calculatedFontSize,
|
||||
).merge(japaneseFont.textStyle),
|
||||
).merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ class LoadingScreen extends StatelessWidget {
|
||||
const LoadingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ class OpaqueBox extends StatelessWidget {
|
||||
const OpaqueBox({required this.child, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
|
||||
@@ -5,9 +5,9 @@ class SplashScreen extends StatelessWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: mugitenWheatBackground),
|
||||
decoration: const BoxDecoration(color: mugitenWheatBackground),
|
||||
child: const Center(
|
||||
child: Image(image: AssetImage('assets/images/logo/mugi.png')),
|
||||
),
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart' hide Ink;
|
||||
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/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
import 'package:signature/signature.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../../settings.dart';
|
||||
|
||||
class DrawingBoard extends StatefulWidget {
|
||||
final Function(String)? onSuggestionChosen;
|
||||
final String? precedingText;
|
||||
@@ -64,7 +65,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
y: controller.points.last.offset.dy,
|
||||
),
|
||||
),
|
||||
onDrawEnd: () => updateSuggestions(),
|
||||
onDrawEnd: updateSuggestions,
|
||||
);
|
||||
|
||||
Future<void> updateSuggestions() async {
|
||||
@@ -80,7 +81,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
);
|
||||
|
||||
final ink = Ink()
|
||||
..strokes = strokes.map((s) => Stroke()..points = s).toList();
|
||||
..strokes = strokes.map((final s) => Stroke()..points = s).toList();
|
||||
|
||||
final newSuggestions = await digitalInkRecognizer.recognize(
|
||||
ink,
|
||||
@@ -88,7 +89,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
);
|
||||
|
||||
setState(() {
|
||||
suggestions = newSuggestions.map((rc) => rc.text).toList();
|
||||
suggestions = newSuggestions.map((final rc) => rc.text).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,11 +102,11 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
deduplicate: true,
|
||||
);
|
||||
final hiraganaSuggestions = suggestions
|
||||
.where((s) => RegExp(hiraganaR).hasMatch(s))
|
||||
.where((final s) => RegExp(hiraganaR).hasMatch(s))
|
||||
.toSet()
|
||||
.toList();
|
||||
final katakanaSuggestions = suggestions
|
||||
.where((s) => RegExp(katakanaR).hasMatch(s))
|
||||
.where((final s) => RegExp(katakanaR).hasMatch(s))
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
@@ -114,11 +115,13 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
if (widget.allowHiragana) ...hiraganaSuggestions,
|
||||
if (widget.allowKatakana) ...katakanaSuggestions,
|
||||
}
|
||||
.where((s) => !widget.onlyOneCharacterSuggestions || s.length == 1)
|
||||
.where(
|
||||
(final s) => !widget.onlyOneCharacterSuggestions || s.length == 1,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Widget kanjiChip(String kanji) => InkWell(
|
||||
Widget kanjiChip(final String kanji) => InkWell(
|
||||
onTap: () => widget.onSuggestionChosen?.call(kanji),
|
||||
child: Container(
|
||||
height: fontSize + 2 * suggestionCirclePadding,
|
||||
@@ -133,7 +136,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
color: panelColor.foregroundColor,
|
||||
).merge(japaneseFont.textStyle),
|
||||
).merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -144,7 +147,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
|
||||
return FutureBuilder<List<String>>(
|
||||
future: filterSuggestions(),
|
||||
builder: (context, snapshot) {
|
||||
builder: (final context, final snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting ||
|
||||
(!snapshot.hasError && (snapshot.data?.isEmpty ?? false))) {
|
||||
return Container(
|
||||
@@ -186,7 +189,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
child: Wrap(
|
||||
spacing: 20,
|
||||
runSpacing: 5,
|
||||
children: filteredSuggestions.map((s) => kanjiChip(s)).toList(),
|
||||
children: filteredSuggestions.map(kanjiChip).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -211,7 +214,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
if (strokes.isNotEmpty) {
|
||||
undoQueue.add(strokes.removeLast());
|
||||
controller.undo();
|
||||
updateSuggestions();
|
||||
unawaited(updateSuggestions());
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.undo),
|
||||
@@ -221,7 +224,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
if (undoQueue.isNotEmpty) {
|
||||
strokes.add(undoQueue.removeLast());
|
||||
controller.redo();
|
||||
updateSuggestions();
|
||||
unawaited(updateSuggestions());
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.redo),
|
||||
@@ -257,7 +260,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
),
|
||||
);
|
||||
|
||||
if (reduceKanjiDrawingBoardSize) {
|
||||
if (reduceKanjiDrawingBoardSize.value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(50, 0, 50, 30),
|
||||
child: board,
|
||||
@@ -267,7 +270,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Column(children: [suggestionBar(), drawingPanel()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ class TextDivider extends StatelessWidget {
|
||||
const TextDivider({super.key, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final colors = Theme.of(context).extension<MenuGreyNormalThemeExtension>()!;
|
||||
|
||||
return Container(
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/components/common/kanji_box.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/circle_badge.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import 'package:mugiten/services/clipboard.dart';
|
||||
import 'package:mugiten/services/datetime.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../routing/routes.dart';
|
||||
import '../../services/datetime.dart';
|
||||
import '../../settings.dart';
|
||||
import '../common/kanji_box.dart';
|
||||
|
||||
class HistoryEntryTile extends StatelessWidget {
|
||||
final HistoryEntry entry;
|
||||
final int objectKey;
|
||||
@@ -27,7 +26,7 @@ class HistoryEntryTile extends StatelessWidget {
|
||||
});
|
||||
|
||||
/// Perform the search again when the entry is tapped.
|
||||
void Function() _onTap(BuildContext context) => entry.isKanji
|
||||
void Function() _onTap(final BuildContext context) => entry.isKanji
|
||||
? () => Navigator.pushNamed(
|
||||
context,
|
||||
Routes.kanjiSearch,
|
||||
@@ -37,17 +36,17 @@ class HistoryEntryTile extends StatelessWidget {
|
||||
Navigator.pushNamed(context, Routes.search, arguments: entry.word);
|
||||
|
||||
/// Copy the kanji/searchword to the clipboard when the entry is long-pressed.
|
||||
void Function() _onLongPress(BuildContext context) =>
|
||||
void Function() _onLongPress(final BuildContext context) =>
|
||||
() =>
|
||||
copyToClipboard(context, entry.isKanji ? entry.kanji! : entry.word!);
|
||||
|
||||
MaterialPageRoute get timestamps => MaterialPageRoute(
|
||||
builder: (context) => Scaffold(
|
||||
builder: (final context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Last searched')),
|
||||
body: ListView(
|
||||
children: entry.timestamps
|
||||
.map(
|
||||
(ts) => ListTile(
|
||||
(final ts) => ListTile(
|
||||
title: Text('${formatDate(ts)} ${formatTime(ts)}'),
|
||||
),
|
||||
)
|
||||
@@ -56,7 +55,7 @@ class HistoryEntryTile extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
|
||||
List<SlidableAction> _actions(BuildContext context) => [
|
||||
List<SlidableAction> _actions(final BuildContext context) => [
|
||||
SlidableAction(
|
||||
backgroundColor: Colors.blue,
|
||||
icon: Icons.access_time,
|
||||
@@ -73,7 +72,7 @@ class HistoryEntryTile extends StatelessWidget {
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final colors = Theme.of(context).extension<MenuGreyNormalThemeExtension>()!;
|
||||
|
||||
return Slidable(
|
||||
@@ -94,12 +93,12 @@ class HistoryEntryTile extends StatelessWidget {
|
||||
child: Text(formatTime(entry.lastTimestamp)),
|
||||
),
|
||||
DefaultTextStyle.merge(
|
||||
style: japaneseFont.textStyle,
|
||||
style: japaneseFont.value.textStyle,
|
||||
child: entry.isKanji
|
||||
? KanjiBox.headline4(context: context, kanji: entry.kanji!)
|
||||
: Expanded(child: Text(entry.word!)),
|
||||
),
|
||||
if (entry.isKanji) Expanded(child: SizedBox.shrink()),
|
||||
if (entry.isKanji) const Expanded(child: SizedBox.shrink()),
|
||||
if (entry.timestampCount > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
import '../../../settings.dart';
|
||||
|
||||
class Grade extends StatelessWidget {
|
||||
final String? grade;
|
||||
final String ifNullChar;
|
||||
@@ -10,7 +9,7 @@ class Grade extends StatelessWidget {
|
||||
const Grade({required this.grade, this.ifNullChar = '⨉', super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final colors = Theme.of(context).extension<KanjiResultThemeExtension>()!;
|
||||
|
||||
return Container(
|
||||
@@ -24,7 +23,7 @@ class Grade extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: colors.foregroundColor,
|
||||
fontSize: 20.0,
|
||||
).merge(japaneseFont.textStyle),
|
||||
).merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
import '../../../settings.dart';
|
||||
|
||||
class Header extends StatelessWidget {
|
||||
final String kanji;
|
||||
|
||||
const Header({required this.kanji, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final colors = Theme.of(context).extension<KanjiResultThemeExtension>()!;
|
||||
|
||||
return AspectRatio(
|
||||
@@ -25,7 +24,7 @@ class Header extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 70.0,
|
||||
color: colors.foregroundColor,
|
||||
).merge(japaneseFont.textStyle),
|
||||
).merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ class JlptLevel extends StatelessWidget {
|
||||
const JlptLevel({required this.jlptLevel, this.ifNullChar = '⨉', super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final colors = Theme.of(context).extension<KanjiResultThemeExtension>()!;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
import '../../../routing/routes.dart';
|
||||
import '../../../settings.dart';
|
||||
|
||||
class Radical extends StatelessWidget {
|
||||
final String radical;
|
||||
|
||||
const Radical({required this.radical, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final colors = Theme.of(context).extension<KanjiResultThemeExtension>()!;
|
||||
|
||||
return InkWell(
|
||||
@@ -30,7 +29,7 @@ class Radical extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: colors.foregroundColor,
|
||||
fontSize: 40.0,
|
||||
).merge(japaneseFont.textStyle),
|
||||
).merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ class Rank extends StatelessWidget {
|
||||
const Rank({required this.rank, this.ifNullChar = '⨉', super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final colors = Theme.of(context).extension<KanjiResultThemeExtension>()!;
|
||||
|
||||
return Container(
|
||||
|
||||
@@ -8,7 +8,7 @@ class StrokeOrderGif extends StatelessWidget {
|
||||
const StrokeOrderGif({required this.kanji, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final kanjiResultColors = Theme.of(
|
||||
context,
|
||||
).extension<KanjiResultThemeExtension>()!;
|
||||
@@ -32,7 +32,7 @@ class StrokeOrderGif extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
child: Kanimaji(
|
||||
kanji: kanji,
|
||||
strokeColor: kanjiResultColors.foregroundColor!,
|
||||
strokeColor: Theme.of(context).colorScheme.onSurface,
|
||||
strokeUnfilledColor: menuGreyLightColors.foregroundColor!.withAlpha(
|
||||
0x40,
|
||||
),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jadb/util/romaji_transliteration.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
import '../../../routing/routes.dart';
|
||||
import '../../../settings.dart';
|
||||
|
||||
enum YomiType { onyomi, kunyomi, meaning }
|
||||
|
||||
extension on YomiType {
|
||||
@@ -19,7 +18,7 @@ extension on YomiType {
|
||||
// }
|
||||
// }
|
||||
|
||||
Color getColor(BuildContext context) {
|
||||
Color getColor(final BuildContext context) {
|
||||
switch (this) {
|
||||
case YomiType.onyomi:
|
||||
return Theme.of(context).extension<YomiThemeExtension>()!.onyomiColor!;
|
||||
@@ -38,11 +37,11 @@ class YomiChips extends StatelessWidget {
|
||||
const YomiChips({required this.yomi, required this.type, super.key});
|
||||
|
||||
Widget yomiCard({
|
||||
required BuildContext context,
|
||||
required String yomi,
|
||||
required Color? color,
|
||||
bool searchable = true,
|
||||
TextStyle? extraTextStyle,
|
||||
required final BuildContext context,
|
||||
required final String yomi,
|
||||
required final Color? color,
|
||||
final bool searchable = true,
|
||||
final TextStyle? extraTextStyle,
|
||||
}) => InkWell(
|
||||
onTap: searchable
|
||||
? () => Navigator.pushNamed(context, Routes.search, arguments: yomi)
|
||||
@@ -64,16 +63,16 @@ class YomiChips extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
|
||||
Widget yomiWrapper(BuildContext context) {
|
||||
Widget yomiWrapper(final BuildContext context) {
|
||||
final yomiCards = yomi
|
||||
.map((y) => romajiEnabled ? transliterateKanaToLatin(y) : y)
|
||||
.map((final y) => romajiEnabled.value ? transliterateKanaToLatin(y) : y)
|
||||
.map(
|
||||
(y) => yomiCard(
|
||||
(final y) => yomiCard(
|
||||
context: context,
|
||||
yomi: y,
|
||||
color: type.getColor(context),
|
||||
extraTextStyle: type != YomiType.meaning && !romajiEnabled
|
||||
? japaneseFont.textStyle
|
||||
extraTextStyle: type != YomiType.meaning && !romajiEnabled.value
|
||||
? japaneseFont.value.textStyle
|
||||
: null,
|
||||
),
|
||||
)
|
||||
@@ -96,7 +95,7 @@ class YomiChips extends StatelessWidget {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0),
|
||||
alignment: Alignment.centerLeft,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:animated_size_and_fade/animated_size_and_fade.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:jadb/search.dart';
|
||||
import 'package:mugiten/components/kanji/kanji_search_body/kanji_grid.dart';
|
||||
import 'package:mugiten/components/kanji/kanji_search_body/kanji_search_bar.dart';
|
||||
import 'package:mugiten/components/kanji/kanji_search_body/kanji_search_options_bar.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import 'kanji_search_body/kanji_grid.dart';
|
||||
import 'kanji_search_body/kanji_search_bar.dart';
|
||||
import 'kanji_search_body/kanji_search_options_bar.dart';
|
||||
|
||||
class KanjiSearchBody extends StatefulWidget {
|
||||
const KanjiSearchBody({super.key});
|
||||
|
||||
@@ -47,10 +48,10 @@ class _KanjiSearchBodyState extends State<KanjiSearchBody>
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: !_isFocused,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
onPopInvokedWithResult: (final didPop, final result) {
|
||||
if (!didPop) {
|
||||
focus.unfocus();
|
||||
_kanjiSearchBarState.currentState!.clearText();
|
||||
@@ -64,7 +65,7 @@ class _KanjiSearchBodyState extends State<KanjiSearchBody>
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: AnimatedBuilder(
|
||||
animation: _searchbarMovementAnimation,
|
||||
builder: (context, _) {
|
||||
builder: (final context, _) {
|
||||
return Container(
|
||||
alignment: _searchbarMovementAnimation.value,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10.0),
|
||||
@@ -73,18 +74,26 @@ class _KanjiSearchBodyState extends State<KanjiSearchBody>
|
||||
children: [
|
||||
Focus(
|
||||
focusNode: focus,
|
||||
onFocusChange: (hasFocus) {
|
||||
onFocusChange: (final hasFocus) {
|
||||
if (hasFocus) {
|
||||
_controller.forward();
|
||||
setState(() => _isFocused = true);
|
||||
unawaited(
|
||||
_controller.forward().then(
|
||||
(_) => setState(() => _isFocused = true),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
_controller.reverse();
|
||||
setState(() => _isFocused = false);
|
||||
unawaited(
|
||||
_controller.reverse().then(
|
||||
(_) => setState(() {
|
||||
_isFocused = false;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: KanjiSearchBar(
|
||||
key: _kanjiSearchBarState,
|
||||
onChanged: (text) => setState(() async {
|
||||
onChanged: (final text) => setState(() async {
|
||||
suggestions = await GetIt.instance
|
||||
.get<Database>()
|
||||
.filterKanji(text.characters.toList());
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import '../../../routing/routes.dart';
|
||||
import '../../../settings.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
class KanjiGrid extends StatelessWidget {
|
||||
final List<String> suggestions;
|
||||
@@ -10,7 +11,7 @@ class KanjiGrid extends StatelessWidget {
|
||||
const KanjiGrid({required this.suggestions, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 40.0),
|
||||
child: GridView.count(
|
||||
@@ -18,7 +19,7 @@ class KanjiGrid extends StatelessWidget {
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 10.0,
|
||||
crossAxisSpacing: 10.0,
|
||||
children: suggestions.map((kanji) => _GridItem(kanji)).toList(),
|
||||
children: suggestions.map(_GridItem.new).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -29,12 +30,14 @@ class _GridItem extends StatelessWidget {
|
||||
const _GridItem(this.kanji);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final colors = Theme.of(context).extension<MenuGreyLightThemeExtension>()!;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, Routes.kanjiSearch, arguments: kanji);
|
||||
unawaited(
|
||||
Navigator.pushNamed(context, Routes.kanjiSearch, arguments: kanji),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -46,7 +49,7 @@ class _GridItem extends StatelessWidget {
|
||||
child: FittedBox(
|
||||
child: Text(
|
||||
kanji,
|
||||
style: japaneseFont.textStyle.merge(
|
||||
style: japaneseFont.value.textStyle.merge(
|
||||
TextStyle(color: colors.foregroundColor),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../settings.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
|
||||
class KanjiSearchBar extends StatefulWidget {
|
||||
final Function(String)? onChanged;
|
||||
@@ -39,22 +39,22 @@ class KanjiSearchBarState extends State<KanjiSearchBar> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final IconButton clearButton = IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () => clearText(),
|
||||
onPressed: clearText,
|
||||
);
|
||||
|
||||
final IconButton pasteButton = IconButton(
|
||||
icon: const Icon(Icons.content_paste),
|
||||
onPressed: () => pasteText(),
|
||||
onPressed: pasteText,
|
||||
);
|
||||
|
||||
return TextField(
|
||||
controller: textController,
|
||||
onChanged: (text) => onChanged(),
|
||||
onChanged: (final text) => onChanged(),
|
||||
onSubmitted: (_) => {},
|
||||
style: japaneseFont.textStyle,
|
||||
style: japaneseFont.value.textStyle,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10.0)),
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
import '../../../routing/routes.dart';
|
||||
|
||||
class KanjiSearchOptionsBar extends StatelessWidget {
|
||||
const KanjiSearchOptionsBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -42,7 +41,7 @@ class _IconButton extends StatelessWidget {
|
||||
const _IconButton({required this.icon, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => IconButton(
|
||||
Widget build(final BuildContext context) => IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: icon,
|
||||
iconSize: 30,
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:jadb/search.dart';
|
||||
import 'package:mugiten/components/common/loading.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:ruby_text/ruby_text.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../common/loading.dart';
|
||||
|
||||
Future<void> showAddToLibraryDialog({
|
||||
required BuildContext context,
|
||||
required int? jmdictEntryId,
|
||||
required String? kanji,
|
||||
required final BuildContext context,
|
||||
required final int? jmdictEntryId,
|
||||
required final String? kanji,
|
||||
}) => showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
@@ -43,16 +44,18 @@ class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListAllListsContain(
|
||||
jmdictEntryId: widget.jmdictEntryId,
|
||||
kanji: widget.kanji,
|
||||
)
|
||||
.then((data) => setState(() => librariesContainEntry = data));
|
||||
unawaited(
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListAllListsContain(
|
||||
jmdictEntryId: widget.jmdictEntryId,
|
||||
kanji: widget.kanji,
|
||||
)
|
||||
.then((final data) => setState(() => librariesContainEntry = data)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> toggleEntry(String libraryName) async {
|
||||
Future<void> toggleEntry(final String libraryName) async {
|
||||
if (toggleLock) return;
|
||||
|
||||
setState(() => toggleLock = true);
|
||||
@@ -71,7 +74,7 @@ class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Add to library'),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 12),
|
||||
@@ -93,7 +96,7 @@ class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
|
||||
future: GetIt.instance.get<Database>().jadbGetWordById(
|
||||
widget.jmdictEntryId!,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
builder: (final context, final snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return ErrorWidget(snapshot.error!);
|
||||
}
|
||||
@@ -131,7 +134,7 @@ class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
|
||||
child: librariesContainEntry == null
|
||||
? const LoadingScreen()
|
||||
: ListView(
|
||||
children: librariesContainEntry!.entries.map((e) {
|
||||
children: librariesContainEntry!.entries.map((final e) {
|
||||
final libraryName = e.key;
|
||||
final checked = e.value;
|
||||
return ListTile(
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/components/common/kanji_box.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/search_card.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import 'package:mugiten/services/clipboard.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../routing/routes.dart';
|
||||
import '../../services/clipboard.dart';
|
||||
import '../../settings.dart';
|
||||
import '../common/kanji_box.dart';
|
||||
|
||||
class LibraryListEntryTile extends StatelessWidget {
|
||||
final int? index;
|
||||
final LibraryList library;
|
||||
@@ -27,20 +26,20 @@ class LibraryListEntryTile extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return entry.kanji != null
|
||||
? _kanjiTile(context, index, entry.kanji!)
|
||||
: _jmdictEntryTile(context, index, entry);
|
||||
}
|
||||
|
||||
Widget _index(BuildContext context, int index) {
|
||||
Widget _index(final BuildContext context, final int index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
(index + 1).toString(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium!.merge(japaneseFont.textStyle),
|
||||
).textTheme.titleMedium!.merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -60,7 +59,11 @@ class LibraryListEntryTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kanjiTile(BuildContext context, int? index, String kanji) {
|
||||
Widget _kanjiTile(
|
||||
final BuildContext context,
|
||||
final int? index,
|
||||
final String kanji,
|
||||
) {
|
||||
return Slidable(
|
||||
endActionPane: ActionPane(
|
||||
motion: const ScrollMotion(),
|
||||
@@ -79,7 +82,7 @@ class LibraryListEntryTile extends StatelessWidget {
|
||||
onLongPress: () => copyToClipboard(context, kanji),
|
||||
title: Row(
|
||||
children: [
|
||||
SizedBox(width: 15),
|
||||
const SizedBox(width: 15),
|
||||
KanjiBox.headline4(context: context, kanji: kanji),
|
||||
],
|
||||
),
|
||||
@@ -88,9 +91,9 @@ class LibraryListEntryTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _jmdictEntryTile(
|
||||
BuildContext context,
|
||||
int? index,
|
||||
LibraryListEntry entry,
|
||||
final BuildContext context,
|
||||
final int? index,
|
||||
final LibraryListEntry entry,
|
||||
) {
|
||||
return SearchResultCard(
|
||||
result: entry.wordSearchResult!,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/components/common/async_text_form_field.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../routing/routes.dart';
|
||||
|
||||
class LibraryListTile extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
final LibraryList library;
|
||||
final void Function()? onDelete;
|
||||
final void Function()? onUpdate;
|
||||
final void Function(String, String)? onRename;
|
||||
final bool isEditable;
|
||||
|
||||
const LibraryListTile({
|
||||
@@ -18,12 +18,12 @@ class LibraryListTile extends StatelessWidget {
|
||||
required this.library,
|
||||
this.leading,
|
||||
this.onDelete,
|
||||
this.onUpdate,
|
||||
this.onRename,
|
||||
this.isEditable = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Slidable(
|
||||
endActionPane: ActionPane(
|
||||
motion: const ScrollMotion(),
|
||||
@@ -34,8 +34,23 @@ class LibraryListTile extends StatelessWidget {
|
||||
backgroundColor: Colors.blue,
|
||||
icon: Icons.edit,
|
||||
onPressed: (_) async {
|
||||
// TODO: update name
|
||||
onUpdate?.call();
|
||||
final String oldName = library.name;
|
||||
|
||||
final String? newName = await showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (_) => _RenameLibraryDialog(oldName: oldName),
|
||||
);
|
||||
|
||||
if (newName == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await GetIt.instance.get<Database>().libraryListRenameList(
|
||||
oldName,
|
||||
newName,
|
||||
);
|
||||
onRename?.call(oldName, newName);
|
||||
},
|
||||
),
|
||||
// TODO: ask for confirmation before deleting
|
||||
@@ -65,3 +80,55 @@ class LibraryListTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RenameLibraryDialog extends StatefulWidget {
|
||||
final String oldName;
|
||||
|
||||
const _RenameLibraryDialog({required this.oldName});
|
||||
|
||||
@override
|
||||
State<_RenameLibraryDialog> createState() => _RenameLibraryDialogState();
|
||||
}
|
||||
|
||||
class _RenameLibraryDialogState extends State<_RenameLibraryDialog> {
|
||||
final controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller.text = widget.oldName;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Rename library'),
|
||||
content: AsyncTextFormField(
|
||||
controller: controller,
|
||||
asyncValidator: (final value) async {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a name';
|
||||
}
|
||||
if (value == 'favourites') {
|
||||
return 'This name is reserved';
|
||||
}
|
||||
if (value != widget.oldName &&
|
||||
await GetIt.instance.get<Database>().libraryListExists(value)) {
|
||||
return 'A library with this name already exists';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text),
|
||||
child: const Text('Confirm'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
void Function() showNewLibraryDialog(BuildContext context) => () async {
|
||||
void Function() showNewLibraryDialog(final BuildContext context) => () async {
|
||||
final String? listName = await showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
@@ -28,7 +28,7 @@ class _NewLibraryDialogState extends State<NewLibraryDialog> {
|
||||
final controller = TextEditingController();
|
||||
_NameState nameState = _NameState.initial;
|
||||
|
||||
Future<void> onNameUpdate(String proposedListName) async {
|
||||
Future<void> onNameUpdate(final String proposedListName) async {
|
||||
setState(() => nameState = _NameState.currentlyChecking);
|
||||
if (proposedListName == '') {
|
||||
setState(() => nameState = _NameState.invalid);
|
||||
@@ -54,7 +54,7 @@ class _NewLibraryDialogState extends State<NewLibraryDialog> {
|
||||
bool get confirmButtonActive => nameState == _NameState.valid;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Add new library'),
|
||||
content: TextField(
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/components/drawing_board/drawing_board.dart';
|
||||
import 'package:mugiten/components/search/language_selector.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
import '../../routing/routes.dart';
|
||||
import '../../settings.dart';
|
||||
import 'language_selector.dart';
|
||||
|
||||
class GlobalSearchBar extends StatelessWidget {
|
||||
final TextEditingController textController = TextEditingController();
|
||||
final FocusNode textFocus = FocusNode();
|
||||
|
||||
GlobalSearchBar({super.key});
|
||||
|
||||
void _search(BuildContext context, String text) =>
|
||||
void _search(final BuildContext context, final String text) =>
|
||||
Navigator.pushNamed(context, Routes.search, arguments: text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
onSubmitted: (text) => _search(context, text),
|
||||
onSubmitted: (final text) => _search(context, text),
|
||||
controller: textController,
|
||||
focusNode: textFocus,
|
||||
style: japaneseFont.textStyle,
|
||||
style: japaneseFont.value.textStyle,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Search',
|
||||
border: OutlineInputBorder(
|
||||
@@ -57,9 +56,7 @@ class GlobalSearchBar extends StatelessWidget {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
color: Colors.red,
|
||||
onPressed: () {
|
||||
textController.clear();
|
||||
},
|
||||
onPressed: textController.clear,
|
||||
),
|
||||
const LanguageSelector(),
|
||||
IconButton(
|
||||
@@ -100,9 +97,11 @@ class GlobalSearchBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> Function(BuildContext) _drawKanji(String? precedingText) {
|
||||
Future<String?> Function(BuildContext) _drawKanji(
|
||||
final String? precedingText,
|
||||
) {
|
||||
final MaterialPageRoute<String> route = MaterialPageRoute(
|
||||
builder: (context) => Scaffold(
|
||||
builder: (final context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Draw a kanji')),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
@@ -111,7 +110,7 @@ class GlobalSearchBar extends StatelessWidget {
|
||||
DrawingBoard(
|
||||
precedingText: precedingText,
|
||||
onlyOneCharacterSuggestions: true,
|
||||
onSuggestionChosen: (suggestion) =>
|
||||
onSuggestionChosen: (final suggestion) =>
|
||||
Navigator.pop(context, suggestion),
|
||||
),
|
||||
],
|
||||
@@ -120,6 +119,6 @@ class GlobalSearchBar extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
|
||||
return (context) => Navigator.push<String>(context, route);
|
||||
return (final context) => Navigator.push<String>(context, route);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../settings.dart';
|
||||
|
||||
class LanguageSelector extends StatefulWidget {
|
||||
const LanguageSelector({super.key});
|
||||
|
||||
@@ -22,33 +21,34 @@ class _LanguageSelectorState extends State<LanguageSelector> {
|
||||
isSelected = _getSelectedStatus() ?? [false, false, false];
|
||||
}
|
||||
|
||||
Future<void> _updateSelectedStatus() async => prefs.setStringList(
|
||||
void _updateSelectedStatus() => prefs.setStringList(
|
||||
'languageSelectorStatus',
|
||||
isSelected.map((b) => b ? '1' : '0').toList(),
|
||||
isSelected.map((final b) => b ? '1' : '0').toList(),
|
||||
);
|
||||
|
||||
List<bool>? _getSelectedStatus() => prefs
|
||||
.getStringList('languageSelectorStatus')
|
||||
?.map((s) => s == '1')
|
||||
?.map((final s) => s == '1')
|
||||
.toList();
|
||||
|
||||
Widget _languageOption(String language, {TextStyle? style}) => Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 20.0),
|
||||
child: Text(language, style: style),
|
||||
);
|
||||
Widget _languageOption(final String language, {final TextStyle? style}) =>
|
||||
Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 20.0),
|
||||
child: Text(language, style: style),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return ToggleButtons(
|
||||
selectedColor: mugitenWheatBackground,
|
||||
isSelected: isSelected,
|
||||
children: [
|
||||
_languageOption('Auto'),
|
||||
_languageOption('日本語', style: japaneseFont.textStyle),
|
||||
_languageOption('日本語', style: japaneseFont.value.textStyle),
|
||||
_languageOption('English'),
|
||||
],
|
||||
onPressed: (buttonIndex) {
|
||||
onPressed: (final buttonIndex) {
|
||||
setState(() {
|
||||
for (final int i in Iterable.generate(isSelected.length)) {
|
||||
isSelected[i] = i == buttonIndex;
|
||||
|
||||
@@ -7,7 +7,7 @@ class CircleBadge extends StatelessWidget {
|
||||
const CircleBadge({super.key, this.child, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(5),
|
||||
width: 30,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'circle_badge.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/circle_badge.dart';
|
||||
|
||||
class CommonBadge extends StatelessWidget {
|
||||
final bool isCommon;
|
||||
@@ -7,7 +7,7 @@ class CommonBadge extends StatelessWidget {
|
||||
const CommonBadge({required this.isCommon, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return CircleBadge(
|
||||
color: isCommon ? Colors.green : Colors.transparent,
|
||||
child: Text(
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jadb/util/romaji_transliteration.dart';
|
||||
|
||||
import '../../../../settings.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
|
||||
class JapaneseHeader extends StatelessWidget {
|
||||
final String baseWord;
|
||||
final String? furigana;
|
||||
final bool dimBase;
|
||||
|
||||
const JapaneseHeader({
|
||||
super.key,
|
||||
required this.baseWord,
|
||||
required this.furigana,
|
||||
super.key,
|
||||
this.dimBase = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
@@ -22,13 +24,23 @@ class JapaneseHeader extends StatelessWidget {
|
||||
children: [
|
||||
(furigana != null)
|
||||
? Text(
|
||||
romajiEnabled
|
||||
romajiEnabled.value
|
||||
? transliterateKanaToLatin(furigana!)
|
||||
: furigana!,
|
||||
style: japaneseFont.textStyle,
|
||||
style: japaneseFont.value.textStyle,
|
||||
)
|
||||
: const Text(''),
|
||||
Text(baseWord, style: japaneseFont.textStyle),
|
||||
Text(
|
||||
baseWord,
|
||||
style: japaneseFont.value.textStyle.merge(
|
||||
TextStyle(
|
||||
color:
|
||||
(japaneseFont.value.textStyle.color ??
|
||||
Theme.of(context).textTheme.bodyMedium?.color)
|
||||
?.withAlpha(dimBase ? 0xA0 : 0xFF),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'circle_badge.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/circle_badge.dart';
|
||||
|
||||
class JLPTBadge extends StatelessWidget {
|
||||
final String? jlptLevel;
|
||||
@@ -7,7 +7,7 @@ class JLPTBadge extends StatelessWidget {
|
||||
const JLPTBadge({required this.jlptLevel, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return CircleBadge(
|
||||
color: jlptLevel != null ? Colors.blue : Colors.transparent,
|
||||
child: Text(jlptLevel ?? '', style: const TextStyle(color: Colors.white)),
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
import '../../../../routing/routes.dart';
|
||||
import '../../../../settings.dart';
|
||||
|
||||
class KanjiRow extends StatelessWidget {
|
||||
final List<String> kanji;
|
||||
final double fontSize;
|
||||
const KanjiRow({super.key, required this.kanji, this.fontSize = 20});
|
||||
|
||||
Widget _kanjiBox(BuildContext context, String kanji) {
|
||||
Widget _kanjiBox(final BuildContext context, final String kanji) {
|
||||
final colors = Theme.of(context).extension<MenuGreyLightThemeExtension>()!;
|
||||
|
||||
return UnconstrainedBox(
|
||||
@@ -29,7 +28,7 @@ class KanjiRow extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: colors.foregroundColor,
|
||||
fontSize: fontSize,
|
||||
).merge(japaneseFont.textStyle),
|
||||
).merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -39,7 +38,7 @@ class KanjiRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jadb/util/romaji_transliteration.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
import '../../../../settings.dart';
|
||||
|
||||
class KanjiKanaBox extends StatelessWidget {
|
||||
final String baseWord;
|
||||
final String? furigana;
|
||||
@@ -31,7 +30,7 @@ class KanjiKanaBox extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final fFontsize =
|
||||
furiganaFontsize ??
|
||||
((kanjiFontsize != null) ? 0.8 * kanjiFontsize! : null);
|
||||
@@ -48,7 +47,7 @@ class KanjiKanaBox extends StatelessWidget {
|
||||
children: [
|
||||
(furigana != null)
|
||||
? Text(
|
||||
romajiEnabled
|
||||
romajiEnabled.value
|
||||
? transliterateKanaToLatin(furigana!)
|
||||
: furigana!,
|
||||
style:
|
||||
@@ -56,9 +55,9 @@ class KanjiKanaBox extends StatelessWidget {
|
||||
fontSize: fFontsize,
|
||||
color: colors.foregroundColor,
|
||||
).merge(
|
||||
romajiEnabled && autoTransliterateRomaji
|
||||
romajiEnabled.value && autoTransliterateRomaji
|
||||
? null
|
||||
: japaneseFont.textStyle,
|
||||
: japaneseFont.value.textStyle,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
@@ -72,9 +71,9 @@ class KanjiKanaBox extends StatelessWidget {
|
||||
child: Text(baseWord),
|
||||
style: TextStyle(
|
||||
fontSize: kanjiFontsize,
|
||||
).merge(japaneseFont.textStyle),
|
||||
).merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
if (romajiEnabled && showRomajiBelow)
|
||||
if (romajiEnabled.value && showRomajiBelow)
|
||||
Text(transliterateKanaToLatin(furigana ?? baseWord)),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@ class Notes extends StatelessWidget {
|
||||
const Notes({super.key, required this.notes});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
Widget build(final BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Notes:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jadb/models/word_search/word_search_ruby.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/kanji_kana_box.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
import 'kanji_kana_box.dart';
|
||||
|
||||
class OtherForms extends StatelessWidget {
|
||||
final List<WordSearchRuby> forms;
|
||||
|
||||
const OtherForms({required this.forms, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final colors = Theme.of(context).extension<MenuGreyLightThemeExtension>()!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/sense/search_chip.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
import 'search_chip.dart';
|
||||
|
||||
class EnglishDefinitions extends StatelessWidget {
|
||||
final List<String> englishDefinitions;
|
||||
@@ -13,7 +13,7 @@ class EnglishDefinitions extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Wrap(
|
||||
Widget build(final BuildContext context) => Wrap(
|
||||
runSpacing: 10.0,
|
||||
spacing: 5,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
|
||||
@@ -14,7 +14,7 @@ class SearchChip extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
Widget build(final BuildContext context) => Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.backgroundColor,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jadb/models/word_search/word_search_sense.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/sense/english_definitions.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/search_card.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
import 'package:sealed_languages/sealed_languages.dart';
|
||||
|
||||
import 'english_definitions.dart';
|
||||
|
||||
final Map<String, String> languageNameMap = {
|
||||
...{
|
||||
for (final lang in NaturalLanguage.list) lang.code: lang.name,
|
||||
@@ -20,33 +20,68 @@ class Sense extends StatelessWidget {
|
||||
|
||||
const Sense({super.key, required this.index, required this.sense});
|
||||
|
||||
String _capitalize(String str) {
|
||||
String _capitalize(final String str) {
|
||||
if (str.isEmpty) return str;
|
||||
return str[0].toUpperCase() + str.substring(1);
|
||||
}
|
||||
|
||||
List<String> _notes() {
|
||||
final _notesTextStyle = const TextStyle(fontSize: 12);
|
||||
List<Text> _notes() {
|
||||
return [
|
||||
...sense.restrictedToReading.map((e) => 'Restricted to $e'),
|
||||
...sense.restrictedToKanji.map((e) => 'Restricted to $e'),
|
||||
...sense.fields.map((e) => 'Field: ${_capitalize(e.description)}'),
|
||||
...sense.misc.map((e) => e.description),
|
||||
...sense.languageSource.map((e) {
|
||||
final languageName =
|
||||
languageNameMap[e.language.toUpperCase()] ?? e.language;
|
||||
...sense.restrictedToReading.map(
|
||||
(final e) => Text.rich(
|
||||
TextSpan(
|
||||
text: 'Restricted to ',
|
||||
style: _notesTextStyle,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '"$e"',
|
||||
style: _notesTextStyle.merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (e.phrase != null) {
|
||||
return 'From $languageName, "${e.phrase}"';
|
||||
} else {
|
||||
return 'From $languageName';
|
||||
}
|
||||
}),
|
||||
...sense.dialects.map((e) => '${_capitalize(e.description)} dialect'),
|
||||
...sense.restrictedToKanji.map(
|
||||
(final e) => Text.rich(
|
||||
TextSpan(
|
||||
text: 'Restricted to ',
|
||||
style: _notesTextStyle,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '"$e"',
|
||||
style: _notesTextStyle.merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
...[
|
||||
...sense.fields.map(
|
||||
(final e) => 'Field: ${_capitalize(e.description)}',
|
||||
),
|
||||
...sense.misc.map((final e) => e.description),
|
||||
...sense.languageSource.map((final e) {
|
||||
final languageName =
|
||||
languageNameMap[e.language.toUpperCase()] ?? e.language;
|
||||
|
||||
if (e.phrase != null) {
|
||||
return 'From $languageName, "${e.phrase}"';
|
||||
} else {
|
||||
return 'From $languageName';
|
||||
}
|
||||
}),
|
||||
...sense.dialects.map(
|
||||
(final e) => '${_capitalize(e.description)} dialect',
|
||||
),
|
||||
].map((final e) => Text(e, style: _notesTextStyle)),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final lightColors = Theme.of(
|
||||
context,
|
||||
).extension<MenuGreyLightThemeExtension>()!;
|
||||
@@ -65,7 +100,7 @@ class Sense extends StatelessWidget {
|
||||
children:
|
||||
<Widget>[
|
||||
Text(
|
||||
'${index + 1}. ${sense.partsOfSpeech.map((pos) => _capitalize(pos.shortDescription)).join(', ')}',
|
||||
'${index + 1}. ${sense.partsOfSpeech.map((final pos) => _capitalize(pos.shortDescription)).join(', ')}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
@@ -76,21 +111,21 @@ class Sense extends StatelessWidget {
|
||||
if (_notes().isNotEmpty)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 5),
|
||||
child: Text(
|
||||
_notes().join('\n'),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _notes(),
|
||||
),
|
||||
),
|
||||
if (sense.antonyms.isNotEmpty &&
|
||||
sense.antonyms.first.xrefResult != null)
|
||||
Text(
|
||||
const Text(
|
||||
'Antonyms:',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
...sense.antonyms
|
||||
.where((antonym) => antonym.xrefResult != null)
|
||||
.where((final antonym) => antonym.xrefResult != null)
|
||||
.map(
|
||||
(antonym) => SearchResultCard(
|
||||
(final antonym) => SearchResultCard(
|
||||
result: antonym.xrefResult!,
|
||||
backgroundColor: Colors.black38,
|
||||
leading: antonym.ambiguous
|
||||
@@ -100,14 +135,14 @@ class Sense extends StatelessWidget {
|
||||
),
|
||||
if (sense.seeAlso.isNotEmpty &&
|
||||
sense.seeAlso.first.xrefResult != null)
|
||||
Text(
|
||||
const Text(
|
||||
'See also:',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
...sense.seeAlso
|
||||
.where((seeAlso) => seeAlso.xrefResult != null)
|
||||
.where((final seeAlso) => seeAlso.xrefResult != null)
|
||||
.map(
|
||||
(seeAlso) => SearchResultCard(
|
||||
(final seeAlso) => SearchResultCard(
|
||||
result: seeAlso.xrefResult!,
|
||||
backgroundColor: Colors.black38,
|
||||
leading: seeAlso.ambiguous
|
||||
@@ -117,7 +152,7 @@ class Sense extends StatelessWidget {
|
||||
),
|
||||
]
|
||||
.map(
|
||||
(e) => Container(
|
||||
(final e) => Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: e,
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jadb/models/word_search/word_search_sense.dart';
|
||||
|
||||
import 'sense/sense.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/sense/sense.dart';
|
||||
|
||||
class Senses extends StatelessWidget {
|
||||
final List<WordSearchSense> senses;
|
||||
@@ -13,7 +13,7 @@ class Senses extends StatelessWidget {
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
Widget build(final BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: _senseWidgets,
|
||||
);
|
||||
|
||||
@@ -1,31 +1,37 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/components/search/search_results_body/parts/common_badge.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/header.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/jlpt_badge.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/kanji.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/other_forms.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/senses.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:mugiten/services/clipboard.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import './parts/common_badge.dart';
|
||||
import './parts/header.dart';
|
||||
import './parts/jlpt_badge.dart';
|
||||
import './parts/other_forms.dart';
|
||||
import './parts/senses.dart';
|
||||
import 'parts/kanji.dart';
|
||||
|
||||
class SearchResultCard extends StatefulWidget {
|
||||
final WordSearchResult result;
|
||||
final List<SlidableAction>? slidableActions;
|
||||
final Widget? leading;
|
||||
final Color? backgroundColor;
|
||||
final bool allowQuickAddLibraryList;
|
||||
final bool initiallyExpanded;
|
||||
|
||||
const SearchResultCard({
|
||||
required this.result,
|
||||
this.slidableActions,
|
||||
this.leading,
|
||||
this.backgroundColor,
|
||||
this.allowQuickAddLibraryList = true,
|
||||
this.initiallyExpanded = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -37,37 +43,76 @@ class _SearchResultCardState extends State<SearchResultCard> {
|
||||
bool get hasAttribution =>
|
||||
widget.result.sources.jmdict || widget.result.sources.jmnedict;
|
||||
|
||||
bool isFavourited = false;
|
||||
bool isQuickListed = false;
|
||||
|
||||
// TODO: only fetch data from the lists we actually care about
|
||||
Future<void> fetchFavouriteAndQuickListStatus() => GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListAllListsContain(jmdictEntryId: widget.result.entryId)
|
||||
.then(
|
||||
(final data) => setState(() {
|
||||
isFavourited = data['favourites'] ?? false;
|
||||
isQuickListed =
|
||||
quickAddLibraryList.value != null &&
|
||||
(data[quickAddLibraryList.value!] ?? false);
|
||||
}),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(fetchFavouriteAndQuickListStatus());
|
||||
}
|
||||
|
||||
List<String> get kanji => kanjiRegex
|
||||
.allMatches(
|
||||
widget.result.japanese
|
||||
.map((w) => '${w.base}${w.furigana ?? ""}')
|
||||
.map((final w) => '${w.base}${w.furigana ?? ""}')
|
||||
.join(),
|
||||
)
|
||||
.map((match) => match.group(0)!)
|
||||
.map((final match) => match.group(0)!)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
Widget get _header => IntrinsicWidth(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
JapaneseHeader(
|
||||
Widget get _header => Row(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// TODO: draw sizedbox to take up space instead
|
||||
if (!quickAddLibraryList.contains('favourites'))
|
||||
Icon(
|
||||
Icons.bookmark,
|
||||
color: isQuickListed ? Colors.blue : Colors.transparent,
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.star,
|
||||
color: isFavourited ? Colors.yellow : Colors.transparent,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: JapaneseHeader(
|
||||
baseWord: widget.result.japanese[0].base,
|
||||
furigana: widget.result.japanese[0].furigana,
|
||||
dimBase: widget.result.hasUnusualKanji,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
JLPTBadge(jlptLevel: widget.result.jlptLevel.toNullableString()),
|
||||
CommonBadge(isCommon: widget.result.isCommon),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
JLPTBadge(jlptLevel: widget.result.jlptLevel.toNullableString()),
|
||||
CommonBadge(isCommon: widget.result.isCommon),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
static const _margin = SizedBox(height: 20);
|
||||
|
||||
List<Widget> _withMargin(Widget w) => [_margin, w];
|
||||
List<Widget> _withMargin(final Widget w) => [_margin, w];
|
||||
|
||||
Widget _body() => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 10),
|
||||
@@ -88,14 +133,38 @@ class _SearchResultCardState extends State<SearchResultCard> {
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final backgroundColor =
|
||||
widget.backgroundColor ?? Theme.of(context).scaffoldBackgroundColor;
|
||||
|
||||
return GestureDetector(
|
||||
onLongPress: () =>
|
||||
copyToClipboard(context, widget.result.japanese.firstOrNull?.base),
|
||||
onDoubleTap: () {
|
||||
if (isQuickListed && quickAddLibraryList.value != null) {
|
||||
unawaited(
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListDeleteEntry(
|
||||
quickAddLibraryList.value!,
|
||||
jmdictEntryId: widget.result.entryId,
|
||||
)
|
||||
.then((_) => fetchFavouriteAndQuickListStatus()),
|
||||
);
|
||||
} else {
|
||||
unawaited(
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListInsertEntry(
|
||||
quickAddLibraryList.value!,
|
||||
jmdictEntryId: widget.result.entryId,
|
||||
)
|
||||
.then((_) => fetchFavouriteAndQuickListStatus()),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Slidable(
|
||||
key: ValueKey('slidable-${widget.result.entryId}'),
|
||||
endActionPane: ActionPane(
|
||||
motion: const ScrollMotion(),
|
||||
children:
|
||||
@@ -104,21 +173,24 @@ class _SearchResultCardState extends State<SearchResultCard> {
|
||||
SlidableAction(
|
||||
backgroundColor: Colors.yellow,
|
||||
icon: Icons.star,
|
||||
onPressed: (_) =>
|
||||
GetIt.instance.get<Database>().libraryListToggleEntry(
|
||||
onPressed: (_) => GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListToggleEntry(
|
||||
'favourites',
|
||||
jmdictEntryId: widget.result.entryId,
|
||||
kanji: null,
|
||||
)
|
||||
.then(
|
||||
(_) => setState(() => isFavourited = !isFavourited),
|
||||
),
|
||||
),
|
||||
SlidableAction(
|
||||
backgroundColor: Colors.blue,
|
||||
icon: Icons.bookmark,
|
||||
onPressed: (context) => showAddToLibraryDialog(
|
||||
onPressed: (final context) => showAddToLibraryDialog(
|
||||
context: context,
|
||||
jmdictEntryId: widget.result.entryId,
|
||||
kanji: null,
|
||||
),
|
||||
).then((_) => fetchFavouriteAndQuickListStatus()),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -126,6 +198,7 @@ class _SearchResultCardState extends State<SearchResultCard> {
|
||||
leading: widget.leading,
|
||||
collapsedBackgroundColor: backgroundColor,
|
||||
backgroundColor: backgroundColor,
|
||||
initiallyExpanded: widget.initiallyExpanded,
|
||||
// onExpansionChanged: (b) async { },
|
||||
title: _header,
|
||||
children: [_body()],
|
||||
|
||||
@@ -9,6 +9,8 @@ import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
/// The expected version of the database. This should be incremented whenever the
|
||||
/// schema of the database has changed in a way that requires reinitialization.
|
||||
const int expectedDatabaseVersion = 2;
|
||||
|
||||
/// Returns the directory where mugiten's database file is stored.
|
||||
@@ -23,6 +25,8 @@ Future<String> databasePath() async {
|
||||
return join((await _databaseDir()).path, 'mugiten.sqlite');
|
||||
}
|
||||
|
||||
// TODO: reinitialization also needs to happen whenever the jadb schema
|
||||
// has been updated.
|
||||
Future<bool> databaseNeedsInitialization() async {
|
||||
final String dbPath = await databasePath();
|
||||
|
||||
@@ -50,7 +54,7 @@ Future<void> quickInitializeDatabase() async {
|
||||
await setupDatabase();
|
||||
}
|
||||
|
||||
/// Migration logic and heavy initialization
|
||||
// Migration logic and heavy initialization
|
||||
|
||||
class DatabaseMigration {
|
||||
final String path;
|
||||
@@ -77,7 +81,7 @@ Future<List<DatabaseMigration>> readMigrationsFromAssets() async {
|
||||
final List<String> migrations = assetManifest
|
||||
.listAssets()
|
||||
.where(
|
||||
(assetPath) =>
|
||||
(final assetPath) =>
|
||||
RegExp(r'^migrations\/\d{4}.*\.sql$').hasMatch(assetPath),
|
||||
)
|
||||
.toList();
|
||||
@@ -92,52 +96,55 @@ Future<List<DatabaseMigration>> readMigrationsFromAssets() async {
|
||||
}
|
||||
|
||||
return Future.wait(
|
||||
migrations.map((migration) async {
|
||||
migrations.map((final migration) async {
|
||||
final content = await rootBundle.loadString(migration, cache: false);
|
||||
return DatabaseMigration(path: migration, content: content);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Migrates the database from version [oldVersion] to [newVersion].
|
||||
Future<void> migrate(Database db, List<DatabaseMigration> migrations) async {
|
||||
/// Migrates the database from version `oldVersion` to `newVersion`.
|
||||
Future<void> migrate(
|
||||
final Database db,
|
||||
final Iterable<DatabaseMigration> migrations,
|
||||
) async {
|
||||
for (final migration in migrations) {
|
||||
log('Running migration ${migration.version} from ${migration.path}');
|
||||
migration.content
|
||||
.split(';')
|
||||
.map(
|
||||
(s) => s
|
||||
(final s) => s
|
||||
.split('\n')
|
||||
.where((l) => !l.startsWith(RegExp(r'\s*--')))
|
||||
.where((final l) => !l.startsWith(RegExp(r'\s*--')))
|
||||
.join('\n')
|
||||
.trim(),
|
||||
)
|
||||
.where((s) => s != '')
|
||||
.where((final s) => s != '')
|
||||
.forEach(db.execute);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Database> openDatabaseWithoutMigrations(
|
||||
String dbPath, {
|
||||
bool readOnly = false,
|
||||
bool verifyTables = true,
|
||||
final String dbPath, {
|
||||
final bool readOnly = false,
|
||||
final bool verifyTables = true,
|
||||
}) async {
|
||||
log('Opening database at $dbPath');
|
||||
final Database database = await openDatabase(
|
||||
dbPath,
|
||||
version: expectedDatabaseVersion,
|
||||
readOnly: readOnly,
|
||||
onConfigure: (db) async {
|
||||
onConfigure: (final db) async {
|
||||
// Enable foreign key constraints
|
||||
await db.execute('PRAGMA foreign_keys=ON');
|
||||
},
|
||||
onOpen: (db) async {
|
||||
onOpen: (final db) async {
|
||||
if (verifyTables) {
|
||||
log('Verifying jadb tables...');
|
||||
db.jadbVerifyTables();
|
||||
await db.jadbVerifyTables();
|
||||
|
||||
log('Verifying mugiten tables...');
|
||||
verifyMugitenTablesWithDbConnection(db);
|
||||
await verifyMugitenTablesWithDbConnection(db);
|
||||
|
||||
log('Database tables verified successfully');
|
||||
}
|
||||
@@ -147,19 +154,19 @@ Future<Database> openDatabaseWithoutMigrations(
|
||||
}
|
||||
|
||||
Future<Database> openAndMigrateDatabase(
|
||||
String dbPath,
|
||||
List<DatabaseMigration> migrations,
|
||||
final String dbPath,
|
||||
final Iterable<DatabaseMigration> migrations,
|
||||
) async {
|
||||
log('Opening database at $dbPath');
|
||||
final Database database = await openDatabase(
|
||||
dbPath,
|
||||
version: expectedDatabaseVersion,
|
||||
readOnly: false,
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
onUpgrade: (final db, final oldVersion, final newVersion) async {
|
||||
log('Migrating database from v$oldVersion to v$newVersion...');
|
||||
final migrationsToRun = migrations
|
||||
.where(
|
||||
(migration) =>
|
||||
(final migration) =>
|
||||
migration.version > oldVersion &&
|
||||
migration.version <= newVersion,
|
||||
)
|
||||
@@ -167,16 +174,16 @@ Future<Database> openAndMigrateDatabase(
|
||||
|
||||
await migrate(db, migrationsToRun);
|
||||
},
|
||||
onConfigure: (db) async {
|
||||
onConfigure: (final db) async {
|
||||
// Enable foreign key constraints
|
||||
await db.execute('PRAGMA foreign_keys=ON');
|
||||
},
|
||||
onOpen: (db) async {
|
||||
onOpen: (final db) async {
|
||||
log('Verifying jadb tables...');
|
||||
db.jadbVerifyTables();
|
||||
await db.jadbVerifyTables();
|
||||
|
||||
log('Verifying jadb tables...');
|
||||
verifyMugitenTablesWithDbConnection(db);
|
||||
await verifyMugitenTablesWithDbConnection(db);
|
||||
|
||||
log('Database tables verified successfully');
|
||||
},
|
||||
@@ -222,12 +229,10 @@ Future<void> resetDatabase() async {
|
||||
await setupDatabase();
|
||||
}
|
||||
|
||||
/// Extracts the jadb.sqlite file from the assets into a writable directory
|
||||
/// Extracts the `jadb.sqlite` file from the assets into a writable directory
|
||||
/// and returns its path.
|
||||
Future<void> extractJadbFromAssets(String path) async {
|
||||
final File jadbFile = File(path);
|
||||
|
||||
jadbFile.createSync();
|
||||
Future<void> extractJadbFromAssets(final String path) async {
|
||||
final File jadbFile = File(path)..createSync();
|
||||
|
||||
final ByteData data = await rootBundle.load('assets/jadb.sqlite');
|
||||
await jadbFile.writeAsBytes(
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// /// Can assume Android for time being
|
||||
// Future<void> exportData(context) async {
|
||||
// // setState(() => dataExportIsLoading = true);
|
||||
|
||||
// final path = (await getExternalStorageDirectory())!;
|
||||
// final dbData = await exportDatabase(db);
|
||||
// final file = File('${path.path}/jisho_data.json');
|
||||
// file.createSync(recursive: true);
|
||||
// await file.writeAsString(jsonEncode(dbData));
|
||||
|
||||
// setState(() => dataExportIsLoading = false);
|
||||
// ScaffoldMessenger.of(context)
|
||||
// .showSnackBar(SnackBar(content: Text('Data exported to ${file.path}')));
|
||||
// }
|
||||
|
||||
// /// Can assume Android for time being
|
||||
// Future<void> importData(context) async {
|
||||
// // setState(() => dataImportIsLoading = true);
|
||||
|
||||
// final path = await FilePicker.platform.pickFiles(
|
||||
// type: FileType.custom,
|
||||
// allowedExtensions: ['json'],
|
||||
// );
|
||||
// final file = File(path!.files[0].path!);
|
||||
|
||||
// final List<Search> prevSearches = (await Search.store.find(db))
|
||||
// .map((e) => Search.fromJson(e.value! as Map<String, Object?>))
|
||||
// .toList();
|
||||
// late final List<Search> importedSearches;
|
||||
// try {
|
||||
// importedSearches = (jsonDecode(await file.readAsString())
|
||||
// as List<dynamic>)
|
||||
// // importedSearches = (((jsonDecode(await file.readAsString())
|
||||
// // as Map<String, Object?>)['stores']! as List<Object?>)
|
||||
// // .map((e) => e! as Map<String, Object?>)
|
||||
// // .where((e) => e['name'] == 'search')
|
||||
// // .first['values'] as List<dynamic>)
|
||||
// .map((item) => Search.fromJson(item))
|
||||
// .toList();
|
||||
// } catch (e) {
|
||||
// debugPrint(e.toString());
|
||||
// showSnackbar(
|
||||
// context,
|
||||
// "Couldn't read file. Did you choose the right one?",
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
|
||||
// final List<Search> mergedSearches =
|
||||
// mergeSearches(prevSearches, importedSearches);
|
||||
|
||||
// // print(mergedSearches);
|
||||
|
||||
// await GetIt.instance.get<Database>().close();
|
||||
// GetIt.instance.unregister<Database>();
|
||||
|
||||
// final importedDb = await importDatabase(
|
||||
// {
|
||||
// 'sembast_export': 1,
|
||||
// 'version': 1,
|
||||
// 'stores': [
|
||||
// {
|
||||
// 'name': 'search',
|
||||
// 'keys': [for (var i = 1; i <= mergedSearches.length; i++) i],
|
||||
// 'values': mergedSearches.map((e) => e.toJson()).toList(),
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// databaseFactoryIo,
|
||||
// await databasePath(),
|
||||
// );
|
||||
// GetIt.instance.registerSingleton<Database>(importedDb);
|
||||
|
||||
// setState(() => dataImportIsLoading = false);
|
||||
// showSnackbar(context, 'Data imported successfully');
|
||||
// }
|
||||
@@ -1,13 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/routing/router.dart';
|
||||
import 'package:mugiten/screens/initialization.dart';
|
||||
import 'package:mugiten/services/archive/archive_controller.dart';
|
||||
import 'package:mugiten/services/initialization/initialization_logic.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
import 'routing/router.dart';
|
||||
|
||||
void runInitializationScreen(bool deleteDatabase) {
|
||||
void runInitializationScreen(final bool deleteDatabase) {
|
||||
runApp(
|
||||
InitializationView(
|
||||
onInitializationComplete: () =>
|
||||
@@ -37,15 +40,18 @@ class MyApp extends StatefulWidget {
|
||||
|
||||
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
final themeController = ThemeController.create();
|
||||
final archiveController = ArchiveController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
unawaited(
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]),
|
||||
);
|
||||
GetIt.instance.registerSingleton<ThemeController>(themeController);
|
||||
}
|
||||
|
||||
@@ -62,19 +68,21 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return ValueListenableBuilder<AppThemeMode>(
|
||||
valueListenable: themeController.themeMode,
|
||||
builder: (context, themeMode, _) {
|
||||
return MaterialApp(
|
||||
title: '麦典',
|
||||
theme: themeMode.lightThemeData,
|
||||
darkTheme: themeMode.darkThemeData,
|
||||
themeMode: themeMode.themeMode,
|
||||
initialRoute: '/',
|
||||
onGenerateRoute: generateRoute,
|
||||
);
|
||||
},
|
||||
builder: (final context, final themeMode, _) =>
|
||||
BlocProvider<ArchiveController>.value(
|
||||
value: archiveController,
|
||||
child: MaterialApp(
|
||||
title: '麦典',
|
||||
theme: themeMode.lightThemeData,
|
||||
darkTheme: themeMode.darkThemeData,
|
||||
themeMode: themeMode.themeMode,
|
||||
initialRoute: '/',
|
||||
onGenerateRoute: generateRoute,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
// Query
|
||||
|
||||
Future<HistoryEntry?> historyEntryGetWord(
|
||||
String word,
|
||||
final String word,
|
||||
// bool includeSearchResult = false,
|
||||
) async {
|
||||
assert(word.isNotEmpty, 'Word must not be empty');
|
||||
@@ -34,7 +34,7 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
orderBy: 'timestamp DESC',
|
||||
))
|
||||
.map(
|
||||
(e) =>
|
||||
(final e) =>
|
||||
DateTime.fromMillisecondsSinceEpoch(e['timestamp']! as int),
|
||||
)
|
||||
.toList();
|
||||
@@ -51,8 +51,8 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
}
|
||||
|
||||
Future<HistoryEntry?> historyEntryGetKanji(
|
||||
String kanji, {
|
||||
bool includeSearchResult = false,
|
||||
final String kanji, {
|
||||
final bool includeSearchResult = false,
|
||||
}) async {
|
||||
assert(kanji.runes.length == 1, 'Kanji must be a single character');
|
||||
|
||||
@@ -76,7 +76,7 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
orderBy: 'timestamp DESC',
|
||||
))
|
||||
.map(
|
||||
(e) =>
|
||||
(final e) =>
|
||||
DateTime.fromMillisecondsSinceEpoch(e['timestamp']! as int),
|
||||
)
|
||||
.toList();
|
||||
@@ -94,13 +94,19 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
}
|
||||
|
||||
Future<List<HistoryEntry>> historyEntryGetAll({
|
||||
int? page,
|
||||
int? pageSize,
|
||||
final int? page,
|
||||
final int? pageSize,
|
||||
// TODO: implement join against jadb
|
||||
// bool includeSearchResult = false,
|
||||
}) async {
|
||||
assert(page == null || page >= 0);
|
||||
assert(pageSize == null || pageSize > 0);
|
||||
assert(
|
||||
page == null || page >= 0,
|
||||
'Page must be a non-negative integer or null',
|
||||
);
|
||||
assert(
|
||||
pageSize == null || pageSize > 0,
|
||||
'Page size must be a positive integer or null',
|
||||
);
|
||||
assert(
|
||||
pageSize != null || page == null,
|
||||
'pageSize must be provided if page is provided',
|
||||
@@ -121,10 +127,10 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
[?pageSize, if (page != null) page * pageSize!],
|
||||
);
|
||||
|
||||
final List<HistoryEntry> entries = result.map((e) {
|
||||
final List<HistoryEntry> entries = result.map((final e) {
|
||||
final timestamps = (e['timestamps'] as String)
|
||||
.split(',')
|
||||
.map((ts) => DateTime.fromMillisecondsSinceEpoch(int.parse(ts)))
|
||||
.map((final ts) => DateTime.fromMillisecondsSinceEpoch(int.parse(ts)))
|
||||
.toList();
|
||||
|
||||
if (e['kanji'] != null) {
|
||||
@@ -147,7 +153,7 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
|
||||
Future<int> historyEntryAmount({
|
||||
/// Whether to ignore duplicate searches
|
||||
bool unique = true,
|
||||
final bool unique = true,
|
||||
}) async {
|
||||
late final int count;
|
||||
|
||||
@@ -170,7 +176,7 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
|
||||
// Modification
|
||||
|
||||
Future<void> historyEntryInsertKanji(String kanji) async {
|
||||
Future<void> historyEntryInsertKanji(final String kanji) async {
|
||||
final DateTime timestamp = DateTime.now();
|
||||
|
||||
final existingEntry = await query(
|
||||
@@ -202,7 +208,10 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> historyEntryInsertWord(String word, {String? language}) async {
|
||||
Future<void> historyEntryInsertWord(
|
||||
final String word, {
|
||||
final String? language,
|
||||
}) async {
|
||||
final DateTime timestamp = DateTime.now();
|
||||
|
||||
final existingEntry = await query(
|
||||
@@ -236,7 +245,76 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> historyEntryDelete(int entryId) async {
|
||||
Future<void> historyEntryInsertEntry(
|
||||
final HistoryEntry entry, {
|
||||
final bool assignNewId = false,
|
||||
}) => historyEntryInsertEntries([entry], assignNewIds: assignNewId);
|
||||
|
||||
Future<void> historyEntryInsertEntries(
|
||||
final Iterable<HistoryEntry> entries, {
|
||||
final bool assignNewIds = false,
|
||||
}) async {
|
||||
late final List<int> newIds;
|
||||
if (assignNewIds) {
|
||||
final b = batch();
|
||||
for (final _ in entries) {
|
||||
b.insert(HistoryTableNames.historyEntry, {}, nullColumnHack: 'id');
|
||||
}
|
||||
newIds = (await b.commit()).map((final result) => result as int).toList();
|
||||
}
|
||||
|
||||
assert(
|
||||
!assignNewIds || newIds.length == entries.length,
|
||||
'Number of new IDs must match number of entries when assignNewIds is true',
|
||||
);
|
||||
|
||||
final b = batch();
|
||||
for (final (i, entry) in entries.indexed) {
|
||||
final int id = assignNewIds ? newIds[i] : entry.id;
|
||||
|
||||
if (!assignNewIds) {
|
||||
b.insert(
|
||||
HistoryTableNames.historyEntry,
|
||||
{'id': id},
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.isKanji) {
|
||||
b.insert(
|
||||
HistoryTableNames.historyEntryKanji,
|
||||
{'entryId': id, 'kanji': entry.kanji},
|
||||
conflictAlgorithm: assignNewIds ? null : ConflictAlgorithm.ignore,
|
||||
);
|
||||
} else {
|
||||
b.insert(
|
||||
HistoryTableNames.historyEntryWord,
|
||||
{
|
||||
'entryId': id,
|
||||
'word': entry.word,
|
||||
'language': {
|
||||
null: null,
|
||||
'japanese': 'j',
|
||||
'english': 'e',
|
||||
}[entry.language],
|
||||
},
|
||||
conflictAlgorithm: assignNewIds ? null : ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
|
||||
for (final timestamp in entry.timestamps) {
|
||||
b.insert(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
{'entryId': id, 'timestamp': timestamp.millisecondsSinceEpoch},
|
||||
conflictAlgorithm: assignNewIds ? null : ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await b.commit(noResult: true);
|
||||
}
|
||||
|
||||
Future<bool> historyEntryDelete(final int entryId) async {
|
||||
await delete(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
where: 'entryId = ?',
|
||||
@@ -265,8 +343,8 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
}
|
||||
|
||||
Future<bool> historyEntryDeleteTimestamp(
|
||||
int entryId,
|
||||
DateTime timestamp,
|
||||
final int entryId,
|
||||
final DateTime timestamp,
|
||||
) async {
|
||||
final timestampCount = await query(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
@@ -296,60 +374,6 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
|
||||
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 {
|
||||
@@ -402,14 +426,16 @@ class HistoryEntry {
|
||||
bool get isKanji => word == null;
|
||||
int get timestampCount => timestamps.length;
|
||||
DateTime get lastTimestamp => timestamps.isNotEmpty
|
||||
? timestamps.reduce((a, b) => a.isAfter(b) ? a : b)
|
||||
? timestamps.reduce((final a, final 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(),
|
||||
'timestamps': timestamps
|
||||
.map((final ts) => ts.millisecondsSinceEpoch)
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import 'package:sqflite/sqlite_api.dart';
|
||||
extension LibraryListExt on DatabaseExecutor {
|
||||
// Query
|
||||
|
||||
/// Get a page of library lists, ordered by insertion time (oldest first).
|
||||
Future<List<LibraryList>> libraryListGetLists({
|
||||
int? page,
|
||||
int? pageSize,
|
||||
final int? page,
|
||||
final int? pageSize,
|
||||
}) async {
|
||||
final result = await rawQuery(
|
||||
'''
|
||||
@@ -33,7 +34,7 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
|
||||
return result
|
||||
.map(
|
||||
(row) => LibraryList(
|
||||
(final row) => LibraryList(
|
||||
name: row['name'] as String,
|
||||
totalCount: row['count'] as int? ?? 0,
|
||||
),
|
||||
@@ -41,7 +42,10 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<LibraryList?> libraryListGetList(String listName) async {
|
||||
/// Get the details of a library list.
|
||||
///
|
||||
/// Note that this does not include its entries, use [libraryListGetListEntries] for that.
|
||||
Future<LibraryList?> libraryListGetList(final String listName) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
final result = await rawQuery(
|
||||
@@ -69,11 +73,17 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
/// Get a page of entries in a library list, ordered by insertion time (oldest first).
|
||||
///
|
||||
/// If [includeSearchResult] is true, also includes the corresponding search results for each entry.
|
||||
///
|
||||
/// Unless [pageSize] (and optionally [page]) is provided, all entries are returned. This can be very
|
||||
/// expensive, so it's recommended to use pagination for lists with many entries.
|
||||
Future<LibraryListPage?> libraryListGetListEntries(
|
||||
String listName, {
|
||||
int? page,
|
||||
int? pageSize,
|
||||
bool includeSearchResult = false,
|
||||
final String listName, {
|
||||
final int? page,
|
||||
final int? pageSize,
|
||||
final bool includeSearchResult = false,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
assert(
|
||||
@@ -141,21 +151,21 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
Map<String, KanjiSearchResult>? kanjiResults;
|
||||
if (includeSearchResult) {
|
||||
final wordResultJmdictIds = entries
|
||||
.where((e) => e['jmdictEntryId'] != null)
|
||||
.map((e) => e['jmdictEntryId'] as int)
|
||||
.where((final e) => e['jmdictEntryId'] != null)
|
||||
.map((final e) => e['jmdictEntryId'] as int)
|
||||
.toSet();
|
||||
|
||||
wordResults = await jadbGetManyWordsByIds(wordResultJmdictIds);
|
||||
|
||||
final kanjiResultKanjis = entries
|
||||
.where((e) => e['kanji'] != null)
|
||||
.map((e) => e['kanji'] as String)
|
||||
.where((final e) => e['kanji'] != null)
|
||||
.map((final e) => e['kanji'] as String)
|
||||
.toSet();
|
||||
|
||||
kanjiResults = await jadbGetManyKanji(kanjiResultKanjis);
|
||||
}
|
||||
|
||||
final result = entries.map((entry) {
|
||||
final result = entries.map((final entry) {
|
||||
if (entry['jmdictEntryId'] != null) {
|
||||
return LibraryListEntry.fromJmdictId(
|
||||
jmdictEntryId: entry['jmdictEntryId'] as int,
|
||||
@@ -186,9 +196,77 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the position of an entry in a library list, or null if the entry is not in the list.
|
||||
Future<int?> libraryListEntryPosition(
|
||||
final String listName, {
|
||||
final int? jmdictEntryId,
|
||||
final 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 null;
|
||||
}
|
||||
|
||||
if (!await libraryListListContains(
|
||||
listName,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final result = await rawQuery(
|
||||
'''
|
||||
WITH RECURSIVE
|
||||
"RecursionTable"(
|
||||
"jmdictEntryId",
|
||||
"kanji",
|
||||
"position"
|
||||
) AS (
|
||||
SELECT
|
||||
"jmdictEntryId",
|
||||
"kanji",
|
||||
0 AS "position"
|
||||
FROM "${LibraryListTableNames.libraryListEntry}"
|
||||
WHERE
|
||||
"listName" = ?
|
||||
AND "prevEntryJmdictEntryId" IS NULL
|
||||
AND "prevEntryKanji" IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
"R"."jmdictEntryId",
|
||||
"R"."kanji",
|
||||
"RecursionTable"."position" + 1 AS "position"
|
||||
FROM "${LibraryListTableNames.libraryListEntry}" AS "R", "RecursionTable"
|
||||
WHERE
|
||||
"R"."listName" = ?
|
||||
AND ("R"."prevEntryJmdictEntryId" = "RecursionTable"."jmdictEntryId"
|
||||
OR "R"."prevEntryKanji" = "RecursionTable"."kanji")
|
||||
)
|
||||
SELECT
|
||||
"position"
|
||||
FROM "RecursionTable"
|
||||
WHERE ("jmdictEntryId" = ? OR "kanji" = ?)
|
||||
''',
|
||||
[listName, listName, jmdictEntryId, kanji],
|
||||
);
|
||||
|
||||
return result.firstOrNull?['position'] as int?;
|
||||
}
|
||||
|
||||
/// Get whether each library list contains the specified entry.
|
||||
///
|
||||
/// Returns a map from library list name to whether the list contains the entry.
|
||||
Future<Map<String, bool>> libraryListAllListsContain({
|
||||
int? jmdictEntryId,
|
||||
String? kanji,
|
||||
final int? jmdictEntryId,
|
||||
final String? kanji,
|
||||
}) async {
|
||||
final result = await rawQuery(
|
||||
'''
|
||||
@@ -210,10 +288,11 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
/// Get whether a specific library list contains an entry.
|
||||
Future<bool> libraryListListContains(
|
||||
String listName, {
|
||||
int? jmdictEntryId,
|
||||
String? kanji,
|
||||
final String listName, {
|
||||
final int? jmdictEntryId,
|
||||
final String? kanji,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
final result = await rawQuery(
|
||||
@@ -230,7 +309,11 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
return (result.firstOrNull?['exists'] as int? ?? 0) == 1;
|
||||
}
|
||||
|
||||
Future<void> libraryListRenameList(String oldName, String newName) async {
|
||||
/// Rename a library list.
|
||||
Future<void> libraryListRenameList(
|
||||
final String oldName,
|
||||
final String newName,
|
||||
) async {
|
||||
if (oldName.isEmpty) {
|
||||
throw ArgumentError('Old library list name must not be empty.');
|
||||
}
|
||||
@@ -251,25 +334,30 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
throw ArgumentError('Library list "$newName" already exists.');
|
||||
}
|
||||
|
||||
final b = batch();
|
||||
|
||||
b.update(
|
||||
LibraryListTableNames.libraryList,
|
||||
{'name': newName},
|
||||
where: '"name" = ?',
|
||||
whereArgs: [oldName],
|
||||
);
|
||||
|
||||
b.update(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
{'listName': newName},
|
||||
where: '"listName" = ?',
|
||||
whereArgs: [oldName],
|
||||
);
|
||||
final b = batch()
|
||||
..update(
|
||||
LibraryListTableNames.libraryList,
|
||||
{'name': newName},
|
||||
where: '"name" = ?',
|
||||
whereArgs: [oldName],
|
||||
)
|
||||
..update(
|
||||
LibraryListTableNames.libraryList,
|
||||
{'prevList': newName},
|
||||
where: '"prevList" = ?',
|
||||
whereArgs: [oldName],
|
||||
)
|
||||
..update(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
{'listName': newName},
|
||||
where: '"listName" = ?',
|
||||
whereArgs: [oldName],
|
||||
);
|
||||
|
||||
await b.commit();
|
||||
}
|
||||
|
||||
/// Get the total number of library lists.
|
||||
Future<int> libraryListAmount() async {
|
||||
final result = await query(
|
||||
LibraryListTableNames.libraryList,
|
||||
@@ -279,7 +367,8 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
return result.firstOrNull?['count'] as int? ?? 0;
|
||||
}
|
||||
|
||||
Future<bool> libraryListExists(String listName) async {
|
||||
/// Get whether a library list with the specified name exists.
|
||||
Future<bool> libraryListExists(final String listName) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
final result = await rawQuery(
|
||||
'''
|
||||
@@ -296,10 +385,10 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
|
||||
// Modification
|
||||
|
||||
/// Inserts a new library list into the database.
|
||||
/// Insert a new library list into the database.
|
||||
Future<bool> libraryListInsertList(
|
||||
String listName, {
|
||||
bool existsOk = true,
|
||||
final String listName, {
|
||||
final bool existsOk = true,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
@@ -317,17 +406,17 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Deletes a library list by its name.
|
||||
/// Delete a library list by its name.
|
||||
Future<bool> libraryListDeleteList(
|
||||
String listName, {
|
||||
bool notEmptyOk = true,
|
||||
bool doesNotExistOk = false,
|
||||
final String listName, {
|
||||
final bool notEmptyOk = true,
|
||||
final 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 (!(await libraryListExists(listName))) {
|
||||
return doesNotExistOk;
|
||||
}
|
||||
|
||||
if (!notEmptyOk &&
|
||||
@@ -335,19 +424,50 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = await delete(
|
||||
final listQuery = (await query(
|
||||
LibraryListTableNames.libraryList,
|
||||
columns: ['prevList'],
|
||||
where: '"name" = ?',
|
||||
whereArgs: [listName],
|
||||
)).map((final row) => row['prevList'] as String?).first;
|
||||
|
||||
assert(
|
||||
listQuery != null,
|
||||
'Library list "$listName" has no prevList, this should only happen for "favourites".',
|
||||
);
|
||||
|
||||
return doesNotExistOk || result > 0;
|
||||
final nextListQuery = (await query(
|
||||
LibraryListTableNames.libraryList,
|
||||
columns: ['name'],
|
||||
where: '"prevList" = ?',
|
||||
whereArgs: [listName],
|
||||
)).map((final row) => row['name'] as String).firstOrNull;
|
||||
|
||||
final b = batch()
|
||||
..delete(
|
||||
LibraryListTableNames.libraryList,
|
||||
where: '"name" = ?',
|
||||
whereArgs: [listName],
|
||||
);
|
||||
|
||||
if (nextListQuery != null) {
|
||||
b.update(
|
||||
LibraryListTableNames.libraryList,
|
||||
{'prevList': listQuery},
|
||||
where: '"name" = ?',
|
||||
whereArgs: [nextListQuery],
|
||||
);
|
||||
}
|
||||
|
||||
await b.commit();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Deletes all entries in a library list.
|
||||
/// Delete all entries in a library list.
|
||||
Future<bool> libraryListDeleteAllEntries(
|
||||
String listName, {
|
||||
bool doesNotExistOk = false,
|
||||
final String listName, {
|
||||
final bool doesNotExistOk = false,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
@@ -366,13 +486,15 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
|
||||
/// Appends an entry into the library list, optionally at a specific position.
|
||||
///
|
||||
/// The position is zero-indexed, and if not provided, the entry will be appended at the end of the list.
|
||||
///
|
||||
/// 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,
|
||||
final String listName, {
|
||||
final int? jmdictEntryId,
|
||||
final String? kanji,
|
||||
final int? position,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
assert(
|
||||
@@ -407,20 +529,20 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
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],
|
||||
);
|
||||
b
|
||||
..insert(LibraryListTableNames.libraryListEntry, {
|
||||
'listName': listName,
|
||||
'jmdictEntryId': jmdictEntryId,
|
||||
'kanji': kanji,
|
||||
'prevEntryJmdictEntryId': prevEntry.jmdictEntryId,
|
||||
'prevEntryKanji': prevEntry.kanji,
|
||||
})
|
||||
..update(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
{'prevEntryJmdictEntryId': jmdictEntryId, 'prevEntryKanji': kanji},
|
||||
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
|
||||
whereArgs: [listName, nextEntry.jmdictEntryId, nextEntry.kanji],
|
||||
);
|
||||
|
||||
await b.commit();
|
||||
|
||||
@@ -443,14 +565,68 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Deletes an entry at a specific position in the library list.
|
||||
/// Append multiple entries into the library list at once.
|
||||
///
|
||||
/// If you already know the last entry in the list, you can provide it as [prevEntry] to avoid an extra query.
|
||||
/// However be careful when doing this, as providing the wrong entry will put the list into an inconsistent state.
|
||||
Future<bool> libraryListInsertEntries(
|
||||
final String listName,
|
||||
// TODO: convert this to Iterable<LibraryListEntry>
|
||||
final List<LibraryListEntry> entries, {
|
||||
final LibraryListEntry? prevEntry,
|
||||
final bool throwErrorOnDuplicate = false,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
if (!await libraryListExists(listName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final lastEntry =
|
||||
prevEntry ??
|
||||
(await libraryListGetListEntries(listName))!.entries.lastOrNull;
|
||||
|
||||
// TODO: set up lastModified insertion
|
||||
|
||||
final List<Map<String, Object?>> entriesToInsert = entries.indexed.map((
|
||||
final e,
|
||||
) {
|
||||
final i = e.$1;
|
||||
final entry = e.$2;
|
||||
final prevEntry = i == 0 ? lastEntry : entries[i - 1];
|
||||
|
||||
return {
|
||||
'listName': listName,
|
||||
'jmdictEntryId': entry.jmdictEntryId,
|
||||
'kanji': entry.kanji,
|
||||
'prevEntryJmdictEntryId': prevEntry?.jmdictEntryId,
|
||||
'prevEntryKanji': prevEntry?.kanji,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final b = batch();
|
||||
for (final entry in entriesToInsert) {
|
||||
b.insert(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
entry,
|
||||
conflictAlgorithm: throwErrorOnDuplicate
|
||||
? ConflictAlgorithm.abort
|
||||
: ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
await b.commit();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Delete 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,
|
||||
final String listName, {
|
||||
final int? jmdictEntryId,
|
||||
final String? kanji,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
assert(
|
||||
@@ -490,7 +666,7 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
final prevEntryKanji = entryQuery.first['prevEntryKanji'] as String?;
|
||||
|
||||
final LibraryListEntry? nextEntry = nextEntryQuery
|
||||
.map((e) => LibraryListEntry.fromDBMap(e))
|
||||
.map(LibraryListEntry.fromDBMap)
|
||||
.firstOrNull;
|
||||
|
||||
// TODO: use a transaction instead of a batch
|
||||
@@ -508,18 +684,18 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
b.delete(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
|
||||
whereArgs: [listName, jmdictEntryId, kanji],
|
||||
);
|
||||
|
||||
b.commit();
|
||||
b
|
||||
..delete(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
|
||||
whereArgs: [listName, jmdictEntryId, kanji],
|
||||
)
|
||||
..commit();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Deletes an entry at a specific position in the library list.
|
||||
/// Delete 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.
|
||||
@@ -528,8 +704,8 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
/// in contrast to `libraryListDeleteEntry` which has a time complexity of whatever
|
||||
/// SQLite uses for its indices.
|
||||
Future<bool> libraryListDeleteEntryByPosition(
|
||||
String listName,
|
||||
int position,
|
||||
final String listName,
|
||||
final int position,
|
||||
) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
@@ -559,27 +735,78 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Reorders an entry within the library list.
|
||||
/// Reorder an entry within the library list.
|
||||
///
|
||||
/// The position is zero-indexed.
|
||||
///
|
||||
/// 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,
|
||||
final String listName,
|
||||
final int newPosition, {
|
||||
final int? jmdictEntryId,
|
||||
final String? kanji,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
throw UnimplementedError();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (newPosition < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((await libraryListGetList(listName))!.totalCount <= newPosition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final currentPosition = await libraryListEntryPosition(
|
||||
listName,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
);
|
||||
|
||||
if (currentPosition == newPosition) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await libraryListDeleteEntry(
|
||||
listName,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
);
|
||||
|
||||
await libraryListInsertEntry(
|
||||
listName,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
position: newPosition > currentPosition! ? newPosition - 1 : newPosition,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Appends an entry to the library list if it's not there already,
|
||||
/// Append 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,
|
||||
final String listName, {
|
||||
final int? jmdictEntryId,
|
||||
final String? kanji,
|
||||
final bool? overrideToggleOn,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
@@ -616,40 +843,16 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
return shouldToggleOn;
|
||||
}
|
||||
|
||||
/// Verifies the linked list structure of the list of library lists.
|
||||
/// Verify 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 {
|
||||
/// Verify the linked list structure of a single library list.
|
||||
Future<bool> libraryListVerifyList(final 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 {
|
||||
final 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 {
|
||||
@@ -681,7 +884,7 @@ class LibraryListEntry {
|
||||
final KanjiSearchResult? kanjiSearchResult;
|
||||
|
||||
LibraryListEntry({
|
||||
DateTime? lastModified,
|
||||
final DateTime? lastModified,
|
||||
this.wordSearchResult,
|
||||
this.jmdictEntryId,
|
||||
this.kanji,
|
||||
@@ -696,18 +899,18 @@ class LibraryListEntry {
|
||||
"Library entry can't have both kanji and jmdictEntryId",
|
||||
),
|
||||
assert(
|
||||
kanjiSearchResult?.kanji == kanji,
|
||||
kanjiSearchResult == null || kanjiSearchResult.kanji == kanji,
|
||||
"KanjiSearchResult's kanji must match the kanji in LibraryListEntry",
|
||||
),
|
||||
assert(
|
||||
wordSearchResult?.entryId == jmdictEntryId,
|
||||
wordSearchResult == null || wordSearchResult.entryId == jmdictEntryId,
|
||||
"WordSearchResult's jmdictEntryId must match the jmdictEntryId in LibraryListEntry",
|
||||
);
|
||||
|
||||
LibraryListEntry.fromJmdictId({
|
||||
required int this.jmdictEntryId,
|
||||
this.wordSearchResult,
|
||||
DateTime? lastModified,
|
||||
final DateTime? lastModified,
|
||||
}) : lastModified = lastModified ?? DateTime.now(),
|
||||
kanji = null,
|
||||
kanjiSearchResult = null;
|
||||
@@ -715,7 +918,7 @@ class LibraryListEntry {
|
||||
LibraryListEntry.fromKanji({
|
||||
required String this.kanji,
|
||||
this.kanjiSearchResult,
|
||||
DateTime? lastModified,
|
||||
final DateTime? lastModified,
|
||||
}) : lastModified = lastModified ?? DateTime.now(),
|
||||
jmdictEntryId = null,
|
||||
wordSearchResult = null;
|
||||
@@ -726,7 +929,7 @@ class LibraryListEntry {
|
||||
'lastModified': lastModified.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
factory LibraryListEntry.fromJson(Map<String, Object?> json) {
|
||||
factory LibraryListEntry.fromJson(final Map<String, Object?> json) {
|
||||
assert(
|
||||
(json.containsKey('kanji') && json['kanji'] != null) ||
|
||||
(json.containsKey('jmdictEntryId') && json['jmdictEntryId'] != null),
|
||||
@@ -755,6 +958,6 @@ class LibraryListEntry {
|
||||
}
|
||||
|
||||
// NOTE: this just happens to be the same as the logic in `fromJson`
|
||||
factory LibraryListEntry.fromDBMap(Map<String, Object?> dbObject) =>
|
||||
factory LibraryListEntry.fromDBMap(final Map<String, Object?> dbObject) =>
|
||||
LibraryListEntry.fromJson(dbObject);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import 'package:mugiten/database/history/table_names.dart';
|
||||
import 'package:mugiten/database/library_list/table_names.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
Future<void> verifyMugitenTablesWithDbConnection(DatabaseExecutor db) async {
|
||||
Future<void> verifyMugitenTablesWithDbConnection(
|
||||
final DatabaseExecutor db,
|
||||
) async {
|
||||
final Set<String> tables = await db
|
||||
.query(
|
||||
'sqlite_master',
|
||||
@@ -10,8 +12,8 @@ Future<void> verifyMugitenTablesWithDbConnection(DatabaseExecutor db) async {
|
||||
where: 'type IN (?, ?)',
|
||||
whereArgs: ['table', 'view'],
|
||||
)
|
||||
.then((result) {
|
||||
return result.map((row) => row['name'] as String).toSet();
|
||||
.then((final result) {
|
||||
return result.map((final row) => row['name'] as String).toSet();
|
||||
});
|
||||
|
||||
final Set<String> expectedTables = {
|
||||
@@ -25,10 +27,10 @@ Future<void> verifyMugitenTablesWithDbConnection(DatabaseExecutor db) async {
|
||||
throw Exception(
|
||||
[
|
||||
'Missing tables:',
|
||||
missingTables.map((table) => ' - $table').join('\n'),
|
||||
missingTables.map((final table) => ' - $table').join('\n'),
|
||||
'',
|
||||
'Found tables:\n',
|
||||
tables.map((table) => ' - $table').join('\n'),
|
||||
tables.map((final table) => ' - $table').join('\n'),
|
||||
'',
|
||||
'Please ensure the database is correctly set up.',
|
||||
].join('\n'),
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import 'package:mugiten/screens/home.dart';
|
||||
import 'package:mugiten/screens/info/changelog.dart';
|
||||
import 'package:mugiten/screens/info/datasources.dart';
|
||||
import 'package:mugiten/screens/info/licenses.dart';
|
||||
import 'package:mugiten/screens/library/library_content_view.dart';
|
||||
import 'package:mugiten/screens/search/kanji_search_result_page.dart';
|
||||
import 'package:mugiten/screens/search/search_mechanisms/drawing.dart';
|
||||
import 'package:mugiten/screens/search/search_mechanisms/grade_list.dart';
|
||||
import 'package:mugiten/screens/search/search_mechanisms/radical_list.dart';
|
||||
import 'package:mugiten/screens/search/word_search_result_page.dart';
|
||||
|
||||
import '../screens/home.dart';
|
||||
import '../screens/info/licenses.dart';
|
||||
import '../screens/search/search_mechanisms/drawing.dart';
|
||||
import '../screens/search/search_mechanisms/grade_list.dart';
|
||||
import '../screens/search/search_mechanisms/radical_list.dart';
|
||||
import 'routes.dart';
|
||||
|
||||
Route<Widget> generateRoute(RouteSettings settings) {
|
||||
Route<Widget> generateRoute(final RouteSettings settings) {
|
||||
final args = settings.arguments;
|
||||
|
||||
switch (settings.name) {
|
||||
@@ -53,6 +53,8 @@ Route<Widget> generateRoute(RouteSettings settings) {
|
||||
return MaterialPageRoute(builder: (_) => const LicensesView());
|
||||
case Routes.aboutChangelog:
|
||||
return MaterialPageRoute(builder: (_) => const ChangelogView());
|
||||
case Routes.aboutDatasources:
|
||||
return MaterialPageRoute(builder: (_) => const DataSourcesView());
|
||||
|
||||
// TODO: Add more specific error screens.
|
||||
case Routes.errorNotFound:
|
||||
|
||||
@@ -7,6 +7,7 @@ abstract class Routes {
|
||||
static const String kanjiSearchRadicals = '/kanjiSearch/radicals';
|
||||
static const String aboutLicenses = '/info/licenses';
|
||||
static const String aboutChangelog = '/info/changelog';
|
||||
static const String aboutDatasources = '/info/datasources';
|
||||
static const String errorNotFound = '/error/404';
|
||||
static const String errorNetwork = '/error/network';
|
||||
static const String errorOther = '/error/other';
|
||||
|
||||
@@ -6,7 +6,7 @@ class DebugView extends StatelessWidget {
|
||||
const DebugView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: GetIt.instance.get<Database>().rawQuery("""
|
||||
SELECT name, type
|
||||
@@ -14,7 +14,7 @@ class DebugView extends StatelessWidget {
|
||||
WHERE name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
"""),
|
||||
builder: (context, snapshot) {
|
||||
builder: (final context, final snapshot) {
|
||||
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@@ -24,7 +24,7 @@ class DebugView extends StatelessWidget {
|
||||
appBar: AppBar(title: const Text('Debug View')),
|
||||
body: ListView.builder(
|
||||
itemCount: (snapshot.data as List<Map<String, dynamic>>).length,
|
||||
itemBuilder: (context, index) {
|
||||
itemBuilder: (final context, final index) {
|
||||
final data = (snapshot.data as List<Map<String, dynamic>>)[index];
|
||||
final tableName =
|
||||
(data['name'] as String) +
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
import 'package:mugiten/components/common/loading.dart';
|
||||
import 'package:mugiten/components/common/opaque_box.dart';
|
||||
import 'package:mugiten/components/history/date_divider.dart';
|
||||
import 'package:mugiten/components/history/history_entry_tile.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/services/datetime.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_entry.dart';
|
||||
import '../services/datetime.dart';
|
||||
|
||||
const int pageSize = 50;
|
||||
const int invisibleItemsThreshold = 25;
|
||||
const int minutesBetweenTimeDividers = 30;
|
||||
|
||||
class HistoryView extends StatefulWidget {
|
||||
const HistoryView({super.key});
|
||||
@@ -22,9 +22,9 @@ class HistoryView extends StatefulWidget {
|
||||
|
||||
class _HistoryViewState extends State<HistoryView> {
|
||||
late final _pagingController = PagingController<int, HistoryEntry?>(
|
||||
getNextPageKey: (state) =>
|
||||
getNextPageKey: (final state) =>
|
||||
state.lastPageIsEmpty ? null : state.nextIntPageKey,
|
||||
fetchPage: (pageKey) async {
|
||||
fetchPage: (final pageKey) async {
|
||||
List<HistoryEntry?> result = await GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryGetAll(page: pageKey - 1, pageSize: pageSize);
|
||||
@@ -45,10 +45,10 @@ class _HistoryViewState extends State<HistoryView> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return FutureBuilder<int>(
|
||||
future: GetIt.instance.get<Database>().historyEntryAmount(),
|
||||
builder: (context, snapshot) {
|
||||
builder: (final context, final snapshot) {
|
||||
// TODO: provide proper error handling
|
||||
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
|
||||
if (!snapshot.hasData) return const LoadingScreen();
|
||||
@@ -70,17 +70,18 @@ class _HistoryViewState extends State<HistoryView> {
|
||||
Expanded(
|
||||
child: PagingListener(
|
||||
controller: _pagingController,
|
||||
builder: (context, state, fetchNextPage) =>
|
||||
builder: (final context, final state, final fetchNextPage) =>
|
||||
PagedListView<int, HistoryEntry?>.separated(
|
||||
state: state,
|
||||
fetchNextPage: fetchNextPage,
|
||||
separatorBuilder: (context, index) {
|
||||
separatorBuilder: (final context, final index) {
|
||||
if (index == 0) {
|
||||
if (_pagingController.items == null ||
|
||||
_pagingController.items!.length < 2) {
|
||||
// No history entries, or the items has not been loaded yet.
|
||||
return SizedBox.shrink();
|
||||
return const SizedBox.shrink();
|
||||
} else {
|
||||
// The first item is a dummy null entry, so we need to get the date from the second item.
|
||||
final firstItemDate =
|
||||
_pagingController.items![1]!.lastTimestamp;
|
||||
return _dateDivider(firstItemDate);
|
||||
@@ -95,6 +96,7 @@ class _HistoryViewState extends State<HistoryView> {
|
||||
final HistoryEntry? previousSearch =
|
||||
data.length > index + 1 ? data[index + 1] : null;
|
||||
|
||||
// Date divider
|
||||
if (previousSearch != null &&
|
||||
!dateIsEqual(
|
||||
search.lastTimestamp,
|
||||
@@ -103,22 +105,36 @@ class _HistoryViewState extends State<HistoryView> {
|
||||
return _dateDivider(previousSearch.lastTimestamp);
|
||||
}
|
||||
|
||||
// Large divider
|
||||
if (previousSearch != null &&
|
||||
search.lastTimestamp
|
||||
.difference(previousSearch.lastTimestamp)
|
||||
.inMinutes >
|
||||
minutesBetweenTimeDividers) {
|
||||
return _timeDivider(previousSearch.lastTimestamp);
|
||||
}
|
||||
|
||||
// Regular divider
|
||||
return _divider();
|
||||
},
|
||||
builderDelegate: PagedChildBuilderDelegate<HistoryEntry?>(
|
||||
invisibleItemsThreshold: invisibleItemsThreshold,
|
||||
itemBuilder: (context, entry, index) => index == 0
|
||||
? SizedBox.shrink()
|
||||
: HistoryEntryTile(
|
||||
entry: entry!,
|
||||
objectKey: entry.id,
|
||||
onDelete: () => _pagingController.refresh(),
|
||||
itemBuilder:
|
||||
(final context, final entry, final index) =>
|
||||
index == 0
|
||||
? const SizedBox.shrink()
|
||||
: HistoryEntryTile(
|
||||
entry: entry!,
|
||||
objectKey: entry.id,
|
||||
onDelete: () =>
|
||||
_pagingController.refresh(),
|
||||
),
|
||||
noItemsFoundIndicatorBuilder: (final context) =>
|
||||
const Center(
|
||||
child: Text(
|
||||
'The history is empty.\nTry searching for something!',
|
||||
),
|
||||
noItemsFoundIndicatorBuilder: (context) => const Center(
|
||||
child: Text(
|
||||
'The history is empty.\nTry searching for something!',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -130,8 +146,24 @@ class _HistoryViewState extends State<HistoryView> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dateDivider(DateTime date) =>
|
||||
Widget _dateDivider(final DateTime date) =>
|
||||
TextDivider(text: formatDate(roundToDay(date)));
|
||||
|
||||
Widget _timeDivider(final DateTime date) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Divider(height: 20, thickness: 5, indent: 10, endIndent: 10),
|
||||
),
|
||||
Text(
|
||||
formatTime(date),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const Expanded(
|
||||
child: Divider(height: 20, thickness: 5, indent: 10, endIndent: 10),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _divider() => const Divider(height: 0, indent: 10, endIndent: 10);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mdi/mdi.dart';
|
||||
import 'package:mugiten/components/common/denshi_jisho_background.dart';
|
||||
import 'package:mugiten/components/library/new_library_dialog.dart';
|
||||
import 'package:mugiten/screens/debug.dart';
|
||||
import 'package:mugiten/screens/history.dart';
|
||||
import 'package:mugiten/screens/library/library_view.dart';
|
||||
import 'package:mugiten/screens/search/kanji_search_view.dart';
|
||||
import 'package:mugiten/screens/search/word_search_view.dart';
|
||||
import 'package:mugiten/screens/settings.dart';
|
||||
import 'package:mugiten/services/snackbar.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
import '../components/common/denshi_jisho_background.dart';
|
||||
import '../components/library/new_library_dialog.dart';
|
||||
import 'debug.dart';
|
||||
import 'history.dart';
|
||||
import 'library/library_view.dart';
|
||||
import 'settings.dart';
|
||||
|
||||
class Home extends StatefulWidget {
|
||||
const Home({super.key});
|
||||
|
||||
@@ -27,26 +26,49 @@ class _HomeState extends State<Home> {
|
||||
_Page get page => pages[pageNum];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final colors = Theme.of(context).extension<MenuGreyDarkThemeExtension>()!;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(page.titleBar),
|
||||
centerTitle: true,
|
||||
backgroundColor: mugitenWheatBackground,
|
||||
foregroundColor: mugitenWheatForeground,
|
||||
actions: page.actions,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(kToolbarHeight),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: incognitoModeEnabled,
|
||||
builder: (final context, final incognitoEnabled, final child) =>
|
||||
AppBar(
|
||||
title: Text(page.titleBar),
|
||||
centerTitle: true,
|
||||
foregroundColor: incognitoEnabled
|
||||
? Colors.white
|
||||
: mugitenWheatForeground,
|
||||
backgroundColor: incognitoEnabled
|
||||
? Colors.deepPurple
|
||||
: mugitenWheatBackground,
|
||||
actions: incognitoEnabled
|
||||
? [
|
||||
IconButton(
|
||||
icon: const Icon(Mdi.incognito),
|
||||
onPressed: () => showSnackbar(
|
||||
context,
|
||||
'History tracking is disabled',
|
||||
),
|
||||
),
|
||||
...page.actions,
|
||||
]
|
||||
: page.actions,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: DenshiJishoBackground(child: page.content),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
fixedColor: mugitenWheatBackground,
|
||||
currentIndex: pageNum,
|
||||
onTap: (index) => setState(() {
|
||||
onTap: (final index) => setState(() {
|
||||
pageNum = index;
|
||||
}),
|
||||
items: pages
|
||||
.map(
|
||||
(p) => BottomNavigationBarItem(label: p.titleBar, icon: p.icon),
|
||||
(final p) =>
|
||||
BottomNavigationBarItem(label: p.titleBar, icon: p.icon),
|
||||
)
|
||||
.toList(),
|
||||
showSelectedLabels: false,
|
||||
@@ -57,31 +79,31 @@ class _HomeState extends State<Home> {
|
||||
}
|
||||
|
||||
List<_Page> get pages => [
|
||||
_Page(
|
||||
const _Page(
|
||||
content: WordSearchView(),
|
||||
titleBar: 'Search',
|
||||
icon: Icon(Icons.search),
|
||||
actions: [
|
||||
if (incognitoModeEnabled)
|
||||
IconButton(
|
||||
icon: const Icon(Mdi.incognito),
|
||||
onPressed: () =>
|
||||
showSnackbar(context, 'History tracking is disabled'),
|
||||
),
|
||||
],
|
||||
// actions: [
|
||||
// if (incognitoModeEnabled.value)
|
||||
// IconButton(
|
||||
// icon: const Icon(Mdi.incognito),
|
||||
// onPressed: () =>
|
||||
// showSnackbar(context, 'History tracking is disabled'),
|
||||
// ),
|
||||
// ],
|
||||
),
|
||||
_Page(
|
||||
const _Page(
|
||||
content: KanjiSearchView(),
|
||||
titleBar: 'Kanji Search',
|
||||
icon: Icon(Mdi.ideogramCjk, size: 30),
|
||||
actions: [
|
||||
if (incognitoModeEnabled)
|
||||
IconButton(
|
||||
icon: const Icon(Mdi.incognito),
|
||||
onPressed: () =>
|
||||
showSnackbar(context, 'History tracking is disabled'),
|
||||
),
|
||||
],
|
||||
// actions: [
|
||||
// if (incognitoModeEnabled.value)
|
||||
// IconButton(
|
||||
// icon: const Icon(Mdi.incognito),
|
||||
// onPressed: () =>
|
||||
// showSnackbar(context, 'History tracking is disabled'),
|
||||
// ),
|
||||
// ],
|
||||
),
|
||||
const _Page(
|
||||
content: HistoryView(),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
@@ -8,12 +10,12 @@ class ChangelogView extends StatelessWidget {
|
||||
const ChangelogView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Changelog')),
|
||||
body: FutureBuilder<List<String>>(
|
||||
future: _fetchChangelogs(),
|
||||
builder: (context, snapshot) {
|
||||
builder: (final context, final snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Error: ${snapshot.error}'));
|
||||
}
|
||||
@@ -29,71 +31,74 @@ class ChangelogView extends StatelessWidget {
|
||||
|
||||
Future<List<String>> _fetchChangelogs() async {
|
||||
final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle);
|
||||
final List<String> changelogs = assetManifest
|
||||
.listAssets()
|
||||
.where(
|
||||
(assetPath) => RegExp(r'^docs/changelog/.*\.md$').hasMatch(assetPath),
|
||||
)
|
||||
.map(
|
||||
(assetPath) => assetPath
|
||||
.replaceFirst('docs/changelog/', '')
|
||||
.replaceFirst('.md', ''),
|
||||
)
|
||||
.toList();
|
||||
|
||||
changelogs.sort((a, b) {
|
||||
final aVersion = a
|
||||
.replaceFirst(RegExp('^v'), '')
|
||||
.replaceFirst(RegExp(r' - .*$'), '') // Replace date
|
||||
.split('.')
|
||||
.map(int.parse)
|
||||
.toList();
|
||||
final bVersion = b
|
||||
.replaceFirst(RegExp('^v'), '')
|
||||
.replaceFirst(RegExp(r' - .*$'), '') // Replace date
|
||||
.split('.')
|
||||
.map(int.parse)
|
||||
.toList();
|
||||
for (int i = 0; i < aVersion.length && i < bVersion.length; i++) {
|
||||
if (aVersion[i] != bVersion[i]) {
|
||||
return bVersion[i].compareTo(aVersion[i]);
|
||||
}
|
||||
}
|
||||
return bVersion.length.compareTo(aVersion.length);
|
||||
});
|
||||
final List<String> changelogs =
|
||||
assetManifest
|
||||
.listAssets()
|
||||
.where(
|
||||
(final assetPath) =>
|
||||
RegExp(r'^docs/changelog/.*\.md$').hasMatch(assetPath),
|
||||
)
|
||||
.map(
|
||||
(final assetPath) => assetPath
|
||||
.replaceFirst('docs/changelog/', '')
|
||||
.replaceFirst('.md', ''),
|
||||
)
|
||||
.toList()
|
||||
..sort((final a, final b) {
|
||||
final aVersion = a
|
||||
.replaceFirst(RegExp('^v'), '')
|
||||
.replaceFirst(RegExp(r' - .*$'), '') // Replace date
|
||||
.split('.')
|
||||
.map(int.parse)
|
||||
.toList();
|
||||
final bVersion = b
|
||||
.replaceFirst(RegExp('^v'), '')
|
||||
.replaceFirst(RegExp(r' - .*$'), '') // Replace date
|
||||
.split('.')
|
||||
.map(int.parse)
|
||||
.toList();
|
||||
for (int i = 0; i < aVersion.length && i < bVersion.length; i++) {
|
||||
if (aVersion[i] != bVersion[i]) {
|
||||
return bVersion[i].compareTo(aVersion[i]);
|
||||
}
|
||||
}
|
||||
return bVersion.length.compareTo(aVersion.length);
|
||||
});
|
||||
|
||||
return changelogs;
|
||||
}
|
||||
|
||||
Widget _buildChangelogList(List<String> versions) {
|
||||
Widget _buildChangelogList(final List<String> versions) {
|
||||
return ListView.builder(
|
||||
itemCount: versions.length,
|
||||
itemBuilder: (context, index) {
|
||||
itemBuilder: (final context, final index) {
|
||||
final version = versions[index];
|
||||
return ListTile(
|
||||
key: ValueKey(version),
|
||||
title: Text(version),
|
||||
onTap: () {
|
||||
Navigator.push(context, _buildChangelogDetailRoute(version));
|
||||
unawaited(
|
||||
Navigator.push(context, _buildChangelogDetailRoute(version)),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _removeHeaders(String markdown) {
|
||||
String _removeHeaders(final String markdown) {
|
||||
final lines = markdown.split('\n');
|
||||
final filteredLines = lines.where((line) => !line.startsWith('# '));
|
||||
final filteredLines = lines.where((final line) => !line.startsWith('# '));
|
||||
return filteredLines.join('\n');
|
||||
}
|
||||
|
||||
MaterialPageRoute _buildChangelogDetailRoute(String versionAndDate) {
|
||||
MaterialPageRoute _buildChangelogDetailRoute(final String versionAndDate) {
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => Scaffold(
|
||||
builder: (final context) => Scaffold(
|
||||
appBar: AppBar(title: Text(versionAndDate)),
|
||||
body: FutureBuilder<String>(
|
||||
future: rootBundle.loadString('docs/changelog/$versionAndDate.md'),
|
||||
builder: (context, snapshot) {
|
||||
builder: (final context, final snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Error: ${snapshot.error}'));
|
||||
}
|
||||
@@ -112,9 +117,9 @@ class ChangelogView extends StatelessWidget {
|
||||
child: MarkdownBody(
|
||||
data: _removeHeaders(snapshot.data!),
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) {
|
||||
onTapLink: (final text, final href, final title) {
|
||||
if (href != null && href.isNotEmpty) {
|
||||
launchUrl(Uri.parse(href));
|
||||
unawaited(launchUrl(Uri.parse(href)));
|
||||
}
|
||||
},
|
||||
extensionSet: ExtensionSet.gitHubFlavored,
|
||||
|
||||
180
lib/screens/info/datasources.dart
Normal file
180
lib/screens/info/datasources.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class DataSourcesView extends StatelessWidget {
|
||||
const DataSourcesView({super.key});
|
||||
|
||||
static final List<Widget> dataSources = [
|
||||
const ListTile(
|
||||
subtitle: Text(
|
||||
'Mugiten is made up of data from various sources, each with their own licenses and copyrights. '
|
||||
'Below is a list detailing the data sources used. '
|
||||
'You can find more detailed information on the data sources by following the links provided.',
|
||||
),
|
||||
),
|
||||
DataSource(
|
||||
title: 'JMDICT',
|
||||
url: Uri.parse(
|
||||
'https://www.edrdg.org/wiki/JMdict-EDICT_Dictionary_Project.html',
|
||||
),
|
||||
licenseIdentifier: 'edrdg',
|
||||
licenseAssetPath: 'assets/licenses/edrdg.txt',
|
||||
copyright:
|
||||
'© James Breen and the Electronic Dictionary Research & Development Group, April 1999',
|
||||
description:
|
||||
'The EDRDG\'s Japanese-Multilingual Dictionary (JMdict) is a comprehensive dictionary containing over 200,000 entries. '
|
||||
'This is what makes up the base of the word data in this application.',
|
||||
),
|
||||
DataSource(
|
||||
title: 'KANJIDIC2',
|
||||
url: Uri.parse('https://www.edrdg.org/wiki/KANJIDIC_Project.html'),
|
||||
licenseIdentifier: 'edrdg',
|
||||
licenseAssetPath: 'assets/licenses/edrdg.txt',
|
||||
copyright:
|
||||
'© James Breen and the Electronic Dictionary Research & Development Group, April 2008',
|
||||
description:
|
||||
'The EDRDG\'s KANJIDIC2 is a comprehensive kanji dictionary containing over 13,000 entries.'
|
||||
'This is what makes up the base of the kanji data in this application.',
|
||||
),
|
||||
DataSource(
|
||||
title: 'RADKFILE/KRADFILE',
|
||||
url: Uri.parse('https://www.edrdg.org/krad/kradinf.html'),
|
||||
licenseIdentifier: 'edrdg',
|
||||
licenseAssetPath: 'assets/licenses/edrdg.txt',
|
||||
copyright:
|
||||
'© Michael Raine, James Breen and the Electronic Dictionary Research & Development Group, 2001/2007',
|
||||
description:
|
||||
'The EDRDG\'s RADKFILE/KRADFILE is a mapping of kanji to their radicals. '
|
||||
'This is used for searching kanji by their radicals.',
|
||||
),
|
||||
DataSource(
|
||||
title: 'Jonathan Waller\'s JLPT resources',
|
||||
url: Uri.parse('https://www.tanos.co.uk/jlpt/'),
|
||||
licenseIdentifier: 'CC-BY-4.0',
|
||||
licenseAssetPath: 'assets/licenses/cc-by-4.0.txt',
|
||||
copyright: '© Jonathan Waller, 2011',
|
||||
description:
|
||||
'Jonathan Waller\'s JLPT resources include lists of vocabulary, kanji, and grammar points by JLPT level. '
|
||||
'This is used for the JLPT tags spread throughout the app.'
|
||||
'\n\n'
|
||||
'Do note that this data was last updated in 2011, so the accuracy of the JLPT tags might have shifted slightly over time.',
|
||||
),
|
||||
DataSource(
|
||||
title: 'KanjiVG',
|
||||
url: Uri.parse('https://github.com/KanjiVG/kanjivg'),
|
||||
licenseIdentifier: 'CC-BY-SA-3.0',
|
||||
licenseAssetPath: 'assets/licenses/cc-by-sa-3.0.txt',
|
||||
copyright: '© Ulrich Apel, 2009-2013',
|
||||
description:
|
||||
'KanjiVG is a collection of SVG files representing the stroke order and radicals of kanji. '
|
||||
'This is used for rendering the kanji stroke order diagrams.',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Datasources')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: ListView.separated(
|
||||
itemCount: dataSources.length,
|
||||
itemBuilder: (final context, final index) => dataSources[index],
|
||||
separatorBuilder: (final context, final index) => const Divider(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DataSource extends StatelessWidget {
|
||||
final String title;
|
||||
final String? description;
|
||||
final Uri url;
|
||||
final String licenseIdentifier;
|
||||
final String? licenseAssetPath;
|
||||
final String copyright;
|
||||
|
||||
const DataSource({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.licenseIdentifier,
|
||||
required this.copyright,
|
||||
this.description,
|
||||
this.licenseAssetPath,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(title),
|
||||
titleTextStyle: Theme.of(context).textTheme.titleLarge,
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not launch URL')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
url.toString(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text(description!),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
TextButton(
|
||||
onPressed: licenseAssetPath == null
|
||||
? null
|
||||
: () async {
|
||||
final licenseText = await DefaultAssetBundle.of(
|
||||
context,
|
||||
).loadString(licenseAssetPath!);
|
||||
if (!context.mounted) return;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (final context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('License: $licenseIdentifier'),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(licenseText),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('License: $licenseIdentifier'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
copyright,
|
||||
style: TextStyle(
|
||||
fontSize: Theme.of(context).textTheme.bodySmall!.fontSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ class LicensesView extends StatelessWidget {
|
||||
const LicensesView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FutureBuilder<PackageInfo>(
|
||||
Widget build(final BuildContext context) => FutureBuilder<PackageInfo>(
|
||||
future: PackageInfo.fromPlatform(),
|
||||
builder: (context, snapshot) {
|
||||
builder: (final context, final snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Error: ${snapshot.error}'));
|
||||
}
|
||||
@@ -20,9 +20,11 @@ class LicensesView extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
|
||||
Widget _buildLicensePage(PackageInfo packageInfo) => LicensePage(
|
||||
applicationName: '麦典',
|
||||
Widget _buildLicensePage(final PackageInfo packageInfo) => LicensePage(
|
||||
applicationName: '麦典 - Mugiten',
|
||||
applicationVersion: 'Version: ${packageInfo.version}',
|
||||
applicationLegalese:
|
||||
'Copyright (c) 2024, h7x4 <mugiten@nani.wtf>\nLicensed under GPL-3.0-only',
|
||||
applicationIcon: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 30),
|
||||
child: Row(
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:mugiten/services/archive/v2/format.dart';
|
||||
import 'package:mugiten/services/initialization/initialization_cubit.dart';
|
||||
import 'package:mugiten/services/initialization/initialization_status.dart';
|
||||
|
||||
@@ -10,11 +14,11 @@ class InitializationView extends StatelessWidget {
|
||||
InitializationView({
|
||||
super.key,
|
||||
required this.onInitializationComplete,
|
||||
required bool deleteDatabase,
|
||||
required final bool deleteDatabase,
|
||||
}) : cubit = InitializationCubit(deleteDatabase);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return MaterialApp(
|
||||
darkTheme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
@@ -27,26 +31,50 @@ class InitializationView extends StatelessWidget {
|
||||
const SizedBox(height: 20),
|
||||
BlocBuilder<InitializationCubit, InitializationStatus>(
|
||||
bloc: cubit,
|
||||
builder: (context, state) {
|
||||
builder: (final context, final state) {
|
||||
switch (state) {
|
||||
case InitializationNotStarted _:
|
||||
cubit.start();
|
||||
unawaited(cubit.start());
|
||||
return const CircularProgressIndicator();
|
||||
|
||||
case InitializationPending _:
|
||||
return const CircularProgressIndicator();
|
||||
case InitializationPending(status: final s):
|
||||
case CheckMLKitDigitalInkModel(status: final s):
|
||||
case DownloadMLKitDigitalInkModel (status: final s):
|
||||
case FinishDownloadMLKitDigitalInkModel(status: final s):
|
||||
case CheckDatabase(status: final s):
|
||||
return s != null ? Text(s) : const CircularProgressIndicator();
|
||||
|
||||
case CheckMLKitDigitalInkModel _:
|
||||
return const Text('Checking for ML Kit updates...');
|
||||
case ConfirmDatabaseExportWithUser(status: final s):
|
||||
return Column(
|
||||
children: [
|
||||
const Text('The database needs to be migrated. Do you want to export your data first?'),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
// TODO: trigger export event, report progress, trigger event to ask where to save the data
|
||||
|
||||
case DownloadMLKitDigitalInkModel _:
|
||||
return const Text('Downloading ML Kit model...');
|
||||
// final saveFile = FilePicker.pickFiles(
|
||||
// dialogTitle: 'Export data',
|
||||
// fileName: getExportFileNameNoSuffix(),
|
||||
// type: FileType.custom,
|
||||
// allowedExtensions: ['zip'],
|
||||
// )
|
||||
|
||||
case FinishDownloadMLKitDigitalInkModel _:
|
||||
return const Text('ML Kit model downloaded successfully');
|
||||
|
||||
case CheckDatabase _:
|
||||
return const Text('Checking for database updates...');
|
||||
// ).then((final result) {
|
||||
// if (result != null && result.files.single.path != null) {
|
||||
// cubit.stage2(result.files.single.path!);
|
||||
// } else {
|
||||
// cubit.stage2(null);
|
||||
// }
|
||||
},
|
||||
child: const Text('Yes'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ElevatedButton(
|
||||
onPressed: () => cubit.stage2(null),
|
||||
child: const Text('No'),
|
||||
),
|
||||
]);
|
||||
|
||||
case final BackupUserData s:
|
||||
return Column(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
import 'package:mugiten/components/library/library_list_entry_tile.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../components/library/library_list_entry_tile.dart';
|
||||
|
||||
const int pageSize = 50;
|
||||
const int invisibleItemsThreshold = 25;
|
||||
|
||||
@@ -19,9 +18,9 @@ class LibraryContentView extends StatefulWidget {
|
||||
|
||||
class _LibraryContentViewState extends State<LibraryContentView> {
|
||||
late final _pagingController = PagingController<int, LibraryListEntry>(
|
||||
getNextPageKey: (state) =>
|
||||
getNextPageKey: (final state) =>
|
||||
state.lastPageIsEmpty ? null : state.nextIntPageKey,
|
||||
fetchPage: (pageKey) => GetIt.instance
|
||||
fetchPage: (final pageKey) => GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListGetListEntries(
|
||||
widget.library.name,
|
||||
@@ -29,7 +28,7 @@ class _LibraryContentViewState extends State<LibraryContentView> {
|
||||
pageSize: pageSize,
|
||||
includeSearchResult: true,
|
||||
)
|
||||
.then((page) => page?.entries ?? []),
|
||||
.then((final page) => page?.entries ?? []),
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -38,10 +37,13 @@ class _LibraryContentViewState extends State<LibraryContentView> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<bool> _confirm(BuildContext context, {required Widget content}) async {
|
||||
Future<bool> _confirm(
|
||||
final BuildContext context, {
|
||||
required final Widget content,
|
||||
}) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (final context) => AlertDialog(
|
||||
content: content,
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
@@ -59,53 +61,55 @@ class _LibraryContentViewState extends State<LibraryContentView> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.library.name),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
final entryCount = widget.library.totalCount;
|
||||
if (!context.mounted) return;
|
||||
final bool userIsSure = await _confirm(
|
||||
context,
|
||||
content: Text(
|
||||
'Are you sure that you want to clear all $entryCount entries?',
|
||||
),
|
||||
);
|
||||
if (!userIsSure) return;
|
||||
if (widget.library.name != 'favourites')
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
final entryCount = widget.library.totalCount;
|
||||
if (!context.mounted) return;
|
||||
final bool userIsSure = await _confirm(
|
||||
context,
|
||||
content: Text(
|
||||
'Are you sure that you want to clear all $entryCount entries?',
|
||||
),
|
||||
);
|
||||
if (!userIsSure) return;
|
||||
|
||||
await GetIt.instance.get<Database>().libraryListDeleteAllEntries(
|
||||
widget.library.name,
|
||||
);
|
||||
await GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListDeleteAllEntries(widget.library.name);
|
||||
|
||||
_pagingController.refresh();
|
||||
},
|
||||
icon: const Icon(Icons.delete),
|
||||
),
|
||||
_pagingController.refresh();
|
||||
},
|
||||
icon: const Icon(Icons.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: PagingListener(
|
||||
controller: _pagingController,
|
||||
builder: (context, state, fetchNextPage) =>
|
||||
builder: (final context, final state, final fetchNextPage) =>
|
||||
PagedListView<int, LibraryListEntry>.separated(
|
||||
state: state,
|
||||
fetchNextPage: fetchNextPage,
|
||||
builderDelegate: PagedChildBuilderDelegate<LibraryListEntry>(
|
||||
invisibleItemsThreshold: invisibleItemsThreshold,
|
||||
itemBuilder: (context, entry, index) => LibraryListEntryTile(
|
||||
key: ValueKey(
|
||||
entry.jmdictEntryId != null
|
||||
? 'jmdict-${entry.jmdictEntryId}'
|
||||
: 'kanji-${entry.kanji}',
|
||||
),
|
||||
index: index,
|
||||
entry: entry,
|
||||
library: widget.library,
|
||||
onDelete: () => _pagingController.refresh(),
|
||||
onUpdate: () => _pagingController.refresh(),
|
||||
),
|
||||
itemBuilder: (final context, final entry, final index) =>
|
||||
LibraryListEntryTile(
|
||||
key: ValueKey(
|
||||
entry.jmdictEntryId != null
|
||||
? 'jmdict-${entry.jmdictEntryId}'
|
||||
: 'kanji-${entry.kanji}',
|
||||
),
|
||||
index: index,
|
||||
entry: entry,
|
||||
library: widget.library,
|
||||
onDelete: () => _pagingController.refresh(),
|
||||
onUpdate: () => _pagingController.refresh(),
|
||||
),
|
||||
firstPageErrorIndicatorBuilder: (_) =>
|
||||
ErrorWidget(_pagingController.error!),
|
||||
noItemsFoundIndicatorBuilder: (_) =>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/components/common/loading.dart';
|
||||
import 'package:mugiten/components/library/library_list_tile.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';
|
||||
|
||||
class LibraryView extends StatefulWidget {
|
||||
const LibraryView({super.key});
|
||||
|
||||
@@ -19,24 +20,23 @@ class _LibraryViewState extends State<LibraryView> {
|
||||
Future<void> getEntriesFromDatabase() => GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListGetLists()
|
||||
.then((libs) => setState(() => libraries = libs));
|
||||
.then((final libs) => setState(() => libraries = libs));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
getEntriesFromDatabase();
|
||||
unawaited(getEntriesFromDatabase());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
if (libraries == null) return const LoadingScreen();
|
||||
return Column(
|
||||
children: [
|
||||
LibraryListTile(
|
||||
key: ValueKey(libraries!.first.name),
|
||||
library: libraries!.first,
|
||||
leading: const Icon(Icons.star),
|
||||
onDelete: getEntriesFromDatabase,
|
||||
onUpdate: getEntriesFromDatabase,
|
||||
isEditable: false,
|
||||
),
|
||||
Expanded(
|
||||
@@ -45,11 +45,11 @@ class _LibraryViewState extends State<LibraryView> {
|
||||
// Skip favourites
|
||||
.skip(1)
|
||||
.map(
|
||||
(e) => LibraryListTile(
|
||||
(final e) => LibraryListTile(
|
||||
key: ValueKey(e.name),
|
||||
library: e,
|
||||
onDelete: getEntriesFromDatabase,
|
||||
onUpdate: getEntriesFromDatabase,
|
||||
onRename: (_, _) => getEntriesFromDatabase(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
@@ -6,6 +8,13 @@ import 'package:jadb/models/word_search/word_search_result.dart';
|
||||
import 'package:jadb/search.dart';
|
||||
import 'package:jadb/search/word_search/word_search.dart';
|
||||
import 'package:mdi/mdi.dart';
|
||||
import 'package:mugiten/components/kanji/kanji_result_body/grade.dart';
|
||||
import 'package:mugiten/components/kanji/kanji_result_body/header.dart';
|
||||
import 'package:mugiten/components/kanji/kanji_result_body/jlpt_level.dart';
|
||||
import 'package:mugiten/components/kanji/kanji_result_body/radical.dart';
|
||||
import 'package:mugiten/components/kanji/kanji_result_body/rank.dart';
|
||||
import 'package:mugiten/components/kanji/kanji_result_body/stroke_order_gif.dart';
|
||||
import 'package:mugiten/components/kanji/kanji_result_body/yomi_chips.dart';
|
||||
import 'package:mugiten/components/library/add_to_library_dialog.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/search_card.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
@@ -14,14 +23,6 @@ import 'package:mugiten/services/snackbar.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:sqflite/sqflite.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';
|
||||
import '../../components/kanji/kanji_result_body/radical.dart';
|
||||
import '../../components/kanji/kanji_result_body/rank.dart';
|
||||
import '../../components/kanji/kanji_result_body/stroke_order_gif.dart';
|
||||
import '../../components/kanji/kanji_result_body/yomi_chips.dart';
|
||||
|
||||
class KanjiSearchResultPage extends StatefulWidget {
|
||||
final String kanji;
|
||||
|
||||
@@ -39,17 +40,17 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
bool isFavourite = false;
|
||||
|
||||
late final _pagingController = PagingController<int, WordSearchResult>(
|
||||
getNextPageKey: (state) =>
|
||||
getNextPageKey: (final state) =>
|
||||
state.lastPageIsEmpty ? null : state.nextIntPageKey,
|
||||
fetchPage: (pageKey) => GetIt.instance
|
||||
fetchPage: (final pageKey) => GetIt.instance
|
||||
.get<Database>()
|
||||
.jadbSearchWord(
|
||||
widget.kanji,
|
||||
page: pageKey - 1,
|
||||
pageSize: pageSize,
|
||||
searchMode: SearchMode.Kanji,
|
||||
searchMode: SearchMode.kanji,
|
||||
)
|
||||
.then((page) {
|
||||
.then((final page) {
|
||||
if (pageKey == 1 && page != null && page.isNotEmpty) {
|
||||
page.insert(0, WordSearchResult.empty());
|
||||
}
|
||||
@@ -64,7 +65,7 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
}
|
||||
|
||||
// TODO: add compart link
|
||||
Widget _headerRow(KanjiSearchResult result) => Container(
|
||||
Widget _headerRow(final KanjiSearchResult result) => Container(
|
||||
margin: const EdgeInsets.fromLTRB(20.0, 20.0, 20.0, 30.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
@@ -86,7 +87,7 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
),
|
||||
);
|
||||
|
||||
Widget _rankingColumn(KanjiSearchResult result) => Column(
|
||||
Widget _rankingColumn(final KanjiSearchResult result) => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -124,7 +125,7 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
],
|
||||
);
|
||||
|
||||
Widget _topBody(KanjiSearchResult result) => Column(
|
||||
Widget _topBody(final KanjiSearchResult result) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_headerRow(result),
|
||||
@@ -149,11 +150,11 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
],
|
||||
);
|
||||
|
||||
Widget _body(KanjiSearchResult result) {
|
||||
Widget _body(final KanjiSearchResult result) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
actions: [
|
||||
if (incognitoModeEnabled)
|
||||
if (incognitoModeEnabled.value)
|
||||
IconButton(
|
||||
icon: const Icon(Mdi.incognito),
|
||||
onPressed: () =>
|
||||
@@ -163,14 +164,16 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
icon: const Icon(Icons.star),
|
||||
color: isFavourite ? Colors.yellow : null,
|
||||
onPressed: () {
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListToggleEntry(
|
||||
'favourites',
|
||||
jmdictEntryId: null,
|
||||
kanji: result.kanji,
|
||||
)
|
||||
.then((state) => setState(() => isFavourite = state));
|
||||
unawaited(
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListToggleEntry(
|
||||
'favourites',
|
||||
jmdictEntryId: null,
|
||||
kanji: result.kanji,
|
||||
)
|
||||
.then((final state) => setState(() => isFavourite = state)),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
@@ -185,13 +188,13 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
),
|
||||
body: PagingListener(
|
||||
controller: _pagingController,
|
||||
builder: (context, state, fetchNextPage) {
|
||||
builder: (final context, final state, final fetchNextPage) {
|
||||
return PagedListView<int, WordSearchResult>.separated(
|
||||
state: state,
|
||||
fetchNextPage: fetchNextPage,
|
||||
builderDelegate: PagedChildBuilderDelegate<WordSearchResult>(
|
||||
invisibleItemsThreshold: invisibleItemsThreshold,
|
||||
itemBuilder: (context, entry, index) {
|
||||
itemBuilder: (final context, final entry, final index) {
|
||||
if (index == 0) {
|
||||
return _topBody(result);
|
||||
} else {
|
||||
@@ -215,8 +218,8 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
separatorBuilder: (_, index) => index == 0
|
||||
? SizedBox.shrink()
|
||||
separatorBuilder: (_, final index) => index == 0
|
||||
? const SizedBox.shrink()
|
||||
: const Divider(height: 0, indent: 10, endIndent: 10),
|
||||
);
|
||||
},
|
||||
@@ -228,24 +231,28 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListListContains('favourites', kanji: widget.kanji)
|
||||
.then((value) => setState(() => isFavourite = value));
|
||||
|
||||
if (!incognitoModeEnabled && !addedToDatabase) {
|
||||
unawaited(
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryInsertKanji(widget.kanji)
|
||||
.then((_) => setState(() => addedToDatabase = true));
|
||||
.libraryListListContains('favourites', kanji: widget.kanji)
|
||||
.then((final value) => setState(() => isFavourite = value)),
|
||||
);
|
||||
|
||||
if (!incognitoModeEnabled.value && !addedToDatabase) {
|
||||
unawaited(
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryInsertKanji(widget.kanji)
|
||||
.then((_) => setState(() => addedToDatabase = true)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: GetIt.instance.get<Database>().jadbSearchKanji(widget.kanji),
|
||||
builder: (context, snapshot) {
|
||||
builder: (final context, final snapshot) {
|
||||
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../components/kanji/kanji_search_body.dart';
|
||||
import 'package:mugiten/components/kanji/kanji_search_body.dart';
|
||||
|
||||
class KanjiSearchView extends StatelessWidget {
|
||||
const KanjiSearchView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return const KanjiSearchBody();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../components/drawing_board/drawing_board.dart';
|
||||
import '../../../routing/routes.dart';
|
||||
import 'package:mugiten/components/drawing_board/drawing_board.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
|
||||
class KanjiDrawingSearch extends StatelessWidget {
|
||||
const KanjiDrawingSearch({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Draw a kanji')),
|
||||
body: SafeArea(
|
||||
@@ -16,11 +16,12 @@ class KanjiDrawingSearch extends StatelessWidget {
|
||||
const Expanded(child: Column()),
|
||||
DrawingBoard(
|
||||
onlyOneCharacterSuggestions: true,
|
||||
onSuggestionChosen: (suggestion) => Navigator.popAndPushNamed(
|
||||
context,
|
||||
Routes.kanjiSearch,
|
||||
arguments: suggestion,
|
||||
),
|
||||
onSuggestionChosen: (final suggestion) =>
|
||||
Navigator.popAndPushNamed(
|
||||
context,
|
||||
Routes.kanjiSearch,
|
||||
arguments: suggestion,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jadb/const_data/kanji_grades.dart';
|
||||
import 'package:mugiten/components/common/loading.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
import '../../../../routing/routes.dart';
|
||||
import '../../../components/common/loading.dart';
|
||||
import '../../../settings.dart';
|
||||
|
||||
class KanjiGradeSearch extends StatefulWidget {
|
||||
const KanjiGradeSearch({super.key});
|
||||
|
||||
@@ -20,7 +19,7 @@ class _GridItem extends StatelessWidget {
|
||||
const _GridItem({required this.text, this.isNumber = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final ForegroundBackgroundThemeExtension colors = isNumber
|
||||
? lightTheme.extension<MenuGreyDarkThemeExtension>()!
|
||||
: lightTheme.extension<MenuGreyNormalThemeExtension>()!;
|
||||
@@ -48,7 +47,7 @@ class _GridItem extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: colors.foregroundColor,
|
||||
fontSize: 25,
|
||||
).merge(japaneseFont.textStyle),
|
||||
).merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -56,30 +55,30 @@ class _GridItem extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _KanjiGradeSearchState extends State<KanjiGradeSearch> {
|
||||
Future<Map<int, Map<int, List<Widget>>>> get gradeWidgets async =>
|
||||
Future<Map<int, Map<int, List<Widget>>>> get gradeWidgets =>
|
||||
compute<
|
||||
Map<int, Map<int, List<String>>>,
|
||||
Map<int, Map<int, List<Widget>>>
|
||||
>(
|
||||
(gs) => gs.map(
|
||||
(grade, sortedByStrokes) => MapEntry(
|
||||
(final gs) => gs.map(
|
||||
(final grade, final sortedByStrokes) => MapEntry(
|
||||
grade,
|
||||
sortedByStrokes.map<int, List<Widget>>(
|
||||
(strokeCount, kanji) => MapEntry(strokeCount, [
|
||||
(final strokeCount, final kanji) => MapEntry(strokeCount, [
|
||||
_GridItem(text: strokeCount.toString(), isNumber: true),
|
||||
...kanji.map((k) => _GridItem(text: k)),
|
||||
...kanji.map((final k) => _GridItem(text: k)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
JOUYOU_KANJI_BY_GRADE_AND_STROKE_COUNT,
|
||||
jouyouKanjiByGradeAndStrokeCount,
|
||||
);
|
||||
|
||||
Future<Widget> get makeGrids async => SingleChildScrollView(
|
||||
child: Column(
|
||||
children: (await Future.wait(
|
||||
JOUYOU_KANJI_BY_GRADE_AND_STROKE_COUNT.keys.map(
|
||||
(grade) async => ExpansionTile(
|
||||
jouyouKanjiByGradeAndStrokeCount.keys.map(
|
||||
(final grade) async => ExpansionTile(
|
||||
title: Text(grade == 7 ? 'Junior Highschool' : 'Grade $grade'),
|
||||
maintainState: true,
|
||||
children: [
|
||||
@@ -91,7 +90,7 @@ class _KanjiGradeSearchState extends State<KanjiGradeSearch> {
|
||||
crossAxisSpacing: 10,
|
||||
padding: const EdgeInsets.all(10),
|
||||
children: (await gradeWidgets)[grade]!.values
|
||||
.expand((l) => l)
|
||||
.expand((final l) => l)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
@@ -102,13 +101,13 @@ class _KanjiGradeSearchState extends State<KanjiGradeSearch> {
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Choose by grade')),
|
||||
body: FutureBuilder<Widget>(
|
||||
future: makeGrids,
|
||||
initialData: const LoadingScreen(),
|
||||
builder: (context, snapshot) => snapshot.data!,
|
||||
builder: (final context, final snapshot) => snapshot.data!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:jadb/const_data/radicals.dart';
|
||||
import 'package:jadb/search.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../../../../routing/routes.dart';
|
||||
import '../../../settings.dart';
|
||||
|
||||
class KanjiRadicalSearch extends StatefulWidget {
|
||||
final String? prechosenRadical;
|
||||
|
||||
@@ -23,11 +24,11 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
List<String> suggestions = [];
|
||||
|
||||
Map<String, bool> radicalToggles = {
|
||||
for (final String r in RADICALS.values.expand((l) => l)) r: false,
|
||||
for (final String r in radicals.values.expand((final l) => l)) r: false,
|
||||
};
|
||||
|
||||
Map<String, bool> allowedToggles = {
|
||||
for (final String r in RADICALS.values.expand((l) => l)) r: true,
|
||||
for (final String r in radicals.values.expand((final l) => l)) r: true,
|
||||
};
|
||||
|
||||
@override
|
||||
@@ -36,21 +37,21 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
radicalToggles.containsKey(widget.prechosenRadical)) {
|
||||
radicalToggles[widget.prechosenRadical!] = true;
|
||||
}
|
||||
updateSuggestions();
|
||||
unawaited(updateSuggestions());
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void resetRadicalToggles() => radicalToggles.forEach((k, _) {
|
||||
void resetRadicalToggles() => radicalToggles.forEach((final k, _) {
|
||||
radicalToggles[k] = false;
|
||||
});
|
||||
|
||||
void resetAllowedToggles() => allowedToggles.forEach((k, _) {
|
||||
void resetAllowedToggles() => allowedToggles.forEach((final k, _) {
|
||||
allowedToggles[k] = true;
|
||||
});
|
||||
|
||||
Future<void> updateSuggestions() async {
|
||||
final toggledRadicals = radicalToggles.keys
|
||||
.where((r) => radicalToggles[r] ?? false)
|
||||
.where((final r) => radicalToggles[r] ?? false)
|
||||
.toList();
|
||||
|
||||
if (toggledRadicals.isEmpty) {
|
||||
@@ -63,16 +64,20 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
late final List<String> newSuggestions;
|
||||
late final List<String> newRadicals;
|
||||
await Future.wait([
|
||||
jadbConnection.jadbSearchKanjiByRadicals(toggledRadicals).then((value) {
|
||||
jadbConnection.jadbSearchKanjiByRadicals(toggledRadicals).then((
|
||||
final value,
|
||||
) {
|
||||
newSuggestions = value;
|
||||
}),
|
||||
jadbConnection.jadbSearchRemainingRadicals(toggledRadicals).then((value) {
|
||||
jadbConnection.jadbSearchRemainingRadicals(toggledRadicals).then((
|
||||
final value,
|
||||
) {
|
||||
newRadicals = value;
|
||||
}),
|
||||
]);
|
||||
|
||||
setState(() {
|
||||
allowedToggles.forEach((key, value) {
|
||||
allowedToggles.forEach((final key, final value) {
|
||||
allowedToggles[key] = false;
|
||||
});
|
||||
for (final r in newRadicals) {
|
||||
@@ -82,7 +87,10 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
});
|
||||
}
|
||||
|
||||
Widget radicalGridElement(String radical, {bool isNumber = false}) {
|
||||
Widget radicalGridElement(
|
||||
final String radical, {
|
||||
final bool isNumber = false,
|
||||
}) {
|
||||
final foregroundColor = isNumber
|
||||
? lightTheme.extension<MenuGreyDarkThemeExtension>()!.foregroundColor
|
||||
: radicalToggles[radical]!
|
||||
@@ -100,7 +108,7 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
: () => setState(() {
|
||||
// TODO: Don't let the user toggle on another kanji before the last one is updated
|
||||
radicalToggles[radical] = !radicalToggles[radical]!;
|
||||
updateSuggestions();
|
||||
unawaited(updateSuggestions());
|
||||
}),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
@@ -127,28 +135,28 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
color: mugitenWheatBackground,
|
||||
iconSize: fontSize * 1.3,
|
||||
),
|
||||
...RADICALS.values
|
||||
.expand((l) => l)
|
||||
.where((k) => radicalToggles[k] ?? false)
|
||||
.map((k) => radicalGridElement(k.toString())),
|
||||
...radicals.values
|
||||
.expand((final l) => l)
|
||||
.where((final k) => radicalToggles[k] ?? false)
|
||||
.map((final k) => radicalGridElement(k.toString())),
|
||||
|
||||
...RADICALS
|
||||
...radicals
|
||||
.map(
|
||||
(key, value) => MapEntry(
|
||||
(final key, final value) => MapEntry(
|
||||
key,
|
||||
value
|
||||
.where((r) => !radicalToggles[r]! && allowedToggles[r]!)
|
||||
.map((r) => radicalGridElement(r))
|
||||
.where((final r) => !radicalToggles[r]! && allowedToggles[r]!)
|
||||
.map(radicalGridElement)
|
||||
.toList()
|
||||
..insert(0, radicalGridElement(key.toString(), isNumber: true)),
|
||||
),
|
||||
)
|
||||
.values
|
||||
.where((element) => element.length != 1)
|
||||
.expand((l) => l),
|
||||
.where((final element) => element.length != 1)
|
||||
.expand((final l) => l),
|
||||
];
|
||||
|
||||
Widget kanjiGridElement(String kanji) {
|
||||
Widget kanjiGridElement(final String kanji) {
|
||||
// const color = LightTheme.defaultMenuGreyNormal;
|
||||
final colors = lightTheme.extension<MenuGreyNormalThemeExtension>()!;
|
||||
return InkWell(
|
||||
@@ -172,13 +180,13 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final colors = Theme.of(context).extension<MenuGreyNormalThemeExtension>()!;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Choose by radicals')),
|
||||
body: DefaultTextStyle.merge(
|
||||
style: japaneseFont.textStyle,
|
||||
style: japaneseFont.value.textStyle,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -197,12 +205,10 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
padding: const EdgeInsets.all(10),
|
||||
children: suggestions
|
||||
.map((s) => kanjiGridElement(s))
|
||||
.toList(),
|
||||
children: suggestions.map(kanjiGridElement).toList(),
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
const Divider(
|
||||
color: mugitenWheatBackground,
|
||||
thickness: 3,
|
||||
height: 30,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
@@ -5,6 +7,7 @@ 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/components/search/search_results_body/parts/circle_badge.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/search_card.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/services/datetime.dart';
|
||||
import 'package:mugiten/services/snackbar.dart';
|
||||
@@ -12,8 +15,6 @@ import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../../components/search/search_results_body/search_card.dart';
|
||||
|
||||
const int pageSize = 50;
|
||||
const int invisibleItemsThreshold = 25;
|
||||
|
||||
@@ -31,46 +32,50 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
|
||||
HistoryEntry? historyEntry;
|
||||
|
||||
late final _pagingController = PagingController<int, WordSearchResult>(
|
||||
getNextPageKey: (state) =>
|
||||
getNextPageKey: (final state) =>
|
||||
state.lastPageIsEmpty ? null : state.nextIntPageKey,
|
||||
fetchPage: (pageKey) => GetIt.instance
|
||||
fetchPage: (final pageKey) => GetIt.instance
|
||||
.get<Database>()
|
||||
.jadbSearchWord(
|
||||
widget.searchTerm,
|
||||
page: pageKey - 1,
|
||||
pageSize: pageSize,
|
||||
)
|
||||
.then((v) => v ?? <WordSearchResult>[]),
|
||||
.then((final 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,
|
||||
if (!incognitoModeEnabled.value && !addedToDatabase) {
|
||||
unawaited(
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryInsertWord(widget.searchTerm)
|
||||
.then(
|
||||
(_) => GetIt.instance.get<Database>().historyEntryGetWord(
|
||||
widget.searchTerm,
|
||||
),
|
||||
)
|
||||
.then(
|
||||
(final entry) => setState(() {
|
||||
addedToDatabase = true;
|
||||
historyEntry = entry;
|
||||
}),
|
||||
),
|
||||
)
|
||||
.then(
|
||||
(entry) => setState(() {
|
||||
addedToDatabase = true;
|
||||
historyEntry = entry;
|
||||
}),
|
||||
);
|
||||
);
|
||||
} else {
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryGetWord(widget.searchTerm)
|
||||
.then(
|
||||
(entry) => setState(() {
|
||||
historyEntry = entry;
|
||||
}),
|
||||
);
|
||||
unawaited(
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryGetWord(widget.searchTerm)
|
||||
.then(
|
||||
(final entry) => setState(() {
|
||||
historyEntry = entry;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,13 +86,16 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
final colors = Theme.of(context).extension<MenuGreyNormalThemeExtension>()!;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('"${widget.searchTerm}"'),
|
||||
title: Text(
|
||||
'"${widget.searchTerm}"',
|
||||
style: japaneseFont.value.textStyle,
|
||||
),
|
||||
actions: [
|
||||
if (incognitoModeEnabled)
|
||||
if (incognitoModeEnabled.value)
|
||||
IconButton(
|
||||
icon: const Icon(Mdi.incognito),
|
||||
onPressed: () =>
|
||||
@@ -98,21 +106,23 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Last searched')),
|
||||
body: ListView(
|
||||
children: historyEntry!.timestamps
|
||||
.map(
|
||||
(ts) => ListTile(
|
||||
title: Text(
|
||||
'${formatDate(ts)} ${formatTime(ts)}',
|
||||
unawaited(
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (final context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Last searched')),
|
||||
body: ListView(
|
||||
children: historyEntry!.timestamps
|
||||
.map(
|
||||
(final ts) => ListTile(
|
||||
title: Text(
|
||||
'${formatDate(ts)} ${formatTime(ts)}',
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -130,34 +140,39 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
|
||||
future: GetIt.instance
|
||||
.get<Database>()
|
||||
.jadbSearchWordCount(widget.searchTerm)
|
||||
.then((v) => v ?? 0),
|
||||
builder: (context, snapshot) {
|
||||
.then((final v) => v ?? 0),
|
||||
builder: (final context, final snapshot) {
|
||||
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final searchCount = snapshot.data!;
|
||||
final singleItem = searchCount == 1;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Center(
|
||||
child: Text(
|
||||
'Found $searchCount results for "${widget.searchTerm}"',
|
||||
'Found $searchCount result${searchCount != 1 ? 's' : ''}',
|
||||
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PagingListener(
|
||||
controller: _pagingController,
|
||||
builder: (context, state, fetchNextPage) =>
|
||||
builder: (final context, final state, final fetchNextPage) =>
|
||||
PagedListView<int, WordSearchResult>(
|
||||
state: state,
|
||||
fetchNextPage: fetchNextPage,
|
||||
builderDelegate: PagedChildBuilderDelegate(
|
||||
invisibleItemsThreshold: invisibleItemsThreshold,
|
||||
itemBuilder: (context, item, index) =>
|
||||
SearchResultCard(result: item),
|
||||
itemBuilder:
|
||||
(final context, final item, final index) =>
|
||||
SearchResultCard(
|
||||
result: item,
|
||||
initiallyExpanded: singleItem,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../components/search/global_search_bar.dart';
|
||||
import 'package:mugiten/components/search/global_search_bar.dart';
|
||||
|
||||
class WordSearchView extends StatelessWidget {
|
||||
const WordSearchView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(final BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[GlobalSearchBar()],
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
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';
|
||||
@@ -9,8 +10,10 @@ import 'package:mugiten/components/common/denshi_jisho_background.dart';
|
||||
import 'package:mugiten/database/history/table_names.dart';
|
||||
import 'package:mugiten/main.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import 'package:mugiten/services/data_export_import.dart';
|
||||
import 'package:mugiten/services/archive/archive_controller.dart';
|
||||
import 'package:mugiten/services/archive/v1/format.dart';
|
||||
import 'package:mugiten/services/snackbar.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
@@ -25,14 +28,13 @@ class SettingsView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SettingsViewState extends State<SettingsView> {
|
||||
final Database db = GetIt.instance.get<Database>();
|
||||
bool dataExportIsLoading = false;
|
||||
bool dataImportIsLoading = false;
|
||||
|
||||
Future<bool> confirm(BuildContext context, {required Widget content}) async {
|
||||
Future<bool> confirm(
|
||||
final BuildContext context, {
|
||||
required final Widget content,
|
||||
}) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (final context) => AlertDialog(
|
||||
content: content,
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
@@ -49,7 +51,7 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
false;
|
||||
}
|
||||
|
||||
Future<void> clearHistory(BuildContext context) async {
|
||||
Future<void> clearHistory(final BuildContext context) async {
|
||||
final historyCount = await GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryAmount();
|
||||
@@ -71,37 +73,57 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
showSnackbar(context, 'Cleared history');
|
||||
}
|
||||
|
||||
Future<void> changeFont(BuildContext context) async {
|
||||
Future<void> changeFont(final BuildContext context) async {
|
||||
final int? i = await _chooseFromList(
|
||||
list: [for (final font in JapaneseFont.values) font.name],
|
||||
chosen: japaneseFont.index,
|
||||
list: [for (final font in JapaneseFontChoice.values) font.name],
|
||||
chosen: japaneseFont.value.index,
|
||||
)(context);
|
||||
if (i != null) {
|
||||
setState(() {
|
||||
japaneseFont = JapaneseFont.values[i];
|
||||
japaneseFont.value = JapaneseFontChoice.values[i];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> exportHandler(BuildContext context) async {
|
||||
late final File zipfile;
|
||||
try {
|
||||
setState(() => dataExportIsLoading = true);
|
||||
final db = GetIt.instance.get<Database>();
|
||||
zipfile = await exportData(db);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showSnackbar(context, 'Error exporting data: $e');
|
||||
} finally {
|
||||
setState(() => dataExportIsLoading = false);
|
||||
Future<void> changeQuickAddLibraryList(final BuildContext context) async {
|
||||
final libraryLists = await GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListGetLists();
|
||||
if (!context.mounted) return;
|
||||
final int? i = await _chooseFromList(
|
||||
list: ['None', ...libraryLists.map((final e) => e.name)],
|
||||
chosen: quickAddLibraryList.value == null
|
||||
? 0
|
||||
: libraryLists.indexWhere(
|
||||
(final l) => l.name == quickAddLibraryList.value,
|
||||
) +
|
||||
1,
|
||||
title: 'Choose library for quick add',
|
||||
)(context);
|
||||
if (i != null) {
|
||||
setState(() {
|
||||
quickAddLibraryList.value = i == 0 ? null : libraryLists[i - 1].name;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final saveFile = await FilePicker.platform.saveFile(
|
||||
Future<void> exportHandler(final BuildContext context) async {
|
||||
final tmpfile = File(
|
||||
Directory.systemTemp
|
||||
.createTempSync('mugiten_data_')
|
||||
.uri
|
||||
.resolve('mugiten_data.zip')
|
||||
.toFilePath(),
|
||||
);
|
||||
|
||||
await BlocProvider.of<ArchiveController>(context).startExport(tmpfile);
|
||||
|
||||
final saveFile = await FilePicker.saveFile(
|
||||
dialogTitle: 'Export data',
|
||||
fileName: getExportFileNameNoSuffix(),
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['zip'],
|
||||
bytes: zipfile.readAsBytesSync(),
|
||||
bytes: tmpfile.readAsBytesSync(),
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
@@ -113,8 +135,8 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> importHandler(BuildContext context) async {
|
||||
final saveFile = await FilePicker.platform.pickFiles(
|
||||
Future<void> importHandler(final BuildContext context) async {
|
||||
final saveFile = await FilePicker.pickFiles(
|
||||
dialogTitle: 'Import data',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['zip'],
|
||||
@@ -123,38 +145,30 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
return;
|
||||
}
|
||||
|
||||
assert(saveFile.files.length == 1);
|
||||
assert(saveFile.files.length == 1, 'Multiple files selected for import');
|
||||
|
||||
final filepath = saveFile.files.first.path;
|
||||
|
||||
final db = GetIt.instance.get<Database>();
|
||||
if (!context.mounted) return;
|
||||
|
||||
try {
|
||||
setState(() => dataImportIsLoading = true);
|
||||
await importData(db, File(filepath!));
|
||||
if (!context.mounted) return;
|
||||
showSnackbar(context, 'Data imported successfully');
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showSnackbar(context, 'Error importing data: $e');
|
||||
} finally {
|
||||
setState(() => dataImportIsLoading = false);
|
||||
}
|
||||
await BlocProvider.of<ArchiveController>(
|
||||
context,
|
||||
).startImport(File(filepath!));
|
||||
}
|
||||
|
||||
Future<int?> Function(BuildContext) _chooseFromList({
|
||||
required List<String> list,
|
||||
int? chosen,
|
||||
String? title,
|
||||
required final List<String> list,
|
||||
final int? chosen,
|
||||
final String? title,
|
||||
}) =>
|
||||
(context) => Navigator.push<int>(
|
||||
(final context) => Navigator.push<int>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => Scaffold(
|
||||
builder: (final context) => Scaffold(
|
||||
appBar: AppBar(title: title == null ? null : Text(title)),
|
||||
body: DenshiJishoBackground(
|
||||
child: ListView.builder(
|
||||
itemBuilder: (context, i) => ListTile(
|
||||
itemBuilder: (final context, final i) => ListTile(
|
||||
title: Text(list[i]),
|
||||
trailing: (chosen != null && chosen == i)
|
||||
? const Icon(Icons.check)
|
||||
@@ -169,19 +183,25 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SettingsList(
|
||||
lightTheme: SettingsThemeData(
|
||||
settingsListBackground: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
darkTheme: SettingsThemeData(
|
||||
settingsListBackground: Theme.of(context).scaffoldBackgroundColor,
|
||||
titleTextColor: mugitenWheatBackground,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||
sections: _sections(context),
|
||||
);
|
||||
Widget build(final BuildContext context) =>
|
||||
BlocBuilder<ArchiveController, ArchiveState>(
|
||||
builder: (final context, final archiveState) => SettingsList(
|
||||
lightTheme: SettingsThemeData(
|
||||
settingsListBackground: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
darkTheme: SettingsThemeData(
|
||||
settingsListBackground: Theme.of(context).scaffoldBackgroundColor,
|
||||
titleTextColor: mugitenWheatBackground,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||
sections: _sections(context, archiveState),
|
||||
),
|
||||
);
|
||||
|
||||
List<SettingsSection> _sections(BuildContext context) => [
|
||||
List<SettingsSection> _sections(
|
||||
final BuildContext context,
|
||||
final ArchiveState archiveState,
|
||||
) => [
|
||||
SettingsSection(
|
||||
title: const Text('Dictionary'),
|
||||
tiles: <SettingsTile>[
|
||||
@@ -191,19 +211,28 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
'Display romaji instead of kana for word readings',
|
||||
),
|
||||
leading: const Icon(Mdi.alphabetical),
|
||||
onToggle: (b) => setState(() => romajiEnabled = b),
|
||||
initialValue: romajiEnabled,
|
||||
onToggle: (final b) => setState(() => romajiEnabled.value = b),
|
||||
initialValue: romajiEnabled.value,
|
||||
activeSwitchColor: mugitenWheatBackground,
|
||||
),
|
||||
SettingsTile(
|
||||
title: const Text('Japanese font'),
|
||||
leading: const Icon(Icons.format_size),
|
||||
onPressed: changeFont,
|
||||
trailing: Text(japaneseFont.name),
|
||||
trailing: Text(japaneseFont.value.name),
|
||||
// subtitle:
|
||||
// 'Which font to use for japanese text. This might be useful if your phone shows kanji with a Chinese font.',
|
||||
// subtitleMaxLines: 3,
|
||||
),
|
||||
SettingsTile(
|
||||
title: const Text('Quick Add Library List'),
|
||||
leading: const Icon(Icons.bookmark),
|
||||
onPressed: changeQuickAddLibraryList,
|
||||
trailing: Text(quickAddLibraryList.value ?? 'None'),
|
||||
description: const Text(
|
||||
'Which library to add words on double tapping in search results',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSection(
|
||||
@@ -213,22 +242,22 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
title: const Text('Automatic theme'),
|
||||
description: const Text('Let theme be determined by system'),
|
||||
leading: const Icon(Icons.brightness_auto),
|
||||
onToggle: (b) {
|
||||
setState(() => autoThemeEnabled = b);
|
||||
onToggle: (final b) {
|
||||
setState(() => autoThemeEnabled.value = b);
|
||||
GetIt.instance.get<ThemeController>().updateThemeMode();
|
||||
},
|
||||
initialValue: autoThemeEnabled,
|
||||
initialValue: autoThemeEnabled.value,
|
||||
activeSwitchColor: mugitenWheatBackground,
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: const Text('Dark Theme'),
|
||||
leading: const Icon(Icons.dark_mode),
|
||||
onToggle: (b) {
|
||||
setState(() => darkThemeEnabled = b);
|
||||
onToggle: (final b) {
|
||||
setState(() => darkThemeEnabled.value = b);
|
||||
GetIt.instance.get<ThemeController>().updateThemeMode();
|
||||
},
|
||||
initialValue: darkThemeEnabled,
|
||||
enabled: !autoThemeEnabled,
|
||||
initialValue: darkThemeEnabled.value,
|
||||
enabled: !autoThemeEnabled.value,
|
||||
activeSwitchColor: mugitenWheatBackground,
|
||||
),
|
||||
],
|
||||
@@ -237,23 +266,41 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
title: const Text('Data'),
|
||||
tiles: <SettingsTile>[
|
||||
SettingsTile(
|
||||
enabled: true,
|
||||
enabled: archiveState is IdleState,
|
||||
leading: const Icon(Icons.file_upload),
|
||||
title: const Text('Import Data'),
|
||||
description: const Text('Import user data from a file'),
|
||||
onPressed: importHandler,
|
||||
value: dataImportIsLoading ? const LinearProgressIndicator() : null,
|
||||
trailing: archiveState is ImportingState
|
||||
? CircularProgressIndicator(
|
||||
value: archiveState.total > 0
|
||||
? archiveState.progress / archiveState.total
|
||||
: null,
|
||||
)
|
||||
: null,
|
||||
value: archiveState is ImportingState
|
||||
? Text(archiveState.status)
|
||||
: null,
|
||||
),
|
||||
SettingsTile(
|
||||
enabled: true,
|
||||
enabled: archiveState is IdleState,
|
||||
leading: const Icon(Icons.file_download),
|
||||
title: const Text('Export Data'),
|
||||
description: const Text('Export user data to a file'),
|
||||
onPressed: exportHandler,
|
||||
value: dataExportIsLoading ? const LinearProgressIndicator() : null,
|
||||
trailing: archiveState is ExportingState
|
||||
? CircularProgressIndicator(
|
||||
value: archiveState.total > 0
|
||||
? archiveState.progress / archiveState.total
|
||||
: null,
|
||||
)
|
||||
: null,
|
||||
value: archiveState is ExportingState
|
||||
? Text(archiveState.status)
|
||||
: null,
|
||||
),
|
||||
SettingsTile(
|
||||
enabled: true,
|
||||
enabled: archiveState is IdleState,
|
||||
leading: const Icon(Icons.delete),
|
||||
title: const Text(
|
||||
'Clear History',
|
||||
@@ -271,10 +318,10 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
leading: const Icon(Mdi.incognito),
|
||||
title: const Text('Disable history tracking'),
|
||||
description: const Text(
|
||||
'Useful for reviewing history for library lists without cluttering the order',
|
||||
'Useful for reviewing search history without creating clutter',
|
||||
),
|
||||
onToggle: (b) => setState(() => incognitoModeEnabled = b),
|
||||
initialValue: incognitoModeEnabled,
|
||||
onToggle: (final b) => setState(() => incognitoModeEnabled.value = b),
|
||||
initialValue: incognitoModeEnabled.value,
|
||||
activeSwitchColor: mugitenWheatBackground,
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
@@ -283,25 +330,26 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
description: const Text(
|
||||
'Useful if you keep accidentally activating system gestures',
|
||||
),
|
||||
onToggle: (b) => setState(() => reduceKanjiDrawingBoardSize = b),
|
||||
initialValue: reduceKanjiDrawingBoardSize,
|
||||
onToggle: (final b) =>
|
||||
setState(() => reduceKanjiDrawingBoardSize.value = b),
|
||||
initialValue: reduceKanjiDrawingBoardSize.value,
|
||||
activeSwitchColor: mugitenWheatBackground,
|
||||
),
|
||||
SettingsTile(
|
||||
enabled: true,
|
||||
enabled: archiveState is IdleState,
|
||||
leading: const Icon(Icons.cached),
|
||||
title: const Text(
|
||||
'Reinitialize application',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
description: const Text(
|
||||
'Reinstall dictionary data and set up internal workings anew',
|
||||
),
|
||||
description: const Text('Reinstall dictionary data and reset state'),
|
||||
onPressed: (_) async {
|
||||
if (!await confirm(
|
||||
context,
|
||||
content: const Text(
|
||||
'Are you sure you want to reinitialize the application?',
|
||||
'Are you sure you want to reinitialize the application?'
|
||||
'\n\n'
|
||||
'Note that this will attempt not to delete user data, but it is recommended to backup data before proceeding.',
|
||||
),
|
||||
)) {
|
||||
return;
|
||||
@@ -320,21 +368,31 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
SettingsTile(
|
||||
leading: const Icon(Icons.copyright),
|
||||
title: const Text('About'),
|
||||
description: const Text(
|
||||
'Information about Mugiten and licenses used',
|
||||
),
|
||||
onPressed: (c) => Navigator.pushNamed(context, Routes.aboutLicenses),
|
||||
description: const Text('Info about Mugiten and its dependencies'),
|
||||
onPressed: (final c) =>
|
||||
Navigator.pushNamed(context, Routes.aboutLicenses),
|
||||
),
|
||||
SettingsTile(
|
||||
leading: const Icon(Mdi.database),
|
||||
title: const Text('Datasources'),
|
||||
description: const Text('List of datasources used in Mugiten'),
|
||||
onPressed: (final c) =>
|
||||
Navigator.pushNamed(context, Routes.aboutDatasources),
|
||||
),
|
||||
SettingsTile(
|
||||
leading: const Icon(Icons.notes),
|
||||
title: const Text('Changelog'),
|
||||
onPressed: (c) => Navigator.pushNamed(context, Routes.aboutChangelog),
|
||||
description: const Text(
|
||||
'See what changed between different versions of the application',
|
||||
),
|
||||
onPressed: (final c) =>
|
||||
Navigator.pushNamed(context, Routes.aboutChangelog),
|
||||
),
|
||||
SettingsTile(
|
||||
leading: const Icon(Mdi.git),
|
||||
title: const Text('Repository'),
|
||||
description: const Text('https://git.pvv.ntnu.no/mugiten'),
|
||||
onPressed: (c) =>
|
||||
onPressed: (final c) =>
|
||||
launchUrl(Uri.parse('https://git.pvv.ntnu.no/mugiten')),
|
||||
),
|
||||
],
|
||||
|
||||
124
lib/services/archive/archive_controller.dart
Normal file
124
lib/services/archive/archive_controller.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'dart:core';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/services/archive/v2/format.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
/// The archive controller is a singleton-like class that keeps track of whether the
|
||||
/// application is currently importing or exporting data. This is used to prevent
|
||||
/// the user from starting multiple imports/exports at the same time, as well as
|
||||
/// to keep the state available when the user navigates away from the widget that
|
||||
/// started the import/export process.
|
||||
class ArchiveController extends Cubit<ArchiveState> {
|
||||
ArchiveController() : super(const IdleState());
|
||||
|
||||
Future<void> startImport(final File archive) async {
|
||||
if (state is! IdleState) {
|
||||
throw StateError('Already importing or exporting data');
|
||||
}
|
||||
|
||||
emit(
|
||||
const ImportingState(progress: 0, total: 0, status: 'Counting data...'),
|
||||
);
|
||||
|
||||
final database = GetIt.instance.get<Database>();
|
||||
|
||||
final totalChunks = totalAmountOfChunksFromArchive(archive);
|
||||
|
||||
emit(
|
||||
ImportingState(
|
||||
progress: 0,
|
||||
total: totalChunks,
|
||||
status: 'Starting import...',
|
||||
),
|
||||
);
|
||||
|
||||
int i = 0;
|
||||
await for (final event in importData(database, archive)) {
|
||||
i += 1;
|
||||
final status = switch (event) {
|
||||
ArchiveV2StreamEvent(type: 'history') =>
|
||||
'Importing history: ${event.progress}/${event.total}',
|
||||
ArchiveV2StreamEvent(type: 'library') =>
|
||||
'Importing library list "${event.name}": ${event.subProgress}/${event.subTotal}',
|
||||
_ => 'Importing unknown data: ${event.progress}/${event.total}',
|
||||
};
|
||||
|
||||
emit(ImportingState(progress: i, total: totalChunks, status: status));
|
||||
}
|
||||
|
||||
emit(const IdleState());
|
||||
}
|
||||
|
||||
Future<void> startExport(final File archive) async {
|
||||
if (state is! IdleState) {
|
||||
throw StateError('Already importing or exporting data');
|
||||
}
|
||||
|
||||
emit(
|
||||
const ExportingState(progress: 0, total: 0, status: 'Counting data...'),
|
||||
);
|
||||
|
||||
final database = GetIt.instance.get<Database>();
|
||||
|
||||
final totalChunks = await totalAmountOfChunksFromDatabase(database);
|
||||
|
||||
emit(
|
||||
ExportingState(
|
||||
progress: 0,
|
||||
total: totalChunks,
|
||||
status: 'Starting export...',
|
||||
),
|
||||
);
|
||||
|
||||
int i = 0;
|
||||
await for (final event in exportData(database, archive)) {
|
||||
i += 1;
|
||||
final status = switch (event) {
|
||||
ArchiveV2StreamEvent(type: 'history') =>
|
||||
'Exporting history: ${event.progress}/${event.total}',
|
||||
ArchiveV2StreamEvent(type: 'library') =>
|
||||
'Exporting library list "${event.name}": ${event.subProgress}/${event.subTotal}',
|
||||
_ => 'Exporting unknown data: ${event.progress}/${event.total}',
|
||||
};
|
||||
|
||||
emit(ExportingState(progress: i, total: totalChunks, status: status));
|
||||
}
|
||||
|
||||
emit(const IdleState());
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ArchiveState {
|
||||
const ArchiveState();
|
||||
}
|
||||
|
||||
class IdleState extends ArchiveState {
|
||||
const IdleState();
|
||||
}
|
||||
|
||||
class ImportingState extends ArchiveState {
|
||||
final int progress;
|
||||
final int total;
|
||||
final String status;
|
||||
|
||||
const ImportingState({
|
||||
required this.progress,
|
||||
required this.total,
|
||||
required this.status,
|
||||
});
|
||||
}
|
||||
|
||||
class ExportingState extends ArchiveState {
|
||||
final int progress;
|
||||
final int total;
|
||||
final String status;
|
||||
|
||||
const ExportingState({
|
||||
required this.progress,
|
||||
required this.total,
|
||||
required this.status,
|
||||
});
|
||||
}
|
||||
116
lib/services/archive/v1/format.dart
Normal file
116
lib/services/archive/v1/format.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'dart:convert';
|
||||
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_entry.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
part './history.dart';
|
||||
part './library_lists.dart';
|
||||
|
||||
const int expectedDataFormatVersion = 1;
|
||||
|
||||
/// Functions and properties that makes up the format of version 1 of the data archive.
|
||||
/// This archive is used to back up user data and optionally to transfer data between devices.
|
||||
///
|
||||
/// Example file Structure:
|
||||
///
|
||||
/// ```
|
||||
/// - jisho_data_2022.01.01_1
|
||||
/// - history.json
|
||||
/// - library/
|
||||
/// - lista.json
|
||||
/// - listb.json
|
||||
/// ```
|
||||
extension ArchiveFormatV1 on Directory {
|
||||
File get versionFile => File(uri.resolve('version.txt').toFilePath());
|
||||
int get version => int.parse(versionFile.readAsStringSync());
|
||||
File get historyFile => File(uri.resolve('history.json').toFilePath());
|
||||
Directory get libraryDir => Directory(uri.resolve('library').toFilePath());
|
||||
|
||||
Iterable<File> get libraryListFiles => libraryDir
|
||||
.listSync()
|
||||
.whereType<File>()
|
||||
.where((final f) => f.path.endsWith('.json'));
|
||||
|
||||
Iterable<String> get libraryListNames => libraryListFiles.map(
|
||||
(final f) => f.uri.pathSegments.last.replaceFirst(RegExp(r'\.json$'), ''),
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a temporary directory for storing exported data files before zipping them.
|
||||
Future<Directory> tmpdir() => Directory.systemTemp.createTemp('mugiten_data_');
|
||||
|
||||
/// Unpacks the given zip file to a temporary directory and returns the directory.
|
||||
Future<Directory> unpackZipToTempDir(final String zipFilePath) async {
|
||||
final outputDir = await tmpdir();
|
||||
await extractFileToDisk(zipFilePath, outputDir.path);
|
||||
return outputDir;
|
||||
}
|
||||
|
||||
/// Packs the given directory into a zip file.
|
||||
///
|
||||
/// If [outputFile] is provided, it will be used as the output file. Otherwise, a new temporary file will be created.
|
||||
Future<File> packZip(final Directory dir, {File? outputFile}) async {
|
||||
if (outputFile == null || !outputFile.existsSync()) {
|
||||
final outputDir = await tmpdir();
|
||||
outputFile = File(outputDir.uri.resolve('mugiten_data.zip').toFilePath())
|
||||
..createSync();
|
||||
}
|
||||
|
||||
final archive = createArchiveFromDirectory(dir, includeDirName: false);
|
||||
|
||||
final outputStream = OutputFileStream(outputFile.path);
|
||||
ZipEncoder().encodeStream(archive, outputStream, autoClose: true);
|
||||
|
||||
return outputFile;
|
||||
}
|
||||
|
||||
/// Generates a file name for the exported data file based on the current date, without the file extension.
|
||||
String getExportFileNameNoSuffix() {
|
||||
final DateTime today = DateTime.now();
|
||||
final String formattedDate =
|
||||
'${today.year}'
|
||||
'.${today.month.toString().padLeft(2, '0')}'
|
||||
'.${today.day.toString().padLeft(2, '0')}';
|
||||
|
||||
return 'mugiten_data_$formattedDate';
|
||||
}
|
||||
|
||||
Future<File> exportData(final DatabaseExecutor db) async {
|
||||
final archiveRoot = await tmpdir();
|
||||
archiveRoot.libraryDir.createSync();
|
||||
|
||||
await Future.wait([
|
||||
exportDataFormatVersionTo(archiveRoot),
|
||||
exportHistoryTo(db, archiveRoot),
|
||||
exportLibraryListsTo(db, archiveRoot),
|
||||
]);
|
||||
|
||||
final zipFile = await packZip(archiveRoot);
|
||||
|
||||
archiveRoot.deleteSync(recursive: true);
|
||||
|
||||
return zipFile;
|
||||
}
|
||||
|
||||
Future<void> importData(final Database db, final File zipFile) async {
|
||||
final archiveRoot = await unpackZipToTempDir(zipFile.path);
|
||||
|
||||
await Future.wait([
|
||||
importHistoryFrom(db, archiveRoot.historyFile),
|
||||
importLibraryListsFrom(db, archiveRoot),
|
||||
]);
|
||||
|
||||
archiveRoot.deleteSync(recursive: true);
|
||||
}
|
||||
|
||||
Future<void> exportDataFormatVersionTo(final Directory dir) async {
|
||||
dir.versionFile
|
||||
..createSync()
|
||||
..writeAsStringSync(expectedDataFormatVersion.toString());
|
||||
}
|
||||
98
lib/services/archive/v1/history.dart
Normal file
98
lib/services/archive/v1/history.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
part of './format.dart';
|
||||
|
||||
class ArchiveV1HistoryEntry {
|
||||
final int id;
|
||||
final List<DateTime> timestamps;
|
||||
|
||||
final String? word;
|
||||
final String? kanji;
|
||||
|
||||
const ArchiveV1HistoryEntry({
|
||||
required this.id,
|
||||
required this.timestamps,
|
||||
this.word,
|
||||
this.kanji,
|
||||
}) : assert(
|
||||
word != null || kanji != null,
|
||||
'At least one of word or kanji must be non-null',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> exportHistoryTo(
|
||||
final DatabaseExecutor db,
|
||||
final Directory dir,
|
||||
) async {
|
||||
final file = dir.historyFile..createSync();
|
||||
|
||||
final List<Map<String, Object?>> jsonEntries = (await db.historyEntryGetAll())
|
||||
.map((final e) => e.toJson())
|
||||
.toList();
|
||||
|
||||
file.writeAsStringSync(jsonEncode(jsonEntries));
|
||||
}
|
||||
|
||||
Future<void> importHistoryFrom(final Database db, final File file) async {
|
||||
final String content = file.readAsStringSync();
|
||||
final List<Map<String, Object?>> json = (jsonDecode(content) as List)
|
||||
.map((final h) => h as Map<String, Object?>)
|
||||
.toList();
|
||||
// log('Importing ${json.length} entries from ${file.path}');
|
||||
await db.transaction(
|
||||
(final txn) => historyEntryInsertManyFromJson(txn, json),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> historyEntryInsertManyFromJson(
|
||||
final DatabaseExecutor db,
|
||||
final Iterable<Map<String, Object?>> json,
|
||||
) async {
|
||||
final b = db.batch();
|
||||
for (final jsonObject in json) {
|
||||
final bool isKanji = jsonObject['word'] == null;
|
||||
final existingEntry = isKanji
|
||||
? await db.query(
|
||||
HistoryTableNames.historyEntryKanji,
|
||||
where: 'kanji = ?',
|
||||
whereArgs: [jsonObject['kanji']! as String],
|
||||
)
|
||||
: await db.query(
|
||||
HistoryTableNames.historyEntryWord,
|
||||
where: 'word = ?',
|
||||
whereArgs: [jsonObject['word']! as String],
|
||||
);
|
||||
|
||||
late final int id;
|
||||
if (existingEntry.isEmpty) {
|
||||
id = await db.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((final ts) => ts as int)
|
||||
.toList();
|
||||
for (final timestamp in timestamps) {
|
||||
b.insert(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
{'entryId': id, 'timestamp': timestamp},
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await b.commit();
|
||||
}
|
||||
109
lib/services/archive/v1/library_lists.dart
Normal file
109
lib/services/archive/v1/library_lists.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
part of './format.dart';
|
||||
|
||||
class ArchiveV1LibraryListEntry {
|
||||
final DateTime lastModified;
|
||||
final int? jmdictEntryId;
|
||||
final String? kanji;
|
||||
|
||||
const ArchiveV1LibraryListEntry({
|
||||
required this.lastModified,
|
||||
this.jmdictEntryId,
|
||||
this.kanji,
|
||||
}) : assert(
|
||||
jmdictEntryId != null || kanji != null,
|
||||
'At least one of jmdictEntryId or kanji must be non-null',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> exportLibraryListsTo(
|
||||
final DatabaseExecutor db,
|
||||
final Directory archiveRoot,
|
||||
) async {
|
||||
final libraryNames = await db
|
||||
.query(LibraryListTableNames.libraryList, columns: ['name'])
|
||||
.then(
|
||||
(final result) =>
|
||||
result.map((final row) => row['name'] as String).toList(),
|
||||
);
|
||||
|
||||
await Future.wait([
|
||||
for (final libraryName in libraryNames)
|
||||
exportLibraryListTo(db, libraryName, archiveRoot.libraryDir),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> exportLibraryListTo(
|
||||
final DatabaseExecutor db,
|
||||
final String libraryName,
|
||||
final Directory dir,
|
||||
) async {
|
||||
final file = File(dir.uri.resolve('$libraryName.json').toFilePath());
|
||||
await file.create();
|
||||
|
||||
// TODO: properly null check
|
||||
final entries = (await db.libraryListGetListEntries(
|
||||
libraryName,
|
||||
))!.entries.map((final e) => e.toJson()).toList();
|
||||
|
||||
await file.writeAsString(jsonEncode(entries));
|
||||
}
|
||||
|
||||
// TODO: how do we handle lists that already exist? There seems to be no good way to merge them?
|
||||
Future<void> importLibraryListsFrom(
|
||||
final DatabaseExecutor db,
|
||||
final Directory archiveRoot,
|
||||
) async {
|
||||
for (final file in archiveRoot.libraryListFiles) {
|
||||
final libraryName = file.uri.pathSegments.last.replaceFirst(
|
||||
RegExp(r'\.json$'),
|
||||
'',
|
||||
);
|
||||
|
||||
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;
|
||||
} else {
|
||||
print(
|
||||
'Library list "$libraryName" already exists but is empty. '
|
||||
'Importing entries from file ${file.path}.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await db.libraryListInsertList(libraryName);
|
||||
}
|
||||
|
||||
final content = await file.readAsString();
|
||||
final List<Map<String, Object?>> jsonEntries = (jsonDecode(content) as List)
|
||||
.map((final e) => e as Map<String, Object?>)
|
||||
.toList();
|
||||
|
||||
await libraryListInsertJsonEntriesForSingleList(
|
||||
db,
|
||||
libraryName,
|
||||
jsonEntries,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append multiple entries into the library list at once, using a list of JSON objects.
|
||||
Future<void> libraryListInsertJsonEntriesForSingleList(
|
||||
final DatabaseExecutor db,
|
||||
final String listName,
|
||||
final Iterable<Map<String, Object?>> jsonEntries, {
|
||||
final LibraryListEntry? prevEntry,
|
||||
final bool throwErrorOnDuplicate = false,
|
||||
}) async {
|
||||
final List<LibraryListEntry> entries = jsonEntries
|
||||
.map(LibraryListEntry.fromJson)
|
||||
.toList();
|
||||
|
||||
await db.libraryListInsertEntries(
|
||||
listName,
|
||||
entries,
|
||||
prevEntry: prevEntry,
|
||||
throwErrorOnDuplicate: throwErrorOnDuplicate,
|
||||
);
|
||||
}
|
||||
232
lib/services/archive/v2/format.dart
Normal file
232
lib/services/archive/v2/format.dart
Normal file
@@ -0,0 +1,232 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:core';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:mugiten/services/archive/v1/format.dart'
|
||||
show tmpdir, packZip, unpackZipToTempDir;
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
export 'package:mugiten/services/archive/v1/format.dart'
|
||||
show getExportFileNameNoSuffix;
|
||||
|
||||
part './history.dart';
|
||||
part './library_lists.dart';
|
||||
|
||||
const int expectedDataFormatVersion = 2;
|
||||
const int historyChunkSize = 100;
|
||||
const int libraryListChunkSize = 100;
|
||||
|
||||
/// Functions and properties that makes up the format of version 2 of the data archive.
|
||||
/// This archive is used to back up user data and optionally to transfer data between devices.
|
||||
/// The main difference to version 1 is that the data is split into chunks, so that it can be
|
||||
/// streamed and processed in parts, instead of having to load the entire data into memory at once.
|
||||
/// This not only reduces the memory usage, but also allows for reporting progress and resuming interrupted imports/exports.
|
||||
///
|
||||
/// Example file Structure:
|
||||
///
|
||||
/// ```
|
||||
/// - jisho_data_2022.01.01_1
|
||||
/// - history/
|
||||
/// - 1.json
|
||||
/// - 2.json
|
||||
/// - ...
|
||||
/// - 99.json
|
||||
/// - ...
|
||||
/// - library/
|
||||
/// - metadata.json
|
||||
/// - lista/
|
||||
/// - 1.json
|
||||
/// - 2.json
|
||||
/// - ...
|
||||
/// - listb/
|
||||
/// - 1.json
|
||||
/// - 2.json
|
||||
/// - ...
|
||||
/// ```
|
||||
extension ArchiveFormatV2 on Directory {
|
||||
File get versionFile => File(uri.resolve('version.txt').toFilePath());
|
||||
int get version => int.parse(versionFile.readAsStringSync());
|
||||
|
||||
// History //
|
||||
|
||||
Directory get historyDir => Directory(uri.resolve('history').toFilePath());
|
||||
|
||||
List<File> get historyChunkFiles =>
|
||||
historyDir.listSync().whereType<File>().sortedBy(
|
||||
(final f) =>
|
||||
int.tryParse(
|
||||
f.uri.pathSegments.last.replaceFirst(RegExp(r'\.json$'), ''),
|
||||
) ??
|
||||
0,
|
||||
);
|
||||
|
||||
File historyChunkFile(final int chunkIndex) =>
|
||||
File(historyDir.uri.resolve('$chunkIndex.json').toFilePath());
|
||||
|
||||
int get historyChunkCount => historyDir.listSync().whereType<File>().length;
|
||||
|
||||
// Library Lists //
|
||||
|
||||
Directory get libraryDir => Directory(uri.resolve('library').toFilePath());
|
||||
|
||||
/// See [libraryMetadata] for the expected content of this file.
|
||||
File get libraryMetadataFile =>
|
||||
File(libraryDir.uri.resolve('metadata.json').toFilePath());
|
||||
|
||||
/// The metadata of all library lists
|
||||
///
|
||||
/// This is expected to be a list of objects, containing:
|
||||
/// - *order*: implicitly from the order of the json list, the index of the library list
|
||||
/// - name: the original name of the library list
|
||||
/// - slug: the slugified name of the library list, used for the directory name
|
||||
Map<String, Object?> get libraryMetadata =>
|
||||
jsonDecode(libraryMetadataFile.readAsStringSync())
|
||||
as Map<String, Object?>;
|
||||
|
||||
List<Directory> get libraryListDirs =>
|
||||
libraryDir.listSync().whereType<Directory>().toList();
|
||||
|
||||
Directory libraryListDir(final String listName) => Directory(
|
||||
libraryDir.uri
|
||||
.resolve('${slugifyLibraryListFileName(listName)}/')
|
||||
.toFilePath(),
|
||||
);
|
||||
|
||||
File libraryListChunkFile(final String listName, final int chunkIndex) =>
|
||||
File(
|
||||
libraryListDir(listName).uri.resolve('$chunkIndex.json').toFilePath(),
|
||||
);
|
||||
|
||||
List<int> get libraryListEntryCounts => libraryListDirs
|
||||
.map(
|
||||
(final d) =>
|
||||
d.listSync().whereType<File>().length -
|
||||
1, // Subtract 1 for metadata.json
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
String slugifyLibraryListFileName(final String name) =>
|
||||
name.toLowerCase().replaceAll(RegExp(r'\s+'), '_');
|
||||
|
||||
Future<int> totalAmountOfChunksFromDatabase(final DatabaseExecutor db) async {
|
||||
final historyCount = await db.historyEntryAmount();
|
||||
final libraryListCounts = (await db.libraryListGetLists())
|
||||
.map((final list) => (list.totalCount / libraryListChunkSize).ceil())
|
||||
.sum;
|
||||
|
||||
return (historyCount / historyChunkSize).ceil() + libraryListCounts;
|
||||
}
|
||||
|
||||
// TODO: skip counting chunks where the library list already exists
|
||||
// and has a non-zero entry count.
|
||||
int totalAmountOfChunksFromArchive(final File archiveFile) {
|
||||
final Archive archive = ZipDecoder().decodeStream(
|
||||
InputFileStream(archiveFile.path),
|
||||
);
|
||||
int result = 0;
|
||||
for (final file in archive) {
|
||||
if (file.isFile &&
|
||||
file.name != 'metadata.json' &&
|
||||
file.name.endsWith('.json')) {
|
||||
result++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
class ArchiveV2StreamEvent {
|
||||
final String type;
|
||||
final int progress;
|
||||
final int total;
|
||||
|
||||
final String? name;
|
||||
final int? subProgress;
|
||||
final int? subTotal;
|
||||
|
||||
const ArchiveV2StreamEvent({
|
||||
required this.type,
|
||||
required this.progress,
|
||||
required this.total,
|
||||
this.name,
|
||||
this.subProgress,
|
||||
this.subTotal,
|
||||
}) : assert(
|
||||
progress > 0 && total > 0 && progress <= total,
|
||||
'0 < progress <= total must hold',
|
||||
),
|
||||
assert(
|
||||
type == 'history' || type == 'library',
|
||||
'Type must be either "history" or "library"',
|
||||
),
|
||||
assert(
|
||||
type != 'history' ||
|
||||
(name == null && subProgress == null && subTotal == null),
|
||||
'history events must not have a name, subProgress or subTotal',
|
||||
),
|
||||
assert(
|
||||
type != 'library' ||
|
||||
(name != null && subProgress != null && subTotal != null),
|
||||
'library events must have a name, subProgress and subTotal',
|
||||
),
|
||||
assert(
|
||||
(subProgress == null && subTotal == null) ||
|
||||
(subProgress != null &&
|
||||
subTotal != null &&
|
||||
subProgress > 0 &&
|
||||
subTotal > 0 &&
|
||||
subProgress <= subTotal),
|
||||
'subProgress and subTotal must both be null or both be positive integers with subProgress <= subTotal',
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (type == 'history') {
|
||||
return 'ArchiveV2StreamEvent.History($progress/$total)';
|
||||
} else {
|
||||
return 'ArchiveV2StreamEvent.Library("$name", $progress/$total, $subProgress/$subTotal)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Stream<ArchiveV2StreamEvent> exportData(
|
||||
final DatabaseExecutor db,
|
||||
final File archiveFile,
|
||||
) async* {
|
||||
if (!archiveFile.existsSync()) {
|
||||
archiveFile.createSync();
|
||||
}
|
||||
|
||||
final archiveRoot = await tmpdir();
|
||||
|
||||
await ArchiveFormatV2(
|
||||
archiveRoot,
|
||||
).versionFile.writeAsString(expectedDataFormatVersion.toString());
|
||||
|
||||
yield* exportHistory(db, archiveRoot);
|
||||
yield* exportLibraryLists(db, archiveRoot);
|
||||
|
||||
await packZip(archiveRoot, outputFile: archiveFile);
|
||||
|
||||
archiveRoot.deleteSync(recursive: true);
|
||||
}
|
||||
|
||||
Stream<ArchiveV2StreamEvent> importData(
|
||||
final DatabaseExecutor db,
|
||||
final File archiveFile,
|
||||
) async* {
|
||||
if (!archiveFile.existsSync()) {
|
||||
throw Exception('Archive file does not exist: ${archiveFile.path}');
|
||||
}
|
||||
|
||||
final archiveRoot = await unpackZipToTempDir(archiveFile.path);
|
||||
|
||||
yield* importHistory(db, archiveRoot);
|
||||
yield* importLibraryLists(db, archiveRoot);
|
||||
|
||||
archiveRoot.deleteSync(recursive: true);
|
||||
}
|
||||
151
lib/services/archive/v2/history.dart
Normal file
151
lib/services/archive/v2/history.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
part of './format.dart';
|
||||
|
||||
class ArchiveV2HistoryEntry {
|
||||
final int id;
|
||||
final List<ArchiveV2HistorySearchInstance> searchInstances;
|
||||
|
||||
// TODO: add information about whether the search had zero, one or more results.
|
||||
// TODO: add information about search mode.
|
||||
|
||||
final String? word;
|
||||
final String? kanji;
|
||||
|
||||
const ArchiveV2HistoryEntry({
|
||||
required this.id,
|
||||
required this.searchInstances,
|
||||
this.word,
|
||||
this.kanji,
|
||||
}) : assert(
|
||||
word != null || kanji != null,
|
||||
'At least one of word or kanji must be non-null',
|
||||
);
|
||||
|
||||
factory ArchiveV2HistoryEntry.fromHistoryEntry(final HistoryEntry entry) {
|
||||
return ArchiveV2HistoryEntry(
|
||||
id: entry.id,
|
||||
searchInstances: entry.timestamps
|
||||
.map(
|
||||
(final timestamp) => ArchiveV2HistorySearchInstance(
|
||||
timestamp: timestamp,
|
||||
mediaName: null,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
word: entry.word,
|
||||
kanji: entry.kanji,
|
||||
);
|
||||
}
|
||||
|
||||
HistoryEntry toHistoryEntry() {
|
||||
return HistoryEntry(
|
||||
id: id,
|
||||
timestamps: searchInstances.map((final si) => si.timestamp).toList(),
|
||||
word: word,
|
||||
kanji: kanji,
|
||||
);
|
||||
}
|
||||
|
||||
factory ArchiveV2HistoryEntry.fromJson(final Map<String, Object?> json) {
|
||||
return ArchiveV2HistoryEntry(
|
||||
id: json['id'] as int,
|
||||
searchInstances: (json['searchInstances'] as List<dynamic>)
|
||||
.map((final si) => si as Map<String, Object?>)
|
||||
.map(
|
||||
(final si) => ArchiveV2HistorySearchInstance(
|
||||
timestamp: DateTime.parse(si['timestamp'] as String),
|
||||
mediaName: si['mediaName'] as String?,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
word: json['word'] as String?,
|
||||
kanji: json['kanji'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'searchInstances': searchInstances
|
||||
.map(
|
||||
(final instance) => {
|
||||
'timestamp': instance.timestamp.toIso8601String(),
|
||||
'mediaName': instance.mediaName,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'word': word,
|
||||
'kanji': kanji,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ArchiveV2HistorySearchInstance {
|
||||
final DateTime timestamp;
|
||||
final String? mediaName;
|
||||
|
||||
const ArchiveV2HistorySearchInstance({
|
||||
required this.timestamp,
|
||||
this.mediaName,
|
||||
});
|
||||
}
|
||||
|
||||
/// Calculate the total number of chunks needed to export the history,
|
||||
/// needed for progress tracking during export.
|
||||
Future<int> exportHistoryChunkCount(final DatabaseExecutor db) async =>
|
||||
(await db.historyEntryAmount() / historyChunkSize).ceil();
|
||||
|
||||
/// Exports the history into json files in the given directory.
|
||||
///
|
||||
/// Streams back the number of chunks that have been exported so far.
|
||||
Stream<ArchiveV2StreamEvent> exportHistory(
|
||||
final DatabaseExecutor db,
|
||||
final Directory archiveRoot,
|
||||
) async* {
|
||||
final int chunkCount = await exportHistoryChunkCount(db);
|
||||
|
||||
archiveRoot.historyDir.createSync();
|
||||
|
||||
for (int i = 0; i < chunkCount; i++) {
|
||||
final List<Map<String, Object?>> jsonEntries =
|
||||
(await db.historyEntryGetAll(page: i, pageSize: historyChunkSize))
|
||||
.map(ArchiveV2HistoryEntry.fromHistoryEntry)
|
||||
.map((final e) => e.toJson())
|
||||
.toList();
|
||||
|
||||
archiveRoot.historyChunkFile(i)
|
||||
..createSync()
|
||||
..writeAsStringSync(jsonEncode(jsonEntries));
|
||||
|
||||
yield ArchiveV2StreamEvent(
|
||||
type: 'history',
|
||||
progress: i + 1,
|
||||
total: chunkCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Stream<ArchiveV2StreamEvent> importHistory(
|
||||
final DatabaseExecutor db,
|
||||
final Directory archiveRoot,
|
||||
) async* {
|
||||
final int chunkCount = archiveRoot.historyChunkCount;
|
||||
|
||||
for (int i = 0; i < chunkCount; i++) {
|
||||
final List<dynamic> jsonEntries =
|
||||
jsonDecode(archiveRoot.historyChunkFile(i).readAsStringSync())
|
||||
as List<dynamic>;
|
||||
|
||||
final historyEntries = jsonEntries
|
||||
.map((final e) => e as Map<String, Object?>)
|
||||
.map(ArchiveV2HistoryEntry.fromJson)
|
||||
.map((final e) => e.toHistoryEntry());
|
||||
|
||||
await db.historyEntryInsertEntries(historyEntries);
|
||||
|
||||
yield ArchiveV2StreamEvent(
|
||||
type: 'history',
|
||||
progress: i + 1,
|
||||
total: chunkCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
245
lib/services/archive/v2/library_lists.dart
Normal file
245
lib/services/archive/v2/library_lists.dart
Normal file
@@ -0,0 +1,245 @@
|
||||
part of './format.dart';
|
||||
|
||||
class ArchiveV2LibraryListMetadata {
|
||||
final String name;
|
||||
final String slug;
|
||||
|
||||
const ArchiveV2LibraryListMetadata({required this.name, required this.slug});
|
||||
|
||||
Map<String, Object?> toJson() => {'name': name, 'slug': slug};
|
||||
}
|
||||
|
||||
class ArchiveV2LibraryListEntry {
|
||||
final DateTime lastModified;
|
||||
final int? jmdictEntryId;
|
||||
final String? kanji;
|
||||
|
||||
const ArchiveV2LibraryListEntry({
|
||||
required this.lastModified,
|
||||
this.jmdictEntryId,
|
||||
this.kanji,
|
||||
}) : assert(
|
||||
jmdictEntryId != null || kanji != null,
|
||||
'At least one of jmdictEntryId or kanji must be non-null',
|
||||
);
|
||||
|
||||
factory ArchiveV2LibraryListEntry.fromLibraryListEntry(
|
||||
final LibraryListEntry entry,
|
||||
) => ArchiveV2LibraryListEntry(
|
||||
lastModified: entry.lastModified,
|
||||
jmdictEntryId: entry.jmdictEntryId,
|
||||
kanji: entry.kanji,
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'lastModified': lastModified.toIso8601String(),
|
||||
'jmdictEntryId': jmdictEntryId,
|
||||
'kanji': kanji,
|
||||
};
|
||||
|
||||
factory ArchiveV2LibraryListEntry.fromJson(final Map<String, Object?> json) =>
|
||||
ArchiveV2LibraryListEntry(
|
||||
lastModified: DateTime.parse(json['lastModified'] as String),
|
||||
jmdictEntryId: json['jmdictEntryId'] as int?,
|
||||
kanji: json['kanji'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Exports metadata about library lists, such as their names and order, into the archive.
|
||||
Future<void> exportLibraryMetadata(
|
||||
final DatabaseExecutor db,
|
||||
final Directory archiveRoot,
|
||||
) async {
|
||||
final libraryLists = await db.libraryListGetLists();
|
||||
final List<ArchiveV2LibraryListMetadata> metadataList = libraryLists
|
||||
.map(
|
||||
(final libraryList) => ArchiveV2LibraryListMetadata(
|
||||
name: libraryList.name,
|
||||
slug: slugifyLibraryListFileName(libraryList.name),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final metadataFile = archiveRoot.libraryMetadataFile..createSync();
|
||||
await metadataFile.writeAsString(jsonEncode(metadataList));
|
||||
}
|
||||
|
||||
List<ArchiveV2LibraryListMetadata> importLibraryMetadata(
|
||||
final Directory archiveRoot,
|
||||
) {
|
||||
final metadataFile = archiveRoot.libraryMetadataFile;
|
||||
assert(metadataFile.existsSync(), 'Library metadata file does not exist');
|
||||
|
||||
final String content = metadataFile.readAsStringSync();
|
||||
final List<dynamic> jsonList = jsonDecode(content) as List<dynamic>;
|
||||
|
||||
return jsonList
|
||||
.map((final e) => e as Map<String, Object?>)
|
||||
.map(
|
||||
(final e) => ArchiveV2LibraryListMetadata(
|
||||
name: e['name']! as String,
|
||||
slug: e['slug']! as String,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Calculate the total number of chunks needed to export all library lists,
|
||||
/// needed for progress tracking during export.
|
||||
Future<int> exportLibraryListChunkCount(final DatabaseExecutor db) async =>
|
||||
(await db.libraryListGetLists())
|
||||
.map(
|
||||
(final libraryList) =>
|
||||
(libraryList.totalCount / libraryListChunkSize).ceil(),
|
||||
)
|
||||
.reduce((final a, final b) => a + b);
|
||||
|
||||
/// Exports all library lists into json files in the given directory.
|
||||
///
|
||||
/// Streams back the number of chunks that have been exported so far.
|
||||
/// See also [exportLibraryListChunkCount].
|
||||
Stream<ArchiveV2StreamEvent> exportLibraryLists(
|
||||
final DatabaseExecutor db,
|
||||
final Directory archiveRoot,
|
||||
) async* {
|
||||
archiveRoot.libraryDir.createSync();
|
||||
|
||||
await exportLibraryMetadata(db, archiveRoot);
|
||||
|
||||
final libraryLists = await db.libraryListGetLists();
|
||||
|
||||
for (final (i, libraryList) in libraryLists.indexed) {
|
||||
yield* exportLibraryList(
|
||||
db,
|
||||
archiveRoot,
|
||||
libraryList,
|
||||
i + 1,
|
||||
libraryLists.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exports a single library list into json files in the given directory.
|
||||
///
|
||||
/// Streams back the number of chunks that have been exported so far.
|
||||
Stream<ArchiveV2StreamEvent> exportLibraryList(
|
||||
final DatabaseExecutor db,
|
||||
final Directory archiveRoot,
|
||||
final LibraryList libraryList,
|
||||
final int index,
|
||||
final int total,
|
||||
) async* {
|
||||
final int totalEntries = libraryList.totalCount;
|
||||
final int chunkCount = (totalEntries / libraryListChunkSize).ceil();
|
||||
|
||||
archiveRoot.libraryListDir(libraryList.name).createSync();
|
||||
|
||||
for (int i = 0; i < chunkCount; i++) {
|
||||
final entryPage = (await db.libraryListGetListEntries(
|
||||
libraryList.name,
|
||||
page: i,
|
||||
pageSize: libraryListChunkSize,
|
||||
))!;
|
||||
|
||||
final archiveEntries = entryPage.entries
|
||||
.map(ArchiveV2LibraryListEntry.fromLibraryListEntry)
|
||||
.toList();
|
||||
|
||||
archiveRoot.libraryListChunkFile(libraryList.name, i)
|
||||
..createSync()
|
||||
..writeAsStringSync(jsonEncode(archiveEntries));
|
||||
|
||||
yield ArchiveV2StreamEvent(
|
||||
type: 'library',
|
||||
progress: index,
|
||||
total: total,
|
||||
name: libraryList.name,
|
||||
subProgress: i + 1,
|
||||
subTotal: chunkCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Stream<ArchiveV2StreamEvent> importLibraryLists(
|
||||
final DatabaseExecutor db,
|
||||
final Directory archiveRoot,
|
||||
) async* {
|
||||
final metadata = importLibraryMetadata(archiveRoot);
|
||||
for (final (i, meta) in metadata.indexed) {
|
||||
final libraryListDir = archiveRoot.libraryListDir(meta.name);
|
||||
if (!libraryListDir.existsSync()) {
|
||||
print(
|
||||
'Library list directory for "${meta.name}" does not exist. Skipping import.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await db.libraryListExists(meta.name)) {
|
||||
if ((await db.libraryListGetList(meta.name))!.totalCount > 0) {
|
||||
print(
|
||||
'Library list "${meta.name}" already exists and is not empty. Skipping import.',
|
||||
);
|
||||
continue;
|
||||
} else {
|
||||
print(
|
||||
'Library list "${meta.name}" already exists but is empty. '
|
||||
'Importing entries from file $libraryListDir.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await db.libraryListInsertList(meta.name);
|
||||
}
|
||||
|
||||
yield* importLibraryList(
|
||||
db,
|
||||
meta.name,
|
||||
libraryListDir,
|
||||
i + 1,
|
||||
metadata.length,
|
||||
);
|
||||
}
|
||||
// TODO: assert that we have not missed any library lists not present in the metadata.
|
||||
}
|
||||
|
||||
Stream<ArchiveV2StreamEvent> importLibraryList(
|
||||
final DatabaseExecutor db,
|
||||
final String libraryListName,
|
||||
final Directory libraryListDir,
|
||||
final int index,
|
||||
final int total,
|
||||
) async* {
|
||||
final chunkFiles = libraryListDir.listSync().whereType<File>();
|
||||
|
||||
for (final (i, chunkFile) in chunkFiles.indexed) {
|
||||
final chunkContent = chunkFile.readAsStringSync();
|
||||
final List<dynamic> jsonEntries = jsonDecode(chunkContent) as List<dynamic>;
|
||||
|
||||
final entries = jsonEntries
|
||||
.map((final e) => e as Map<String, Object?>)
|
||||
.map(ArchiveV2LibraryListEntry.fromJson)
|
||||
.map(
|
||||
(final e) => LibraryListEntry(
|
||||
lastModified: e.lastModified,
|
||||
jmdictEntryId: e.jmdictEntryId,
|
||||
kanji: e.kanji,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final result = await db.libraryListInsertEntries(libraryListName, entries);
|
||||
if (!result) {
|
||||
throw Exception(
|
||||
'Failed to insert entries for library list "$libraryListName" from chunk file "${chunkFile.path}".',
|
||||
);
|
||||
}
|
||||
|
||||
yield ArchiveV2StreamEvent(
|
||||
type: 'library',
|
||||
progress: index,
|
||||
total: total,
|
||||
name: libraryListName,
|
||||
subProgress: i + 1,
|
||||
subTotal: chunkFiles.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
void copyToClipboard(BuildContext context, String? clipboardContent) {
|
||||
void copyToClipboard(
|
||||
final BuildContext context,
|
||||
final String? clipboardContent,
|
||||
) {
|
||||
if (clipboardContent == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -12,7 +17,7 @@ void copyToClipboard(BuildContext context, String? clipboardContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
Clipboard.setData(ClipboardData(text: clipboardContent));
|
||||
unawaited(Clipboard.setData(ClipboardData(text: clipboardContent)));
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:core';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:mugiten/database/library_list/table_names.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:
|
||||
// jisho_data_2022.01.01_1
|
||||
// - history.json
|
||||
// - library/
|
||||
// - lista.json
|
||||
// - listb.json
|
||||
|
||||
extension ArchiveFormat on Directory {
|
||||
File get historyFile => File(uri.resolve('history.json').toFilePath());
|
||||
Directory get libraryDir => Directory(uri.resolve('library').toFilePath());
|
||||
}
|
||||
|
||||
Future<Directory> tmpdir() async =>
|
||||
Directory.systemTemp.createTemp('mugiten_data_');
|
||||
|
||||
Future<Directory> unpackZipToTempDir(String zipFilePath) async {
|
||||
final outputDir = await tmpdir();
|
||||
await extractFileToDisk(zipFilePath, outputDir.path);
|
||||
return outputDir;
|
||||
}
|
||||
|
||||
Future<File> packZip(Directory dir, {File? outputFile}) async {
|
||||
if (outputFile == null || !outputFile.existsSync()) {
|
||||
final outputDir = await tmpdir();
|
||||
outputFile = File(outputDir.uri.resolve('mugiten_data.zip').toFilePath());
|
||||
outputFile.createSync();
|
||||
}
|
||||
|
||||
final archive = createArchiveFromDirectory(dir, includeDirName: false);
|
||||
|
||||
final outputStream = OutputFileStream(outputFile.path);
|
||||
ZipEncoder().encodeStream(archive, outputStream, autoClose: true);
|
||||
|
||||
return outputFile;
|
||||
}
|
||||
|
||||
String getExportFileNameNoSuffix() {
|
||||
final DateTime today = DateTime.now();
|
||||
final String formattedDate =
|
||||
'${today.year}'
|
||||
'.${today.month.toString().padLeft(2, '0')}'
|
||||
'.${today.day.toString().padLeft(2, '0')}';
|
||||
|
||||
return 'mugiten_data_$formattedDate';
|
||||
}
|
||||
|
||||
Future<File> exportData(DatabaseExecutor db) async {
|
||||
final dir = await tmpdir();
|
||||
|
||||
final libraryDir = Directory(dir.uri.resolve('library').toFilePath());
|
||||
libraryDir.createSync();
|
||||
|
||||
await Future.wait([
|
||||
exportHistoryTo(db, dir),
|
||||
exportLibraryListsTo(db, libraryDir),
|
||||
]);
|
||||
|
||||
final zipFile = await packZip(dir);
|
||||
|
||||
return zipFile;
|
||||
}
|
||||
|
||||
Future<void> importData(Database db, File zipFile) async {
|
||||
final dir = await unpackZipToTempDir(zipFile.path);
|
||||
|
||||
await Future.wait([
|
||||
importHistoryFrom(db, dir.historyFile),
|
||||
importLibraryListsFrom(db, dir.libraryDir),
|
||||
]);
|
||||
|
||||
dir.deleteSync(recursive: true);
|
||||
}
|
||||
|
||||
/////////////
|
||||
// HISTORY //
|
||||
/////////////
|
||||
|
||||
Future<void> exportHistoryTo(DatabaseExecutor db, Directory dir) async {
|
||||
final file = dir.historyFile;
|
||||
file.createSync();
|
||||
|
||||
final List<Map<String, Object?>> jsonEntries = (await db.historyEntryGetAll())
|
||||
.map((e) => e.toJson())
|
||||
.toList();
|
||||
|
||||
file.writeAsStringSync(jsonEncode(jsonEntries));
|
||||
}
|
||||
|
||||
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 db.transaction((txn) => txn.historyEntryInsertManyFromJson(json));
|
||||
}
|
||||
|
||||
///////////////////
|
||||
// LIBRARY LISTS //
|
||||
///////////////////
|
||||
|
||||
Future<void> exportLibraryListsTo(DatabaseExecutor db, Directory dir) async {
|
||||
final libraryNames = await db
|
||||
.query(LibraryListTableNames.libraryList, columns: ['name'])
|
||||
.then((result) => result.map((row) => row['name'] as String).toList());
|
||||
|
||||
await Future.wait([
|
||||
for (final libraryName in libraryNames)
|
||||
exportLibraryListTo(db, libraryName, dir),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> exportLibraryListTo(
|
||||
DatabaseExecutor db,
|
||||
String libraryName,
|
||||
Directory dir,
|
||||
) async {
|
||||
final file = File(dir.uri.resolve('$libraryName.json').toFilePath());
|
||||
await file.create();
|
||||
|
||||
// TODO: properly null check
|
||||
final entries = (await db.libraryListGetListEntries(
|
||||
libraryName,
|
||||
))!.entries.map((e) => e.toJson()).toList();
|
||||
|
||||
await file.writeAsString(jsonEncode(entries));
|
||||
}
|
||||
|
||||
// TODO: how do we handle lists that already exist? There seems to be no good way to merge them?
|
||||
Future<void> importLibraryListsFrom(
|
||||
DatabaseExecutor db,
|
||||
Directory libraryListsDir,
|
||||
) async {
|
||||
for (final file in libraryListsDir.listSync()) {
|
||||
if (file is! File) continue;
|
||||
|
||||
assert(file.path.endsWith('.json'));
|
||||
|
||||
final libraryName = file.uri.pathSegments.last.replaceFirst(
|
||||
RegExp(r'\.json$'),
|
||||
'',
|
||||
);
|
||||
|
||||
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;
|
||||
} else {
|
||||
print(
|
||||
'Library list "$libraryName" already exists but is empty. '
|
||||
'Importing entries from file ${file.path}.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await db.libraryListInsertList(libraryName);
|
||||
}
|
||||
|
||||
final content = await file.readAsString();
|
||||
final List<Map<String, Object?>> jsonEntries = (jsonDecode(content) as List)
|
||||
.map((e) => e as Map<String, Object?>)
|
||||
.toList();
|
||||
|
||||
await db.libraryListInsertJsonEntriesForSingleList(
|
||||
libraryName,
|
||||
jsonEntries,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
DateTime roundToDay(DateTime date) => DateTime(date.year, date.month, date.day);
|
||||
DateTime roundToDay(final DateTime date) =>
|
||||
DateTime(date.year, date.month, date.day);
|
||||
|
||||
bool dateIsEqual(DateTime date1, DateTime date2) =>
|
||||
bool dateIsEqual(final DateTime date1, final DateTime date2) =>
|
||||
roundToDay(date1) == roundToDay(date2);
|
||||
|
||||
DateTime get today => roundToDay(DateTime.now());
|
||||
DateTime get yesterday =>
|
||||
roundToDay(DateTime.now().subtract(const Duration(days: 1)));
|
||||
|
||||
String formatTime(DateTime timestamp) {
|
||||
String formatTime(final DateTime timestamp) {
|
||||
final hours = timestamp.hour.toString().padLeft(2, '0');
|
||||
final mins = timestamp.minute.toString().padLeft(2, '0');
|
||||
return '$hours:$mins';
|
||||
}
|
||||
|
||||
String formatDate(DateTime date) {
|
||||
String formatDate(final DateTime date) {
|
||||
if (date == today) return 'Today';
|
||||
if (date == yesterday) return 'Yesterday';
|
||||
|
||||
|
||||
@@ -11,30 +11,47 @@ import 'package:mugiten/database/database.dart'
|
||||
openAndMigrateDatabase,
|
||||
openDatabaseWithoutMigrations,
|
||||
readMigrationsFromAssets;
|
||||
import 'package:mugiten/services/data_export_import.dart';
|
||||
import 'package:mugiten/services/archive/v1/format.dart'
|
||||
show exportData, importData;
|
||||
import 'package:mugiten/services/initialization/initialization_status.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
// TODO: add progress numbers to the different event types, and precalculate how many
|
||||
// event types there likely will be in total before running the initialization process,
|
||||
// so that we can give a somewhat accurate progress indicator.
|
||||
class InitializationCubit extends Cubit<InitializationStatus> {
|
||||
final bool deleteDatabase;
|
||||
|
||||
InitializationCubit(this.deleteDatabase) : super(InitializationNotStarted());
|
||||
InitializationCubit(this.deleteDatabase)
|
||||
: super(const InitializationNotStarted());
|
||||
|
||||
Future<void> start() async {
|
||||
emit(InitializationPending());
|
||||
Future<void> start() => stage1();
|
||||
|
||||
emit(CheckMLKitDigitalInkModel());
|
||||
Future<void> stage1() async {
|
||||
emit(const InitializationPending());
|
||||
|
||||
emit(const CheckMLKitDigitalInkModel());
|
||||
final modelManager = DigitalInkRecognizerModelManager();
|
||||
final isDownloaded = await modelManager.isModelDownloaded('ja');
|
||||
|
||||
// TODO: check for wifi connectivity
|
||||
|
||||
// TODO: ask user for permission to download the model
|
||||
|
||||
if (!isDownloaded) {
|
||||
emit(DownloadMLKitDigitalInkModel());
|
||||
emit(const DownloadMLKitDigitalInkModel());
|
||||
await modelManager.downloadModel('ja');
|
||||
}
|
||||
|
||||
emit(FinishDownloadMLKitDigitalInkModel());
|
||||
emit(const FinishDownloadMLKitDigitalInkModel());
|
||||
|
||||
emit(CheckDatabase());
|
||||
emit(const CheckDatabase());
|
||||
|
||||
// TODO: ask user if they want to do a proper export before attempting to migrate the database
|
||||
emit(const ConfirmDatabaseExportWithUser());
|
||||
}
|
||||
|
||||
Future<void> stage2(final File? exportLocation) async {
|
||||
if (deleteDatabase || await databaseNeedsInitialization()) {
|
||||
final String dbPath = await databasePath();
|
||||
final databaseAlreadyExists = File(dbPath).existsSync();
|
||||
@@ -71,6 +88,8 @@ class InitializationCubit extends Cubit<InitializationStatus> {
|
||||
|
||||
emit(MigrateDatabase(total: 2, progress: 2));
|
||||
|
||||
// TODO: notify the user if anything went wrong during data restoration.
|
||||
|
||||
if (databaseAlreadyExists) {
|
||||
emit(RestoreUserData(total: 2, progress: 1));
|
||||
|
||||
@@ -81,9 +100,9 @@ class InitializationCubit extends Cubit<InitializationStatus> {
|
||||
|
||||
database.close();
|
||||
}
|
||||
emit(DatabaseUpdateFinished());
|
||||
emit(const DatabaseUpdateFinished());
|
||||
|
||||
// Initialization complete
|
||||
emit(InitializationComplete());
|
||||
emit(const InitializationComplete());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
/// Determine whether the application needs to show the initialization screen.
|
||||
///
|
||||
/// This is either because some data is outdated, or because the application is being
|
||||
/// launched for the first time. It could also be that the data itself is not outdated,
|
||||
/// but the structure of the database has changed.
|
||||
Future<bool> needsInitialization() async {
|
||||
final modelManager = DigitalInkRecognizerModelManager();
|
||||
if (!await modelManager.isModelDownloaded('ja')) {
|
||||
@@ -23,17 +27,19 @@ Future<bool> needsInitialization() async {
|
||||
}
|
||||
|
||||
/// Quick initialization used for normal startup without initialization screen.
|
||||
///
|
||||
/// This sets up:
|
||||
/// - sqlite
|
||||
/// - shared prefs
|
||||
/// - ensures MLKit models are present
|
||||
/// - register licenses
|
||||
Future<void> quickInitialization() async {
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
|
||||
await Future.wait([
|
||||
quickInitializeDatabase(),
|
||||
setupSharedPreferences(),
|
||||
(() async {
|
||||
final modelManager = DigitalInkRecognizerModelManager();
|
||||
final isDownloaded = await modelManager.isModelDownloaded('ja');
|
||||
assert(isDownloaded, 'Japanese model should be downloaded at this point');
|
||||
})(),
|
||||
ensureDigitalInkModelDownloaded(),
|
||||
]);
|
||||
|
||||
registerExtraLicenses();
|
||||
@@ -45,6 +51,12 @@ Future<void> setupSharedPreferences() async {
|
||||
GetIt.instance.registerSingleton<SharedPreferences>(prefs);
|
||||
}
|
||||
|
||||
Future<void> ensureDigitalInkModelDownloaded() async {
|
||||
final modelManager = DigitalInkRecognizerModelManager();
|
||||
final isDownloaded = await modelManager.isModelDownloaded('ja');
|
||||
assert(isDownloaded, 'Japanese model should be downloaded at this point');
|
||||
}
|
||||
|
||||
void registerExtraLicenses() => LicenseRegistry.addLicense(() async* {
|
||||
final jsonString = await rootBundle.loadString('assets/licenses.json');
|
||||
final Map<String, dynamic> jsonData = jsonDecode(jsonString);
|
||||
|
||||
@@ -1,44 +1,77 @@
|
||||
abstract class InitializationStatus {}
|
||||
/// Base class
|
||||
abstract class InitializationStatus {
|
||||
final String? status;
|
||||
|
||||
class InitializationNotStarted extends InitializationStatus {}
|
||||
const InitializationStatus({this.status});
|
||||
}
|
||||
|
||||
class InitializationPending extends InitializationStatus {}
|
||||
class InitializationNotStarted extends InitializationStatus {
|
||||
const InitializationNotStarted() : super();
|
||||
}
|
||||
|
||||
class InitializationPending extends InitializationStatus {
|
||||
const InitializationPending() : super();
|
||||
}
|
||||
|
||||
class CheckMLKitDigitalInkModel extends InitializationStatus {
|
||||
const CheckMLKitDigitalInkModel()
|
||||
: super(status: 'Checking for ML Kit updates...');
|
||||
}
|
||||
|
||||
// class ConfirmMLKitDownloadWithUser extends InitializationStatus {
|
||||
// const ConfirmMLKitDownloadWithUser()
|
||||
// : super(status: 'Waiting for user to confirm ML Kit model download');
|
||||
// }
|
||||
|
||||
class DownloadMLKitDigitalInkModel extends InitializationStatus {
|
||||
const DownloadMLKitDigitalInkModel()
|
||||
: super(status: 'Downloading ML Kit model...');
|
||||
}
|
||||
|
||||
class FinishDownloadMLKitDigitalInkModel extends InitializationStatus {
|
||||
const FinishDownloadMLKitDigitalInkModel()
|
||||
: super(status: 'ML Kit model downloaded successfully');
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
class CheckMLKitDigitalInkModel extends InitializationStatus {}
|
||||
class CheckDatabase extends InitializationStatus {
|
||||
const CheckDatabase() : super(status: 'Checking for database updates...');
|
||||
}
|
||||
|
||||
class DownloadMLKitDigitalInkModel extends InitializationStatus {}
|
||||
|
||||
class FinishDownloadMLKitDigitalInkModel extends InitializationStatus {}
|
||||
|
||||
//
|
||||
|
||||
class CheckDatabase extends InitializationStatus {}
|
||||
class ConfirmDatabaseExportWithUser extends InitializationStatus {
|
||||
const ConfirmDatabaseExportWithUser()
|
||||
: super(status: 'Waiting for user to confirm database export');
|
||||
}
|
||||
|
||||
class BackupUserData extends InitializationStatus {
|
||||
final int progress;
|
||||
final int total;
|
||||
|
||||
BackupUserData({required this.progress, required this.total});
|
||||
BackupUserData({required this.progress, required this.total})
|
||||
: super(status: 'Backing up user data...');
|
||||
}
|
||||
|
||||
class MigrateDatabase extends InitializationStatus {
|
||||
final int progress;
|
||||
final int total;
|
||||
|
||||
MigrateDatabase({required this.progress, required this.total});
|
||||
MigrateDatabase({required this.progress, required this.total})
|
||||
: super(status: 'Performing database migrations...');
|
||||
}
|
||||
|
||||
class RestoreUserData extends InitializationStatus {
|
||||
final int progress;
|
||||
final int total;
|
||||
|
||||
RestoreUserData({required this.progress, required this.total});
|
||||
RestoreUserData({required this.progress, required this.total})
|
||||
: super(status: 'Restoring user data...');
|
||||
}
|
||||
|
||||
class DatabaseUpdateFinished extends InitializationStatus {}
|
||||
class DatabaseUpdateFinished extends InitializationStatus {
|
||||
const DatabaseUpdateFinished() : super(status: 'Database update finished');
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
class InitializationComplete extends InitializationStatus {}
|
||||
class InitializationComplete extends InitializationStatus {
|
||||
const InitializationComplete() : super(status: 'Initialization Complete');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void showSnackbar(BuildContext context, String text) =>
|
||||
void showSnackbar(final BuildContext context, final String text) =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
@@ -5,7 +7,138 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
final SharedPreferences _prefs = GetIt.instance.get<SharedPreferences>();
|
||||
|
||||
enum JapaneseFont {
|
||||
abstract interface class StringifySharedPrefItem<T> {
|
||||
String serializeSetting(final T value) => value.toString();
|
||||
T deserializeSetting(final String s);
|
||||
}
|
||||
|
||||
abstract class SharedPrefItem<T> extends ValueNotifier<T> {
|
||||
final String key;
|
||||
final T defaultValue;
|
||||
|
||||
SharedPrefItem(this.key, this.defaultValue)
|
||||
: super(_getValue<T>(key, defaultValue));
|
||||
|
||||
static T _getValue<T>(final String key, final T defaultValue) {
|
||||
Object? result = _prefs.get(key);
|
||||
|
||||
switch (defaultValue) {
|
||||
case StringifySharedPrefItem():
|
||||
if (result is String) {
|
||||
try {
|
||||
result = (defaultValue as StringifySharedPrefItem)
|
||||
.deserializeSetting(result);
|
||||
} catch (e) {
|
||||
// If deserialization fails, reset to default value.
|
||||
unawaited(_setValue<T>(key, defaultValue));
|
||||
result = defaultValue;
|
||||
}
|
||||
} else {
|
||||
// If the stored value is not a String, reset to default value.
|
||||
unawaited(_setValue<T>(key, defaultValue));
|
||||
result = defaultValue;
|
||||
}
|
||||
default:
|
||||
// Try to cast the result to the expected type. If it fails, reset to default value.
|
||||
if (result is! T) {
|
||||
unawaited(_setValue<T>(key, defaultValue));
|
||||
result = defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
static Future<void> _setValue<T>(final String key, final T value) async {
|
||||
switch (value) {
|
||||
case null:
|
||||
await _prefs.remove(key);
|
||||
case bool():
|
||||
await _prefs.setBool(key, value);
|
||||
case int():
|
||||
await _prefs.setInt(key, value);
|
||||
case double():
|
||||
await _prefs.setDouble(key, value);
|
||||
case String():
|
||||
await _prefs.setString(key, value);
|
||||
case List<String>():
|
||||
await _prefs.setStringList(key, value);
|
||||
case StringifySharedPrefItem():
|
||||
await _prefs.setString(
|
||||
key,
|
||||
(value as StringifySharedPrefItem).serializeSetting(value),
|
||||
);
|
||||
default:
|
||||
throw Exception(
|
||||
'Unsupported type for SharedPrefItem: ${value.runtimeType}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
T get value => _getValue<T>(key, defaultValue);
|
||||
|
||||
@override
|
||||
set value(final T newValue) {
|
||||
final oldValue = _getValue<T>(key, defaultValue);
|
||||
unawaited(
|
||||
_setValue<T>(key, newValue).then((_) {
|
||||
if (oldValue != newValue) {
|
||||
notifyListeners();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns whether the value stored in shared preferences is equal to [value].
|
||||
bool contains(final T value) => _getValue<T>(key, defaultValue) == value;
|
||||
}
|
||||
|
||||
/// Whether to save search history and other data to the database.
|
||||
class IncognitoModeEnabled extends SharedPrefItem<bool> {
|
||||
IncognitoModeEnabled() : super('incognitoModeEnabled', false);
|
||||
}
|
||||
|
||||
final incognitoModeEnabled = IncognitoModeEnabled();
|
||||
|
||||
/// Whether to show romaji readings in the word search results and elsewhere.
|
||||
class RomajiEnabled extends SharedPrefItem<bool> {
|
||||
RomajiEnabled() : super('romajiEnabled', false);
|
||||
}
|
||||
|
||||
final romajiEnabled = RomajiEnabled();
|
||||
|
||||
/// Whether to use a dark theme.
|
||||
class DarkThemeEnabled extends SharedPrefItem<bool> {
|
||||
DarkThemeEnabled() : super('darkThemeEnabled', false);
|
||||
}
|
||||
|
||||
final darkThemeEnabled = DarkThemeEnabled();
|
||||
|
||||
/// Whether to let the system control which theme to use.
|
||||
class AutoThemeEnabled extends SharedPrefItem<bool> {
|
||||
AutoThemeEnabled() : super('autoThemeEnabled', true);
|
||||
}
|
||||
|
||||
final autoThemeEnabled = AutoThemeEnabled();
|
||||
|
||||
/// Whether to reduce the size of the kanji drawing board.
|
||||
///
|
||||
/// This is a workaround for an issue where it's easy to activate the 'go back' gesture when
|
||||
/// drawing a little too close to the edge of the screen.
|
||||
class ReduceKanjiDrawingBoardSize extends SharedPrefItem<bool> {
|
||||
ReduceKanjiDrawingBoardSize() : super('reduceKanjiDrawingBoardSize', false);
|
||||
}
|
||||
|
||||
final reduceKanjiDrawingBoardSize = ReduceKanjiDrawingBoardSize();
|
||||
|
||||
class QuickAddLibraryList extends SharedPrefItem<String?> {
|
||||
QuickAddLibraryList() : super('quickAddLibraryList', null);
|
||||
}
|
||||
|
||||
final quickAddLibraryList = QuickAddLibraryList();
|
||||
|
||||
enum JapaneseFontChoice implements StringifySharedPrefItem<JapaneseFontChoice> {
|
||||
system,
|
||||
droidSansJapanese,
|
||||
hinaMincho,
|
||||
@@ -16,80 +149,53 @@ enum JapaneseFont {
|
||||
mPlusRounded1c,
|
||||
notoSansJapanese,
|
||||
notoSerifJapanese,
|
||||
zenKurenaido,
|
||||
}
|
||||
zenKurenaido;
|
||||
|
||||
extension Methods on JapaneseFont {
|
||||
TextStyle get textStyle {
|
||||
switch (this) {
|
||||
case JapaneseFont.droidSansJapanese:
|
||||
TextStyle(fontFamily: 'Droid Sans Japanese');
|
||||
case JapaneseFont.notoSansJapanese:
|
||||
return GoogleFonts.notoSansJp();
|
||||
case JapaneseFont.notoSerifJapanese:
|
||||
return GoogleFonts.notoSerifJp();
|
||||
case JapaneseFont.hinaMincho:
|
||||
return GoogleFonts.hinaMincho();
|
||||
case JapaneseFont.ibmPlexSansJP:
|
||||
return GoogleFonts.ibmPlexSansJp();
|
||||
case JapaneseFont.kleeOne:
|
||||
return GoogleFonts.kleeOne();
|
||||
case JapaneseFont.kosugi:
|
||||
return GoogleFonts.kosugi();
|
||||
case JapaneseFont.mPlus2:
|
||||
return GoogleFonts.mPlus2();
|
||||
case JapaneseFont.mPlusRounded1c:
|
||||
return GoogleFonts.mPlusRounded1c();
|
||||
case JapaneseFont.zenKurenaido:
|
||||
return GoogleFonts.zenTokyoZoo();
|
||||
case JapaneseFont.system:
|
||||
}
|
||||
|
||||
return const TextStyle();
|
||||
}
|
||||
|
||||
String get name => switch (this) {
|
||||
JapaneseFont.system => 'System Default',
|
||||
JapaneseFont.droidSansJapanese => 'Droid Sans Japanese',
|
||||
JapaneseFont.notoSansJapanese => 'Noto Sans Japanese',
|
||||
JapaneseFont.notoSerifJapanese => 'Noto Serif Japanese',
|
||||
JapaneseFont.hinaMincho => 'Hina Mincho',
|
||||
JapaneseFont.ibmPlexSansJP => 'IBM Plex Sans JP',
|
||||
JapaneseFont.kleeOne => 'Klee One',
|
||||
JapaneseFont.kosugi => 'Kosugi',
|
||||
JapaneseFont.mPlus2 => 'M PLUS 2',
|
||||
JapaneseFont.mPlusRounded1c => 'M PLUS Rounded 1c',
|
||||
JapaneseFont.zenKurenaido => 'Zen Kurenaido',
|
||||
TextStyle get textStyle => switch (this) {
|
||||
JapaneseFontChoice.droidSansJapanese => const TextStyle(
|
||||
fontFamily: 'Droid Sans Japanese',
|
||||
),
|
||||
JapaneseFontChoice.notoSansJapanese => GoogleFonts.notoSansJp(),
|
||||
JapaneseFontChoice.notoSerifJapanese => GoogleFonts.notoSerifJp(),
|
||||
JapaneseFontChoice.hinaMincho => GoogleFonts.hinaMincho(),
|
||||
JapaneseFontChoice.ibmPlexSansJP => GoogleFonts.ibmPlexSansJp(),
|
||||
JapaneseFontChoice.kleeOne => GoogleFonts.kleeOne(),
|
||||
JapaneseFontChoice.kosugi => GoogleFonts.kosugi(),
|
||||
JapaneseFontChoice.mPlus2 => GoogleFonts.mPlus2(),
|
||||
JapaneseFontChoice.mPlusRounded1c => GoogleFonts.mPlusRounded1c(),
|
||||
JapaneseFontChoice.zenKurenaido => GoogleFonts.zenTokyoZoo(),
|
||||
JapaneseFontChoice.system => const TextStyle(),
|
||||
};
|
||||
|
||||
static Map<JapaneseFontChoice, String> get _fontToName => {
|
||||
JapaneseFontChoice.system: 'System Default',
|
||||
JapaneseFontChoice.droidSansJapanese: 'Droid Sans Japanese',
|
||||
JapaneseFontChoice.notoSansJapanese: 'Noto Sans Japanese',
|
||||
JapaneseFontChoice.notoSerifJapanese: 'Noto Serif Japanese',
|
||||
JapaneseFontChoice.hinaMincho: 'Hina Mincho',
|
||||
JapaneseFontChoice.ibmPlexSansJP: 'IBM Plex Sans JP',
|
||||
JapaneseFontChoice.kleeOne: 'Klee One',
|
||||
JapaneseFontChoice.kosugi: 'Kosugi',
|
||||
JapaneseFontChoice.mPlus2: 'M PLUS 2',
|
||||
JapaneseFontChoice.mPlusRounded1c: 'M PLUS Rounded 1c',
|
||||
JapaneseFontChoice.zenKurenaido: 'Zen Kurenaido',
|
||||
};
|
||||
|
||||
static Map<String, JapaneseFontChoice> get _nameToFont =>
|
||||
_fontToName.map((final k, final v) => MapEntry(v, k));
|
||||
|
||||
String get name => _fontToName[this]!;
|
||||
|
||||
@override
|
||||
String serializeSetting(final JapaneseFontChoice value) =>
|
||||
_fontToName[value]!;
|
||||
|
||||
@override
|
||||
JapaneseFontChoice deserializeSetting(final String s) => _nameToFont[s]!;
|
||||
}
|
||||
|
||||
const Map<String, dynamic> _defaults = {
|
||||
'incognitoModeEnabled': false,
|
||||
'romajiEnabled': false,
|
||||
'darkThemeEnabled': false,
|
||||
'autoThemeEnabled': true,
|
||||
'japaneseFont': JapaneseFont.droidSansJapanese,
|
||||
'reduceKanjiDrawingBoardSize': false,
|
||||
};
|
||||
|
||||
bool _getSettingOrDefault(String settingName) =>
|
||||
_prefs.getBool(settingName) ?? _defaults[settingName];
|
||||
|
||||
bool get incognitoModeEnabled => _getSettingOrDefault('incognitoModeEnabled');
|
||||
bool get romajiEnabled => _getSettingOrDefault('romajiEnabled');
|
||||
bool get darkThemeEnabled => _getSettingOrDefault('darkThemeEnabled');
|
||||
bool get autoThemeEnabled => _getSettingOrDefault('autoThemeEnabled');
|
||||
bool get reduceKanjiDrawingBoardSize =>
|
||||
_getSettingOrDefault('reduceKanjiDrawingBoardSize');
|
||||
JapaneseFont get japaneseFont {
|
||||
final int? i = _prefs.getInt('japaneseFont');
|
||||
return (i != null) ? JapaneseFont.values[i] : _defaults['japaneseFont'];
|
||||
class JapaneseFont extends SharedPrefItem<JapaneseFontChoice> {
|
||||
JapaneseFont() : super('japaneseFont', JapaneseFontChoice.droidSansJapanese);
|
||||
}
|
||||
|
||||
set incognitoModeEnabled(bool b) => _prefs.setBool('incognitoModeEnabled', b);
|
||||
set romajiEnabled(bool b) => _prefs.setBool('romajiEnabled', b);
|
||||
set darkThemeEnabled(bool b) => _prefs.setBool('darkThemeEnabled', b);
|
||||
set autoThemeEnabled(bool b) => _prefs.setBool('autoThemeEnabled', b);
|
||||
set reduceKanjiDrawingBoardSize(bool b) =>
|
||||
_prefs.setBool('reduceKanjiDrawingBoardSize', b);
|
||||
set japaneseFont(JapaneseFont jf) => _prefs.setInt('japaneseFont', jf.index);
|
||||
final japaneseFont = JapaneseFont();
|
||||
|
||||
@@ -9,8 +9,8 @@ class YomiThemeExtension extends ThemeExtension<YomiThemeExtension> {
|
||||
|
||||
@override
|
||||
ThemeExtension<YomiThemeExtension> copyWith({
|
||||
Color? onyomiColor,
|
||||
Color? kunyomiColor,
|
||||
final Color? onyomiColor,
|
||||
final Color? kunyomiColor,
|
||||
}) => YomiThemeExtension(
|
||||
onyomiColor: onyomiColor ?? this.onyomiColor,
|
||||
kunyomiColor: kunyomiColor ?? this.kunyomiColor,
|
||||
@@ -18,8 +18,8 @@ class YomiThemeExtension extends ThemeExtension<YomiThemeExtension> {
|
||||
|
||||
@override
|
||||
ThemeExtension<YomiThemeExtension> lerp(
|
||||
ThemeExtension<YomiThemeExtension>? other,
|
||||
double t,
|
||||
final ThemeExtension<YomiThemeExtension>? other,
|
||||
final double t,
|
||||
) => other is! YomiThemeExtension
|
||||
? this
|
||||
: YomiThemeExtension(
|
||||
@@ -39,10 +39,13 @@ abstract class ForegroundBackgroundThemeExtension<T extends ThemeExtension<T>>
|
||||
});
|
||||
|
||||
@override
|
||||
ThemeExtension<T> copyWith({Color? foregroundColor, Color? backgroundColor});
|
||||
ThemeExtension<T> copyWith({
|
||||
final Color? foregroundColor,
|
||||
final Color? backgroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
ThemeExtension<T> lerp(ThemeExtension<T>? other, double t) =>
|
||||
ThemeExtension<T> lerp(final ThemeExtension<T>? other, final double t) =>
|
||||
other is! ForegroundBackgroundThemeExtension<T>
|
||||
? this as T
|
||||
: copyWith(
|
||||
@@ -69,8 +72,8 @@ class KanjiResultThemeExtension
|
||||
|
||||
@override
|
||||
ThemeExtension<KanjiResultThemeExtension> copyWith({
|
||||
Color? foregroundColor,
|
||||
Color? backgroundColor,
|
||||
final Color? foregroundColor,
|
||||
final Color? backgroundColor,
|
||||
}) => KanjiResultThemeExtension(
|
||||
foregroundColor: foregroundColor ?? this.foregroundColor,
|
||||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||
@@ -86,8 +89,8 @@ class MenuGreyLightThemeExtension
|
||||
|
||||
@override
|
||||
ThemeExtension<MenuGreyLightThemeExtension> copyWith({
|
||||
Color? foregroundColor,
|
||||
Color? backgroundColor,
|
||||
final Color? foregroundColor,
|
||||
final Color? backgroundColor,
|
||||
}) => MenuGreyLightThemeExtension(
|
||||
foregroundColor: foregroundColor ?? this.foregroundColor,
|
||||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||
@@ -103,8 +106,8 @@ class MenuGreyNormalThemeExtension
|
||||
|
||||
@override
|
||||
ThemeExtension<MenuGreyNormalThemeExtension> copyWith({
|
||||
Color? foregroundColor,
|
||||
Color? backgroundColor,
|
||||
final Color? foregroundColor,
|
||||
final Color? backgroundColor,
|
||||
}) => MenuGreyNormalThemeExtension(
|
||||
foregroundColor: foregroundColor ?? this.foregroundColor,
|
||||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||
@@ -120,8 +123,8 @@ class MenuGreyDarkThemeExtension
|
||||
|
||||
@override
|
||||
ThemeExtension<MenuGreyDarkThemeExtension> copyWith({
|
||||
Color? foregroundColor,
|
||||
Color? backgroundColor,
|
||||
final Color? foregroundColor,
|
||||
final Color? backgroundColor,
|
||||
}) => MenuGreyDarkThemeExtension(
|
||||
foregroundColor: foregroundColor ?? this.foregroundColor,
|
||||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||
@@ -140,7 +143,7 @@ const Color mugitenCommonForeground = Colors.white;
|
||||
const Color mugitenCommonBackground = Color(0xFF8ABC83);
|
||||
|
||||
/// Source: https://blog.usejournal.com/creating-a-custom-color-swatch-in-flutter-554bcdcb27f3
|
||||
MaterialColor createMaterialColor(Color color) {
|
||||
MaterialColor createMaterialColor(final Color color) {
|
||||
final List<double> strengths = [.05];
|
||||
final swatch = <int, Color>{};
|
||||
final int r = (color.r * 255.0).round().clamp(0, 255);
|
||||
@@ -294,7 +297,7 @@ enum AppThemeMode {
|
||||
AppThemeMode.system => 'system',
|
||||
};
|
||||
|
||||
factory AppThemeMode.fromId(String id) => switch (id) {
|
||||
factory AppThemeMode.fromId(final String id) => switch (id) {
|
||||
'light' => AppThemeMode.light,
|
||||
'dark' => AppThemeMode.dark,
|
||||
'system' => AppThemeMode.system,
|
||||
@@ -330,40 +333,42 @@ enum AppThemeMode {
|
||||
class ThemeController {
|
||||
final ValueNotifier<AppThemeMode> themeMode;
|
||||
|
||||
ThemeController(AppThemeMode mode) : themeMode = ValueNotifier(mode);
|
||||
ThemeController(final AppThemeMode mode) : themeMode = ValueNotifier(mode);
|
||||
|
||||
factory ThemeController.create() {
|
||||
AppThemeMode initialMode;
|
||||
if (autoThemeEnabled) {
|
||||
if (autoThemeEnabled.value) {
|
||||
initialMode = AppThemeMode.system;
|
||||
} else {
|
||||
initialMode = darkThemeEnabled ? AppThemeMode.dark : AppThemeMode.light;
|
||||
initialMode = darkThemeEnabled.value
|
||||
? AppThemeMode.dark
|
||||
: AppThemeMode.light;
|
||||
}
|
||||
|
||||
return ThemeController(initialMode);
|
||||
}
|
||||
|
||||
void setThemeMode(AppThemeMode mode) {
|
||||
void setThemeMode(final AppThemeMode mode) {
|
||||
if (mode != themeMode.value) {
|
||||
if (mode == AppThemeMode.system) {
|
||||
autoThemeEnabled = true;
|
||||
autoThemeEnabled.value = true;
|
||||
} else {
|
||||
autoThemeEnabled = false;
|
||||
darkThemeEnabled = mode == AppThemeMode.dark;
|
||||
autoThemeEnabled.value = false;
|
||||
darkThemeEnabled.value = mode == AppThemeMode.dark;
|
||||
}
|
||||
themeMode.value = mode;
|
||||
}
|
||||
}
|
||||
|
||||
void updateThemeMode() {
|
||||
if (autoThemeEnabled) {
|
||||
if (autoThemeEnabled.value) {
|
||||
final platformBrightness =
|
||||
WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
themeMode.value = platformBrightness == Brightness.dark
|
||||
? AppThemeMode.dark
|
||||
: AppThemeMode.light;
|
||||
} else {
|
||||
themeMode.value = darkThemeEnabled
|
||||
themeMode.value = darkThemeEnabled.value
|
||||
? AppThemeMode.dark
|
||||
: AppThemeMode.light;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ CREATE TABLE "Mugiten_LibraryListEntry" (
|
||||
"lastModified" INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
|
||||
"prevEntryJmdictEntryId" INTEGER,
|
||||
"prevEntryKanji" CHAR(1),
|
||||
|
||||
-- The entry cannot show up more than once in the same list
|
||||
PRIMARY KEY ("listName", "jmdictEntryId", "kanji"),
|
||||
|
||||
-- This is true for all other entries than the first one in the list.
|
||||
|
||||
118
pubspec.lock
118
pubspec.lock
@@ -34,13 +34,13 @@ packages:
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
version: "2.13.1"
|
||||
bisection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -141,18 +141,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csv
|
||||
sha256: bef2950f7a753eb82f894a2eabc3072e73cf21c17096296a5a992797e50b1d0d
|
||||
sha256: "2e0a52fb729f2faacd19c9c0c954ff450bba37aa8ab999410309e2342e7013a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.1.0"
|
||||
version: "8.0.0"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
version: "1.0.9"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -205,10 +205,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
|
||||
sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.3.10"
|
||||
version: "11.0.2"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -274,10 +274,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.33"
|
||||
version: "2.0.34"
|
||||
flutter_settings_ui:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -306,10 +306,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
|
||||
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
version: "2.2.4"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -364,10 +364,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
version: "1.0.2"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -413,10 +413,26 @@ packages:
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: "48f50628a1a79689b7eb1bf7272b08c91036d2b3"
|
||||
resolved-ref: "88278931018f289c4ee100b16c9ebbd4d5d44bef"
|
||||
url: "https://git.pvv.ntnu.no/mugiten/jadb.git"
|
||||
source: git
|
||||
version: "1.0.0"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -430,7 +446,7 @@ packages:
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: "89652c401fe6d89348d537bddb52a851d1dbfed5"
|
||||
resolved-ref: c447257af3d53bd7530aad022af8497d8c06b724
|
||||
url: "https://git.pvv.ntnu.no/mugiten/kanimaji-dart.git"
|
||||
source: git
|
||||
version: "0.0.1"
|
||||
@@ -486,18 +502,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: markdown
|
||||
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
|
||||
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.0"
|
||||
version: "7.3.1"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.18"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -534,10 +550,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.4"
|
||||
version: "0.17.6"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -554,14 +570,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
package_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.0"
|
||||
version: "9.0.1"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -598,10 +622,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.22"
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -694,18 +718,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sealed_languages
|
||||
sha256: bf7a479389196ae29a074a8451734a71d11282f5380bd72e07d339047b011a29
|
||||
sha256: "8e1d71f5bf0dc647bd9bc327089625d4d11576fe69df5e8b3a39f87ab1872568"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.1.0"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840"
|
||||
sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.1"
|
||||
version: "12.0.2"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -718,18 +742,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
|
||||
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.20"
|
||||
version: "2.4.23"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -750,10 +774,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.4.2"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -811,10 +835,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2+2"
|
||||
version: "2.4.2+3"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -851,10 +875,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: b7cf6b37667f6a921281797d2499ffc60fb878b161058d422064f0ddc78f6aa6
|
||||
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
version: "3.3.1"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -907,10 +931,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.9"
|
||||
version: "0.7.10"
|
||||
tuple:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -947,10 +971,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.28"
|
||||
version: "6.3.29"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1011,10 +1035,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
|
||||
sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.19"
|
||||
version: "1.1.21"
|
||||
vector_graphics_codec:
|
||||
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.5.0+1
|
||||
version: 0.6.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.0
|
||||
@@ -20,10 +20,11 @@ dependencies:
|
||||
|
||||
animated_size_and_fade: ^5.1.1
|
||||
archive: ^4.0.7
|
||||
async: ^2.13.0
|
||||
collection: ^1.19.0
|
||||
cupertino_icons: ^1.0.8
|
||||
division: ^0.9.0
|
||||
file_picker: ^10.2.0
|
||||
file_picker: ^11.0.2
|
||||
flutter_bloc: ^9.1.0
|
||||
flutter_markdown_plus: ^1.0.3
|
||||
flutter_settings_ui: ^3.0.1
|
||||
|
||||
@@ -39,9 +39,10 @@ fi
|
||||
declare -r PACKAGE_API_ENDPOINT="https://git.pvv.ntnu.no/api/packages/mugiten/generic/mugiten/$VERSION/mugiten.apk"
|
||||
|
||||
declare -r PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
||||
declare -r APK_PATH="$PROJECT_ROOT/build/app/outputs/flutter-apk/app-release.apk"
|
||||
|
||||
if [[ ! -f "$PROJECT_ROOT/build/app/outputs/apk/release/app-release.apk" ]]; then
|
||||
echo "$PROJECT_ROOT/build/app/outputs/apk/release/app-release.apk does not exist"
|
||||
if [[ ! -f "$APK_PATH" ]]; then
|
||||
echo "$APK_PATH does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -55,6 +56,6 @@ else
|
||||
-X PUT \
|
||||
--user "$GITEA_USER:$GITEA_TOKEN" \
|
||||
--progress-bar \
|
||||
--upload-file "$PROJECT_ROOT/build/app/outputs/apk/release/app-release.apk" \
|
||||
--upload-file "$APK_PATH" \
|
||||
"$PACKAGE_API_ENDPOINT"
|
||||
fi
|
||||
|
||||
@@ -1 +1,146 @@
|
||||
void main() {}
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../testutils.dart';
|
||||
|
||||
void main() {
|
||||
late final String libsqlitePath;
|
||||
late final String jadbPath;
|
||||
late Database database;
|
||||
|
||||
setUpAll(() {
|
||||
if (!Platform.environment.containsKey('LIBSQLITE_PATH')) {
|
||||
throw Exception('LIBSQLITE_PATH environment variable is not set.');
|
||||
}
|
||||
|
||||
if (!Platform.environment.containsKey('JADB_PATH')) {
|
||||
throw Exception('JADB_PATH environment variable is not set.');
|
||||
}
|
||||
|
||||
libsqlitePath = File(
|
||||
Platform.environment['LIBSQLITE_PATH']!,
|
||||
).resolveSymbolicLinksSync();
|
||||
jadbPath = File(
|
||||
Platform.environment['JADB_PATH']!,
|
||||
).resolveSymbolicLinksSync();
|
||||
});
|
||||
|
||||
// Setup sqflite_common_ffi for flutter test
|
||||
setUp(() async {
|
||||
database = await createDatabaseCopy(
|
||||
libsqlitePath: libsqlitePath,
|
||||
jadbPath: jadbPath,
|
||||
);
|
||||
|
||||
GetIt.instance.registerSingleton<Database>(database);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.close();
|
||||
|
||||
GetIt.instance.unregister<Database>();
|
||||
|
||||
final jadbCopyPath = database.path;
|
||||
|
||||
if (File(jadbCopyPath).existsSync()) {
|
||||
await File(jadbCopyPath).delete();
|
||||
}
|
||||
});
|
||||
group('Merge history timestamps', () {
|
||||
test('Merge non-overlapping timestamps', () async {
|
||||
final historyEntry = await createRandomHistoryEntries(
|
||||
db: database,
|
||||
count: 1,
|
||||
).then((final entries) => entries.first);
|
||||
historyEntry.timestamps.clear();
|
||||
historyEntry.timestamps.addAll([
|
||||
DateTime(2024, 1, 1),
|
||||
DateTime(2024, 2, 1),
|
||||
DateTime(2024, 3, 1),
|
||||
]);
|
||||
await database.historyEntryInsertEntry(historyEntry);
|
||||
|
||||
historyEntry.timestamps.clear();
|
||||
historyEntry.timestamps.addAll([
|
||||
DateTime(2024, 4, 1),
|
||||
DateTime(2024, 5, 1),
|
||||
DateTime(2024, 6, 1),
|
||||
]);
|
||||
await database.historyEntryInsertEntry(historyEntry);
|
||||
|
||||
final entries = await database.historyEntryGetAll();
|
||||
assert(
|
||||
entries.length == 1,
|
||||
'There should be only one history entry after merging, but got ${entries.length}',
|
||||
);
|
||||
final mergedTimestamps = entries.first.timestamps;
|
||||
assert(
|
||||
mergedTimestamps.length == 6,
|
||||
'Merged timestamps should have 6 entries, but got ${mergedTimestamps.length}',
|
||||
);
|
||||
});
|
||||
|
||||
test('Merge partially overlapping timestamps', () async {
|
||||
final historyEntry = await createRandomHistoryEntries(
|
||||
db: database,
|
||||
count: 1,
|
||||
).then((final entries) => entries.first);
|
||||
historyEntry.timestamps.clear();
|
||||
historyEntry.timestamps.addAll([
|
||||
DateTime(2024, 1, 1),
|
||||
DateTime(2024, 2, 1),
|
||||
DateTime(2024, 3, 1),
|
||||
]);
|
||||
await database.historyEntryInsertEntry(historyEntry);
|
||||
|
||||
historyEntry.timestamps.clear();
|
||||
historyEntry.timestamps.addAll([
|
||||
DateTime(2024, 2, 1),
|
||||
DateTime(2024, 3, 1),
|
||||
DateTime(2024, 4, 1),
|
||||
]);
|
||||
await database.historyEntryInsertEntry(historyEntry);
|
||||
|
||||
final entries = await database.historyEntryGetAll();
|
||||
assert(
|
||||
entries.length == 1,
|
||||
'There should be only one history entry after merging, but got ${entries.length}',
|
||||
);
|
||||
final mergedTimestamps = entries.first.timestamps;
|
||||
assert(
|
||||
mergedTimestamps.length == 4,
|
||||
'Merged timestamps should have 4 entries, but got ${mergedTimestamps.length}',
|
||||
);
|
||||
});
|
||||
|
||||
test('Merge fully overlapping timestamps', () async {
|
||||
final historyEntry = await createRandomHistoryEntries(
|
||||
db: database,
|
||||
count: 1,
|
||||
).then((final entries) => entries.first);
|
||||
historyEntry.timestamps.clear();
|
||||
historyEntry.timestamps.addAll([
|
||||
DateTime(2024, 1, 1),
|
||||
DateTime(2024, 2, 1),
|
||||
DateTime(2024, 3, 1),
|
||||
]);
|
||||
await database.historyEntryInsertEntry(historyEntry);
|
||||
await database.historyEntryInsertEntry(historyEntry);
|
||||
|
||||
final entries = await database.historyEntryGetAll();
|
||||
assert(
|
||||
entries.length == 1,
|
||||
'There should be only one history entry after merging, but got ${entries.length}',
|
||||
);
|
||||
final mergedTimestamps = entries.first.timestamps;
|
||||
assert(
|
||||
mergedTimestamps.length == 3,
|
||||
'Merged timestamps should have 3 entries, but got ${mergedTimestamps.length}',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,72 +1,34 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/database/database.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:sqlite3/open.dart';
|
||||
|
||||
Future<Database> createDatabaseCopy({
|
||||
required String libsqlitePath,
|
||||
required String jadbPath,
|
||||
}) async {
|
||||
final jadbFile = File(jadbPath);
|
||||
if (!jadbFile.existsSync()) {
|
||||
throw Exception('JADB_PATH does not exist: $jadbPath');
|
||||
import '../testutils.dart';
|
||||
|
||||
Future<void> insertTestData(final Database db) async {
|
||||
const listNames = ['Test Library 1', 'Test Library 2', 'Test Library 3'];
|
||||
|
||||
for (final listName in listNames) {
|
||||
await db.libraryListInsertList(listName);
|
||||
final exists = await db.libraryListExists(listName);
|
||||
assert(exists, 'Library list "$listName" does not exist after insertion');
|
||||
}
|
||||
|
||||
// Make a copy of jadbPath
|
||||
final randomSuffix = Random()
|
||||
.nextInt((pow(2, 32) - 1) as int)
|
||||
.toRadixString(16);
|
||||
final jadbCopyPath = jadbFile.parent.uri
|
||||
.resolve('jadb_copy_$randomSuffix.sqlite')
|
||||
.path;
|
||||
|
||||
await jadbFile.copy(jadbCopyPath);
|
||||
|
||||
print('Using database copy: $jadbCopyPath');
|
||||
|
||||
// Initialize FFI
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = createDatabaseFactoryFfi(
|
||||
ffiInit: () =>
|
||||
open.overrideForAll(() => DynamicLibrary.open(libsqlitePath)),
|
||||
);
|
||||
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
return await openAndMigrateDatabase(
|
||||
jadbCopyPath,
|
||||
await readMigrationsFromAssets(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertTestData(Database db) async {
|
||||
final libraryList1 = await db.libraryListInsertList('Test Library 1');
|
||||
assert(libraryList1 == true);
|
||||
|
||||
await db.libraryListInsertEntry(
|
||||
'Test Library 1',
|
||||
jmdictEntryId: null,
|
||||
kanji: '漢',
|
||||
);
|
||||
|
||||
await db.libraryListInsertEntry(
|
||||
'Test Library 1',
|
||||
jmdictEntryId: null,
|
||||
kanji: '字',
|
||||
);
|
||||
for (final kanji in ['漢', '字', '学', '習']) {
|
||||
await db.libraryListInsertEntry(
|
||||
'Test Library 1',
|
||||
jmdictEntryId: null,
|
||||
kanji: kanji,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late final String libsqlitePath;
|
||||
late final String jadbPath;
|
||||
late final Database database;
|
||||
late Database database;
|
||||
|
||||
setUpAll(() {
|
||||
if (!Platform.environment.containsKey('LIBSQLITE_PATH')) {
|
||||
@@ -93,8 +55,6 @@ void main() {
|
||||
);
|
||||
|
||||
GetIt.instance.registerSingleton<Database>(database);
|
||||
|
||||
await insertTestData(database);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@@ -109,7 +69,395 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('Database is open', () async {
|
||||
test('Database is open', () {
|
||||
expect(database.isOpen, isTrue);
|
||||
});
|
||||
|
||||
test('Can insert and query library list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final libraryExists = await database.libraryListExists('Test Library 1');
|
||||
expect(libraryExists, isTrue);
|
||||
|
||||
final libraryLists = await database.libraryListGetLists();
|
||||
expect(libraryLists.length, 4);
|
||||
expect(libraryLists[0].name, 'favourites');
|
||||
expect(libraryLists[1].name, 'Test Library 1');
|
||||
expect(libraryLists[2].name, 'Test Library 2');
|
||||
expect(libraryLists[3].name, 'Test Library 3');
|
||||
|
||||
final listPage = (await database.libraryListGetListEntries(
|
||||
'Test Library 1',
|
||||
))!;
|
||||
expect(listPage.entries.length, 4);
|
||||
expect(listPage.entries[0].kanji, '漢');
|
||||
expect(listPage.entries[1].kanji, '字');
|
||||
expect(listPage.entries[2].kanji, '学');
|
||||
expect(listPage.entries[3].kanji, '習');
|
||||
});
|
||||
|
||||
group('Library list CRUD', () {
|
||||
test('Can create another list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
await database.libraryListInsertList('Test Library 4');
|
||||
|
||||
final libraryExists = await database.libraryListExists('Test Library 4');
|
||||
expect(libraryExists, isTrue);
|
||||
|
||||
final libraryLists = await database.libraryListGetLists();
|
||||
expect(libraryLists.length, 5);
|
||||
});
|
||||
|
||||
test('Can delete middle list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
await database.libraryListDeleteList('Test Library 2');
|
||||
|
||||
final libraryExists = await database.libraryListExists('Test Library 2');
|
||||
expect(libraryExists, isFalse);
|
||||
|
||||
final libraryLists = await database.libraryListGetLists();
|
||||
expect(libraryLists.length, 3);
|
||||
});
|
||||
test('Can delete last list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
await database.libraryListDeleteList('Test Library 3');
|
||||
|
||||
final libraryExists = await database.libraryListExists('Test Library 3');
|
||||
expect(libraryExists, isFalse);
|
||||
|
||||
final libraryLists = await database.libraryListGetLists();
|
||||
expect(libraryLists.length, 3);
|
||||
});
|
||||
|
||||
test('Can rename middle list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
await database.libraryListRenameList(
|
||||
'Test Library 2',
|
||||
'Renamed Test Library 2',
|
||||
);
|
||||
|
||||
final libraryExists = await database.libraryListExists(
|
||||
'Renamed Test Library 2',
|
||||
);
|
||||
expect(libraryExists, isTrue);
|
||||
|
||||
final libraryLists = await database.libraryListGetLists();
|
||||
expect(libraryLists.length, 4);
|
||||
expect(libraryLists[2].name, 'Renamed Test Library 2');
|
||||
});
|
||||
test('Can rename last list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
await database.libraryListRenameList(
|
||||
'Test Library 3',
|
||||
'Renamed Test Library 3',
|
||||
);
|
||||
|
||||
final libraryExists = await database.libraryListExists(
|
||||
'Renamed Test Library 3',
|
||||
);
|
||||
expect(libraryExists, isTrue);
|
||||
|
||||
final libraryLists = await database.libraryListGetLists();
|
||||
expect(libraryLists.length, 4);
|
||||
expect(libraryLists[3].name, 'Renamed Test Library 3');
|
||||
});
|
||||
|
||||
test('Can not delete favourites list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
try {
|
||||
await database.libraryListDeleteList('favourites');
|
||||
fail('Expected an exception when trying to delete the favourites list');
|
||||
} catch (e) {
|
||||
expect(e.toString(), contains('Cannot delete the "favourites" list'));
|
||||
}
|
||||
|
||||
final libraryExists = await database.libraryListExists('favourites');
|
||||
expect(libraryExists, isTrue);
|
||||
});
|
||||
test('Can not rename favourites list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
try {
|
||||
await database.libraryListRenameList(
|
||||
'favourites',
|
||||
'Renamed Favourites',
|
||||
);
|
||||
fail('Expected an exception when trying to rename the favourites list');
|
||||
} catch (e) {
|
||||
expect(e.toString(), contains('Cannot rename the "favourites" list'));
|
||||
}
|
||||
|
||||
final libraryExists = await database.libraryListExists('favourites');
|
||||
expect(libraryExists, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Library list insert entries', () {
|
||||
test('Can insert entry into list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListInsertEntry(
|
||||
'Test Library 1',
|
||||
jmdictEntryId: null,
|
||||
kanji: '新',
|
||||
);
|
||||
expect(result, isTrue);
|
||||
|
||||
final listPage = (await database.libraryListGetListEntries(
|
||||
'Test Library 1',
|
||||
))!;
|
||||
expect(listPage.entries.length, 5);
|
||||
expect(listPage.entries[4].kanji, '新');
|
||||
});
|
||||
|
||||
test('Can insert entry into list at specific position', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListInsertEntry(
|
||||
'Test Library 1',
|
||||
jmdictEntryId: null,
|
||||
kanji: '新',
|
||||
position: 2,
|
||||
);
|
||||
expect(result, isTrue);
|
||||
|
||||
final listPage = (await database.libraryListGetListEntries(
|
||||
'Test Library 1',
|
||||
))!;
|
||||
expect(listPage.entries.length, 5);
|
||||
expect(listPage.entries[2].kanji, '新');
|
||||
});
|
||||
|
||||
test('Cannot insert entry into non-existent list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListInsertEntry(
|
||||
'Non-existent List',
|
||||
jmdictEntryId: null,
|
||||
kanji: '新',
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test('Cannot insert duplicate entry into list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListInsertEntry(
|
||||
'Test Library 1',
|
||||
jmdictEntryId: null,
|
||||
kanji: '漢',
|
||||
);
|
||||
expect(result, isFalse);
|
||||
|
||||
final listPage = (await database.libraryListGetListEntries(
|
||||
'Test Library 1',
|
||||
))!;
|
||||
expect(listPage.entries.length, 4);
|
||||
});
|
||||
|
||||
// test('Can bulk insert entries into list', () async {
|
||||
// await insertTestData(database);
|
||||
|
||||
// final entriesToInsert = [
|
||||
// LibraryListEntry(jmdictEntryId: null, kanji: '古'),
|
||||
// LibraryListEntry(jmdictEntryId: null, kanji: '高'),
|
||||
// ];
|
||||
|
||||
// await database.libraryListInsertEntries(
|
||||
// 'Test Library 1',
|
||||
// entriesToInsert,
|
||||
// );
|
||||
|
||||
// final listPage = (await database.libraryListGetListEntries(
|
||||
// 'Test Library 1',
|
||||
// ))!;
|
||||
// expect(listPage.entries.length, 6);
|
||||
// expect(listPage.entries[4].kanji, '古');
|
||||
// expect(listPage.entries[5].kanji, '高');
|
||||
// });
|
||||
|
||||
// test('Bulk insert does not insert duplicates', () async {
|
||||
// await insertTestData(database);
|
||||
|
||||
// final entriesToInsert = [
|
||||
// LibraryListEntry(jmdictEntryId: null, kanji: '漢'),
|
||||
// LibraryListEntry(jmdictEntryId: null, kanji: '新'),
|
||||
// ];
|
||||
|
||||
// await database.libraryListInsertEntries(
|
||||
// 'Test Library 1',
|
||||
// entriesToInsert,
|
||||
// );
|
||||
|
||||
// final listPage = (await database.libraryListGetListEntries(
|
||||
// 'Test Library 1',
|
||||
// ))!;
|
||||
// expect(listPage.entries.length, 5);
|
||||
// expect(listPage.entries[4].kanji, '新');
|
||||
// });
|
||||
});
|
||||
|
||||
group('Library list delete entries', () {
|
||||
test('Can delete entry from list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListDeleteEntry(
|
||||
'Test Library 1',
|
||||
kanji: '学',
|
||||
);
|
||||
expect(result, isTrue);
|
||||
|
||||
final listPage = (await database.libraryListGetListEntries(
|
||||
'Test Library 1',
|
||||
))!;
|
||||
expect(listPage.entries.length, 3);
|
||||
expect(listPage.entries.any((final e) => e.kanji == '学'), isFalse);
|
||||
});
|
||||
|
||||
test('Cannot delete non-existent entry from list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListDeleteEntry(
|
||||
'Test Library 1',
|
||||
kanji: '非',
|
||||
);
|
||||
expect(result, isFalse);
|
||||
|
||||
final listPage = (await database.libraryListGetListEntries(
|
||||
'Test Library 1',
|
||||
))!;
|
||||
expect(listPage.entries.length, 4);
|
||||
});
|
||||
|
||||
test('Cannot delete entry from non-existent list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListDeleteEntry(
|
||||
'Non-existent List',
|
||||
kanji: '学',
|
||||
);
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test('Delete by position', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListDeleteEntryByPosition(
|
||||
'Test Library 1',
|
||||
1,
|
||||
);
|
||||
expect(result, isTrue);
|
||||
|
||||
final listPage = (await database.libraryListGetListEntries(
|
||||
'Test Library 1',
|
||||
))!;
|
||||
expect(listPage.entries.length, 3);
|
||||
expect(listPage.entries[0].kanji, '漢');
|
||||
expect(listPage.entries[1].kanji, '学');
|
||||
expect(listPage.entries[2].kanji, '習');
|
||||
});
|
||||
|
||||
test('Cannot delete entry by position from non-existent list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListDeleteEntryByPosition(
|
||||
'Non-existent List',
|
||||
0,
|
||||
);
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test('Cannot delete entry by invalid position', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListDeleteEntryByPosition(
|
||||
'Test Library 1',
|
||||
10,
|
||||
);
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test('Cannot delete entry by negative position', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
try {
|
||||
await database.libraryListDeleteEntryByPosition('Test Library 1', -1);
|
||||
} catch (e) {
|
||||
expect(
|
||||
e.toString(),
|
||||
contains('Position must be a non-negative integer'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Delete all entries from list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListDeleteAllEntries(
|
||||
'Test Library 1',
|
||||
);
|
||||
expect(result, isTrue);
|
||||
|
||||
final listPage = (await database.libraryListGetListEntries(
|
||||
'Test Library 1',
|
||||
))!;
|
||||
expect(listPage.entries.length, 0);
|
||||
});
|
||||
|
||||
test('Cannot delete all entries from non-existent list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListDeleteAllEntries(
|
||||
'Non-existent List',
|
||||
);
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test('Delete list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListDeleteList('Test Library 1');
|
||||
expect(result, isTrue);
|
||||
|
||||
final libraryExists = await database.libraryListExists('Test Library 1');
|
||||
expect(libraryExists, isFalse);
|
||||
});
|
||||
|
||||
test('Cannot delete non-existent list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListDeleteList('Non-existent List');
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test('Cannot delete favourites list', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
try {
|
||||
await database.libraryListDeleteList('favourites');
|
||||
} catch (e) {
|
||||
expect(e.toString(), contains('Cannot delete the "favourites" list'));
|
||||
}
|
||||
});
|
||||
|
||||
test('Cannot delete list with items', () async {
|
||||
await insertTestData(database);
|
||||
|
||||
final result = await database.libraryListDeleteList(
|
||||
'Test Library 1',
|
||||
notEmptyOk: false,
|
||||
);
|
||||
expect(result, isFalse);
|
||||
|
||||
final libraryExists = await database.libraryListExists('Test Library 1');
|
||||
expect(libraryExists, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
165
test/services/archive/v1/archive_history_test.dart
Normal file
165
test/services/archive/v1/archive_history_test.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/database/history/table_names.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/services/archive/v1/format.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../../testutils.dart';
|
||||
|
||||
void main() {
|
||||
late final String libsqlitePath;
|
||||
late final String jadbPath;
|
||||
late Directory tmpdir;
|
||||
late Database database;
|
||||
|
||||
setUpAll(() {
|
||||
if (!Platform.environment.containsKey('LIBSQLITE_PATH')) {
|
||||
throw Exception('LIBSQLITE_PATH environment variable is not set.');
|
||||
}
|
||||
|
||||
if (!Platform.environment.containsKey('JADB_PATH')) {
|
||||
throw Exception('JADB_PATH environment variable is not set.');
|
||||
}
|
||||
|
||||
libsqlitePath = File(
|
||||
Platform.environment['LIBSQLITE_PATH']!,
|
||||
).resolveSymbolicLinksSync();
|
||||
jadbPath = File(
|
||||
Platform.environment['JADB_PATH']!,
|
||||
).resolveSymbolicLinksSync();
|
||||
});
|
||||
|
||||
// Setup sqflite_common_ffi for flutter test
|
||||
setUp(() async {
|
||||
database = await createDatabaseCopy(
|
||||
libsqlitePath: libsqlitePath,
|
||||
jadbPath: jadbPath,
|
||||
);
|
||||
|
||||
GetIt.instance.registerSingleton<Database>(database);
|
||||
|
||||
tmpdir = await test_tmpdir();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.close();
|
||||
|
||||
GetIt.instance.unregister<Database>();
|
||||
|
||||
final jadbCopyPath = database.path;
|
||||
|
||||
if (File(jadbCopyPath).existsSync()) {
|
||||
await File(jadbCopyPath).delete();
|
||||
}
|
||||
|
||||
if (tmpdir.existsSync()) {
|
||||
await tmpdir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
group('Export-import history', () {
|
||||
test('Full reimport', () async {
|
||||
final historyEntries = await createRandomHistoryEntries(
|
||||
db: database,
|
||||
count: 300,
|
||||
);
|
||||
await database.historyEntryInsertEntries(historyEntries);
|
||||
final historyEntryAmount = await database.historyEntryAmount();
|
||||
assert(
|
||||
historyEntryAmount == historyEntries.length,
|
||||
'History entry amount should be ${historyEntries.length}, but got $historyEntryAmount',
|
||||
);
|
||||
|
||||
await exportHistoryTo(database, tmpdir);
|
||||
assert(
|
||||
tmpdir.historyFile.existsSync(),
|
||||
'History file should exist at ${tmpdir.historyFile.path}',
|
||||
);
|
||||
|
||||
await database.delete(HistoryTableNames.historyEntry);
|
||||
final int emptyHistoryEntryAmount = await database.historyEntryAmount();
|
||||
assert(
|
||||
emptyHistoryEntryAmount == 0,
|
||||
'History entry amount should be 0 after deletion, but got $emptyHistoryEntryAmount',
|
||||
);
|
||||
|
||||
await importHistoryFrom(database, tmpdir.historyFile);
|
||||
final int importedHistoryEntryAmount = await database
|
||||
.historyEntryAmount();
|
||||
assert(
|
||||
importedHistoryEntryAmount == historyEntries.length,
|
||||
'History entry amount should be ${historyEntries.length} after import, but got $importedHistoryEntryAmount',
|
||||
);
|
||||
});
|
||||
|
||||
test('Partially delete, idempotent reimport', () async {
|
||||
final historyEntries = await createRandomHistoryEntries(
|
||||
db: database,
|
||||
count: 300,
|
||||
);
|
||||
await database.historyEntryInsertEntries(historyEntries);
|
||||
final historyEntryAmount = await database.historyEntryAmount();
|
||||
assert(
|
||||
historyEntryAmount == historyEntries.length,
|
||||
'History entry amount should be ${historyEntries.length}, but got $historyEntryAmount',
|
||||
);
|
||||
|
||||
await exportHistoryTo(database, tmpdir);
|
||||
assert(
|
||||
tmpdir.historyFile.existsSync(),
|
||||
'History file should exist at ${tmpdir.historyFile.path}',
|
||||
);
|
||||
|
||||
final List<HistoryEntry> entriesToDelete = historyEntries.sublist(
|
||||
0,
|
||||
historyEntries.length ~/ 2,
|
||||
);
|
||||
final b = database.batch()
|
||||
..delete(
|
||||
HistoryTableNames.historyEntry,
|
||||
where:
|
||||
'id IN (${List.filled(entriesToDelete.length, '?').join(',')})',
|
||||
whereArgs: entriesToDelete.map((final e) => e.id).toList(),
|
||||
);
|
||||
await b.commit(noResult: true);
|
||||
|
||||
await importHistoryFrom(database, tmpdir.historyFile);
|
||||
final int importedHistoryEntryAmount = await database
|
||||
.historyEntryAmount();
|
||||
assert(
|
||||
importedHistoryEntryAmount == historyEntries.length,
|
||||
'History entry amount should be ${historyEntries.length} after import, but got $importedHistoryEntryAmount',
|
||||
);
|
||||
});
|
||||
|
||||
test('Do not delete, idempotent reimport', () async {
|
||||
final historyEntries = await createRandomHistoryEntries(
|
||||
db: database,
|
||||
count: 300,
|
||||
);
|
||||
await database.historyEntryInsertEntries(historyEntries);
|
||||
final historyEntryAmount = await database.historyEntryAmount();
|
||||
assert(
|
||||
historyEntryAmount == historyEntries.length,
|
||||
'History entry amount should be ${historyEntries.length}, but got $historyEntryAmount',
|
||||
);
|
||||
|
||||
await exportHistoryTo(database, tmpdir);
|
||||
assert(
|
||||
tmpdir.historyFile.existsSync(),
|
||||
'History file should exist at ${tmpdir.historyFile.path}',
|
||||
);
|
||||
|
||||
await importHistoryFrom(database, tmpdir.historyFile);
|
||||
final int importedHistoryEntryAmount = await database
|
||||
.historyEntryAmount();
|
||||
assert(
|
||||
importedHistoryEntryAmount == historyEntries.length,
|
||||
'History entry amount should be ${historyEntries.length} after import, but got $importedHistoryEntryAmount',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
149
test/services/archive/v1/archive_librarylists_test.dart
Normal file
149
test/services/archive/v1/archive_librarylists_test.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:mugiten/services/archive/v1/format.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../../testutils.dart';
|
||||
|
||||
void main() {
|
||||
late final String libsqlitePath;
|
||||
late final String jadbPath;
|
||||
late Directory tmpdir;
|
||||
late Database database;
|
||||
|
||||
setUpAll(() {
|
||||
if (!Platform.environment.containsKey('LIBSQLITE_PATH')) {
|
||||
throw Exception('LIBSQLITE_PATH environment variable is not set.');
|
||||
}
|
||||
|
||||
if (!Platform.environment.containsKey('JADB_PATH')) {
|
||||
throw Exception('JADB_PATH environment variable is not set.');
|
||||
}
|
||||
|
||||
libsqlitePath = File(
|
||||
Platform.environment['LIBSQLITE_PATH']!,
|
||||
).resolveSymbolicLinksSync();
|
||||
jadbPath = File(
|
||||
Platform.environment['JADB_PATH']!,
|
||||
).resolveSymbolicLinksSync();
|
||||
});
|
||||
|
||||
// Setup sqflite_common_ffi for flutter test
|
||||
setUp(() async {
|
||||
database = await createDatabaseCopy(
|
||||
libsqlitePath: libsqlitePath,
|
||||
jadbPath: jadbPath,
|
||||
);
|
||||
|
||||
GetIt.instance.registerSingleton<Database>(database);
|
||||
|
||||
tmpdir = await test_tmpdir();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.close();
|
||||
|
||||
GetIt.instance.unregister<Database>();
|
||||
|
||||
final jadbCopyPath = database.path;
|
||||
|
||||
if (File(jadbCopyPath).existsSync()) {
|
||||
await File(jadbCopyPath).delete();
|
||||
}
|
||||
|
||||
if (tmpdir.existsSync()) {
|
||||
await tmpdir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('Full reimport', () async {
|
||||
final libraryEntries1 = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 150,
|
||||
jmdictEntryCount: 150,
|
||||
);
|
||||
|
||||
final libraryEntries2 = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 150,
|
||||
jmdictEntryCount: 300,
|
||||
);
|
||||
|
||||
final libraryEntries3 = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 150,
|
||||
jmdictEntryCount: 150,
|
||||
);
|
||||
|
||||
await database.libraryListInsertList('Test List 1');
|
||||
await database.libraryListInsertList('Test List 2');
|
||||
await database.libraryListInsertList('Test List 3');
|
||||
|
||||
await database.libraryListInsertEntries('Test List 1', libraryEntries1);
|
||||
await database.libraryListInsertEntries('Test List 2', libraryEntries2);
|
||||
await database.libraryListInsertEntries('Test List 3', libraryEntries3);
|
||||
|
||||
final listCount1 = await database.libraryListAmount();
|
||||
assert(
|
||||
listCount1 == 4,
|
||||
'Library list amount should be 4 after insertion, but got $listCount1',
|
||||
);
|
||||
|
||||
tmpdir.libraryDir.createSync();
|
||||
await exportLibraryListsTo(database, tmpdir);
|
||||
|
||||
await database.libraryListDeleteList('Test List 1');
|
||||
await database.libraryListDeleteList('Test List 2');
|
||||
await database.libraryListDeleteList('Test List 3');
|
||||
|
||||
final listCount2 = await database.libraryListAmount();
|
||||
assert(
|
||||
listCount2 == 1,
|
||||
'Library list amount should be 1 after deletion, but got $listCount2',
|
||||
);
|
||||
|
||||
await importLibraryListsFrom(database, tmpdir);
|
||||
|
||||
final listCount3 = await database.libraryListAmount();
|
||||
assert(
|
||||
listCount3 == 4,
|
||||
'Library list amount should be 4 after import, but got $listCount3',
|
||||
);
|
||||
});
|
||||
|
||||
test('Full reimport favourites', () async {
|
||||
final libraryEntries = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 150,
|
||||
jmdictEntryCount: 150,
|
||||
);
|
||||
|
||||
await database.libraryListInsertEntries('favourites', libraryEntries);
|
||||
final favourites = (await database.libraryListGetLists()).first;
|
||||
assert(
|
||||
favourites.totalCount == libraryEntries.length,
|
||||
'Favourites entry count should be ${libraryEntries.length} after insertion, but got ${favourites.totalCount}',
|
||||
);
|
||||
|
||||
tmpdir.libraryDir.createSync();
|
||||
await exportLibraryListsTo(database, tmpdir);
|
||||
|
||||
await database.libraryListDeleteAllEntries('favourites');
|
||||
final emptyFavourites = (await database.libraryListGetLists()).first;
|
||||
assert(
|
||||
emptyFavourites.totalCount == 0,
|
||||
'Favourites entry count should be 0 after deletion, but got ${emptyFavourites.totalCount}',
|
||||
);
|
||||
|
||||
await importLibraryListsFrom(database, tmpdir);
|
||||
|
||||
final importedFavourites = (await database.libraryListGetLists()).first;
|
||||
assert(
|
||||
importedFavourites.totalCount == libraryEntries.length,
|
||||
'Favourites entry count should be ${libraryEntries.length} after import, but got ${importedFavourites.totalCount}',
|
||||
);
|
||||
});
|
||||
}
|
||||
136
test/services/archive/v1/archive_zip_test.dart
Normal file
136
test/services/archive/v1/archive_zip_test.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/database/history/table_names.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:mugiten/services/archive/v1/format.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../../testutils.dart';
|
||||
|
||||
void main() {
|
||||
late final String libsqlitePath;
|
||||
late final String jadbPath;
|
||||
late Directory tmpdir;
|
||||
late Database database;
|
||||
|
||||
setUpAll(() {
|
||||
if (!Platform.environment.containsKey('LIBSQLITE_PATH')) {
|
||||
throw Exception('LIBSQLITE_PATH environment variable is not set.');
|
||||
}
|
||||
|
||||
if (!Platform.environment.containsKey('JADB_PATH')) {
|
||||
throw Exception('JADB_PATH environment variable is not set.');
|
||||
}
|
||||
|
||||
libsqlitePath = File(
|
||||
Platform.environment['LIBSQLITE_PATH']!,
|
||||
).resolveSymbolicLinksSync();
|
||||
jadbPath = File(
|
||||
Platform.environment['JADB_PATH']!,
|
||||
).resolveSymbolicLinksSync();
|
||||
});
|
||||
|
||||
// Setup sqflite_common_ffi for flutter test
|
||||
setUp(() async {
|
||||
database = await createDatabaseCopy(
|
||||
libsqlitePath: libsqlitePath,
|
||||
jadbPath: jadbPath,
|
||||
);
|
||||
|
||||
GetIt.instance.registerSingleton<Database>(database);
|
||||
|
||||
tmpdir = await test_tmpdir();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.close();
|
||||
|
||||
GetIt.instance.unregister<Database>();
|
||||
|
||||
final jadbCopyPath = database.path;
|
||||
|
||||
if (File(jadbCopyPath).existsSync()) {
|
||||
await File(jadbCopyPath).delete();
|
||||
}
|
||||
|
||||
if (tmpdir.existsSync()) {
|
||||
await tmpdir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('Archive V1 export to and import from zip archive', () async {
|
||||
// Insert data
|
||||
final historyEntries = await createRandomHistoryEntries(
|
||||
db: database,
|
||||
count: 300,
|
||||
);
|
||||
await database.historyEntryInsertEntries(historyEntries);
|
||||
|
||||
final libraryEntriesF = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 400,
|
||||
jmdictEntryCount: 440,
|
||||
);
|
||||
final libraryEntries1 = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 150,
|
||||
jmdictEntryCount: 150,
|
||||
);
|
||||
final libraryEntries2 = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 150,
|
||||
jmdictEntryCount: 300,
|
||||
);
|
||||
final libraryEntries3 = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 150,
|
||||
jmdictEntryCount: 150,
|
||||
);
|
||||
|
||||
await database.libraryListInsertList('Test List 1');
|
||||
await database.libraryListInsertList('Test List 2');
|
||||
await database.libraryListInsertList('Test List 3');
|
||||
|
||||
await database.libraryListInsertEntries('favourites', libraryEntriesF);
|
||||
await database.libraryListInsertEntries('Test List 1', libraryEntries1);
|
||||
await database.libraryListInsertEntries('Test List 2', libraryEntries2);
|
||||
await database.libraryListInsertEntries('Test List 3', libraryEntries3);
|
||||
|
||||
// Export to zip
|
||||
final zipFile = await exportData(database);
|
||||
|
||||
// Delete all data
|
||||
await database.delete(HistoryTableNames.historyEntry);
|
||||
await database.libraryListDeleteAllEntries('favourites');
|
||||
await database.libraryListDeleteList('Test List 1');
|
||||
await database.libraryListDeleteList('Test List 2');
|
||||
await database.libraryListDeleteList('Test List 3');
|
||||
|
||||
// Import from zip
|
||||
await importData(database, zipFile);
|
||||
|
||||
// Verify data
|
||||
final int historyEntryAmount = await database.historyEntryAmount();
|
||||
assert(
|
||||
historyEntryAmount == historyEntries.length,
|
||||
'History entry amount should be ${historyEntries.length} after import, but got $historyEntryAmount',
|
||||
);
|
||||
|
||||
final favourites = (await database.libraryListGetLists()).firstWhere(
|
||||
(final list) => list.name == 'favourites',
|
||||
);
|
||||
assert(
|
||||
favourites.totalCount == libraryEntriesF.length,
|
||||
'Favourites entry count should be ${libraryEntriesF.length} after import, but got ${favourites.totalCount}',
|
||||
);
|
||||
|
||||
final listCount = await database.libraryListAmount();
|
||||
assert(
|
||||
listCount == 4,
|
||||
'Library list amount should be 4 after import, but got $listCount',
|
||||
);
|
||||
});
|
||||
}
|
||||
161
test/services/archive/v2/archive_history_test.dart
Normal file
161
test/services/archive/v2/archive_history_test.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/database/history/table_names.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:mugiten/services/archive/v2/format.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../../testutils.dart';
|
||||
|
||||
Future<void> insertTestData(final DatabaseExecutor db) async {
|
||||
db
|
||||
..libraryListInsertList('Test List 1')
|
||||
..libraryListInsertList('Test List 2');
|
||||
}
|
||||
|
||||
void main() {
|
||||
late final String libsqlitePath;
|
||||
late final String jadbPath;
|
||||
late Directory tmpdir;
|
||||
late Database database;
|
||||
|
||||
setUpAll(() {
|
||||
if (!Platform.environment.containsKey('LIBSQLITE_PATH')) {
|
||||
throw Exception('LIBSQLITE_PATH environment variable is not set.');
|
||||
}
|
||||
|
||||
if (!Platform.environment.containsKey('JADB_PATH')) {
|
||||
throw Exception('JADB_PATH environment variable is not set.');
|
||||
}
|
||||
|
||||
libsqlitePath = File(
|
||||
Platform.environment['LIBSQLITE_PATH']!,
|
||||
).resolveSymbolicLinksSync();
|
||||
jadbPath = File(
|
||||
Platform.environment['JADB_PATH']!,
|
||||
).resolveSymbolicLinksSync();
|
||||
});
|
||||
|
||||
// Setup sqflite_common_ffi for flutter test
|
||||
setUp(() async {
|
||||
database = await createDatabaseCopy(
|
||||
libsqlitePath: libsqlitePath,
|
||||
jadbPath: jadbPath,
|
||||
);
|
||||
|
||||
GetIt.instance.registerSingleton<Database>(database);
|
||||
|
||||
tmpdir = await test_tmpdir();
|
||||
tmpdir.historyDir.createSync();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.close();
|
||||
|
||||
GetIt.instance.unregister<Database>();
|
||||
|
||||
final jadbCopyPath = database.path;
|
||||
|
||||
if (File(jadbCopyPath).existsSync()) {
|
||||
await File(jadbCopyPath).delete();
|
||||
}
|
||||
|
||||
if (tmpdir.existsSync()) {
|
||||
await tmpdir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
group('Export-import history', () {
|
||||
test('Full reimport', () async {
|
||||
final historyEntries = await createRandomHistoryEntries(
|
||||
db: database,
|
||||
count: 300,
|
||||
);
|
||||
await database.historyEntryInsertEntries(historyEntries);
|
||||
final historyEntryAmount = await database.historyEntryAmount();
|
||||
assert(
|
||||
historyEntryAmount == historyEntries.length,
|
||||
'History entry amount should be ${historyEntries.length}, but got $historyEntryAmount',
|
||||
);
|
||||
|
||||
await exportHistory(database, tmpdir).drain();
|
||||
|
||||
await database.delete(HistoryTableNames.historyEntry);
|
||||
final int emptyHistoryEntryAmount = await database.historyEntryAmount();
|
||||
assert(
|
||||
emptyHistoryEntryAmount == 0,
|
||||
'History entry amount should be 0 after deletion, but got $emptyHistoryEntryAmount',
|
||||
);
|
||||
|
||||
await importHistory(database, tmpdir).drain();
|
||||
final int importedHistoryEntryAmount = await database
|
||||
.historyEntryAmount();
|
||||
assert(
|
||||
importedHistoryEntryAmount == historyEntries.length,
|
||||
'History entry amount should be ${historyEntries.length} after import, but got $importedHistoryEntryAmount',
|
||||
);
|
||||
});
|
||||
|
||||
test('Partially delete, idempotent reimport', () async {
|
||||
final historyEntries = await createRandomHistoryEntries(
|
||||
db: database,
|
||||
count: 300,
|
||||
);
|
||||
await database.historyEntryInsertEntries(historyEntries);
|
||||
final historyEntryAmount = await database.historyEntryAmount();
|
||||
assert(
|
||||
historyEntryAmount == historyEntries.length,
|
||||
'History entry amount should be ${historyEntries.length}, but got $historyEntryAmount',
|
||||
);
|
||||
|
||||
await exportHistory(database, tmpdir).drain();
|
||||
|
||||
final List<HistoryEntry> entriesToDelete = historyEntries.sublist(
|
||||
0,
|
||||
historyEntries.length ~/ 2,
|
||||
);
|
||||
final b = database.batch()
|
||||
..delete(
|
||||
HistoryTableNames.historyEntry,
|
||||
where:
|
||||
'id IN (${List.filled(entriesToDelete.length, '?').join(',')})',
|
||||
whereArgs: entriesToDelete.map((final e) => e.id).toList(),
|
||||
);
|
||||
await b.commit(noResult: true);
|
||||
|
||||
await importHistory(database, tmpdir).drain();
|
||||
final int importedHistoryEntryAmount = await database
|
||||
.historyEntryAmount();
|
||||
assert(
|
||||
importedHistoryEntryAmount == historyEntries.length,
|
||||
'History entry amount should be ${historyEntries.length} after import, but got $importedHistoryEntryAmount',
|
||||
);
|
||||
});
|
||||
|
||||
test('Do not delete, idempotent reimport', () async {
|
||||
final historyEntries = await createRandomHistoryEntries(
|
||||
db: database,
|
||||
count: 300,
|
||||
);
|
||||
await database.historyEntryInsertEntries(historyEntries);
|
||||
final historyEntryAmount = await database.historyEntryAmount();
|
||||
assert(
|
||||
historyEntryAmount == historyEntries.length,
|
||||
'History entry amount should be ${historyEntries.length}, but got $historyEntryAmount',
|
||||
);
|
||||
|
||||
await exportHistory(database, tmpdir).drain();
|
||||
|
||||
await importHistory(database, tmpdir).drain();
|
||||
final int importedHistoryEntryAmount = await database
|
||||
.historyEntryAmount();
|
||||
assert(
|
||||
importedHistoryEntryAmount == historyEntries.length,
|
||||
'History entry amount should be ${historyEntries.length} after import, but got $importedHistoryEntryAmount',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
148
test/services/archive/v2/archive_librarylists_test.dart
Normal file
148
test/services/archive/v2/archive_librarylists_test.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:mugiten/services/archive/v2/format.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../../testutils.dart';
|
||||
|
||||
void main() {
|
||||
late final String libsqlitePath;
|
||||
late final String jadbPath;
|
||||
late Directory tmpdir;
|
||||
late Database database;
|
||||
|
||||
setUpAll(() {
|
||||
if (!Platform.environment.containsKey('LIBSQLITE_PATH')) {
|
||||
throw Exception('LIBSQLITE_PATH environment variable is not set.');
|
||||
}
|
||||
|
||||
if (!Platform.environment.containsKey('JADB_PATH')) {
|
||||
throw Exception('JADB_PATH environment variable is not set.');
|
||||
}
|
||||
|
||||
libsqlitePath = File(
|
||||
Platform.environment['LIBSQLITE_PATH']!,
|
||||
).resolveSymbolicLinksSync();
|
||||
jadbPath = File(
|
||||
Platform.environment['JADB_PATH']!,
|
||||
).resolveSymbolicLinksSync();
|
||||
});
|
||||
|
||||
// Setup sqflite_common_ffi for flutter test
|
||||
setUp(() async {
|
||||
database = await createDatabaseCopy(
|
||||
libsqlitePath: libsqlitePath,
|
||||
jadbPath: jadbPath,
|
||||
);
|
||||
|
||||
GetIt.instance.registerSingleton<Database>(database);
|
||||
|
||||
tmpdir = await test_tmpdir();
|
||||
tmpdir.libraryDir.createSync();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.close();
|
||||
|
||||
GetIt.instance.unregister<Database>();
|
||||
|
||||
final jadbCopyPath = database.path;
|
||||
|
||||
if (File(jadbCopyPath).existsSync()) {
|
||||
await File(jadbCopyPath).delete();
|
||||
}
|
||||
|
||||
if (tmpdir.existsSync()) {
|
||||
await tmpdir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('Full reimport', () async {
|
||||
final libraryEntries1 = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 150,
|
||||
jmdictEntryCount: 150,
|
||||
);
|
||||
|
||||
final libraryEntries2 = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 150,
|
||||
jmdictEntryCount: 300,
|
||||
);
|
||||
|
||||
final libraryEntries3 = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 150,
|
||||
jmdictEntryCount: 150,
|
||||
);
|
||||
|
||||
await database.libraryListInsertList('Test List 1');
|
||||
await database.libraryListInsertList('Test List 2');
|
||||
await database.libraryListInsertList('Test List 3');
|
||||
|
||||
await database.libraryListInsertEntries('Test List 1', libraryEntries1);
|
||||
await database.libraryListInsertEntries('Test List 2', libraryEntries2);
|
||||
await database.libraryListInsertEntries('Test List 3', libraryEntries3);
|
||||
|
||||
final listCount1 = await database.libraryListAmount();
|
||||
assert(
|
||||
listCount1 == 4,
|
||||
'Library list amount should be 4 after insertion, but got $listCount1',
|
||||
);
|
||||
|
||||
await exportLibraryLists(database, tmpdir).drain();
|
||||
|
||||
await database.libraryListDeleteList('Test List 1');
|
||||
await database.libraryListDeleteList('Test List 2');
|
||||
await database.libraryListDeleteList('Test List 3');
|
||||
|
||||
final listCount2 = await database.libraryListAmount();
|
||||
assert(
|
||||
listCount2 == 1,
|
||||
'Library list amount should be 1 after deletion, but got $listCount2',
|
||||
);
|
||||
|
||||
await importLibraryLists(database, tmpdir).drain();
|
||||
|
||||
final listCount3 = await database.libraryListAmount();
|
||||
assert(
|
||||
listCount3 == 4,
|
||||
'Library list amount should be 4 after import, but got $listCount3',
|
||||
);
|
||||
});
|
||||
|
||||
test('Full reimport favourites', () async {
|
||||
final libraryEntries = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 150,
|
||||
jmdictEntryCount: 150,
|
||||
);
|
||||
|
||||
await database.libraryListInsertEntries('favourites', libraryEntries);
|
||||
final favourites = (await database.libraryListGetLists()).first;
|
||||
assert(
|
||||
favourites.totalCount == libraryEntries.length,
|
||||
'Favourites entry count should be ${libraryEntries.length} after insertion, but got ${favourites.totalCount}',
|
||||
);
|
||||
|
||||
await exportLibraryLists(database, tmpdir).drain();
|
||||
|
||||
await database.libraryListDeleteAllEntries('favourites');
|
||||
final emptyFavourites = (await database.libraryListGetLists()).first;
|
||||
assert(
|
||||
emptyFavourites.totalCount == 0,
|
||||
'Favourites entry count should be 0 after deletion, but got ${emptyFavourites.totalCount}',
|
||||
);
|
||||
|
||||
await importLibraryLists(database, tmpdir).drain();
|
||||
|
||||
final importedFavourites = (await database.libraryListGetLists()).first;
|
||||
assert(
|
||||
importedFavourites.totalCount == libraryEntries.length,
|
||||
'Favourites entry count should be ${libraryEntries.length} after import, but got ${importedFavourites.totalCount}',
|
||||
);
|
||||
});
|
||||
}
|
||||
138
test/services/archive/v2/archive_zip_test.dart
Normal file
138
test/services/archive/v2/archive_zip_test.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/database/history/table_names.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:mugiten/services/archive/v2/format.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../../testutils.dart';
|
||||
|
||||
void main() {
|
||||
late final String libsqlitePath;
|
||||
late final String jadbPath;
|
||||
late Directory tmpdir;
|
||||
late Database database;
|
||||
|
||||
setUpAll(() {
|
||||
if (!Platform.environment.containsKey('LIBSQLITE_PATH')) {
|
||||
throw Exception('LIBSQLITE_PATH environment variable is not set.');
|
||||
}
|
||||
|
||||
if (!Platform.environment.containsKey('JADB_PATH')) {
|
||||
throw Exception('JADB_PATH environment variable is not set.');
|
||||
}
|
||||
|
||||
libsqlitePath = File(
|
||||
Platform.environment['LIBSQLITE_PATH']!,
|
||||
).resolveSymbolicLinksSync();
|
||||
jadbPath = File(
|
||||
Platform.environment['JADB_PATH']!,
|
||||
).resolveSymbolicLinksSync();
|
||||
});
|
||||
|
||||
// Setup sqflite_common_ffi for flutter test
|
||||
setUp(() async {
|
||||
database = await createDatabaseCopy(
|
||||
libsqlitePath: libsqlitePath,
|
||||
jadbPath: jadbPath,
|
||||
);
|
||||
|
||||
GetIt.instance.registerSingleton<Database>(database);
|
||||
|
||||
tmpdir = await test_tmpdir();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.close();
|
||||
|
||||
GetIt.instance.unregister<Database>();
|
||||
|
||||
final jadbCopyPath = database.path;
|
||||
|
||||
if (File(jadbCopyPath).existsSync()) {
|
||||
await File(jadbCopyPath).delete();
|
||||
}
|
||||
|
||||
if (tmpdir.existsSync()) {
|
||||
await tmpdir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('Archive V2 export to and import from zip archive', () async {
|
||||
// Insert data
|
||||
final historyEntries = await createRandomHistoryEntries(
|
||||
db: database,
|
||||
count: 300,
|
||||
);
|
||||
await database.historyEntryInsertEntries(historyEntries);
|
||||
|
||||
final libraryEntriesF = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 400,
|
||||
jmdictEntryCount: 440,
|
||||
);
|
||||
final libraryEntries1 = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 150,
|
||||
jmdictEntryCount: 150,
|
||||
);
|
||||
final libraryEntries2 = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 150,
|
||||
jmdictEntryCount: 300,
|
||||
);
|
||||
final libraryEntries3 = await createRandomLibraryListEntries(
|
||||
db: database,
|
||||
kanjiCount: 150,
|
||||
jmdictEntryCount: 150,
|
||||
);
|
||||
|
||||
await database.libraryListInsertList('Test List 1');
|
||||
await database.libraryListInsertList('Test List 2');
|
||||
await database.libraryListInsertList('Test List 3');
|
||||
|
||||
await database.libraryListInsertEntries('favourites', libraryEntriesF);
|
||||
await database.libraryListInsertEntries('Test List 1', libraryEntries1);
|
||||
await database.libraryListInsertEntries('Test List 2', libraryEntries2);
|
||||
await database.libraryListInsertEntries('Test List 3', libraryEntries3);
|
||||
|
||||
// Export to zip
|
||||
final zipFile = File(tmpdir.uri.resolve('export.zip').toFilePath())
|
||||
..createSync();
|
||||
await exportData(database, zipFile).drain();
|
||||
|
||||
// Delete all data
|
||||
await database.delete(HistoryTableNames.historyEntry);
|
||||
await database.libraryListDeleteAllEntries('favourites');
|
||||
await database.libraryListDeleteList('Test List 1');
|
||||
await database.libraryListDeleteList('Test List 2');
|
||||
await database.libraryListDeleteList('Test List 3');
|
||||
|
||||
// Import from zip
|
||||
await importData(database, zipFile).drain();
|
||||
|
||||
// Verify data
|
||||
final int historyEntryAmount = await database.historyEntryAmount();
|
||||
assert(
|
||||
historyEntryAmount == historyEntries.length,
|
||||
'History entry amount should be ${historyEntries.length} after import, but got $historyEntryAmount',
|
||||
);
|
||||
|
||||
final favourites = (await database.libraryListGetLists()).firstWhere(
|
||||
(final list) => list.name == 'favourites',
|
||||
);
|
||||
assert(
|
||||
favourites.totalCount == libraryEntriesF.length,
|
||||
'Favourites entry count should be ${libraryEntriesF.length} after import, but got ${favourites.totalCount}',
|
||||
);
|
||||
|
||||
final listCount = await database.libraryListAmount();
|
||||
assert(
|
||||
listCount == 4,
|
||||
'Library list amount should be 4 after import, but got $listCount',
|
||||
);
|
||||
});
|
||||
}
|
||||
139
test/testutils.dart
Normal file
139
test/testutils.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jadb/search.dart';
|
||||
import 'package:jadb/table_names/jmdict.dart';
|
||||
import 'package:jadb/table_names/kanjidic.dart';
|
||||
import 'package:mugiten/database/database.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
Future<Database> createDatabaseCopy({
|
||||
required final String libsqlitePath,
|
||||
required final String jadbPath,
|
||||
}) async {
|
||||
final jadbFile = File(jadbPath);
|
||||
if (!jadbFile.existsSync()) {
|
||||
throw Exception('JADB_PATH does not exist: $jadbPath');
|
||||
}
|
||||
|
||||
// Make a copy of jadbPath
|
||||
final randomSuffix = Random()
|
||||
.nextInt((pow(2, 32) - 1) as int)
|
||||
.toRadixString(16);
|
||||
final jadbCopyPath = jadbFile.parent.uri
|
||||
.resolve('jadb_copy_$randomSuffix.sqlite')
|
||||
.path;
|
||||
|
||||
await jadbFile.copy(jadbCopyPath);
|
||||
|
||||
print('Using database copy: $jadbCopyPath');
|
||||
|
||||
// Initialize FFI
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = createDatabaseFactoryFfi();
|
||||
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
return await openAndMigrateDatabase(
|
||||
jadbCopyPath,
|
||||
await readMigrationsFromAssets(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<LibraryListEntry>> createRandomLibraryListEntries({
|
||||
required final DatabaseExecutor db,
|
||||
final int kanjiCount = 10,
|
||||
final int jmdictEntryCount = 10,
|
||||
}) async {
|
||||
final kanji = (await db.query(
|
||||
KANJIDICTableNames.character,
|
||||
columns: ['literal'],
|
||||
limit: kanjiCount,
|
||||
orderBy: 'RANDOM()',
|
||||
)).map((final row) => row['literal'] as String).toList();
|
||||
|
||||
final jmdictEntries = (await db.query(
|
||||
JMdictTableNames.entry,
|
||||
columns: ['entryId'],
|
||||
limit: jmdictEntryCount,
|
||||
orderBy: 'RANDOM()',
|
||||
)).map((final row) => row['entryId'] as int).toList();
|
||||
|
||||
final rng = Random();
|
||||
final result = <LibraryListEntry>[];
|
||||
for (int i = 0; i < kanjiCount + jmdictEntryCount; i++) {
|
||||
if (rng.nextBool() && kanji.isNotEmpty || jmdictEntries.isEmpty) {
|
||||
result.add(LibraryListEntry.fromKanji(kanji: kanji.removeLast()));
|
||||
} else {
|
||||
result.add(
|
||||
LibraryListEntry.fromJmdictId(
|
||||
jmdictEntryId: jmdictEntries.removeLast(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// TODO: fix the timestamps so that they differ within each entry.
|
||||
|
||||
Future<List<HistoryEntry>> createRandomHistoryEntries({
|
||||
required final DatabaseExecutor db,
|
||||
final int count = 20,
|
||||
}) async {
|
||||
final kanji = (await db.query(
|
||||
KANJIDICTableNames.character,
|
||||
columns: ['literal'],
|
||||
limit: (count / 2).ceil(),
|
||||
orderBy: 'RANDOM()',
|
||||
)).map((final row) => row['literal'] as String).toList();
|
||||
|
||||
final jmdictIds = (await db.query(
|
||||
JMdictTableNames.entry,
|
||||
columns: ['entryId'],
|
||||
limit: (count / 2).ceil(),
|
||||
orderBy: 'RANDOM()',
|
||||
)).map((final row) => row['entryId'] as int).toList();
|
||||
|
||||
final wordSearchResults = await db.jadbGetManyWordsByIds(jmdictIds.toSet());
|
||||
|
||||
final rng = Random();
|
||||
final result = <HistoryEntry>[];
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (rng.nextBool() && kanji.isNotEmpty || jmdictIds.isEmpty) {
|
||||
result.add(
|
||||
HistoryEntry(
|
||||
id: i,
|
||||
timestamps: [
|
||||
for (int j = 0; j < rng.nextInt(5) + 1; j++)
|
||||
DateTime.now().subtract(Duration(days: rng.nextInt(30))),
|
||||
],
|
||||
word: null,
|
||||
kanji: kanji.removeLast(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final entryId = jmdictIds.removeLast();
|
||||
result.add(
|
||||
HistoryEntry(
|
||||
timestamps: [
|
||||
for (int j = 0; j < rng.nextInt(5) + 1; j++)
|
||||
DateTime.now().subtract(Duration(days: rng.nextInt(30))),
|
||||
],
|
||||
id: i,
|
||||
word: wordSearchResults[entryId]!.japanese.first.base,
|
||||
kanji: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Directory> test_tmpdir() =>
|
||||
Directory.systemTemp.createTemp('mugiten_test_data_');
|
||||
Reference in New Issue
Block a user