Compare commits
1 Commits
streaming-
...
colorize-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
7d351c61de
|
@@ -10,11 +10,6 @@
|
||||
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`
|
||||
@@ -27,64 +22,20 @@ linter:
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
- 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
|
||||
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
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1774386573,
|
||||
"narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=",
|
||||
"lastModified": 1771848320,
|
||||
"narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9",
|
||||
"rev": "2fc6539b481e1d2569f25f8799236694180c0993",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -34,11 +34,9 @@
|
||||
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";
|
||||
|
||||
@@ -23,12 +23,12 @@ class AsyncTextFormFieldState extends State<AsyncTextFormField> {
|
||||
String? errorText;
|
||||
CancelableOperation? currentValidation;
|
||||
|
||||
Future<void> validate(final String text) async {
|
||||
Future<void> validate(String text) async {
|
||||
currentValidation?.cancel();
|
||||
setState(() {
|
||||
errorText = null;
|
||||
currentValidation = CancelableOperation.fromFuture(
|
||||
widget.asyncValidator(text).then((final newErrorText) {
|
||||
widget.asyncValidator(text).then((newErrorText) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
errorText = newErrorText;
|
||||
@@ -40,7 +40,7 @@ class AsyncTextFormFieldState extends State<AsyncTextFormField> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
print(
|
||||
'Building AsyncTextFormField with errorText: $errorText and currentValidation: $currentValidation',
|
||||
);
|
||||
@@ -54,7 +54,7 @@ class AsyncTextFormFieldState extends State<AsyncTextFormField> {
|
||||
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)
|
||||
? CircularProgressIndicator(strokeWidth: 2)
|
||||
: errorText != null
|
||||
? const Icon(Icons.error, color: Colors.red)
|
||||
: const Icon(Icons.check, color: Colors.green),
|
||||
|
||||
@@ -6,7 +6,7 @@ class DenshiJishoBackground extends StatelessWidget {
|
||||
const DenshiJishoBackground({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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'.
|
||||
///
|
||||
@@ -59,21 +60,21 @@ class KanjiBox extends StatelessWidget {
|
||||
);
|
||||
|
||||
const factory KanjiBox.withFontSizeAndPadding({
|
||||
required final String kanji,
|
||||
required final double fontSize,
|
||||
required final double padding,
|
||||
final Color? foreground,
|
||||
final Color? background,
|
||||
final double borderRadius,
|
||||
required String kanji,
|
||||
required double fontSize,
|
||||
required double padding,
|
||||
Color? foreground,
|
||||
Color? background,
|
||||
double borderRadius,
|
||||
}) = KanjiBox._;
|
||||
|
||||
factory KanjiBox.withFontSize({
|
||||
required final String kanji,
|
||||
required final double fontSize,
|
||||
final double ratio = defaultRatio,
|
||||
final Color? foreground,
|
||||
final Color? background,
|
||||
final double borderRadius = defaultBorderRadius,
|
||||
required String kanji,
|
||||
required double fontSize,
|
||||
double ratio = defaultRatio,
|
||||
Color? foreground,
|
||||
Color? background,
|
||||
double borderRadius = defaultBorderRadius,
|
||||
}) => KanjiBox._(
|
||||
kanji: kanji,
|
||||
fontSize: fontSize,
|
||||
@@ -84,12 +85,12 @@ class KanjiBox extends StatelessWidget {
|
||||
);
|
||||
|
||||
factory KanjiBox.withPadding({
|
||||
required final String kanji,
|
||||
final double ratio = defaultRatio,
|
||||
required final double padding,
|
||||
final Color? foreground,
|
||||
final Color? background,
|
||||
final double borderRadius = defaultBorderRadius,
|
||||
required String kanji,
|
||||
double ratio = defaultRatio,
|
||||
required double padding,
|
||||
Color? foreground,
|
||||
Color? background,
|
||||
double borderRadius = defaultBorderRadius,
|
||||
}) => KanjiBox._(
|
||||
kanji: kanji,
|
||||
fontSize: ratio * padding,
|
||||
@@ -100,11 +101,11 @@ class KanjiBox extends StatelessWidget {
|
||||
);
|
||||
|
||||
factory KanjiBox.expanded({
|
||||
required final String kanji,
|
||||
final double ratio = defaultRatio,
|
||||
final Color? foreground,
|
||||
final Color? background,
|
||||
final double borderRadius = defaultBorderRadius,
|
||||
required String kanji,
|
||||
double ratio = defaultRatio,
|
||||
Color? foreground,
|
||||
Color? background,
|
||||
double borderRadius = defaultBorderRadius,
|
||||
}) => KanjiBox._(
|
||||
kanji: kanji,
|
||||
contentPaddingRatio: ratio,
|
||||
@@ -115,12 +116,12 @@ class KanjiBox extends StatelessWidget {
|
||||
|
||||
/// A shortcut
|
||||
factory KanjiBox.headline4({
|
||||
required final BuildContext context,
|
||||
required final String kanji,
|
||||
final double ratio = defaultRatio,
|
||||
final Color? foreground,
|
||||
final Color? background,
|
||||
final double borderRadius = defaultBorderRadius,
|
||||
required BuildContext context,
|
||||
required String kanji,
|
||||
double ratio = defaultRatio,
|
||||
Color? foreground,
|
||||
Color? background,
|
||||
double borderRadius = defaultBorderRadius,
|
||||
}) => KanjiBox.withFontSize(
|
||||
kanji: kanji,
|
||||
fontSize: Theme.of(context).textTheme.displaySmall!.fontSize!,
|
||||
@@ -131,7 +132,7 @@ class KanjiBox extends StatelessWidget {
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final calculatedForeground =
|
||||
foreground ??
|
||||
Theme.of(
|
||||
@@ -144,7 +145,7 @@ class KanjiBox extends StatelessWidget {
|
||||
).extension<MenuGreyLightThemeExtension>()!.backgroundColor;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (final context, final constraints) {
|
||||
builder: (context, constraints) {
|
||||
final sizeConstraint = min(constraints.maxHeight, constraints.maxWidth);
|
||||
final calculatedFontSize = fontSize ?? sizeConstraint * fontSizeFactor;
|
||||
final calculatedPadding =
|
||||
@@ -162,7 +163,7 @@ class KanjiBox extends StatelessWidget {
|
||||
child: FittedBox(
|
||||
child: Text(
|
||||
kanji,
|
||||
textScaler: TextScaler.noScaling,
|
||||
textScaler: TextScaler.linear(1),
|
||||
style: TextStyle(
|
||||
color: calculatedForeground,
|
||||
fontSize: calculatedFontSize,
|
||||
|
||||
@@ -4,7 +4,7 @@ class LoadingScreen extends StatelessWidget {
|
||||
const LoadingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(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(final BuildContext context) {
|
||||
Widget build(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(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(color: mugitenWheatBackground),
|
||||
decoration: BoxDecoration(color: mugitenWheatBackground),
|
||||
child: const Center(
|
||||
child: Image(image: AssetImage('assets/images/logo/mugi.png')),
|
||||
),
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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;
|
||||
@@ -65,7 +64,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
y: controller.points.last.offset.dy,
|
||||
),
|
||||
),
|
||||
onDrawEnd: updateSuggestions,
|
||||
onDrawEnd: () => updateSuggestions(),
|
||||
);
|
||||
|
||||
Future<void> updateSuggestions() async {
|
||||
@@ -81,7 +80,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
);
|
||||
|
||||
final ink = Ink()
|
||||
..strokes = strokes.map((final s) => Stroke()..points = s).toList();
|
||||
..strokes = strokes.map((s) => Stroke()..points = s).toList();
|
||||
|
||||
final newSuggestions = await digitalInkRecognizer.recognize(
|
||||
ink,
|
||||
@@ -89,7 +88,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
);
|
||||
|
||||
setState(() {
|
||||
suggestions = newSuggestions.map((final rc) => rc.text).toList();
|
||||
suggestions = newSuggestions.map((rc) => rc.text).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,11 +101,11 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
deduplicate: true,
|
||||
);
|
||||
final hiraganaSuggestions = suggestions
|
||||
.where((final s) => RegExp(hiraganaR).hasMatch(s))
|
||||
.where((s) => RegExp(hiraganaR).hasMatch(s))
|
||||
.toSet()
|
||||
.toList();
|
||||
final katakanaSuggestions = suggestions
|
||||
.where((final s) => RegExp(katakanaR).hasMatch(s))
|
||||
.where((s) => RegExp(katakanaR).hasMatch(s))
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
@@ -115,13 +114,11 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
if (widget.allowHiragana) ...hiraganaSuggestions,
|
||||
if (widget.allowKatakana) ...katakanaSuggestions,
|
||||
}
|
||||
.where(
|
||||
(final s) => !widget.onlyOneCharacterSuggestions || s.length == 1,
|
||||
)
|
||||
.where((s) => !widget.onlyOneCharacterSuggestions || s.length == 1)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Widget kanjiChip(final String kanji) => InkWell(
|
||||
Widget kanjiChip(String kanji) => InkWell(
|
||||
onTap: () => widget.onSuggestionChosen?.call(kanji),
|
||||
child: Container(
|
||||
height: fontSize + 2 * suggestionCirclePadding,
|
||||
@@ -147,7 +144,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
|
||||
return FutureBuilder<List<String>>(
|
||||
future: filterSuggestions(),
|
||||
builder: (final context, final snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting ||
|
||||
(!snapshot.hasError && (snapshot.data?.isEmpty ?? false))) {
|
||||
return Container(
|
||||
@@ -189,7 +186,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
child: Wrap(
|
||||
spacing: 20,
|
||||
runSpacing: 5,
|
||||
children: filteredSuggestions.map(kanjiChip).toList(),
|
||||
children: filteredSuggestions.map((s) => kanjiChip(s)).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -214,7 +211,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
if (strokes.isNotEmpty) {
|
||||
undoQueue.add(strokes.removeLast());
|
||||
controller.undo();
|
||||
unawaited(updateSuggestions());
|
||||
updateSuggestions();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.undo),
|
||||
@@ -224,7 +221,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
if (undoQueue.isNotEmpty) {
|
||||
strokes.add(undoQueue.removeLast());
|
||||
controller.redo();
|
||||
unawaited(updateSuggestions());
|
||||
updateSuggestions();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.redo),
|
||||
@@ -270,7 +267,7 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(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(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).extension<MenuGreyNormalThemeExtension>()!;
|
||||
|
||||
return Container(
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
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;
|
||||
@@ -26,7 +27,7 @@ class HistoryEntryTile extends StatelessWidget {
|
||||
});
|
||||
|
||||
/// Perform the search again when the entry is tapped.
|
||||
void Function() _onTap(final BuildContext context) => entry.isKanji
|
||||
void Function() _onTap(BuildContext context) => entry.isKanji
|
||||
? () => Navigator.pushNamed(
|
||||
context,
|
||||
Routes.kanjiSearch,
|
||||
@@ -36,17 +37,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(final BuildContext context) =>
|
||||
void Function() _onLongPress(BuildContext context) =>
|
||||
() =>
|
||||
copyToClipboard(context, entry.isKanji ? entry.kanji! : entry.word!);
|
||||
|
||||
MaterialPageRoute get timestamps => MaterialPageRoute(
|
||||
builder: (final context) => Scaffold(
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Last searched')),
|
||||
body: ListView(
|
||||
children: entry.timestamps
|
||||
.map(
|
||||
(final ts) => ListTile(
|
||||
(ts) => ListTile(
|
||||
title: Text('${formatDate(ts)} ${formatTime(ts)}'),
|
||||
),
|
||||
)
|
||||
@@ -55,7 +56,7 @@ class HistoryEntryTile extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
|
||||
List<SlidableAction> _actions(final BuildContext context) => [
|
||||
List<SlidableAction> _actions(BuildContext context) => [
|
||||
SlidableAction(
|
||||
backgroundColor: Colors.blue,
|
||||
icon: Icons.access_time,
|
||||
@@ -72,7 +73,7 @@ class HistoryEntryTile extends StatelessWidget {
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).extension<MenuGreyNormalThemeExtension>()!;
|
||||
|
||||
return Slidable(
|
||||
@@ -98,7 +99,7 @@ class HistoryEntryTile extends StatelessWidget {
|
||||
? KanjiBox.headline4(context: context, kanji: entry.kanji!)
|
||||
: Expanded(child: Text(entry.word!)),
|
||||
),
|
||||
if (entry.isKanji) const Expanded(child: SizedBox.shrink()),
|
||||
if (entry.isKanji) Expanded(child: SizedBox.shrink()),
|
||||
if (entry.timestampCount > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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;
|
||||
@@ -9,7 +10,7 @@ class Grade extends StatelessWidget {
|
||||
const Grade({required this.grade, this.ifNullChar = '⨉', super.key});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).extension<KanjiResultThemeExtension>()!;
|
||||
|
||||
return Container(
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
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(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).extension<KanjiResultThemeExtension>()!;
|
||||
|
||||
return AspectRatio(
|
||||
|
||||
@@ -8,7 +8,7 @@ class JlptLevel extends StatelessWidget {
|
||||
const JlptLevel({required this.jlptLevel, this.ifNullChar = '⨉', super.key});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).extension<KanjiResultThemeExtension>()!;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
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(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).extension<KanjiResultThemeExtension>()!;
|
||||
|
||||
return InkWell(
|
||||
|
||||
@@ -8,7 +8,7 @@ class Rank extends StatelessWidget {
|
||||
const Rank({required this.rank, this.ifNullChar = '⨉', super.key});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(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(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final kanjiResultColors = Theme.of(
|
||||
context,
|
||||
).extension<KanjiResultThemeExtension>()!;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 {
|
||||
@@ -18,7 +19,7 @@ extension on YomiType {
|
||||
// }
|
||||
// }
|
||||
|
||||
Color getColor(final BuildContext context) {
|
||||
Color getColor(BuildContext context) {
|
||||
switch (this) {
|
||||
case YomiType.onyomi:
|
||||
return Theme.of(context).extension<YomiThemeExtension>()!.onyomiColor!;
|
||||
@@ -37,11 +38,11 @@ class YomiChips extends StatelessWidget {
|
||||
const YomiChips({required this.yomi, required this.type, super.key});
|
||||
|
||||
Widget yomiCard({
|
||||
required final BuildContext context,
|
||||
required final String yomi,
|
||||
required final Color? color,
|
||||
final bool searchable = true,
|
||||
final TextStyle? extraTextStyle,
|
||||
required BuildContext context,
|
||||
required String yomi,
|
||||
required Color? color,
|
||||
bool searchable = true,
|
||||
TextStyle? extraTextStyle,
|
||||
}) => InkWell(
|
||||
onTap: searchable
|
||||
? () => Navigator.pushNamed(context, Routes.search, arguments: yomi)
|
||||
@@ -63,11 +64,11 @@ class YomiChips extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
|
||||
Widget yomiWrapper(final BuildContext context) {
|
||||
Widget yomiWrapper(BuildContext context) {
|
||||
final yomiCards = yomi
|
||||
.map((final y) => romajiEnabled.value ? transliterateKanaToLatin(y) : y)
|
||||
.map((y) => romajiEnabled.value ? transliterateKanaToLatin(y) : y)
|
||||
.map(
|
||||
(final y) => yomiCard(
|
||||
(y) => yomiCard(
|
||||
context: context,
|
||||
yomi: y,
|
||||
color: type.getColor(context),
|
||||
@@ -95,7 +96,7 @@ class YomiChips extends StatelessWidget {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0),
|
||||
alignment: Alignment.centerLeft,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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});
|
||||
|
||||
@@ -48,10 +47,10 @@ class _KanjiSearchBodyState extends State<KanjiSearchBody>
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: !_isFocused,
|
||||
onPopInvokedWithResult: (final didPop, final result) {
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) {
|
||||
focus.unfocus();
|
||||
_kanjiSearchBarState.currentState!.clearText();
|
||||
@@ -65,7 +64,7 @@ class _KanjiSearchBodyState extends State<KanjiSearchBody>
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: AnimatedBuilder(
|
||||
animation: _searchbarMovementAnimation,
|
||||
builder: (final context, _) {
|
||||
builder: (context, _) {
|
||||
return Container(
|
||||
alignment: _searchbarMovementAnimation.value,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10.0),
|
||||
@@ -74,26 +73,18 @@ class _KanjiSearchBodyState extends State<KanjiSearchBody>
|
||||
children: [
|
||||
Focus(
|
||||
focusNode: focus,
|
||||
onFocusChange: (final hasFocus) {
|
||||
onFocusChange: (hasFocus) {
|
||||
if (hasFocus) {
|
||||
unawaited(
|
||||
_controller.forward().then(
|
||||
(_) => setState(() => _isFocused = true),
|
||||
),
|
||||
);
|
||||
_controller.forward();
|
||||
setState(() => _isFocused = true);
|
||||
} else {
|
||||
unawaited(
|
||||
_controller.reverse().then(
|
||||
(_) => setState(() {
|
||||
_isFocused = false;
|
||||
}),
|
||||
),
|
||||
);
|
||||
_controller.reverse();
|
||||
setState(() => _isFocused = false);
|
||||
}
|
||||
},
|
||||
child: KanjiSearchBar(
|
||||
key: _kanjiSearchBarState,
|
||||
onChanged: (final text) => setState(() async {
|
||||
onChanged: (text) => setState(() async {
|
||||
suggestions = await GetIt.instance
|
||||
.get<Database>()
|
||||
.filterKanji(text.characters.toList());
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
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 KanjiGrid extends StatelessWidget {
|
||||
final List<String> suggestions;
|
||||
|
||||
const KanjiGrid({required this.suggestions, super.key});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 40.0),
|
||||
child: GridView.count(
|
||||
@@ -19,7 +18,7 @@ class KanjiGrid extends StatelessWidget {
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 10.0,
|
||||
crossAxisSpacing: 10.0,
|
||||
children: suggestions.map(_GridItem.new).toList(),
|
||||
children: suggestions.map((kanji) => _GridItem(kanji)).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -30,14 +29,12 @@ class _GridItem extends StatelessWidget {
|
||||
const _GridItem(this.kanji);
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).extension<MenuGreyLightThemeExtension>()!;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
unawaited(
|
||||
Navigator.pushNamed(context, Routes.kanjiSearch, arguments: kanji),
|
||||
);
|
||||
Navigator.pushNamed(context, Routes.kanjiSearch, arguments: kanji);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:mugiten/settings.dart';
|
||||
import '../../../settings.dart';
|
||||
|
||||
class KanjiSearchBar extends StatefulWidget {
|
||||
final Function(String)? onChanged;
|
||||
@@ -39,20 +39,20 @@ class KanjiSearchBarState extends State<KanjiSearchBar> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(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: (final text) => onChanged(),
|
||||
onChanged: (text) => onChanged(),
|
||||
onSubmitted: (_) => {},
|
||||
style: japaneseFont.value.textStyle,
|
||||
decoration: InputDecoration(
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -41,7 +42,7 @@ class _IconButton extends StatelessWidget {
|
||||
const _IconButton({required this.icon, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => IconButton(
|
||||
Widget build(BuildContext context) => IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: icon,
|
||||
iconSize: 30,
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
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 final BuildContext context,
|
||||
required final int? jmdictEntryId,
|
||||
required final String? kanji,
|
||||
required BuildContext context,
|
||||
required int? jmdictEntryId,
|
||||
required String? kanji,
|
||||
}) => showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
@@ -44,18 +43,16 @@ class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
unawaited(
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListAllListsContain(
|
||||
jmdictEntryId: widget.jmdictEntryId,
|
||||
kanji: widget.kanji,
|
||||
)
|
||||
.then((final data) => setState(() => librariesContainEntry = data)),
|
||||
);
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListAllListsContain(
|
||||
jmdictEntryId: widget.jmdictEntryId,
|
||||
kanji: widget.kanji,
|
||||
)
|
||||
.then((data) => setState(() => librariesContainEntry = data));
|
||||
}
|
||||
|
||||
Future<void> toggleEntry(final String libraryName) async {
|
||||
Future<void> toggleEntry(String libraryName) async {
|
||||
if (toggleLock) return;
|
||||
|
||||
setState(() => toggleLock = true);
|
||||
@@ -74,7 +71,7 @@ class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Add to library'),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 12),
|
||||
@@ -96,7 +93,7 @@ class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
|
||||
future: GetIt.instance.get<Database>().jadbGetWordById(
|
||||
widget.jmdictEntryId!,
|
||||
),
|
||||
builder: (final context, final snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return ErrorWidget(snapshot.error!);
|
||||
}
|
||||
@@ -134,7 +131,7 @@ class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
|
||||
child: librariesContainEntry == null
|
||||
? const LoadingScreen()
|
||||
: ListView(
|
||||
children: librariesContainEntry!.entries.map((final e) {
|
||||
children: librariesContainEntry!.entries.map((e) {
|
||||
final libraryName = e.key;
|
||||
final checked = e.value;
|
||||
return ListTile(
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
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;
|
||||
@@ -26,13 +27,13 @@ class LibraryListEntryTile extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return entry.kanji != null
|
||||
? _kanjiTile(context, index, entry.kanji!)
|
||||
: _jmdictEntryTile(context, index, entry);
|
||||
}
|
||||
|
||||
Widget _index(final BuildContext context, final int index) {
|
||||
Widget _index(BuildContext context, int index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
@@ -59,11 +60,7 @@ class LibraryListEntryTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kanjiTile(
|
||||
final BuildContext context,
|
||||
final int? index,
|
||||
final String kanji,
|
||||
) {
|
||||
Widget _kanjiTile(BuildContext context, int? index, String kanji) {
|
||||
return Slidable(
|
||||
endActionPane: ActionPane(
|
||||
motion: const ScrollMotion(),
|
||||
@@ -82,7 +79,7 @@ class LibraryListEntryTile extends StatelessWidget {
|
||||
onLongPress: () => copyToClipboard(context, kanji),
|
||||
title: Row(
|
||||
children: [
|
||||
const SizedBox(width: 15),
|
||||
SizedBox(width: 15),
|
||||
KanjiBox.headline4(context: context, kanji: kanji),
|
||||
],
|
||||
),
|
||||
@@ -91,9 +88,9 @@ class LibraryListEntryTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _jmdictEntryTile(
|
||||
final BuildContext context,
|
||||
final int? index,
|
||||
final LibraryListEntry entry,
|
||||
BuildContext context,
|
||||
int? index,
|
||||
LibraryListEntry entry,
|
||||
) {
|
||||
return SearchResultCard(
|
||||
result: entry.wordSearchResult!,
|
||||
|
||||
@@ -3,9 +3,10 @@ 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;
|
||||
@@ -23,7 +24,7 @@ class LibraryListTile extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Slidable(
|
||||
endActionPane: ActionPane(
|
||||
motion: const ScrollMotion(),
|
||||
@@ -100,12 +101,12 @@ class _RenameLibraryDialogState extends State<_RenameLibraryDialog> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Rename library'),
|
||||
content: AsyncTextFormField(
|
||||
controller: controller,
|
||||
asyncValidator: (final value) async {
|
||||
asyncValidator: (value) async {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a name';
|
||||
}
|
||||
|
||||
@@ -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(final BuildContext context) => () async {
|
||||
void Function() showNewLibraryDialog(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(final String proposedListName) async {
|
||||
Future<void> onNameUpdate(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(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Add new library'),
|
||||
content: TextField(
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
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(final BuildContext context, final String text) =>
|
||||
void _search(BuildContext context, String text) =>
|
||||
Navigator.pushNamed(context, Routes.search, arguments: text);
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
onSubmitted: (final text) => _search(context, text),
|
||||
onSubmitted: (text) => _search(context, text),
|
||||
controller: textController,
|
||||
focusNode: textFocus,
|
||||
style: japaneseFont.value.textStyle,
|
||||
@@ -56,7 +57,9 @@ class GlobalSearchBar extends StatelessWidget {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
color: Colors.red,
|
||||
onPressed: textController.clear,
|
||||
onPressed: () {
|
||||
textController.clear();
|
||||
},
|
||||
),
|
||||
const LanguageSelector(),
|
||||
IconButton(
|
||||
@@ -97,11 +100,9 @@ class GlobalSearchBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> Function(BuildContext) _drawKanji(
|
||||
final String? precedingText,
|
||||
) {
|
||||
Future<String?> Function(BuildContext) _drawKanji(String? precedingText) {
|
||||
final MaterialPageRoute<String> route = MaterialPageRoute(
|
||||
builder: (final context) => Scaffold(
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Draw a kanji')),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
@@ -110,7 +111,7 @@ class GlobalSearchBar extends StatelessWidget {
|
||||
DrawingBoard(
|
||||
precedingText: precedingText,
|
||||
onlyOneCharacterSuggestions: true,
|
||||
onSuggestionChosen: (final suggestion) =>
|
||||
onSuggestionChosen: (suggestion) =>
|
||||
Navigator.pop(context, suggestion),
|
||||
),
|
||||
],
|
||||
@@ -119,6 +120,6 @@ class GlobalSearchBar extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
|
||||
return (final context) => Navigator.push<String>(context, route);
|
||||
return (context) => Navigator.push<String>(context, route);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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});
|
||||
|
||||
@@ -21,25 +22,24 @@ class _LanguageSelectorState extends State<LanguageSelector> {
|
||||
isSelected = _getSelectedStatus() ?? [false, false, false];
|
||||
}
|
||||
|
||||
void _updateSelectedStatus() => prefs.setStringList(
|
||||
Future<void> _updateSelectedStatus() async => prefs.setStringList(
|
||||
'languageSelectorStatus',
|
||||
isSelected.map((final b) => b ? '1' : '0').toList(),
|
||||
isSelected.map((b) => b ? '1' : '0').toList(),
|
||||
);
|
||||
|
||||
List<bool>? _getSelectedStatus() => prefs
|
||||
.getStringList('languageSelectorStatus')
|
||||
?.map((final s) => s == '1')
|
||||
?.map((s) => s == '1')
|
||||
.toList();
|
||||
|
||||
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),
|
||||
);
|
||||
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),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return ToggleButtons(
|
||||
selectedColor: mugitenWheatBackground,
|
||||
isSelected: isSelected,
|
||||
@@ -48,7 +48,7 @@ class _LanguageSelectorState extends State<LanguageSelector> {
|
||||
_languageOption('日本語', style: japaneseFont.value.textStyle),
|
||||
_languageOption('English'),
|
||||
],
|
||||
onPressed: (final buttonIndex) {
|
||||
onPressed: (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(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(5),
|
||||
width: 30,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/circle_badge.dart';
|
||||
import '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(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return CircleBadge(
|
||||
color: isCommon ? Colors.green : Colors.transparent,
|
||||
child: Text(
|
||||
|
||||
@@ -1,44 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jadb/util/romaji_transliteration.dart';
|
||||
|
||||
import 'package:mugiten/settings.dart';
|
||||
import '../../../../settings.dart';
|
||||
|
||||
class JapaneseHeader extends StatelessWidget {
|
||||
final String baseWord;
|
||||
final String? furigana;
|
||||
final bool dimBase;
|
||||
final (int, int)? colorSpanBase;
|
||||
final (int, int)? colorSpanFurigana;
|
||||
|
||||
const JapaneseHeader({
|
||||
super.key,
|
||||
required this.baseWord,
|
||||
required this.furigana,
|
||||
this.dimBase = false,
|
||||
this.colorSpanBase,
|
||||
this.colorSpanFurigana,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Column(
|
||||
children: [
|
||||
(furigana != null)
|
||||
? Text(
|
||||
romajiEnabled.value
|
||||
? transliterateKanaToLatin(furigana!)
|
||||
: furigana!,
|
||||
style: japaneseFont.value.textStyle,
|
||||
? RichText(
|
||||
text: TextSpan(
|
||||
children: colorSpanFurigana != null && !romajiEnabled.value
|
||||
? [
|
||||
TextSpan(
|
||||
text: furigana!.substring(
|
||||
0,
|
||||
colorSpanFurigana!.$1,
|
||||
),
|
||||
style: japaneseFont.value.textStyle,
|
||||
),
|
||||
TextSpan(
|
||||
text: furigana!.substring(
|
||||
colorSpanFurigana!.$1,
|
||||
colorSpanFurigana!.$2,
|
||||
),
|
||||
style: japaneseFont.value.textStyle.copyWith(
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: furigana!.substring(colorSpanFurigana!.$2),
|
||||
style: japaneseFont.value.textStyle,
|
||||
),
|
||||
]
|
||||
: [
|
||||
TextSpan(
|
||||
text: romajiEnabled.value
|
||||
? transliterateKanaToLatin(furigana!)
|
||||
: furigana!,
|
||||
style: japaneseFont.value.textStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const Text(''),
|
||||
Text(
|
||||
baseWord,
|
||||
style: japaneseFont.value.textStyle.merge(
|
||||
TextStyle(
|
||||
color:
|
||||
(japaneseFont.value.textStyle.color ??
|
||||
Theme.of(context).textTheme.bodyMedium?.color)
|
||||
?.withAlpha(dimBase ? 0xA0 : 0xFF),
|
||||
),
|
||||
: const SizedBox.shrink(),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
children: colorSpanBase != null
|
||||
? [
|
||||
TextSpan(
|
||||
text: baseWord.substring(0, colorSpanBase!.$1),
|
||||
style: japaneseFont.value.textStyle,
|
||||
),
|
||||
TextSpan(
|
||||
text: baseWord.substring(
|
||||
colorSpanBase!.$1,
|
||||
colorSpanBase!.$2,
|
||||
),
|
||||
style: japaneseFont.value.textStyle.copyWith(
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: baseWord.substring(colorSpanBase!.$2),
|
||||
style: japaneseFont.value.textStyle,
|
||||
),
|
||||
]
|
||||
: [
|
||||
TextSpan(
|
||||
text: baseWord,
|
||||
style: japaneseFont.value.textStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/circle_badge.dart';
|
||||
import '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(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return CircleBadge(
|
||||
color: jlptLevel != null ? Colors.blue : Colors.transparent,
|
||||
child: Text(jlptLevel ?? '', style: const TextStyle(color: Colors.white)),
|
||||
|
||||
@@ -1,14 +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 KanjiRow extends StatelessWidget {
|
||||
final List<String> kanji;
|
||||
final double fontSize;
|
||||
const KanjiRow({super.key, required this.kanji, this.fontSize = 20});
|
||||
|
||||
Widget _kanjiBox(final BuildContext context, final String kanji) {
|
||||
Widget _kanjiBox(BuildContext context, String kanji) {
|
||||
final colors = Theme.of(context).extension<MenuGreyLightThemeExtension>()!;
|
||||
|
||||
return UnconstrainedBox(
|
||||
@@ -38,7 +39,7 @@ class KanjiRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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;
|
||||
final (int, int)? colorSpanBase;
|
||||
final (int, int)? colorSpanFurigana;
|
||||
final bool showRomajiBelow;
|
||||
final ForegroundBackgroundThemeExtension colors;
|
||||
final bool autoTransliterateRomaji;
|
||||
@@ -20,6 +23,8 @@ class KanjiKanaBox extends StatelessWidget {
|
||||
required this.baseWord,
|
||||
required this.furigana,
|
||||
required this.colors,
|
||||
this.colorSpanBase,
|
||||
this.colorSpanFurigana,
|
||||
this.showRomajiBelow = false,
|
||||
this.autoTransliterateRomaji = true,
|
||||
this.centerFurigana = true,
|
||||
@@ -30,7 +35,7 @@ class KanjiKanaBox extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final fFontsize =
|
||||
furiganaFontsize ??
|
||||
((kanjiFontsize != null) ? 0.8 * kanjiFontsize! : null);
|
||||
@@ -46,19 +51,61 @@ class KanjiKanaBox extends StatelessWidget {
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
(furigana != null)
|
||||
? Text(
|
||||
romajiEnabled.value
|
||||
? transliterateKanaToLatin(furigana!)
|
||||
: furigana!,
|
||||
style:
|
||||
TextStyle(
|
||||
fontSize: fFontsize,
|
||||
color: colors.foregroundColor,
|
||||
).merge(
|
||||
romajiEnabled.value && autoTransliterateRomaji
|
||||
? null
|
||||
: japaneseFont.value.textStyle,
|
||||
),
|
||||
? RichText(
|
||||
text: TextSpan(
|
||||
children:
|
||||
colorSpanFurigana != null && !romajiEnabled.value
|
||||
? [
|
||||
TextSpan(
|
||||
text: furigana!.substring(
|
||||
0,
|
||||
colorSpanFurigana!.$1,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: fFontsize,
|
||||
color: colors.foregroundColor,
|
||||
).merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
TextSpan(
|
||||
text: furigana!.substring(
|
||||
colorSpanFurigana!.$1,
|
||||
colorSpanFurigana!.$2,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: fFontsize,
|
||||
color: Colors.red,
|
||||
).merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
TextSpan(
|
||||
text: furigana!.substring(
|
||||
colorSpanFurigana!.$2,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: fFontsize,
|
||||
color: colors.foregroundColor,
|
||||
).merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
]
|
||||
: [
|
||||
TextSpan(
|
||||
text:
|
||||
autoTransliterateRomaji &&
|
||||
romajiEnabled.value
|
||||
? transliterateKanaToLatin(furigana!)
|
||||
: furigana!,
|
||||
style:
|
||||
TextStyle(
|
||||
fontSize: fFontsize,
|
||||
color: colors.foregroundColor,
|
||||
).merge(
|
||||
autoTransliterateRomaji &&
|
||||
romajiEnabled.value
|
||||
? null
|
||||
: japaneseFont.value.textStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'あ',
|
||||
@@ -68,7 +115,25 @@ class KanjiKanaBox extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
DefaultTextStyle.merge(
|
||||
child: Text(baseWord),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: colorSpanBase != null
|
||||
? [
|
||||
TextSpan(
|
||||
text: baseWord.substring(0, colorSpanBase!.$1),
|
||||
),
|
||||
TextSpan(
|
||||
text: baseWord.substring(
|
||||
colorSpanBase!.$1,
|
||||
colorSpanBase!.$2,
|
||||
),
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
TextSpan(text: baseWord.substring(colorSpanBase!.$2)),
|
||||
]
|
||||
: [TextSpan(text: baseWord)],
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: kanjiFontsize,
|
||||
).merge(japaneseFont.value.textStyle),
|
||||
|
||||
@@ -5,7 +5,7 @@ class Notes extends StatelessWidget {
|
||||
const Notes({super.key, required this.notes});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => Column(
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Notes:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jadb/models/word_search/word_search_match_span.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;
|
||||
final List<WordSearchMatchSpan>? matchSpans;
|
||||
|
||||
const OtherForms({required this.forms, super.key});
|
||||
const OtherForms({super.key, required this.forms, this.matchSpans});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).extension<MenuGreyLightThemeExtension>()!;
|
||||
final matchSpansAsMap = matchSpans != null
|
||||
? {
|
||||
for (var i = matchSpans!.length - 1; i >= 0; i--)
|
||||
(matchSpans![i].spanType, matchSpans![i].index): (
|
||||
matchSpans![i].start,
|
||||
matchSpans![i].end,
|
||||
),
|
||||
}
|
||||
: <(WordSearchMatchSpanType, int), (int, int)>{};
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: forms.isNotEmpty
|
||||
@@ -21,11 +34,21 @@ class OtherForms extends StatelessWidget {
|
||||
),
|
||||
Wrap(
|
||||
children: [
|
||||
for (final form in forms)
|
||||
for (final (i, form) in forms.indexed)
|
||||
KanjiKanaBox(
|
||||
baseWord: form.base,
|
||||
furigana: form.furigana,
|
||||
colors: colors,
|
||||
colorSpanBase:
|
||||
matchSpansAsMap[(
|
||||
WordSearchMatchSpanType.kanji,
|
||||
i + 1,
|
||||
)],
|
||||
colorSpanFurigana:
|
||||
matchSpansAsMap[(
|
||||
WordSearchMatchSpanType.kana,
|
||||
i + 1,
|
||||
)],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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(final BuildContext context) => Wrap(
|
||||
Widget build(BuildContext context) => Wrap(
|
||||
runSpacing: 10.0,
|
||||
spacing: 5,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
|
||||
@@ -14,7 +14,7 @@ class SearchChip extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => Container(
|
||||
Widget build(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,68 +20,33 @@ class Sense extends StatelessWidget {
|
||||
|
||||
const Sense({super.key, required this.index, required this.sense});
|
||||
|
||||
String _capitalize(final String str) {
|
||||
String _capitalize(String str) {
|
||||
if (str.isEmpty) return str;
|
||||
return str[0].toUpperCase() + str.substring(1);
|
||||
}
|
||||
|
||||
final _notesTextStyle = const TextStyle(fontSize: 12);
|
||||
List<Text> _notes() {
|
||||
List<String> _notes() {
|
||||
return [
|
||||
...sense.restrictedToReading.map(
|
||||
(final e) => Text.rich(
|
||||
TextSpan(
|
||||
text: 'Restricted to ',
|
||||
style: _notesTextStyle,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '"$e"',
|
||||
style: _notesTextStyle.merge(japaneseFont.value.textStyle),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
...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.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)),
|
||||
if (e.phrase != null) {
|
||||
return 'From $languageName, "${e.phrase}"';
|
||||
} else {
|
||||
return 'From $languageName';
|
||||
}
|
||||
}),
|
||||
...sense.dialects.map((e) => '${_capitalize(e.description)} dialect'),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final lightColors = Theme.of(
|
||||
context,
|
||||
).extension<MenuGreyLightThemeExtension>()!;
|
||||
@@ -100,7 +65,7 @@ class Sense extends StatelessWidget {
|
||||
children:
|
||||
<Widget>[
|
||||
Text(
|
||||
'${index + 1}. ${sense.partsOfSpeech.map((final pos) => _capitalize(pos.shortDescription)).join(', ')}',
|
||||
'${index + 1}. ${sense.partsOfSpeech.map((pos) => _capitalize(pos.shortDescription)).join(', ')}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
@@ -111,21 +76,21 @@ class Sense extends StatelessWidget {
|
||||
if (_notes().isNotEmpty)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _notes(),
|
||||
child: Text(
|
||||
_notes().join('\n'),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (sense.antonyms.isNotEmpty &&
|
||||
sense.antonyms.first.xrefResult != null)
|
||||
const Text(
|
||||
Text(
|
||||
'Antonyms:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
...sense.antonyms
|
||||
.where((final antonym) => antonym.xrefResult != null)
|
||||
.where((antonym) => antonym.xrefResult != null)
|
||||
.map(
|
||||
(final antonym) => SearchResultCard(
|
||||
(antonym) => SearchResultCard(
|
||||
result: antonym.xrefResult!,
|
||||
backgroundColor: Colors.black38,
|
||||
leading: antonym.ambiguous
|
||||
@@ -135,14 +100,14 @@ class Sense extends StatelessWidget {
|
||||
),
|
||||
if (sense.seeAlso.isNotEmpty &&
|
||||
sense.seeAlso.first.xrefResult != null)
|
||||
const Text(
|
||||
Text(
|
||||
'See also:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
...sense.seeAlso
|
||||
.where((final seeAlso) => seeAlso.xrefResult != null)
|
||||
.where((seeAlso) => seeAlso.xrefResult != null)
|
||||
.map(
|
||||
(final seeAlso) => SearchResultCard(
|
||||
(seeAlso) => SearchResultCard(
|
||||
result: seeAlso.xrefResult!,
|
||||
backgroundColor: Colors.black38,
|
||||
leading: seeAlso.ambiguous
|
||||
@@ -152,7 +117,7 @@ class Sense extends StatelessWidget {
|
||||
),
|
||||
]
|
||||
.map(
|
||||
(final e) => Container(
|
||||
(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 'package:mugiten/components/search/search_results_body/parts/sense/sense.dart';
|
||||
import 'sense/sense.dart';
|
||||
|
||||
class Senses extends StatelessWidget {
|
||||
final List<WordSearchSense> senses;
|
||||
@@ -13,7 +13,7 @@ class Senses extends StatelessWidget {
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => Column(
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: _senseWidgets,
|
||||
);
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
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_match_span.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;
|
||||
@@ -51,7 +51,7 @@ class _SearchResultCardState extends State<SearchResultCard> {
|
||||
.get<Database>()
|
||||
.libraryListAllListsContain(jmdictEntryId: widget.result.entryId)
|
||||
.then(
|
||||
(final data) => setState(() {
|
||||
(data) => setState(() {
|
||||
isFavourited = data['favourites'] ?? false;
|
||||
isQuickListed =
|
||||
quickAddLibraryList.value != null &&
|
||||
@@ -62,78 +62,109 @@ class _SearchResultCardState extends State<SearchResultCard> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(fetchFavouriteAndQuickListStatus());
|
||||
fetchFavouriteAndQuickListStatus();
|
||||
}
|
||||
|
||||
List<String> get kanji => kanjiRegex
|
||||
.allMatches(
|
||||
widget.result.japanese
|
||||
.map((final w) => '${w.base}${w.furigana ?? ""}')
|
||||
.map((w) => '${w.base}${w.furigana ?? ""}')
|
||||
.join(),
|
||||
)
|
||||
.map((final match) => match.group(0)!)
|
||||
.map((match) => match.group(0)!)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
Widget get _header => Row(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
Widget get _header {
|
||||
final firstMatchSpans =
|
||||
widget.result.matchSpans
|
||||
?.where((span) => span.index == 0)
|
||||
.toList(growable: false) ??
|
||||
[];
|
||||
final colorSpanBase = firstMatchSpans
|
||||
.where((span) => span.spanType == WordSearchMatchSpanType.kanji)
|
||||
.map((span) => (span.start, span.end))
|
||||
.firstOrNull;
|
||||
final colorSpanFurigana = firstMatchSpans
|
||||
.where((span) => span.spanType == WordSearchMatchSpanType.kana)
|
||||
.map((span) => (span.start, span.end))
|
||||
.firstOrNull;
|
||||
|
||||
return IntrinsicWidth(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// TODO: draw sizedbox to take up space instead
|
||||
if (!quickAddLibraryList.contains('favourites'))
|
||||
Icon(
|
||||
Icons.bookmark,
|
||||
color: isQuickListed ? Colors.blue : Colors.transparent,
|
||||
size: 20,
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// TODO: draw sizedbox to take up space instead
|
||||
if (quickAddLibraryList.value != '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,
|
||||
colorSpanBase: colorSpanBase,
|
||||
colorSpanFurigana: colorSpanFurigana,
|
||||
),
|
||||
Icon(
|
||||
Icons.star,
|
||||
color: isFavourited ? Colors.yellow : Colors.transparent,
|
||||
size: 20,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
JLPTBadge(jlptLevel: widget.result.jlptLevel.toNullableString()),
|
||||
CommonBadge(isCommon: widget.result.isCommon),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
static const _margin = SizedBox(height: 20);
|
||||
|
||||
List<Widget> _withMargin(final Widget w) => [_margin, w];
|
||||
List<Widget> _withMargin(Widget w) => [_margin, w];
|
||||
|
||||
Widget _body() => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Senses(senses: widget.result.senses),
|
||||
Widget _body() {
|
||||
final List<WordSearchMatchSpan>? matchSpans = widget.result.matchSpans
|
||||
?.where((span) => span.index != 0)
|
||||
.toList(growable: false);
|
||||
|
||||
if (widget.result.japanese.length > 1)
|
||||
..._withMargin(OtherForms(forms: widget.result.japanese.sublist(1))),
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Senses(senses: widget.result.senses),
|
||||
|
||||
// TODO:
|
||||
// if (extendedData != null && extendedData.notes.isNotEmpty)
|
||||
// ..._withMargin(Notes(notes: extendedData.notes)),
|
||||
if (kanji.isNotEmpty) ..._withMargin(KanjiRow(kanji: kanji)),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (widget.result.japanese.length > 1)
|
||||
..._withMargin(
|
||||
OtherForms(
|
||||
forms: widget.result.japanese.sublist(1),
|
||||
matchSpans: matchSpans,
|
||||
),
|
||||
),
|
||||
|
||||
// TODO:
|
||||
// if (extendedData != null && extendedData.notes.isNotEmpty)
|
||||
// ..._withMargin(Notes(notes: extendedData.notes)),
|
||||
if (kanji.isNotEmpty) ..._withMargin(KanjiRow(kanji: kanji)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final backgroundColor =
|
||||
widget.backgroundColor ?? Theme.of(context).scaffoldBackgroundColor;
|
||||
|
||||
@@ -142,25 +173,21 @@ class _SearchResultCardState extends State<SearchResultCard> {
|
||||
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()),
|
||||
);
|
||||
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()),
|
||||
);
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListInsertEntry(
|
||||
quickAddLibraryList.value!,
|
||||
jmdictEntryId: widget.result.entryId,
|
||||
)
|
||||
.then((_) => fetchFavouriteAndQuickListStatus());
|
||||
}
|
||||
},
|
||||
child: Slidable(
|
||||
@@ -186,7 +213,7 @@ class _SearchResultCardState extends State<SearchResultCard> {
|
||||
SlidableAction(
|
||||
backgroundColor: Colors.blue,
|
||||
icon: Icons.bookmark,
|
||||
onPressed: (final context) => showAddToLibraryDialog(
|
||||
onPressed: (context) => showAddToLibraryDialog(
|
||||
context: context,
|
||||
jmdictEntryId: widget.result.entryId,
|
||||
kanji: null,
|
||||
|
||||
@@ -50,7 +50,7 @@ Future<void> quickInitializeDatabase() async {
|
||||
await setupDatabase();
|
||||
}
|
||||
|
||||
// Migration logic and heavy initialization
|
||||
/// Migration logic and heavy initialization
|
||||
|
||||
class DatabaseMigration {
|
||||
final String path;
|
||||
@@ -77,7 +77,7 @@ Future<List<DatabaseMigration>> readMigrationsFromAssets() async {
|
||||
final List<String> migrations = assetManifest
|
||||
.listAssets()
|
||||
.where(
|
||||
(final assetPath) =>
|
||||
(assetPath) =>
|
||||
RegExp(r'^migrations\/\d{4}.*\.sql$').hasMatch(assetPath),
|
||||
)
|
||||
.toList();
|
||||
@@ -92,55 +92,52 @@ Future<List<DatabaseMigration>> readMigrationsFromAssets() async {
|
||||
}
|
||||
|
||||
return Future.wait(
|
||||
migrations.map((final migration) async {
|
||||
migrations.map((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(
|
||||
final Database db,
|
||||
final Iterable<DatabaseMigration> migrations,
|
||||
) async {
|
||||
/// Migrates the database from version [oldVersion] to [newVersion].
|
||||
Future<void> migrate(Database db, List<DatabaseMigration> migrations) async {
|
||||
for (final migration in migrations) {
|
||||
log('Running migration ${migration.version} from ${migration.path}');
|
||||
migration.content
|
||||
.split(';')
|
||||
.map(
|
||||
(final s) => s
|
||||
(s) => s
|
||||
.split('\n')
|
||||
.where((final l) => !l.startsWith(RegExp(r'\s*--')))
|
||||
.where((l) => !l.startsWith(RegExp(r'\s*--')))
|
||||
.join('\n')
|
||||
.trim(),
|
||||
)
|
||||
.where((final s) => s != '')
|
||||
.where((s) => s != '')
|
||||
.forEach(db.execute);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Database> openDatabaseWithoutMigrations(
|
||||
final String dbPath, {
|
||||
final bool readOnly = false,
|
||||
final bool verifyTables = true,
|
||||
String dbPath, {
|
||||
bool readOnly = false,
|
||||
bool verifyTables = true,
|
||||
}) async {
|
||||
log('Opening database at $dbPath');
|
||||
final Database database = await openDatabase(
|
||||
dbPath,
|
||||
version: expectedDatabaseVersion,
|
||||
readOnly: readOnly,
|
||||
onConfigure: (final db) async {
|
||||
onConfigure: (db) async {
|
||||
// Enable foreign key constraints
|
||||
await db.execute('PRAGMA foreign_keys=ON');
|
||||
},
|
||||
onOpen: (final db) async {
|
||||
onOpen: (db) async {
|
||||
if (verifyTables) {
|
||||
log('Verifying jadb tables...');
|
||||
await db.jadbVerifyTables();
|
||||
db.jadbVerifyTables();
|
||||
|
||||
log('Verifying mugiten tables...');
|
||||
await verifyMugitenTablesWithDbConnection(db);
|
||||
verifyMugitenTablesWithDbConnection(db);
|
||||
|
||||
log('Database tables verified successfully');
|
||||
}
|
||||
@@ -150,19 +147,19 @@ Future<Database> openDatabaseWithoutMigrations(
|
||||
}
|
||||
|
||||
Future<Database> openAndMigrateDatabase(
|
||||
final String dbPath,
|
||||
final Iterable<DatabaseMigration> migrations,
|
||||
String dbPath,
|
||||
List<DatabaseMigration> migrations,
|
||||
) async {
|
||||
log('Opening database at $dbPath');
|
||||
final Database database = await openDatabase(
|
||||
dbPath,
|
||||
version: expectedDatabaseVersion,
|
||||
readOnly: false,
|
||||
onUpgrade: (final db, final oldVersion, final newVersion) async {
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
log('Migrating database from v$oldVersion to v$newVersion...');
|
||||
final migrationsToRun = migrations
|
||||
.where(
|
||||
(final migration) =>
|
||||
(migration) =>
|
||||
migration.version > oldVersion &&
|
||||
migration.version <= newVersion,
|
||||
)
|
||||
@@ -170,16 +167,16 @@ Future<Database> openAndMigrateDatabase(
|
||||
|
||||
await migrate(db, migrationsToRun);
|
||||
},
|
||||
onConfigure: (final db) async {
|
||||
onConfigure: (db) async {
|
||||
// Enable foreign key constraints
|
||||
await db.execute('PRAGMA foreign_keys=ON');
|
||||
},
|
||||
onOpen: (final db) async {
|
||||
onOpen: (db) async {
|
||||
log('Verifying jadb tables...');
|
||||
await db.jadbVerifyTables();
|
||||
db.jadbVerifyTables();
|
||||
|
||||
log('Verifying jadb tables...');
|
||||
await verifyMugitenTablesWithDbConnection(db);
|
||||
verifyMugitenTablesWithDbConnection(db);
|
||||
|
||||
log('Database tables verified successfully');
|
||||
},
|
||||
@@ -225,10 +222,12 @@ 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(final String path) async {
|
||||
final File jadbFile = File(path)..createSync();
|
||||
Future<void> extractJadbFromAssets(String path) async {
|
||||
final File jadbFile = File(path);
|
||||
|
||||
jadbFile.createSync();
|
||||
|
||||
final ByteData data = await rootBundle.load('assets/jadb.sqlite');
|
||||
await jadbFile.writeAsBytes(
|
||||
|
||||
76
lib/database/history/export.dart
Normal file
76
lib/database/history/export.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
// /// Can assume Android for time being
|
||||
// Future<void> exportData(context) async {
|
||||
// // setState(() => dataExportIsLoading = true);
|
||||
|
||||
// final path = (await getExternalStorageDirectory())!;
|
||||
// final dbData = await exportDatabase(db);
|
||||
// final file = File('${path.path}/jisho_data.json');
|
||||
// file.createSync(recursive: true);
|
||||
// await file.writeAsString(jsonEncode(dbData));
|
||||
|
||||
// setState(() => dataExportIsLoading = false);
|
||||
// ScaffoldMessenger.of(context)
|
||||
// .showSnackBar(SnackBar(content: Text('Data exported to ${file.path}')));
|
||||
// }
|
||||
|
||||
// /// Can assume Android for time being
|
||||
// Future<void> importData(context) async {
|
||||
// // setState(() => dataImportIsLoading = true);
|
||||
|
||||
// final path = await FilePicker.platform.pickFiles(
|
||||
// type: FileType.custom,
|
||||
// allowedExtensions: ['json'],
|
||||
// );
|
||||
// final file = File(path!.files[0].path!);
|
||||
|
||||
// final List<Search> prevSearches = (await Search.store.find(db))
|
||||
// .map((e) => Search.fromJson(e.value! as Map<String, Object?>))
|
||||
// .toList();
|
||||
// late final List<Search> importedSearches;
|
||||
// try {
|
||||
// importedSearches = (jsonDecode(await file.readAsString())
|
||||
// as List<dynamic>)
|
||||
// // importedSearches = (((jsonDecode(await file.readAsString())
|
||||
// // as Map<String, Object?>)['stores']! as List<Object?>)
|
||||
// // .map((e) => e! as Map<String, Object?>)
|
||||
// // .where((e) => e['name'] == 'search')
|
||||
// // .first['values'] as List<dynamic>)
|
||||
// .map((item) => Search.fromJson(item))
|
||||
// .toList();
|
||||
// } catch (e) {
|
||||
// debugPrint(e.toString());
|
||||
// showSnackbar(
|
||||
// context,
|
||||
// "Couldn't read file. Did you choose the right one?",
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
|
||||
// final List<Search> mergedSearches =
|
||||
// mergeSearches(prevSearches, importedSearches);
|
||||
|
||||
// // print(mergedSearches);
|
||||
|
||||
// await GetIt.instance.get<Database>().close();
|
||||
// GetIt.instance.unregister<Database>();
|
||||
|
||||
// final importedDb = await importDatabase(
|
||||
// {
|
||||
// 'sembast_export': 1,
|
||||
// 'version': 1,
|
||||
// 'stores': [
|
||||
// {
|
||||
// 'name': 'search',
|
||||
// 'keys': [for (var i = 1; i <= mergedSearches.length; i++) i],
|
||||
// 'values': mergedSearches.map((e) => e.toJson()).toList(),
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// databaseFactoryIo,
|
||||
// await databasePath(),
|
||||
// );
|
||||
// GetIt.instance.registerSingleton<Database>(importedDb);
|
||||
|
||||
// setState(() => dataImportIsLoading = false);
|
||||
// showSnackbar(context, 'Data imported successfully');
|
||||
// }
|
||||
@@ -1,14 +1,13 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/routing/router.dart';
|
||||
import 'package:mugiten/screens/initialization.dart';
|
||||
import 'package:mugiten/services/initialization/initialization_logic.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
|
||||
void runInitializationScreen(final bool deleteDatabase) {
|
||||
import 'routing/router.dart';
|
||||
|
||||
void runInitializationScreen(bool deleteDatabase) {
|
||||
runApp(
|
||||
InitializationView(
|
||||
onInitializationComplete: () =>
|
||||
@@ -43,12 +42,10 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
unawaited(
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]),
|
||||
);
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
GetIt.instance.registerSingleton<ThemeController>(themeController);
|
||||
}
|
||||
|
||||
@@ -65,10 +62,10 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<AppThemeMode>(
|
||||
valueListenable: themeController.themeMode,
|
||||
builder: (final context, final themeMode, _) {
|
||||
builder: (context, themeMode, _) {
|
||||
return MaterialApp(
|
||||
title: '麦典',
|
||||
theme: themeMode.lightThemeData,
|
||||
|
||||
@@ -8,7 +8,7 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
// Query
|
||||
|
||||
Future<HistoryEntry?> historyEntryGetWord(
|
||||
final String word,
|
||||
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(
|
||||
(final e) =>
|
||||
(e) =>
|
||||
DateTime.fromMillisecondsSinceEpoch(e['timestamp']! as int),
|
||||
)
|
||||
.toList();
|
||||
@@ -51,8 +51,8 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
}
|
||||
|
||||
Future<HistoryEntry?> historyEntryGetKanji(
|
||||
final String kanji, {
|
||||
final bool includeSearchResult = false,
|
||||
String kanji, {
|
||||
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(
|
||||
(final e) =>
|
||||
(e) =>
|
||||
DateTime.fromMillisecondsSinceEpoch(e['timestamp']! as int),
|
||||
)
|
||||
.toList();
|
||||
@@ -94,19 +94,13 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
}
|
||||
|
||||
Future<List<HistoryEntry>> historyEntryGetAll({
|
||||
final int? page,
|
||||
final int? pageSize,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
// TODO: implement join against jadb
|
||||
// bool includeSearchResult = false,
|
||||
}) async {
|
||||
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(page == null || page >= 0);
|
||||
assert(pageSize == null || pageSize > 0);
|
||||
assert(
|
||||
pageSize != null || page == null,
|
||||
'pageSize must be provided if page is provided',
|
||||
@@ -127,10 +121,10 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
[?pageSize, if (page != null) page * pageSize!],
|
||||
);
|
||||
|
||||
final List<HistoryEntry> entries = result.map((final e) {
|
||||
final List<HistoryEntry> entries = result.map((e) {
|
||||
final timestamps = (e['timestamps'] as String)
|
||||
.split(',')
|
||||
.map((final ts) => DateTime.fromMillisecondsSinceEpoch(int.parse(ts)))
|
||||
.map((ts) => DateTime.fromMillisecondsSinceEpoch(int.parse(ts)))
|
||||
.toList();
|
||||
|
||||
if (e['kanji'] != null) {
|
||||
@@ -153,7 +147,7 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
|
||||
Future<int> historyEntryAmount({
|
||||
/// Whether to ignore duplicate searches
|
||||
final bool unique = true,
|
||||
bool unique = true,
|
||||
}) async {
|
||||
late final int count;
|
||||
|
||||
@@ -176,7 +170,7 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
|
||||
// Modification
|
||||
|
||||
Future<void> historyEntryInsertKanji(final String kanji) async {
|
||||
Future<void> historyEntryInsertKanji(String kanji) async {
|
||||
final DateTime timestamp = DateTime.now();
|
||||
|
||||
final existingEntry = await query(
|
||||
@@ -208,10 +202,7 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> historyEntryInsertWord(
|
||||
final String word, {
|
||||
final String? language,
|
||||
}) async {
|
||||
Future<void> historyEntryInsertWord(String word, {String? language}) async {
|
||||
final DateTime timestamp = DateTime.now();
|
||||
|
||||
final existingEntry = await query(
|
||||
@@ -245,77 +236,7 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: add a parameter flag to ignore existing id and assign a new one.
|
||||
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 {
|
||||
Future<bool> historyEntryDelete(int entryId) async {
|
||||
await delete(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
where: 'entryId = ?',
|
||||
@@ -344,8 +265,8 @@ extension HistoryEntryExt on DatabaseExecutor {
|
||||
}
|
||||
|
||||
Future<bool> historyEntryDeleteTimestamp(
|
||||
final int entryId,
|
||||
final DateTime timestamp,
|
||||
int entryId,
|
||||
DateTime timestamp,
|
||||
) async {
|
||||
final timestampCount = await query(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
@@ -375,6 +296,60 @@ 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 {
|
||||
@@ -427,16 +402,14 @@ class HistoryEntry {
|
||||
bool get isKanji => word == null;
|
||||
int get timestampCount => timestamps.length;
|
||||
DateTime get lastTimestamp => timestamps.isNotEmpty
|
||||
? timestamps.reduce((final a, final b) => a.isAfter(b) ? a : b)
|
||||
? timestamps.reduce((a, b) => a.isAfter(b) ? a : b)
|
||||
: DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
'word': word,
|
||||
'kanji': kanji,
|
||||
'timestamps': timestamps
|
||||
.map((final ts) => ts.millisecondsSinceEpoch)
|
||||
.toList(),
|
||||
'timestamps': timestamps.map((ts) => ts.millisecondsSinceEpoch).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,9 @@ 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({
|
||||
final int? page,
|
||||
final int? pageSize,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
}) async {
|
||||
final result = await rawQuery(
|
||||
'''
|
||||
@@ -34,7 +33,7 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
|
||||
return result
|
||||
.map(
|
||||
(final row) => LibraryList(
|
||||
(row) => LibraryList(
|
||||
name: row['name'] as String,
|
||||
totalCount: row['count'] as int? ?? 0,
|
||||
),
|
||||
@@ -42,10 +41,7 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
Future<LibraryList?> libraryListGetList(String listName) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
final result = await rawQuery(
|
||||
@@ -73,17 +69,11 @@ 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(
|
||||
final String listName, {
|
||||
final int? page,
|
||||
final int? pageSize,
|
||||
final bool includeSearchResult = false,
|
||||
String listName, {
|
||||
int? page,
|
||||
int? pageSize,
|
||||
bool includeSearchResult = false,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
assert(
|
||||
@@ -151,21 +141,21 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
Map<String, KanjiSearchResult>? kanjiResults;
|
||||
if (includeSearchResult) {
|
||||
final wordResultJmdictIds = entries
|
||||
.where((final e) => e['jmdictEntryId'] != null)
|
||||
.map((final e) => e['jmdictEntryId'] as int)
|
||||
.where((e) => e['jmdictEntryId'] != null)
|
||||
.map((e) => e['jmdictEntryId'] as int)
|
||||
.toSet();
|
||||
|
||||
wordResults = await jadbGetManyWordsByIds(wordResultJmdictIds);
|
||||
|
||||
final kanjiResultKanjis = entries
|
||||
.where((final e) => e['kanji'] != null)
|
||||
.map((final e) => e['kanji'] as String)
|
||||
.where((e) => e['kanji'] != null)
|
||||
.map((e) => e['kanji'] as String)
|
||||
.toSet();
|
||||
|
||||
kanjiResults = await jadbGetManyKanji(kanjiResultKanjis);
|
||||
}
|
||||
|
||||
final result = entries.map((final entry) {
|
||||
final result = entries.map((entry) {
|
||||
if (entry['jmdictEntryId'] != null) {
|
||||
return LibraryListEntry.fromJmdictId(
|
||||
jmdictEntryId: entry['jmdictEntryId'] as int,
|
||||
@@ -196,77 +186,9 @@ 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({
|
||||
final int? jmdictEntryId,
|
||||
final String? kanji,
|
||||
int? jmdictEntryId,
|
||||
String? kanji,
|
||||
}) async {
|
||||
final result = await rawQuery(
|
||||
'''
|
||||
@@ -288,11 +210,10 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
/// Get whether a specific library list contains an entry.
|
||||
Future<bool> libraryListListContains(
|
||||
final String listName, {
|
||||
final int? jmdictEntryId,
|
||||
final String? kanji,
|
||||
String listName, {
|
||||
int? jmdictEntryId,
|
||||
String? kanji,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
final result = await rawQuery(
|
||||
@@ -309,11 +230,7 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
return (result.firstOrNull?['exists'] as int? ?? 0) == 1;
|
||||
}
|
||||
|
||||
/// Rename a library list.
|
||||
Future<void> libraryListRenameList(
|
||||
final String oldName,
|
||||
final String newName,
|
||||
) async {
|
||||
Future<void> libraryListRenameList(String oldName, String newName) async {
|
||||
if (oldName.isEmpty) {
|
||||
throw ArgumentError('Old library list name must not be empty.');
|
||||
}
|
||||
@@ -334,30 +251,25 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
throw ArgumentError('Library list "$newName" already exists.');
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
final b = batch();
|
||||
|
||||
b.update(
|
||||
LibraryListTableNames.libraryList,
|
||||
{'name': newName},
|
||||
where: '"name" = ?',
|
||||
whereArgs: [oldName],
|
||||
);
|
||||
|
||||
b.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,
|
||||
@@ -367,8 +279,7 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
return result.firstOrNull?['count'] as int? ?? 0;
|
||||
}
|
||||
|
||||
/// Get whether a library list with the specified name exists.
|
||||
Future<bool> libraryListExists(final String listName) async {
|
||||
Future<bool> libraryListExists(String listName) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
final result = await rawQuery(
|
||||
'''
|
||||
@@ -385,10 +296,10 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
|
||||
// Modification
|
||||
|
||||
/// Insert a new library list into the database.
|
||||
/// Inserts a new library list into the database.
|
||||
Future<bool> libraryListInsertList(
|
||||
final String listName, {
|
||||
final bool existsOk = true,
|
||||
String listName, {
|
||||
bool existsOk = true,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
@@ -406,17 +317,17 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Delete a library list by its name.
|
||||
/// Deletes a library list by its name.
|
||||
Future<bool> libraryListDeleteList(
|
||||
final String listName, {
|
||||
final bool notEmptyOk = true,
|
||||
final bool doesNotExistOk = false,
|
||||
String listName, {
|
||||
bool notEmptyOk = true,
|
||||
bool doesNotExistOk = false,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
assert(listName != 'favourites', 'Cannot delete the "favourites" list.');
|
||||
|
||||
if (!(await libraryListExists(listName))) {
|
||||
return doesNotExistOk;
|
||||
if (!doesNotExistOk && !(await libraryListExists(listName))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!notEmptyOk &&
|
||||
@@ -424,50 +335,19 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
return false;
|
||||
}
|
||||
|
||||
final listQuery = (await query(
|
||||
final result = await delete(
|
||||
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".',
|
||||
);
|
||||
|
||||
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;
|
||||
return doesNotExistOk || result > 0;
|
||||
}
|
||||
|
||||
/// Delete all entries in a library list.
|
||||
/// Deletes all entries in a library list.
|
||||
Future<bool> libraryListDeleteAllEntries(
|
||||
final String listName, {
|
||||
final bool doesNotExistOk = false,
|
||||
String listName, {
|
||||
bool doesNotExistOk = false,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
@@ -486,15 +366,13 @@ 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(
|
||||
final String listName, {
|
||||
final int? jmdictEntryId,
|
||||
final String? kanji,
|
||||
final int? position,
|
||||
String listName, {
|
||||
int? jmdictEntryId,
|
||||
String? kanji,
|
||||
int? position,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
assert(
|
||||
@@ -529,20 +407,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,
|
||||
})
|
||||
..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,
|
||||
});
|
||||
|
||||
b.update(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
{'prevEntryJmdictEntryId': jmdictEntryId, 'prevEntryKanji': kanji},
|
||||
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
|
||||
whereArgs: [listName, nextEntry.jmdictEntryId, nextEntry.kanji],
|
||||
);
|
||||
|
||||
await b.commit();
|
||||
|
||||
@@ -565,68 +443,14 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Deletes an entry at a specific position in the library list.
|
||||
///
|
||||
/// This function returns false if the list does not exist,
|
||||
/// or if the entry is not already a part of the list.
|
||||
Future<bool> libraryListDeleteEntry(
|
||||
final String listName, {
|
||||
final int? jmdictEntryId,
|
||||
final String? kanji,
|
||||
String listName, {
|
||||
int? jmdictEntryId,
|
||||
String? kanji,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
assert(
|
||||
@@ -666,7 +490,7 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
final prevEntryKanji = entryQuery.first['prevEntryKanji'] as String?;
|
||||
|
||||
final LibraryListEntry? nextEntry = nextEntryQuery
|
||||
.map(LibraryListEntry.fromDBMap)
|
||||
.map((e) => LibraryListEntry.fromDBMap(e))
|
||||
.firstOrNull;
|
||||
|
||||
// TODO: use a transaction instead of a batch
|
||||
@@ -684,18 +508,18 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
b
|
||||
..delete(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
|
||||
whereArgs: [listName, jmdictEntryId, kanji],
|
||||
)
|
||||
..commit();
|
||||
b.delete(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
|
||||
whereArgs: [listName, jmdictEntryId, kanji],
|
||||
);
|
||||
|
||||
b.commit();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Delete an entry at a specific position in the library list.
|
||||
/// Deletes an entry at a specific position in the library list.
|
||||
///
|
||||
/// This function returns false if the position is out of bounds,
|
||||
/// or if the list does not exist.
|
||||
@@ -704,8 +528,8 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
/// in contrast to `libraryListDeleteEntry` which has a time complexity of whatever
|
||||
/// SQLite uses for its indices.
|
||||
Future<bool> libraryListDeleteEntryByPosition(
|
||||
final String listName,
|
||||
final int position,
|
||||
String listName,
|
||||
int position,
|
||||
) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
@@ -735,78 +559,27 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Reorder an entry within the library list.
|
||||
///
|
||||
/// The position is zero-indexed.
|
||||
/// Reorders an entry within the library list.
|
||||
///
|
||||
/// This function returns false if the position is out of bounds,
|
||||
/// if the list does not exist, or if the entry is not already a part of the list.
|
||||
Future<bool> libraryListMoveEntry(
|
||||
final String listName,
|
||||
final int newPosition, {
|
||||
final int? jmdictEntryId,
|
||||
final String? kanji,
|
||||
String listName,
|
||||
int newPosition, {
|
||||
int? jmdictEntryId,
|
||||
String? kanji,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
assert(
|
||||
(jmdictEntryId == null) != (kanji == null),
|
||||
'Either jmdictEntryId or kanji must be provided, but not both.',
|
||||
);
|
||||
|
||||
if (!await libraryListExists(listName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!await libraryListListContains(
|
||||
listName,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Append an entry to the library list if it's not there already,
|
||||
/// Appends an entry to the library list if it's not there already,
|
||||
/// or removes it if it is. Returns whether the entry is now in the list.
|
||||
Future<bool> libraryListToggleEntry(
|
||||
final String listName, {
|
||||
final int? jmdictEntryId,
|
||||
final String? kanji,
|
||||
final bool? overrideToggleOn,
|
||||
String listName, {
|
||||
int? jmdictEntryId,
|
||||
String? kanji,
|
||||
bool? overrideToggleOn,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
@@ -843,16 +616,40 @@ extension LibraryListExt on DatabaseExecutor {
|
||||
return shouldToggleOn;
|
||||
}
|
||||
|
||||
/// Verify the linked list structure of the list of library lists.
|
||||
/// Verifies the linked list structure of the list of library lists.
|
||||
Future<bool> libraryListVerifyLists() async {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Verify the linked list structure of a single library list.
|
||||
Future<bool> libraryListVerifyList(final String listName) async {
|
||||
/// Verifies the linked list structure of a single library list.
|
||||
Future<bool> libraryListVerifyList(String listName) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
// Future<void> libraryListInsertJsonEntries(
|
||||
// List<Map<String, Object?>> jsonEntries,
|
||||
// ) async {
|
||||
// throw UnimplementedError();
|
||||
// }
|
||||
|
||||
Future<void> libraryListInsertJsonEntriesForSingleList(
|
||||
String listName,
|
||||
List<Map<String, Object?>> jsonEntries,
|
||||
) async {
|
||||
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 {
|
||||
@@ -884,7 +681,7 @@ class LibraryListEntry {
|
||||
final KanjiSearchResult? kanjiSearchResult;
|
||||
|
||||
LibraryListEntry({
|
||||
final DateTime? lastModified,
|
||||
DateTime? lastModified,
|
||||
this.wordSearchResult,
|
||||
this.jmdictEntryId,
|
||||
this.kanji,
|
||||
@@ -899,18 +696,18 @@ class LibraryListEntry {
|
||||
"Library entry can't have both kanji and jmdictEntryId",
|
||||
),
|
||||
assert(
|
||||
kanjiSearchResult == null || kanjiSearchResult.kanji == kanji,
|
||||
kanjiSearchResult?.kanji == kanji,
|
||||
"KanjiSearchResult's kanji must match the kanji in LibraryListEntry",
|
||||
),
|
||||
assert(
|
||||
wordSearchResult == null || wordSearchResult.entryId == jmdictEntryId,
|
||||
wordSearchResult?.entryId == jmdictEntryId,
|
||||
"WordSearchResult's jmdictEntryId must match the jmdictEntryId in LibraryListEntry",
|
||||
);
|
||||
|
||||
LibraryListEntry.fromJmdictId({
|
||||
required int this.jmdictEntryId,
|
||||
this.wordSearchResult,
|
||||
final DateTime? lastModified,
|
||||
DateTime? lastModified,
|
||||
}) : lastModified = lastModified ?? DateTime.now(),
|
||||
kanji = null,
|
||||
kanjiSearchResult = null;
|
||||
@@ -918,7 +715,7 @@ class LibraryListEntry {
|
||||
LibraryListEntry.fromKanji({
|
||||
required String this.kanji,
|
||||
this.kanjiSearchResult,
|
||||
final DateTime? lastModified,
|
||||
DateTime? lastModified,
|
||||
}) : lastModified = lastModified ?? DateTime.now(),
|
||||
jmdictEntryId = null,
|
||||
wordSearchResult = null;
|
||||
@@ -929,7 +726,7 @@ class LibraryListEntry {
|
||||
'lastModified': lastModified.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
factory LibraryListEntry.fromJson(final Map<String, Object?> json) {
|
||||
factory LibraryListEntry.fromJson(Map<String, Object?> json) {
|
||||
assert(
|
||||
(json.containsKey('kanji') && json['kanji'] != null) ||
|
||||
(json.containsKey('jmdictEntryId') && json['jmdictEntryId'] != null),
|
||||
@@ -958,6 +755,6 @@ class LibraryListEntry {
|
||||
}
|
||||
|
||||
// NOTE: this just happens to be the same as the logic in `fromJson`
|
||||
factory LibraryListEntry.fromDBMap(final Map<String, Object?> dbObject) =>
|
||||
factory LibraryListEntry.fromDBMap(Map<String, Object?> dbObject) =>
|
||||
LibraryListEntry.fromJson(dbObject);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ 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(
|
||||
final DatabaseExecutor db,
|
||||
) async {
|
||||
Future<void> verifyMugitenTablesWithDbConnection(DatabaseExecutor db) async {
|
||||
final Set<String> tables = await db
|
||||
.query(
|
||||
'sqlite_master',
|
||||
@@ -12,8 +10,8 @@ Future<void> verifyMugitenTablesWithDbConnection(
|
||||
where: 'type IN (?, ?)',
|
||||
whereArgs: ['table', 'view'],
|
||||
)
|
||||
.then((final result) {
|
||||
return result.map((final row) => row['name'] as String).toSet();
|
||||
.then((result) {
|
||||
return result.map((row) => row['name'] as String).toSet();
|
||||
});
|
||||
|
||||
final Set<String> expectedTables = {
|
||||
@@ -27,10 +25,10 @@ Future<void> verifyMugitenTablesWithDbConnection(
|
||||
throw Exception(
|
||||
[
|
||||
'Missing tables:',
|
||||
missingTables.map((final table) => ' - $table').join('\n'),
|
||||
missingTables.map((table) => ' - $table').join('\n'),
|
||||
'',
|
||||
'Found tables:\n',
|
||||
tables.map((final table) => ' - $table').join('\n'),
|
||||
tables.map((table) => ' - $table').join('\n'),
|
||||
'',
|
||||
'Please ensure the database is correctly set up.',
|
||||
].join('\n'),
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
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';
|
||||
|
||||
Route<Widget> generateRoute(final RouteSettings settings) {
|
||||
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) {
|
||||
final args = settings.arguments;
|
||||
|
||||
switch (settings.name) {
|
||||
|
||||
@@ -6,7 +6,7 @@ class DebugView extends StatelessWidget {
|
||||
const DebugView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(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: (final context, final snapshot) {
|
||||
builder: (context, 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: (final context, final index) {
|
||||
itemBuilder: (context, 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: (final state) =>
|
||||
getNextPageKey: (state) =>
|
||||
state.lastPageIsEmpty ? null : state.nextIntPageKey,
|
||||
fetchPage: (final pageKey) async {
|
||||
fetchPage: (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(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<int>(
|
||||
future: GetIt.instance.get<Database>().historyEntryAmount(),
|
||||
builder: (final context, final snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
// TODO: provide proper error handling
|
||||
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
|
||||
if (!snapshot.hasData) return const LoadingScreen();
|
||||
@@ -70,18 +70,17 @@ class _HistoryViewState extends State<HistoryView> {
|
||||
Expanded(
|
||||
child: PagingListener(
|
||||
controller: _pagingController,
|
||||
builder: (final context, final state, final fetchNextPage) =>
|
||||
builder: (context, state, fetchNextPage) =>
|
||||
PagedListView<int, HistoryEntry?>.separated(
|
||||
state: state,
|
||||
fetchNextPage: fetchNextPage,
|
||||
separatorBuilder: (final context, final index) {
|
||||
separatorBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
if (_pagingController.items == null ||
|
||||
_pagingController.items!.length < 2) {
|
||||
// No history entries, or the items has not been loaded yet.
|
||||
return const SizedBox.shrink();
|
||||
return 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);
|
||||
@@ -96,7 +95,6 @@ class _HistoryViewState extends State<HistoryView> {
|
||||
final HistoryEntry? previousSearch =
|
||||
data.length > index + 1 ? data[index + 1] : null;
|
||||
|
||||
// Date divider
|
||||
if (previousSearch != null &&
|
||||
!dateIsEqual(
|
||||
search.lastTimestamp,
|
||||
@@ -105,36 +103,22 @@ 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:
|
||||
(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!',
|
||||
itemBuilder: (context, entry, index) => index == 0
|
||||
? SizedBox.shrink()
|
||||
: HistoryEntryTile(
|
||||
entry: entry!,
|
||||
objectKey: entry.id,
|
||||
onDelete: () => _pagingController.refresh(),
|
||||
),
|
||||
),
|
||||
noItemsFoundIndicatorBuilder: (context) => const Center(
|
||||
child: Text(
|
||||
'The history is empty.\nTry searching for something!',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -146,24 +130,8 @@ class _HistoryViewState extends State<HistoryView> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dateDivider(final DateTime date) =>
|
||||
Widget _dateDivider(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,18 +1,19 @@
|
||||
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});
|
||||
|
||||
@@ -26,49 +27,45 @@ class _HomeState extends State<Home> {
|
||||
_Page get page => pages[pageNum];
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).extension<MenuGreyDarkThemeExtension>()!;
|
||||
return Scaffold(
|
||||
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,
|
||||
),
|
||||
builder: (context, incognitoEnabled, 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: (final index) => setState(() {
|
||||
onTap: (index) => setState(() {
|
||||
pageNum = index;
|
||||
}),
|
||||
items: pages
|
||||
.map(
|
||||
(final p) =>
|
||||
BottomNavigationBarItem(label: p.titleBar, icon: p.icon),
|
||||
(p) => BottomNavigationBarItem(label: p.titleBar, icon: p.icon),
|
||||
)
|
||||
.toList(),
|
||||
showSelectedLabels: false,
|
||||
@@ -79,7 +76,7 @@ class _HomeState extends State<Home> {
|
||||
}
|
||||
|
||||
List<_Page> get pages => [
|
||||
const _Page(
|
||||
_Page(
|
||||
content: WordSearchView(),
|
||||
titleBar: 'Search',
|
||||
icon: Icon(Icons.search),
|
||||
@@ -92,7 +89,7 @@ class _HomeState extends State<Home> {
|
||||
// ),
|
||||
// ],
|
||||
),
|
||||
const _Page(
|
||||
_Page(
|
||||
content: KanjiSearchView(),
|
||||
titleBar: 'Kanji Search',
|
||||
icon: Icon(Mdi.ideogramCjk, size: 30),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
@@ -10,12 +8,12 @@ class ChangelogView extends StatelessWidget {
|
||||
const ChangelogView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Changelog')),
|
||||
body: FutureBuilder<List<String>>(
|
||||
future: _fetchChangelogs(),
|
||||
builder: (final context, final snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Error: ${snapshot.error}'));
|
||||
}
|
||||
@@ -31,74 +29,71 @@ class ChangelogView extends StatelessWidget {
|
||||
|
||||
Future<List<String>> _fetchChangelogs() async {
|
||||
final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle);
|
||||
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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
return changelogs;
|
||||
}
|
||||
|
||||
Widget _buildChangelogList(final List<String> versions) {
|
||||
Widget _buildChangelogList(List<String> versions) {
|
||||
return ListView.builder(
|
||||
itemCount: versions.length,
|
||||
itemBuilder: (final context, final index) {
|
||||
itemBuilder: (context, index) {
|
||||
final version = versions[index];
|
||||
return ListTile(
|
||||
key: ValueKey(version),
|
||||
title: Text(version),
|
||||
onTap: () {
|
||||
unawaited(
|
||||
Navigator.push(context, _buildChangelogDetailRoute(version)),
|
||||
);
|
||||
Navigator.push(context, _buildChangelogDetailRoute(version));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _removeHeaders(final String markdown) {
|
||||
String _removeHeaders(String markdown) {
|
||||
final lines = markdown.split('\n');
|
||||
final filteredLines = lines.where((final line) => !line.startsWith('# '));
|
||||
final filteredLines = lines.where((line) => !line.startsWith('# '));
|
||||
return filteredLines.join('\n');
|
||||
}
|
||||
|
||||
MaterialPageRoute _buildChangelogDetailRoute(final String versionAndDate) {
|
||||
MaterialPageRoute _buildChangelogDetailRoute(String versionAndDate) {
|
||||
return MaterialPageRoute(
|
||||
builder: (final context) => Scaffold(
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(title: Text(versionAndDate)),
|
||||
body: FutureBuilder<String>(
|
||||
future: rootBundle.loadString('docs/changelog/$versionAndDate.md'),
|
||||
builder: (final context, final snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Error: ${snapshot.error}'));
|
||||
}
|
||||
@@ -117,9 +112,9 @@ class ChangelogView extends StatelessWidget {
|
||||
child: MarkdownBody(
|
||||
data: _removeHeaders(snapshot.data!),
|
||||
selectable: true,
|
||||
onTapLink: (final text, final href, final title) {
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null && href.isNotEmpty) {
|
||||
unawaited(launchUrl(Uri.parse(href)));
|
||||
launchUrl(Uri.parse(href));
|
||||
}
|
||||
},
|
||||
extensionSet: ExtensionSet.gitHubFlavored,
|
||||
|
||||
@@ -5,7 +5,7 @@ class DataSourcesView extends StatelessWidget {
|
||||
const DataSourcesView({super.key});
|
||||
|
||||
static final List<Widget> dataSources = [
|
||||
const ListTile(
|
||||
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. '
|
||||
@@ -72,15 +72,15 @@ class DataSourcesView extends StatelessWidget {
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(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(),
|
||||
itemBuilder: (context, index) => dataSources[index],
|
||||
separatorBuilder: (context, index) => const Divider(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -106,14 +106,14 @@ class DataSource extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(title),
|
||||
titleTextStyle: Theme.of(context).textTheme.titleLarge,
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
if (await canLaunchUrl(url)) {
|
||||
@@ -135,9 +135,9 @@ class DataSource extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(height: 20),
|
||||
Text(description!),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(height: 20),
|
||||
],
|
||||
TextButton(
|
||||
onPressed: licenseAssetPath == null
|
||||
@@ -150,7 +150,7 @@ class DataSource extends StatelessWidget {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (final context) => Scaffold(
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('License: $licenseIdentifier'),
|
||||
),
|
||||
@@ -166,7 +166,7 @@ class DataSource extends StatelessWidget {
|
||||
},
|
||||
child: Text('License: $licenseIdentifier'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(height: 10),
|
||||
Text(
|
||||
copyright,
|
||||
style: TextStyle(
|
||||
|
||||
@@ -5,9 +5,9 @@ class LicensesView extends StatelessWidget {
|
||||
const LicensesView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => FutureBuilder<PackageInfo>(
|
||||
Widget build(BuildContext context) => FutureBuilder<PackageInfo>(
|
||||
future: PackageInfo.fromPlatform(),
|
||||
builder: (final context, final snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Error: ${snapshot.error}'));
|
||||
}
|
||||
@@ -20,11 +20,10 @@ class LicensesView extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
|
||||
Widget _buildLicensePage(final PackageInfo packageInfo) => LicensePage(
|
||||
Widget _buildLicensePage(PackageInfo packageInfo) => LicensePage(
|
||||
applicationName: '麦典 - Mugiten',
|
||||
applicationVersion: 'Version: ${packageInfo.version}',
|
||||
applicationLegalese:
|
||||
'Copyright (c) 2024, h7x4 <mugiten@nani.wtf>\nLicensed under GPL-3.0-only',
|
||||
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,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:mugiten/services/initialization/initialization_cubit.dart';
|
||||
@@ -12,11 +10,11 @@ class InitializationView extends StatelessWidget {
|
||||
InitializationView({
|
||||
super.key,
|
||||
required this.onInitializationComplete,
|
||||
required final bool deleteDatabase,
|
||||
required bool deleteDatabase,
|
||||
}) : cubit = InitializationCubit(deleteDatabase);
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
darkTheme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
@@ -29,10 +27,10 @@ class InitializationView extends StatelessWidget {
|
||||
const SizedBox(height: 20),
|
||||
BlocBuilder<InitializationCubit, InitializationStatus>(
|
||||
bloc: cubit,
|
||||
builder: (final context, final state) {
|
||||
builder: (context, state) {
|
||||
switch (state) {
|
||||
case InitializationNotStarted _:
|
||||
unawaited(cubit.start());
|
||||
cubit.start();
|
||||
return const CircularProgressIndicator();
|
||||
|
||||
case InitializationPending _:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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;
|
||||
|
||||
@@ -18,9 +19,9 @@ class LibraryContentView extends StatefulWidget {
|
||||
|
||||
class _LibraryContentViewState extends State<LibraryContentView> {
|
||||
late final _pagingController = PagingController<int, LibraryListEntry>(
|
||||
getNextPageKey: (final state) =>
|
||||
getNextPageKey: (state) =>
|
||||
state.lastPageIsEmpty ? null : state.nextIntPageKey,
|
||||
fetchPage: (final pageKey) => GetIt.instance
|
||||
fetchPage: (pageKey) => GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListGetListEntries(
|
||||
widget.library.name,
|
||||
@@ -28,7 +29,7 @@ class _LibraryContentViewState extends State<LibraryContentView> {
|
||||
pageSize: pageSize,
|
||||
includeSearchResult: true,
|
||||
)
|
||||
.then((final page) => page?.entries ?? []),
|
||||
.then((page) => page?.entries ?? []),
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -37,13 +38,10 @@ class _LibraryContentViewState extends State<LibraryContentView> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<bool> _confirm(
|
||||
final BuildContext context, {
|
||||
required final Widget content,
|
||||
}) async {
|
||||
Future<bool> _confirm(BuildContext context, {required Widget content}) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (final context) => AlertDialog(
|
||||
builder: (context) => AlertDialog(
|
||||
content: content,
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
@@ -61,55 +59,53 @@ class _LibraryContentViewState extends State<LibraryContentView> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.library.name),
|
||||
actions: [
|
||||
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;
|
||||
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: (final context, final state, final fetchNextPage) =>
|
||||
builder: (context, state, fetchNextPage) =>
|
||||
PagedListView<int, LibraryListEntry>.separated(
|
||||
state: state,
|
||||
fetchNextPage: fetchNextPage,
|
||||
builderDelegate: PagedChildBuilderDelegate<LibraryListEntry>(
|
||||
invisibleItemsThreshold: invisibleItemsThreshold,
|
||||
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(),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
firstPageErrorIndicatorBuilder: (_) =>
|
||||
ErrorWidget(_pagingController.error!),
|
||||
noItemsFoundIndicatorBuilder: (_) =>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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});
|
||||
|
||||
@@ -20,16 +19,16 @@ class _LibraryViewState extends State<LibraryView> {
|
||||
Future<void> getEntriesFromDatabase() => GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListGetLists()
|
||||
.then((final libs) => setState(() => libraries = libs));
|
||||
.then((libs) => setState(() => libraries = libs));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(getEntriesFromDatabase());
|
||||
getEntriesFromDatabase();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
if (libraries == null) return const LoadingScreen();
|
||||
return Column(
|
||||
children: [
|
||||
@@ -45,7 +44,7 @@ class _LibraryViewState extends State<LibraryView> {
|
||||
// Skip favourites
|
||||
.skip(1)
|
||||
.map(
|
||||
(final e) => LibraryListTile(
|
||||
(e) => LibraryListTile(
|
||||
key: ValueKey(e.name),
|
||||
library: e,
|
||||
onDelete: getEntriesFromDatabase,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
@@ -8,13 +6,6 @@ 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';
|
||||
@@ -23,6 +14,14 @@ 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;
|
||||
|
||||
@@ -40,17 +39,17 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
bool isFavourite = false;
|
||||
|
||||
late final _pagingController = PagingController<int, WordSearchResult>(
|
||||
getNextPageKey: (final state) =>
|
||||
getNextPageKey: (state) =>
|
||||
state.lastPageIsEmpty ? null : state.nextIntPageKey,
|
||||
fetchPage: (final pageKey) => GetIt.instance
|
||||
fetchPage: (pageKey) => GetIt.instance
|
||||
.get<Database>()
|
||||
.jadbSearchWord(
|
||||
widget.kanji,
|
||||
page: pageKey - 1,
|
||||
pageSize: pageSize,
|
||||
searchMode: SearchMode.kanji,
|
||||
searchMode: SearchMode.Kanji,
|
||||
)
|
||||
.then((final page) {
|
||||
.then((page) {
|
||||
if (pageKey == 1 && page != null && page.isNotEmpty) {
|
||||
page.insert(0, WordSearchResult.empty());
|
||||
}
|
||||
@@ -65,7 +64,7 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
}
|
||||
|
||||
// TODO: add compart link
|
||||
Widget _headerRow(final KanjiSearchResult result) => Container(
|
||||
Widget _headerRow(KanjiSearchResult result) => Container(
|
||||
margin: const EdgeInsets.fromLTRB(20.0, 20.0, 20.0, 30.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
@@ -87,7 +86,7 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
),
|
||||
);
|
||||
|
||||
Widget _rankingColumn(final KanjiSearchResult result) => Column(
|
||||
Widget _rankingColumn(KanjiSearchResult result) => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -125,7 +124,7 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
],
|
||||
);
|
||||
|
||||
Widget _topBody(final KanjiSearchResult result) => Column(
|
||||
Widget _topBody(KanjiSearchResult result) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_headerRow(result),
|
||||
@@ -150,7 +149,7 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
],
|
||||
);
|
||||
|
||||
Widget _body(final KanjiSearchResult result) {
|
||||
Widget _body(KanjiSearchResult result) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
actions: [
|
||||
@@ -164,16 +163,14 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
icon: const Icon(Icons.star),
|
||||
color: isFavourite ? Colors.yellow : null,
|
||||
onPressed: () {
|
||||
unawaited(
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListToggleEntry(
|
||||
'favourites',
|
||||
jmdictEntryId: null,
|
||||
kanji: result.kanji,
|
||||
)
|
||||
.then((final state) => setState(() => isFavourite = state)),
|
||||
);
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListToggleEntry(
|
||||
'favourites',
|
||||
jmdictEntryId: null,
|
||||
kanji: result.kanji,
|
||||
)
|
||||
.then((state) => setState(() => isFavourite = state));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
@@ -188,13 +185,13 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
),
|
||||
body: PagingListener(
|
||||
controller: _pagingController,
|
||||
builder: (final context, final state, final fetchNextPage) {
|
||||
builder: (context, state, fetchNextPage) {
|
||||
return PagedListView<int, WordSearchResult>.separated(
|
||||
state: state,
|
||||
fetchNextPage: fetchNextPage,
|
||||
builderDelegate: PagedChildBuilderDelegate<WordSearchResult>(
|
||||
invisibleItemsThreshold: invisibleItemsThreshold,
|
||||
itemBuilder: (final context, final entry, final index) {
|
||||
itemBuilder: (context, entry, index) {
|
||||
if (index == 0) {
|
||||
return _topBody(result);
|
||||
} else {
|
||||
@@ -218,8 +215,8 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
separatorBuilder: (_, final index) => index == 0
|
||||
? const SizedBox.shrink()
|
||||
separatorBuilder: (_, index) => index == 0
|
||||
? SizedBox.shrink()
|
||||
: const Divider(height: 0, indent: 10, endIndent: 10),
|
||||
);
|
||||
},
|
||||
@@ -231,28 +228,24 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
unawaited(
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListListContains('favourites', kanji: widget.kanji)
|
||||
.then((final value) => setState(() => isFavourite = value)),
|
||||
);
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListListContains('favourites', kanji: widget.kanji)
|
||||
.then((value) => setState(() => isFavourite = value));
|
||||
|
||||
if (!incognitoModeEnabled.value && !addedToDatabase) {
|
||||
unawaited(
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryInsertKanji(widget.kanji)
|
||||
.then((_) => setState(() => addedToDatabase = true)),
|
||||
);
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryInsertKanji(widget.kanji)
|
||||
.then((_) => setState(() => addedToDatabase = true));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: GetIt.instance.get<Database>().jadbSearchKanji(widget.kanji),
|
||||
builder: (final context, final snapshot) {
|
||||
builder: (context, 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 'package:mugiten/components/kanji/kanji_search_body.dart';
|
||||
import '../../components/kanji/kanji_search_body.dart';
|
||||
|
||||
class KanjiSearchView extends StatelessWidget {
|
||||
const KanjiSearchView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return const KanjiSearchBody();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:mugiten/components/drawing_board/drawing_board.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import '../../../components/drawing_board/drawing_board.dart';
|
||||
import '../../../routing/routes.dart';
|
||||
|
||||
class KanjiDrawingSearch extends StatelessWidget {
|
||||
const KanjiDrawingSearch({super.key});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Draw a kanji')),
|
||||
body: SafeArea(
|
||||
@@ -16,12 +16,11 @@ class KanjiDrawingSearch extends StatelessWidget {
|
||||
const Expanded(child: Column()),
|
||||
DrawingBoard(
|
||||
onlyOneCharacterSuggestions: true,
|
||||
onSuggestionChosen: (final suggestion) =>
|
||||
Navigator.popAndPushNamed(
|
||||
context,
|
||||
Routes.kanjiSearch,
|
||||
arguments: suggestion,
|
||||
),
|
||||
onSuggestionChosen: (suggestion) => Navigator.popAndPushNamed(
|
||||
context,
|
||||
Routes.kanjiSearch,
|
||||
arguments: suggestion,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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});
|
||||
|
||||
@@ -19,7 +20,7 @@ class _GridItem extends StatelessWidget {
|
||||
const _GridItem({required this.text, this.isNumber = false});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final ForegroundBackgroundThemeExtension colors = isNumber
|
||||
? lightTheme.extension<MenuGreyDarkThemeExtension>()!
|
||||
: lightTheme.extension<MenuGreyNormalThemeExtension>()!;
|
||||
@@ -55,30 +56,30 @@ class _GridItem extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _KanjiGradeSearchState extends State<KanjiGradeSearch> {
|
||||
Future<Map<int, Map<int, List<Widget>>>> get gradeWidgets =>
|
||||
Future<Map<int, Map<int, List<Widget>>>> get gradeWidgets async =>
|
||||
compute<
|
||||
Map<int, Map<int, List<String>>>,
|
||||
Map<int, Map<int, List<Widget>>>
|
||||
>(
|
||||
(final gs) => gs.map(
|
||||
(final grade, final sortedByStrokes) => MapEntry(
|
||||
(gs) => gs.map(
|
||||
(grade, sortedByStrokes) => MapEntry(
|
||||
grade,
|
||||
sortedByStrokes.map<int, List<Widget>>(
|
||||
(final strokeCount, final kanji) => MapEntry(strokeCount, [
|
||||
(strokeCount, kanji) => MapEntry(strokeCount, [
|
||||
_GridItem(text: strokeCount.toString(), isNumber: true),
|
||||
...kanji.map((final k) => _GridItem(text: k)),
|
||||
...kanji.map((k) => _GridItem(text: k)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
jouyouKanjiByGradeAndStrokeCount,
|
||||
JOUYOU_KANJI_BY_GRADE_AND_STROKE_COUNT,
|
||||
);
|
||||
|
||||
Future<Widget> get makeGrids async => SingleChildScrollView(
|
||||
child: Column(
|
||||
children: (await Future.wait(
|
||||
jouyouKanjiByGradeAndStrokeCount.keys.map(
|
||||
(final grade) async => ExpansionTile(
|
||||
JOUYOU_KANJI_BY_GRADE_AND_STROKE_COUNT.keys.map(
|
||||
(grade) async => ExpansionTile(
|
||||
title: Text(grade == 7 ? 'Junior Highschool' : 'Grade $grade'),
|
||||
maintainState: true,
|
||||
children: [
|
||||
@@ -90,7 +91,7 @@ class _KanjiGradeSearchState extends State<KanjiGradeSearch> {
|
||||
crossAxisSpacing: 10,
|
||||
padding: const EdgeInsets.all(10),
|
||||
children: (await gradeWidgets)[grade]!.values
|
||||
.expand((final l) => l)
|
||||
.expand((l) => l)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
@@ -101,13 +102,13 @@ class _KanjiGradeSearchState extends State<KanjiGradeSearch> {
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Choose by grade')),
|
||||
body: FutureBuilder<Widget>(
|
||||
future: makeGrids,
|
||||
initialData: const LoadingScreen(),
|
||||
builder: (final context, final snapshot) => snapshot.data!,
|
||||
builder: (context, snapshot) => snapshot.data!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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;
|
||||
|
||||
@@ -24,11 +23,11 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
List<String> suggestions = [];
|
||||
|
||||
Map<String, bool> radicalToggles = {
|
||||
for (final String r in radicals.values.expand((final l) => l)) r: false,
|
||||
for (final String r in RADICALS.values.expand((l) => l)) r: false,
|
||||
};
|
||||
|
||||
Map<String, bool> allowedToggles = {
|
||||
for (final String r in radicals.values.expand((final l) => l)) r: true,
|
||||
for (final String r in RADICALS.values.expand((l) => l)) r: true,
|
||||
};
|
||||
|
||||
@override
|
||||
@@ -37,21 +36,21 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
radicalToggles.containsKey(widget.prechosenRadical)) {
|
||||
radicalToggles[widget.prechosenRadical!] = true;
|
||||
}
|
||||
unawaited(updateSuggestions());
|
||||
updateSuggestions();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void resetRadicalToggles() => radicalToggles.forEach((final k, _) {
|
||||
void resetRadicalToggles() => radicalToggles.forEach((k, _) {
|
||||
radicalToggles[k] = false;
|
||||
});
|
||||
|
||||
void resetAllowedToggles() => allowedToggles.forEach((final k, _) {
|
||||
void resetAllowedToggles() => allowedToggles.forEach((k, _) {
|
||||
allowedToggles[k] = true;
|
||||
});
|
||||
|
||||
Future<void> updateSuggestions() async {
|
||||
final toggledRadicals = radicalToggles.keys
|
||||
.where((final r) => radicalToggles[r] ?? false)
|
||||
.where((r) => radicalToggles[r] ?? false)
|
||||
.toList();
|
||||
|
||||
if (toggledRadicals.isEmpty) {
|
||||
@@ -64,20 +63,16 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
late final List<String> newSuggestions;
|
||||
late final List<String> newRadicals;
|
||||
await Future.wait([
|
||||
jadbConnection.jadbSearchKanjiByRadicals(toggledRadicals).then((
|
||||
final value,
|
||||
) {
|
||||
jadbConnection.jadbSearchKanjiByRadicals(toggledRadicals).then((value) {
|
||||
newSuggestions = value;
|
||||
}),
|
||||
jadbConnection.jadbSearchRemainingRadicals(toggledRadicals).then((
|
||||
final value,
|
||||
) {
|
||||
jadbConnection.jadbSearchRemainingRadicals(toggledRadicals).then((value) {
|
||||
newRadicals = value;
|
||||
}),
|
||||
]);
|
||||
|
||||
setState(() {
|
||||
allowedToggles.forEach((final key, final value) {
|
||||
allowedToggles.forEach((key, value) {
|
||||
allowedToggles[key] = false;
|
||||
});
|
||||
for (final r in newRadicals) {
|
||||
@@ -87,10 +82,7 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
});
|
||||
}
|
||||
|
||||
Widget radicalGridElement(
|
||||
final String radical, {
|
||||
final bool isNumber = false,
|
||||
}) {
|
||||
Widget radicalGridElement(String radical, {bool isNumber = false}) {
|
||||
final foregroundColor = isNumber
|
||||
? lightTheme.extension<MenuGreyDarkThemeExtension>()!.foregroundColor
|
||||
: radicalToggles[radical]!
|
||||
@@ -108,7 +100,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]!;
|
||||
unawaited(updateSuggestions());
|
||||
updateSuggestions();
|
||||
}),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
@@ -135,28 +127,28 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
color: mugitenWheatBackground,
|
||||
iconSize: fontSize * 1.3,
|
||||
),
|
||||
...radicals.values
|
||||
.expand((final l) => l)
|
||||
.where((final k) => radicalToggles[k] ?? false)
|
||||
.map((final k) => radicalGridElement(k.toString())),
|
||||
...RADICALS.values
|
||||
.expand((l) => l)
|
||||
.where((k) => radicalToggles[k] ?? false)
|
||||
.map((k) => radicalGridElement(k.toString())),
|
||||
|
||||
...radicals
|
||||
...RADICALS
|
||||
.map(
|
||||
(final key, final value) => MapEntry(
|
||||
(key, value) => MapEntry(
|
||||
key,
|
||||
value
|
||||
.where((final r) => !radicalToggles[r]! && allowedToggles[r]!)
|
||||
.map(radicalGridElement)
|
||||
.where((r) => !radicalToggles[r]! && allowedToggles[r]!)
|
||||
.map((r) => radicalGridElement(r))
|
||||
.toList()
|
||||
..insert(0, radicalGridElement(key.toString(), isNumber: true)),
|
||||
),
|
||||
)
|
||||
.values
|
||||
.where((final element) => element.length != 1)
|
||||
.expand((final l) => l),
|
||||
.where((element) => element.length != 1)
|
||||
.expand((l) => l),
|
||||
];
|
||||
|
||||
Widget kanjiGridElement(final String kanji) {
|
||||
Widget kanjiGridElement(String kanji) {
|
||||
// const color = LightTheme.defaultMenuGreyNormal;
|
||||
final colors = lightTheme.extension<MenuGreyNormalThemeExtension>()!;
|
||||
return InkWell(
|
||||
@@ -180,7 +172,7 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).extension<MenuGreyNormalThemeExtension>()!;
|
||||
|
||||
return Scaffold(
|
||||
@@ -205,10 +197,12 @@ class _KanjiRadicalSearchState extends State<KanjiRadicalSearch> {
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
padding: const EdgeInsets.all(10),
|
||||
children: suggestions.map(kanjiGridElement).toList(),
|
||||
children: suggestions
|
||||
.map((s) => kanjiGridElement(s))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
const Divider(
|
||||
Divider(
|
||||
color: mugitenWheatBackground,
|
||||
thickness: 3,
|
||||
height: 30,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
@@ -7,7 +5,6 @@ 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';
|
||||
@@ -15,6 +12,8 @@ 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;
|
||||
|
||||
@@ -32,16 +31,16 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
|
||||
HistoryEntry? historyEntry;
|
||||
|
||||
late final _pagingController = PagingController<int, WordSearchResult>(
|
||||
getNextPageKey: (final state) =>
|
||||
getNextPageKey: (state) =>
|
||||
state.lastPageIsEmpty ? null : state.nextIntPageKey,
|
||||
fetchPage: (final pageKey) => GetIt.instance
|
||||
fetchPage: (pageKey) => GetIt.instance
|
||||
.get<Database>()
|
||||
.jadbSearchWord(
|
||||
widget.searchTerm,
|
||||
page: pageKey - 1,
|
||||
pageSize: pageSize,
|
||||
)
|
||||
.then((final v) => v ?? <WordSearchResult>[]),
|
||||
.then((v) => v ?? <WordSearchResult>[]),
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -49,33 +48,29 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
|
||||
super.initState();
|
||||
|
||||
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;
|
||||
}),
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryInsertWord(widget.searchTerm)
|
||||
.then(
|
||||
(_) => GetIt.instance.get<Database>().historyEntryGetWord(
|
||||
widget.searchTerm,
|
||||
),
|
||||
);
|
||||
)
|
||||
.then(
|
||||
(entry) => setState(() {
|
||||
addedToDatabase = true;
|
||||
historyEntry = entry;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
unawaited(
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryGetWord(widget.searchTerm)
|
||||
.then(
|
||||
(final entry) => setState(() {
|
||||
historyEntry = entry;
|
||||
}),
|
||||
),
|
||||
);
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryGetWord(widget.searchTerm)
|
||||
.then(
|
||||
(entry) => setState(() {
|
||||
historyEntry = entry;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,14 +81,11 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).extension<MenuGreyNormalThemeExtension>()!;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
'"${widget.searchTerm}"',
|
||||
style: japaneseFont.value.textStyle,
|
||||
),
|
||||
title: Text('"${widget.searchTerm}"'),
|
||||
actions: [
|
||||
if (incognitoModeEnabled.value)
|
||||
IconButton(
|
||||
@@ -106,23 +98,21 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
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)}',
|
||||
),
|
||||
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)}',
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -140,8 +130,8 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
|
||||
future: GetIt.instance
|
||||
.get<Database>()
|
||||
.jadbSearchWordCount(widget.searchTerm)
|
||||
.then((final v) => v ?? 0),
|
||||
builder: (final context, final snapshot) {
|
||||
.then((v) => v ?? 0),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@@ -154,25 +144,24 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
|
||||
children: [
|
||||
Center(
|
||||
child: Text(
|
||||
'Found $searchCount result${searchCount != 1 ? 's' : ''}',
|
||||
'Found $searchCount results for "${widget.searchTerm}"',
|
||||
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PagingListener(
|
||||
controller: _pagingController,
|
||||
builder: (final context, final state, final fetchNextPage) =>
|
||||
builder: (context, state, fetchNextPage) =>
|
||||
PagedListView<int, WordSearchResult>(
|
||||
state: state,
|
||||
fetchNextPage: fetchNextPage,
|
||||
builderDelegate: PagedChildBuilderDelegate(
|
||||
invisibleItemsThreshold: invisibleItemsThreshold,
|
||||
itemBuilder:
|
||||
(final context, final item, final index) =>
|
||||
SearchResultCard(
|
||||
result: item,
|
||||
initiallyExpanded: singleItem,
|
||||
),
|
||||
itemBuilder: (context, item, index) =>
|
||||
SearchResultCard(
|
||||
result: item,
|
||||
initiallyExpanded: singleItem,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/components/search/global_search_bar.dart';
|
||||
import '../../components/search/global_search_bar.dart';
|
||||
|
||||
class WordSearchView extends StatelessWidget {
|
||||
const WordSearchView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[GlobalSearchBar()],
|
||||
|
||||
@@ -11,7 +11,7 @@ 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/archive/v1/format.dart';
|
||||
import 'package:mugiten/services/data_export_import.dart';
|
||||
import 'package:mugiten/services/snackbar.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:mugiten/theme.dart';
|
||||
@@ -30,13 +30,10 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
bool dataExportIsLoading = false;
|
||||
bool dataImportIsLoading = false;
|
||||
|
||||
Future<bool> confirm(
|
||||
final BuildContext context, {
|
||||
required final Widget content,
|
||||
}) async {
|
||||
Future<bool> confirm(BuildContext context, {required Widget content}) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (final context) => AlertDialog(
|
||||
builder: (context) => AlertDialog(
|
||||
content: content,
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
@@ -53,7 +50,7 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
false;
|
||||
}
|
||||
|
||||
Future<void> clearHistory(final BuildContext context) async {
|
||||
Future<void> clearHistory(BuildContext context) async {
|
||||
final historyCount = await GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryAmount();
|
||||
@@ -75,7 +72,7 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
showSnackbar(context, 'Cleared history');
|
||||
}
|
||||
|
||||
Future<void> changeFont(final BuildContext context) async {
|
||||
Future<void> changeFont(BuildContext context) async {
|
||||
final int? i = await _chooseFromList(
|
||||
list: [for (final font in JapaneseFontChoice.values) font.name],
|
||||
chosen: japaneseFont.value.index,
|
||||
@@ -87,19 +84,16 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> changeQuickAddLibraryList(final BuildContext context) async {
|
||||
Future<void> changeQuickAddLibraryList(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)],
|
||||
list: ['None', ...libraryLists.map((e) => e.name)],
|
||||
chosen: quickAddLibraryList.value == null
|
||||
? 0
|
||||
: libraryLists.indexWhere(
|
||||
(final l) => l.name == quickAddLibraryList.value,
|
||||
) +
|
||||
1,
|
||||
: libraryLists.indexWhere((l) => l.name == quickAddLibraryList.value) + 1,
|
||||
title: 'Choose library for quick add',
|
||||
)(context);
|
||||
if (i != null) {
|
||||
@@ -109,7 +103,7 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> exportHandler(final BuildContext context) async {
|
||||
Future<void> exportHandler(BuildContext context) async {
|
||||
late final File zipfile;
|
||||
try {
|
||||
setState(() => dataExportIsLoading = true);
|
||||
@@ -122,7 +116,7 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
setState(() => dataExportIsLoading = false);
|
||||
}
|
||||
|
||||
final saveFile = await FilePicker.saveFile(
|
||||
final saveFile = await FilePicker.platform.saveFile(
|
||||
dialogTitle: 'Export data',
|
||||
fileName: getExportFileNameNoSuffix(),
|
||||
type: FileType.custom,
|
||||
@@ -139,8 +133,8 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> importHandler(final BuildContext context) async {
|
||||
final saveFile = await FilePicker.pickFiles(
|
||||
Future<void> importHandler(BuildContext context) async {
|
||||
final saveFile = await FilePicker.platform.pickFiles(
|
||||
dialogTitle: 'Import data',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['zip'],
|
||||
@@ -149,7 +143,7 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
return;
|
||||
}
|
||||
|
||||
assert(saveFile.files.length == 1, 'Multiple files selected for import');
|
||||
assert(saveFile.files.length == 1);
|
||||
|
||||
final filepath = saveFile.files.first.path;
|
||||
|
||||
@@ -169,18 +163,18 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
}
|
||||
|
||||
Future<int?> Function(BuildContext) _chooseFromList({
|
||||
required final List<String> list,
|
||||
final int? chosen,
|
||||
final String? title,
|
||||
required List<String> list,
|
||||
int? chosen,
|
||||
String? title,
|
||||
}) =>
|
||||
(final context) => Navigator.push<int>(
|
||||
(context) => Navigator.push<int>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (final context) => Scaffold(
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(title: title == null ? null : Text(title)),
|
||||
body: DenshiJishoBackground(
|
||||
child: ListView.builder(
|
||||
itemBuilder: (final context, final i) => ListTile(
|
||||
itemBuilder: (context, i) => ListTile(
|
||||
title: Text(list[i]),
|
||||
trailing: (chosen != null && chosen == i)
|
||||
? const Icon(Icons.check)
|
||||
@@ -195,7 +189,7 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => SettingsList(
|
||||
Widget build(BuildContext context) => SettingsList(
|
||||
lightTheme: SettingsThemeData(
|
||||
settingsListBackground: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
@@ -207,7 +201,7 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
sections: _sections(context),
|
||||
);
|
||||
|
||||
List<SettingsSection> _sections(final BuildContext context) => [
|
||||
List<SettingsSection> _sections(BuildContext context) => [
|
||||
SettingsSection(
|
||||
title: const Text('Dictionary'),
|
||||
tiles: <SettingsTile>[
|
||||
@@ -217,7 +211,7 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
'Display romaji instead of kana for word readings',
|
||||
),
|
||||
leading: const Icon(Mdi.alphabetical),
|
||||
onToggle: (final b) => setState(() => romajiEnabled.value = b),
|
||||
onToggle: (b) => setState(() => romajiEnabled.value = b),
|
||||
initialValue: romajiEnabled.value,
|
||||
activeSwitchColor: mugitenWheatBackground,
|
||||
),
|
||||
@@ -248,7 +242,7 @@ 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: (final b) {
|
||||
onToggle: (b) {
|
||||
setState(() => autoThemeEnabled.value = b);
|
||||
GetIt.instance.get<ThemeController>().updateThemeMode();
|
||||
},
|
||||
@@ -258,7 +252,7 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
SettingsTile.switchTile(
|
||||
title: const Text('Dark Theme'),
|
||||
leading: const Icon(Icons.dark_mode),
|
||||
onToggle: (final b) {
|
||||
onToggle: (b) {
|
||||
setState(() => darkThemeEnabled.value = b);
|
||||
GetIt.instance.get<ThemeController>().updateThemeMode();
|
||||
},
|
||||
@@ -308,7 +302,7 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
description: const Text(
|
||||
'Useful for reviewing search history without creating clutter',
|
||||
),
|
||||
onToggle: (final b) => setState(() => incognitoModeEnabled.value = b),
|
||||
onToggle: (b) => setState(() => incognitoModeEnabled.value = b),
|
||||
initialValue: incognitoModeEnabled.value,
|
||||
activeSwitchColor: mugitenWheatBackground,
|
||||
),
|
||||
@@ -318,8 +312,7 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
description: const Text(
|
||||
'Useful if you keep accidentally activating system gestures',
|
||||
),
|
||||
onToggle: (final b) =>
|
||||
setState(() => reduceKanjiDrawingBoardSize.value = b),
|
||||
onToggle: (b) => setState(() => reduceKanjiDrawingBoardSize.value = b),
|
||||
initialValue: reduceKanjiDrawingBoardSize.value,
|
||||
activeSwitchColor: mugitenWheatBackground,
|
||||
),
|
||||
@@ -357,14 +350,13 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
leading: const Icon(Icons.copyright),
|
||||
title: const Text('About'),
|
||||
description: const Text('Info about Mugiten and its dependencies'),
|
||||
onPressed: (final c) =>
|
||||
Navigator.pushNamed(context, Routes.aboutLicenses),
|
||||
onPressed: (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) =>
|
||||
onPressed: (c) =>
|
||||
Navigator.pushNamed(context, Routes.aboutDatasources),
|
||||
),
|
||||
SettingsTile(
|
||||
@@ -373,14 +365,13 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
description: const Text(
|
||||
'See what changed between different versions of the application',
|
||||
),
|
||||
onPressed: (final c) =>
|
||||
Navigator.pushNamed(context, Routes.aboutChangelog),
|
||||
onPressed: (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: (final c) =>
|
||||
onPressed: (c) =>
|
||||
launchUrl(Uri.parse('https://git.pvv.ntnu.no/mugiten')),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
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());
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:core';
|
||||
import 'dart:io';
|
||||
|
||||
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+'), '_');
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
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,12 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
void copyToClipboard(
|
||||
final BuildContext context,
|
||||
final String? clipboardContent,
|
||||
) {
|
||||
void copyToClipboard(BuildContext context, String? clipboardContent) {
|
||||
if (clipboardContent == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -17,7 +12,7 @@ void copyToClipboard(
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(Clipboard.setData(ClipboardData(text: clipboardContent)));
|
||||
Clipboard.setData(ClipboardData(text: clipboardContent));
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
||||
199
lib/services/data_export_import.dart
Normal file
199
lib/services/data_export_import.dart
Normal file
@@ -0,0 +1,199 @@
|
||||
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 versionFile => File(uri.resolve('version.txt').toFilePath());
|
||||
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([
|
||||
exportDataFormatVersionTo(dir),
|
||||
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);
|
||||
}
|
||||
|
||||
/////////////////////////
|
||||
// DATA FORMAT VERSION //
|
||||
/////////////////////////
|
||||
|
||||
const int expectedDataFormatVersion = 1;
|
||||
|
||||
Future<void> exportDataFormatVersionTo(Directory dir) async {
|
||||
final file = dir.versionFile;
|
||||
file.createSync();
|
||||
file.writeAsStringSync(expectedDataFormatVersion.toString());
|
||||
}
|
||||
|
||||
Future<int> importDataFormatVersionFrom(File file) async {
|
||||
final String content = file.readAsStringSync();
|
||||
return int.parse(content);
|
||||
}
|
||||
|
||||
/////////////
|
||||
// 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,20 +1,19 @@
|
||||
DateTime roundToDay(final DateTime date) =>
|
||||
DateTime(date.year, date.month, date.day);
|
||||
DateTime roundToDay(DateTime date) => DateTime(date.year, date.month, date.day);
|
||||
|
||||
bool dateIsEqual(final DateTime date1, final DateTime date2) =>
|
||||
bool dateIsEqual(DateTime date1, 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(final DateTime timestamp) {
|
||||
String formatTime(DateTime timestamp) {
|
||||
final hours = timestamp.hour.toString().padLeft(2, '0');
|
||||
final mins = timestamp.minute.toString().padLeft(2, '0');
|
||||
return '$hours:$mins';
|
||||
}
|
||||
|
||||
String formatDate(final DateTime date) {
|
||||
String formatDate(DateTime date) {
|
||||
if (date == today) return 'Today';
|
||||
if (date == yesterday) return 'Yesterday';
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ import 'package:mugiten/database/database.dart'
|
||||
openAndMigrateDatabase,
|
||||
openDatabaseWithoutMigrations,
|
||||
readMigrationsFromAssets;
|
||||
import 'package:mugiten/services/archive/v1/format.dart'
|
||||
show exportData, importData;
|
||||
import 'package:mugiten/services/data_export_import.dart';
|
||||
import 'package:mugiten/services/initialization/initialization_status.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
|
||||
@@ -29,7 +29,11 @@ Future<void> quickInitialization() async {
|
||||
await Future.wait([
|
||||
quickInitializeDatabase(),
|
||||
setupSharedPreferences(),
|
||||
ensureDigitalInkModelDownloaded(),
|
||||
(() async {
|
||||
final modelManager = DigitalInkRecognizerModelManager();
|
||||
final isDownloaded = await modelManager.isModelDownloaded('ja');
|
||||
assert(isDownloaded, 'Japanese model should be downloaded at this point');
|
||||
})(),
|
||||
]);
|
||||
|
||||
registerExtraLicenses();
|
||||
@@ -41,12 +45,6 @@ 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,4 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void showSnackbar(final BuildContext context, final String text) =>
|
||||
void showSnackbar(BuildContext context, String text) =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
@@ -8,8 +6,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
final SharedPreferences _prefs = GetIt.instance.get<SharedPreferences>();
|
||||
|
||||
abstract interface class StringifySharedPrefItem<T> {
|
||||
String serializeSetting(final T value) => value.toString();
|
||||
T deserializeSetting(final String s);
|
||||
String serializeSetting(T value) => value.toString();
|
||||
T deserializeSetting(String s);
|
||||
}
|
||||
|
||||
abstract class SharedPrefItem<T> extends ValueNotifier<T> {
|
||||
@@ -19,7 +17,7 @@ abstract class SharedPrefItem<T> extends ValueNotifier<T> {
|
||||
SharedPrefItem(this.key, this.defaultValue)
|
||||
: super(_getValue<T>(key, defaultValue));
|
||||
|
||||
static T _getValue<T>(final String key, final T defaultValue) {
|
||||
static T _getValue<T>(String key, T defaultValue) {
|
||||
Object? result = _prefs.get(key);
|
||||
|
||||
switch (defaultValue) {
|
||||
@@ -30,41 +28,36 @@ abstract class SharedPrefItem<T> extends ValueNotifier<T> {
|
||||
.deserializeSetting(result);
|
||||
} catch (e) {
|
||||
// If deserialization fails, reset to default value.
|
||||
unawaited(_setValue<T>(key, defaultValue));
|
||||
_setValue<T>(key, defaultValue);
|
||||
result = defaultValue;
|
||||
}
|
||||
} else {
|
||||
// If the stored value is not a String, reset to default value.
|
||||
unawaited(_setValue<T>(key, defaultValue));
|
||||
_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 {
|
||||
static void _setValue<T>(String key, T value) {
|
||||
switch (value) {
|
||||
case null:
|
||||
await _prefs.remove(key);
|
||||
_prefs.remove(key);
|
||||
case bool():
|
||||
await _prefs.setBool(key, value);
|
||||
_prefs.setBool(key, value);
|
||||
case int():
|
||||
await _prefs.setInt(key, value);
|
||||
_prefs.setInt(key, value);
|
||||
case double():
|
||||
await _prefs.setDouble(key, value);
|
||||
_prefs.setDouble(key, value);
|
||||
case String():
|
||||
await _prefs.setString(key, value);
|
||||
_prefs.setString(key, value);
|
||||
case List<String>():
|
||||
await _prefs.setStringList(key, value);
|
||||
_prefs.setStringList(key, value);
|
||||
case StringifySharedPrefItem():
|
||||
await _prefs.setString(
|
||||
_prefs.setString(
|
||||
key,
|
||||
(value as StringifySharedPrefItem).serializeSetting(value),
|
||||
);
|
||||
@@ -79,19 +72,14 @@ abstract class SharedPrefItem<T> extends ValueNotifier<T> {
|
||||
T get value => _getValue<T>(key, defaultValue);
|
||||
|
||||
@override
|
||||
set value(final T newValue) {
|
||||
set value(T newValue) {
|
||||
final oldValue = _getValue<T>(key, defaultValue);
|
||||
unawaited(
|
||||
_setValue<T>(key, newValue).then((_) {
|
||||
if (oldValue != newValue) {
|
||||
notifyListeners();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
_setValue<T>(key, newValue);
|
||||
|
||||
/// Returns whether the value stored in shared preferences is equal to [value].
|
||||
bool contains(final T value) => _getValue<T>(key, defaultValue) == value;
|
||||
if (oldValue != newValue) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to save search history and other data to the database.
|
||||
@@ -182,16 +170,15 @@ enum JapaneseFontChoice implements StringifySharedPrefItem<JapaneseFontChoice> {
|
||||
};
|
||||
|
||||
static Map<String, JapaneseFontChoice> get _nameToFont =>
|
||||
_fontToName.map((final k, final v) => MapEntry(v, k));
|
||||
_fontToName.map((k, v) => MapEntry(v, k));
|
||||
|
||||
String get name => _fontToName[this]!;
|
||||
|
||||
@override
|
||||
String serializeSetting(final JapaneseFontChoice value) =>
|
||||
_fontToName[value]!;
|
||||
String serializeSetting(JapaneseFontChoice value) => _fontToName[value]!;
|
||||
|
||||
@override
|
||||
JapaneseFontChoice deserializeSetting(final String s) => _nameToFont[s]!;
|
||||
JapaneseFontChoice deserializeSetting(String s) => _nameToFont[s]!;
|
||||
}
|
||||
|
||||
class JapaneseFont extends SharedPrefItem<JapaneseFontChoice> {
|
||||
|
||||
@@ -9,8 +9,8 @@ class YomiThemeExtension extends ThemeExtension<YomiThemeExtension> {
|
||||
|
||||
@override
|
||||
ThemeExtension<YomiThemeExtension> copyWith({
|
||||
final Color? onyomiColor,
|
||||
final Color? kunyomiColor,
|
||||
Color? onyomiColor,
|
||||
Color? kunyomiColor,
|
||||
}) => YomiThemeExtension(
|
||||
onyomiColor: onyomiColor ?? this.onyomiColor,
|
||||
kunyomiColor: kunyomiColor ?? this.kunyomiColor,
|
||||
@@ -18,8 +18,8 @@ class YomiThemeExtension extends ThemeExtension<YomiThemeExtension> {
|
||||
|
||||
@override
|
||||
ThemeExtension<YomiThemeExtension> lerp(
|
||||
final ThemeExtension<YomiThemeExtension>? other,
|
||||
final double t,
|
||||
ThemeExtension<YomiThemeExtension>? other,
|
||||
double t,
|
||||
) => other is! YomiThemeExtension
|
||||
? this
|
||||
: YomiThemeExtension(
|
||||
@@ -39,13 +39,10 @@ abstract class ForegroundBackgroundThemeExtension<T extends ThemeExtension<T>>
|
||||
});
|
||||
|
||||
@override
|
||||
ThemeExtension<T> copyWith({
|
||||
final Color? foregroundColor,
|
||||
final Color? backgroundColor,
|
||||
});
|
||||
ThemeExtension<T> copyWith({Color? foregroundColor, Color? backgroundColor});
|
||||
|
||||
@override
|
||||
ThemeExtension<T> lerp(final ThemeExtension<T>? other, final double t) =>
|
||||
ThemeExtension<T> lerp(ThemeExtension<T>? other, double t) =>
|
||||
other is! ForegroundBackgroundThemeExtension<T>
|
||||
? this as T
|
||||
: copyWith(
|
||||
@@ -72,8 +69,8 @@ class KanjiResultThemeExtension
|
||||
|
||||
@override
|
||||
ThemeExtension<KanjiResultThemeExtension> copyWith({
|
||||
final Color? foregroundColor,
|
||||
final Color? backgroundColor,
|
||||
Color? foregroundColor,
|
||||
Color? backgroundColor,
|
||||
}) => KanjiResultThemeExtension(
|
||||
foregroundColor: foregroundColor ?? this.foregroundColor,
|
||||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||
@@ -89,8 +86,8 @@ class MenuGreyLightThemeExtension
|
||||
|
||||
@override
|
||||
ThemeExtension<MenuGreyLightThemeExtension> copyWith({
|
||||
final Color? foregroundColor,
|
||||
final Color? backgroundColor,
|
||||
Color? foregroundColor,
|
||||
Color? backgroundColor,
|
||||
}) => MenuGreyLightThemeExtension(
|
||||
foregroundColor: foregroundColor ?? this.foregroundColor,
|
||||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||
@@ -106,8 +103,8 @@ class MenuGreyNormalThemeExtension
|
||||
|
||||
@override
|
||||
ThemeExtension<MenuGreyNormalThemeExtension> copyWith({
|
||||
final Color? foregroundColor,
|
||||
final Color? backgroundColor,
|
||||
Color? foregroundColor,
|
||||
Color? backgroundColor,
|
||||
}) => MenuGreyNormalThemeExtension(
|
||||
foregroundColor: foregroundColor ?? this.foregroundColor,
|
||||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||
@@ -123,8 +120,8 @@ class MenuGreyDarkThemeExtension
|
||||
|
||||
@override
|
||||
ThemeExtension<MenuGreyDarkThemeExtension> copyWith({
|
||||
final Color? foregroundColor,
|
||||
final Color? backgroundColor,
|
||||
Color? foregroundColor,
|
||||
Color? backgroundColor,
|
||||
}) => MenuGreyDarkThemeExtension(
|
||||
foregroundColor: foregroundColor ?? this.foregroundColor,
|
||||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||
@@ -143,7 +140,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(final Color color) {
|
||||
MaterialColor createMaterialColor(Color color) {
|
||||
final List<double> strengths = [.05];
|
||||
final swatch = <int, Color>{};
|
||||
final int r = (color.r * 255.0).round().clamp(0, 255);
|
||||
@@ -297,7 +294,7 @@ enum AppThemeMode {
|
||||
AppThemeMode.system => 'system',
|
||||
};
|
||||
|
||||
factory AppThemeMode.fromId(final String id) => switch (id) {
|
||||
factory AppThemeMode.fromId(String id) => switch (id) {
|
||||
'light' => AppThemeMode.light,
|
||||
'dark' => AppThemeMode.dark,
|
||||
'system' => AppThemeMode.system,
|
||||
@@ -333,22 +330,20 @@ enum AppThemeMode {
|
||||
class ThemeController {
|
||||
final ValueNotifier<AppThemeMode> themeMode;
|
||||
|
||||
ThemeController(final AppThemeMode mode) : themeMode = ValueNotifier(mode);
|
||||
ThemeController(AppThemeMode mode) : themeMode = ValueNotifier(mode);
|
||||
|
||||
factory ThemeController.create() {
|
||||
AppThemeMode initialMode;
|
||||
if (autoThemeEnabled.value) {
|
||||
initialMode = AppThemeMode.system;
|
||||
} else {
|
||||
initialMode = darkThemeEnabled.value
|
||||
? AppThemeMode.dark
|
||||
: AppThemeMode.light;
|
||||
initialMode = darkThemeEnabled.value ? AppThemeMode.dark : AppThemeMode.light;
|
||||
}
|
||||
|
||||
return ThemeController(initialMode);
|
||||
}
|
||||
|
||||
void setThemeMode(final AppThemeMode mode) {
|
||||
void setThemeMode(AppThemeMode mode) {
|
||||
if (mode != themeMode.value) {
|
||||
if (mode == AppThemeMode.system) {
|
||||
autoThemeEnabled.value = true;
|
||||
|
||||
@@ -42,8 +42,6 @@ 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.
|
||||
|
||||
116
pubspec.lock
116
pubspec.lock
@@ -37,10 +37,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
version: "2.13.0"
|
||||
bisection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -141,18 +141,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csv
|
||||
sha256: "2e0a52fb729f2faacd19c9c0c954ff450bba37aa8ab999410309e2342e7013a2"
|
||||
sha256: bef2950f7a753eb82f894a2eabc3072e73cf21c17096296a5a992797e50b1d0d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
version: "7.1.0"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
version: "1.0.8"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -205,10 +205,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387
|
||||
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
version: "10.3.10"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -274,10 +274,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
|
||||
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.34"
|
||||
version: "2.0.33"
|
||||
flutter_settings_ui:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -306,10 +306,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
|
||||
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.4"
|
||||
version: "2.2.3"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -364,10 +364,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
version: "1.0.1"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -413,26 +413,10 @@ packages:
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: "88278931018f289c4ee100b16c9ebbd4d5d44bef"
|
||||
resolved-ref: f5bca6183908308bb9857558d8b94e2fe7a69fde
|
||||
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:
|
||||
@@ -446,7 +430,7 @@ packages:
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: c447257af3d53bd7530aad022af8497d8c06b724
|
||||
resolved-ref: b96ac7378d43c42bfa1a9b148115a32e01f3f8a0
|
||||
url: "https://git.pvv.ntnu.no/mugiten/kanimaji-dart.git"
|
||||
source: git
|
||||
version: "0.0.1"
|
||||
@@ -502,18 +486,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: markdown
|
||||
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
|
||||
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.1"
|
||||
version: "7.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
version: "0.12.18"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -550,10 +534,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
version: "0.17.4"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -570,22 +554,14 @@ 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: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.1"
|
||||
version: "9.0.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -622,10 +598,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
version: "2.2.22"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -718,18 +694,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sealed_languages
|
||||
sha256: "8e1d71f5bf0dc647bd9bc327089625d4d11576fe69df5e8b3a39f87ab1872568"
|
||||
sha256: bf7a479389196ae29a074a8451734a71d11282f5380bd72e07d339047b011a29
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
version: "3.0.1"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa"
|
||||
sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.2"
|
||||
version: "12.0.1"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -742,18 +718,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
version: "2.5.4"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
||||
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.23"
|
||||
version: "2.4.20"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -774,10 +750,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.4.1"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -835,10 +811,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
|
||||
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2+3"
|
||||
version: "2.4.2+2"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -875,10 +851,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
|
||||
sha256: b7cf6b37667f6a921281797d2499ffc60fb878b161058d422064f0ddc78f6aa6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.1"
|
||||
version: "3.1.6"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -931,10 +907,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.9"
|
||||
tuple:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -971,10 +947,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
|
||||
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.29"
|
||||
version: "6.3.28"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1035,10 +1011,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373"
|
||||
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.21"
|
||||
version: "1.1.19"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -24,7 +24,7 @@ dependencies:
|
||||
collection: ^1.19.0
|
||||
cupertino_icons: ^1.0.8
|
||||
division: ^0.9.0
|
||||
file_picker: ^11.0.2
|
||||
file_picker: ^10.2.0
|
||||
flutter_bloc: ^9.1.0
|
||||
flutter_markdown_plus: ^1.0.3
|
||||
flutter_settings_ui: ^3.0.1
|
||||
|
||||
@@ -1,146 +1 @@
|
||||
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}',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
void main() {}
|
||||
|
||||
@@ -1,34 +1,67 @@
|
||||
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 '../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');
|
||||
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');
|
||||
}
|
||||
|
||||
for (final kanji in ['漢', '字', '学', '習']) {
|
||||
await db.libraryListInsertEntry(
|
||||
'Test Library 1',
|
||||
jmdictEntryId: null,
|
||||
kanji: kanji,
|
||||
);
|
||||
}
|
||||
// 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<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: '字',
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late final String libsqlitePath;
|
||||
late final String jadbPath;
|
||||
late Database database;
|
||||
late final Database database;
|
||||
|
||||
setUpAll(() {
|
||||
if (!Platform.environment.containsKey('LIBSQLITE_PATH')) {
|
||||
@@ -55,6 +88,8 @@ void main() {
|
||||
);
|
||||
|
||||
GetIt.instance.registerSingleton<Database>(database);
|
||||
|
||||
await insertTestData(database);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@@ -69,395 +104,7 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('Database is open', () {
|
||||
test('Database is open', () async {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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}',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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}',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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