1 Commits

Author SHA1 Message Date
7d351c61de WIP: colorize matching part of search results 2026-02-28 01:17:33 +09:00
92 changed files with 1560 additions and 4035 deletions

View File

@@ -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
View File

@@ -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": {

View File

@@ -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";

View File

@@ -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),

View File

@@ -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(

View File

@@ -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,

View File

@@ -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());
}
}

View File

@@ -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,

View File

@@ -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')),
),

View File

@@ -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()]);
}
}

View File

@@ -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(

View File

@@ -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),

View File

@@ -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(

View File

@@ -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(

View File

@@ -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),

View File

@@ -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(

View File

@@ -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(

View File

@@ -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>()!;

View File

@@ -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,

View File

@@ -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());

View File

@@ -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(

View File

@@ -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(

View File

@@ -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,

View File

@@ -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(

View File

@@ -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!,

View File

@@ -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';
}

View File

@@ -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(

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,
),
],
),
),
],

View File

@@ -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)),

View File

@@ -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: [

View File

@@ -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),

View File

@@ -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)),

View File

@@ -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,
)],
),
],
),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
),

View File

@@ -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,
);

View File

@@ -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,

View File

@@ -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(

View 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');
// }

View File

@@ -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,

View File

@@ -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(),
};
}
}

View File

@@ -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);
}

View File

@@ -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'),

View File

@@ -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) {

View File

@@ -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) +

View File

@@ -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);
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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(

View File

@@ -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(

View File

@@ -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 _:

View File

@@ -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: (_) =>

View File

@@ -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,

View File

@@ -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());

View File

@@ -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();
}
}

View File

@@ -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,
),
),
],
),

View File

@@ -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!,
),
);
}

View File

@@ -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,

View File

@@ -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,
),
),
),
),

View File

@@ -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()],

View File

@@ -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')),
),
],

View File

@@ -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());
}

View File

@@ -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();
}

View File

@@ -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,
);
}

View File

@@ -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);
}

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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(

View 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,
);
}
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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);

View File

@@ -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)));

View File

@@ -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> {

View File

@@ -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;

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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() {}

View File

@@ -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);
});
});
}

View File

@@ -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',
);
});
});
}

View File

@@ -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}',
);
});
}

View File

@@ -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',
);
});
}

View File

@@ -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',
);
});
});
}

View File

@@ -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}',
);
});
}

View File

@@ -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',
);
});
}

View File

@@ -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_');