migrate-to-sqlite
Oystein Kristoffer Tveit 2023-02-24 09:55:55 +01:00
parent ea220e25f5
commit d2d8ea07a6
Signed by: oysteikt
GPG Key ID: 9F2F7D8250F35146
45 changed files with 1888 additions and 645 deletions

View File

@ -26,7 +26,7 @@ apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android {
compileSdkVersion flutter.compileSdkVersion
compileSdkVersion 33
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
@ -42,7 +42,6 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "app.jishostudytool.jisho_study_tool"
// minSdkVersion flutter.minSdkVersion
minSdkVersion 19

View File

@ -1,16 +1,21 @@
// Generated file.
//
// If you wish to remove Flutter's multidex support, delete this entire file.
//
// Modifications to this file should be done in a copy under a different name
// as this file may be regenerated.
package io.flutter.app;
import android.app.Application;
import android.content.Context;
import androidx.annotation.CallSuper;
import androidx.multidex.MultiDex;
/**
* Extension of {@link io.flutter.app.FlutterApplication}, adding multidex support.
* Extension of {@link android.app.Application}, adding multidex support.
*/
public class FlutterMultiDexApplication extends FlutterApplication {
public class FlutterMultiDexApplication extends Application {
@Override
@CallSuper
protected void attachBaseContext(Context base) {

View File

@ -1,32 +1,58 @@
CREATE TABLE "JST_LibraryList" (
"name" TEXT PRIMARY KEY NOT NULL,
"nextList" TEXT REFERENCES "JST_LibraryList"("name")
"prevList" TEXT
UNIQUE
REFERENCES "JST_LibraryList"("name"),
-- The list can't link to itself
CHECK("prevList" != "name"),
-- 'favourites' should always be the first list
CHECK (NOT (("name" = 'favourites') <> ("prevList" IS NULL)))
);
CREATE INDEX "JST_LibraryList_byNextList" ON "JST_LibraryList"("nextList");
-- This entry should always exist
INSERT INTO "JST_LibraryList"("name") VALUES ('favourites');
-- Useful for the view below
CREATE INDEX "JST_LibraryList_byPrevList" ON "JST_LibraryList"("prevList");
-- A view that sorts the LibraryLists in their custom order.
CREATE VIEW "JST_LibraryListOrdered" AS
WITH RECURSIVE "RecursionTable"("name") AS (
SELECT "name"
FROM "JST_LibraryList" "I"
WHERE "I"."prevList" IS NULL
UNION ALL
SELECT "R"."name"
FROM "JST_LibraryList" "R"
JOIN "RecursionTable" ON
("R"."prevList" = "RecursionTable"."name")
)
SELECT * FROM "RecursionTable";
CREATE TABLE "JST_LibraryListEntry" (
"listName" TEXT NOT NULL REFERENCES "JST_LibraryList"("name") ON DELETE CASCADE,
"entryText" TEXT NOT NULL,
"isKanji" BOOLEAN NOT NULL DEFAULT 0,
"lastModified" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"nextEntry" TEXT NOT NULL,
-- Defaults to unix timestamp in milliseconds
"lastModified" INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
"prevEntryText" TEXT,
"prevEntryIsKanji" BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY ("listName", "entryText", "isKanji"),
FOREIGN KEY ("listName", "nextEntry") REFERENCES "JST_LibraryListEntry"("listName", "entryText"),
CHECK ((NOT "isKanji") OR ("nextEntry" <> 0))
FOREIGN KEY ("listName", "prevEntryText", "prevEntryIsKanji")
REFERENCES "JST_LibraryListEntry"("listName", "entryText", "isKanji"),
-- Two entries can not appear directly after the same entry
UNIQUE("listName", "prevEntryText", "prevEntryIsKanji"),
-- The entry can't link to itself
CHECK(NOT ("prevEntryText" == "entryText" AND "prevEntryIsKanji" == "isKanji")),
-- Kanji entries should only have a length of 1
CHECK ((NOT "isKanji") OR ("isKanji" AND length("entryText") = 1))
);
CREATE INDEX "JST_LibraryListEntry_byListName" ON "JST_LibraryListEntry"("listName");
-- CREATE VIEW "JST_LibraryListEntry_sortedByLists" AS
-- WITH RECURSIVE "JST_LibraryListEntry_sorted"("next") AS (
-- -- Initial SELECT
-- UNION ALL
-- SELECT * FROM ""
-- -- Recursive Select
-- )
-- SELECT * FROM "JST_LibraryListEntry_sorted";
-- Useful when doing the recursive ordering statement
CREATE INDEX "JST_LibraryListEntry_byListNameAndPrevEntry"
ON "JST_LibraryListEntry"("listName", "prevEntryText", "prevEntryIsKanji");
CREATE TABLE "JST_HistoryEntry" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT
@ -47,18 +73,17 @@ CREATE TABLE "JST_HistoryEntryWord" (
CREATE TABLE "JST_HistoryEntryTimestamp" (
"entryId" INTEGER NOT NULL REFERENCES "JST_HistoryEntry"("id") ON DELETE CASCADE,
-- Here, I'm using INTEGER insted of TIMESTAMP or DATETIME, because it seems to be
-- the easiest way to deal with global and local timeconversion between dart and
-- SQLite.
"timestamp" INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Defaults to unix timestamp in milliseconds
"timestamp" INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
PRIMARY KEY ("entryId", "timestamp")
);
-- Useful when ordering entries by the timestamps
CREATE INDEX "JST_HistoryEntryTimestamp_byTimestamp" ON "JST_HistoryEntryTimestamp"("timestamp");
CREATE VIEW "JST_HistoryEntry_orderedByTimestamp" AS
SELECT * FROM "JST_HistoryEntryTimestamp"
LEFT JOIN "JST_HistoryEntryWord" USING ("entryId")
LEFT JOIN "JST_HistoryEntryKanji" USING ("entryId")
GROUP BY "entryId"
ORDER BY MAX("timestamp") DESC;
SELECT * FROM "JST_HistoryEntryTimestamp"
LEFT JOIN "JST_HistoryEntryWord" USING ("entryId")
LEFT JOIN "JST_HistoryEntryKanji" USING ("entryId")
GROUP BY "entryId"
ORDER BY MAX("timestamp") DESC;

View File

@ -3,17 +3,17 @@
"android-nixpkgs": {
"inputs": {
"devshell": "devshell",
"flake-utils": "flake-utils_2",
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1651782096,
"narHash": "sha256-rrj0HPwmDf6Q14sljnVf2hkMvc97rndgi4PJkFtpFPk=",
"lastModified": 1677183680,
"narHash": "sha256-xPg1gYyZ8UYNWcQYBtvmmbum3l1hx5cFpoWKrJA15DI=",
"owner": "tadfisher",
"repo": "android-nixpkgs",
"rev": "ccd2a8f58709ea3413fcb72769b2f62a98332215",
"rev": "61410f48b49495f38f835bd79f98c9a0528151dd",
"type": "github"
},
"original": {
@ -24,15 +24,21 @@
},
"devshell": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
"flake-utils": [
"android-nixpkgs",
"nixpkgs"
],
"nixpkgs": [
"android-nixpkgs",
"nixpkgs"
]
},
"locked": {
"lastModified": 1650900878,
"narHash": "sha256-qhNncMBSa9STnhiLfELEQpYC1L4GrYHNIzyCZ/pilsI=",
"lastModified": 1676293499,
"narHash": "sha256-uIOTlTxvrXxpKeTvwBI1JGDGtCxMXE3BI0LFwoQMhiQ=",
"owner": "numtide",
"repo": "devshell",
"rev": "d97df53b5ddaa1cfbea7cddbd207eb2634304733",
"rev": "71e3022e3ab20bbf1342640547ef5bc14fb43bf4",
"type": "github"
},
"original": {
@ -43,11 +49,11 @@
},
"flake-utils": {
"locked": {
"lastModified": 1642700792,
"narHash": "sha256-XqHrk7hFb+zBvRg6Ghl+AZDq03ov6OshJLiSWOoX5es=",
"lastModified": 1676283394,
"narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "846b2ae0fc4cc943637d3d1def4454213e203cba",
"rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
"type": "github"
},
"original": {
@ -58,11 +64,11 @@
},
"flake-utils_2": {
"locked": {
"lastModified": 1649676176,
"narHash": "sha256-OWKJratjt2RW151VUlJPRALb7OU2S5s+f0vLj4o1bHM=",
"lastModified": 1676283394,
"narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "a4b154ebbdc88c8498a5c7b01589addc9e9cb678",
"rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
"type": "github"
},
"original": {
@ -86,35 +92,20 @@
"type": "github"
}
},
"flake-utils_4": {
"locked": {
"lastModified": 1649676176,
"narHash": "sha256-OWKJratjt2RW151VUlJPRALb7OU2S5s+f0vLj4o1bHM=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "a4b154ebbdc88c8498a5c7b01589addc9e9cb678",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nix-dart": {
"inputs": {
"flake-utils": "flake-utils_4",
"flake-utils": "flake-utils_3",
"nixpkgs": [
"nixpkgs"
],
"pub2nix": "pub2nix"
},
"locked": {
"lastModified": 1651781526,
"narHash": "sha256-q01e+S69g4UDrMcEitaQOccr2aHeiJ+VEmPS94h/7WY=",
"lastModified": 1652213615,
"narHash": "sha256-+eehm2JlhoKgY+Ea4DTxDMei/x4Fgz7S+ZPqWpZysuI=",
"owner": "tadfisher",
"repo": "nix-dart",
"rev": "71d2fda0f9590d5de917fb736dee312d9fef7e27",
"rev": "6f686ddf984306d944e9b5adf9f35f3a0a0a70b7",
"type": "github"
},
"original": {
@ -125,32 +116,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1643381941,
"narHash": "sha256-pHTwvnN4tTsEKkWlXQ8JMY423epos8wUOhthpwJjtpc=",
"lastModified": 1677075010,
"narHash": "sha256-X+UmR1AkdR//lPVcShmLy8p1n857IGf7y+cyCArp8bU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5efc8ca954272c4376ac929f4c5ffefcc20551d5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1651743098,
"narHash": "sha256-NuQNu6yHh54li0kZffM59FRC5bWCJusygL4Cy+3O0fY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d4191fe35cbe52f755ef73009d4d37b9e002efa2",
"rev": "c95bf18beba4290af25c60cbaaceea1110d0f727",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-21.11",
"ref": "nixos-22.11",
"type": "indirect"
}
},
@ -173,9 +148,9 @@
"root": {
"inputs": {
"android-nixpkgs": "android-nixpkgs",
"flake-utils": "flake-utils_3",
"flake-utils": "flake-utils_2",
"nix-dart": "nix-dart",
"nixpkgs": "nixpkgs_2"
"nixpkgs": "nixpkgs"
}
}
},

View File

@ -2,12 +2,8 @@
description = "A dictionary app for studying japanese";
inputs = {
nixpkgs.url = "nixpkgs/nixos-21.11";
flake-utils = {
url = "github:numtide/flake-utils";
inputs.nixpkgs.follows = "nixpkgs";
};
nixpkgs.url = "nixpkgs/nixos-22.11";
flake-utils.url = "github:numtide/flake-utils";
android-nixpkgs = {
url = "github:tadfisher/android-nixpkgs";
@ -18,11 +14,6 @@
url = "github:tadfisher/nix-dart";
inputs.nixpkgs.follows = "nixpkgs";
};
# nix-flutter = {
# url = "path:/home/h7x4/git/flutter_linux_2.5.1-stable/flutter";
# inputs.nixpkgs.follows = "nixpkgs";
# };
};
outputs = { self, nixpkgs, flake-utils, android-nixpkgs, nix-dart }:
@ -35,68 +26,37 @@
allowUnfree = true;
};
};
dartVersion = "2.14.2";
dartChannel = "stable";
flutterVersion = "2.5.1";
flutterChannel = "stable";
in {
packages.${system} = {
android-sdk = android-nixpkgs.sdk.${system} (sdkPkgs: with sdkPkgs; [
cmdline-tools-latest
build-tools-33-0-0
build-tools-32-0-0
build-tools-31-0-0
build-tools-30-0-2
build-tools-29-0-2
platform-tools
platforms-android-33
platforms-android-32
platforms-android-31
platforms-android-30
platforms-android-29
emulator
]);
# dart = nix-dart.packages.${system}.dart;
dart = (pkgs.callPackage ./nix/dart.nix {});
inherit (pkgs.callPackage ./nix/flutter.nix { inherit (self.packages.${system}) dart; }) flutter;
# pub2nix-lock = nix-dart.packages.${system}.pub2nix-lock;
};
# apps.${system} = {
# web-debug = {
# type = "app";
# program = "";
# };
# web-release = {
# type = "app";
# program = "";
# };
# apk-debug = {
# type = "app";
# program = "";
# };
# apk-release = {
# type = "app";
# program = "${self.packages.${system}.flutter}/bin/flutter run --release";
# };
# default = self.apps.${system}.apk-debug;
# };
devShell.${system} = let
inherit (pkgs) lcov google-chrome sqlite sqlite-web;
inherit (self.packages.${system}) android-sdk flutter dart;
inherit (pkgs) lcov google-chrome sqlite sqlite-web flutter dart;
jdk = pkgs.jdk11;
inherit (self.packages.${system}) android-sdk;
inherit (nix-dart.packages.${system}) pub2nix-lock;
java = pkgs.jdk8;
in pkgs.mkShell rec {
ANDROID_JAVA_HOME="${java.home}";
ANDROID_JAVA_HOME="${jdk.home}";
ANDROID_SDK_ROOT = "${android-sdk}/share/android-sdk";
CHROME_EXECUTABLE = "${google-chrome}/bin/google-chrome-stable";
FLUTTER_SDK="${flutter}";
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_SDK_ROOT}/build-tools/32.0.0/aapt2";
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_SDK_ROOT}/build-tools/33.0.0/aapt2";
JAVA_HOME="${ANDROID_JAVA_HOME}";
USE_CCACHE=0;
@ -105,7 +65,7 @@
dart
flutter
google-chrome
java
jdk
lcov
pub2nix-lock
sqlite

View File

@ -20,7 +20,7 @@ class ThemeBloc extends Bloc<ThemeEvent, ThemeState> {
);
final bool autoThemeIsDark =
SchedulerBinding.instance!.window.platformBrightness == Brightness.dark;
SchedulerBinding.instance?.window.platformBrightness == Brightness.dark;
add(
SetTheme(

View File

@ -0,0 +1,184 @@
import 'dart:math';
import 'package:flutter/material.dart';
import '../../bloc/theme/theme_bloc.dart';
import '../../settings.dart';
// TODO: Check that it looks right in
// - saved
/// The ratio is defined as 'the amount of space the text should take'
/// divided by 'the amount of space the padding should take'.
///
/// So if the KanjiBox should span 50 pixels, and you wanted 10 of those pixels
/// to be used for padding (5 on each side), and 40 to be used for the text,
/// you could write:
///
/// ```dart
/// KanjiBox.withRatioAndFontSize({
/// kanji: '',
/// ratio: 4 / 1,
/// fontSize: 40,
/// })
/// ```
///
class KanjiBox extends StatelessWidget {
final String kanji;
final double? fontSize;
final double? padding;
final Color? foreground;
final Color? background;
final double? contentPaddingRatio;
final double borderRadius;
static const double defaultRatio = 3 / 1;
static const double defaultBorderRadius = 10;
double get ratio => contentPaddingRatio ?? fontSize! / padding!;
double get fontSizeFactor => ratio / (ratio + 1);
double get paddingSizeFactor => 1 / (ratio + 1);
bool get isExpanded => contentPaddingRatio != null;
double? get size => isExpanded ? null : fontSize! + (2 * padding!);
double? get oneSidePadding => padding != null ? padding! / 2 : null;
const KanjiBox._({
Key? key,
required this.kanji,
this.fontSize,
this.padding,
this.contentPaddingRatio,
this.foreground,
this.background,
this.borderRadius = defaultBorderRadius,
}) : assert(
kanji.length == 1,
'KanjiBox can not show more than one character at a time',
),
assert(
contentPaddingRatio != null || (fontSize != null && padding != null),
'Either contentPaddingRatio or both the fontSize and padding need to be '
'explicitly defined in order for the box to be able to render correctly',
),
super(key: key);
const factory KanjiBox.withFontSizeAndPadding({
required String kanji,
required double fontSize,
required double padding,
Color? foreground,
Color? background,
double borderRadius,
}) = KanjiBox._;
factory KanjiBox.withFontSize({
required String kanji,
required double fontSize,
double ratio = defaultRatio,
Color? foreground,
Color? background,
double borderRadius = defaultBorderRadius,
}) =>
KanjiBox._(
kanji: kanji,
fontSize: fontSize,
padding: pow(ratio * (1 / fontSize), -1).toDouble(),
foreground: foreground,
background: background,
borderRadius: borderRadius,
);
factory KanjiBox.withPadding({
required String kanji,
double ratio = defaultRatio,
required double padding,
Color? foreground,
Color? background,
double borderRadius = defaultBorderRadius,
}) =>
KanjiBox._(
kanji: kanji,
fontSize: ratio * padding,
padding: padding,
foreground: foreground,
background: background,
borderRadius: borderRadius,
);
factory KanjiBox.expanded({
required String kanji,
double ratio = defaultRatio,
Color? foreground,
Color? background,
double borderRadius = defaultBorderRadius,
}) =>
KanjiBox._(
kanji: kanji,
contentPaddingRatio: ratio,
foreground: foreground,
background: background,
borderRadius: borderRadius,
);
/// A shortcut
factory KanjiBox.headline4({
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.headline4!.fontSize!,
ratio: ratio,
foreground: foreground,
background: background,
borderRadius: borderRadius,
);
@override
Widget build(BuildContext context) {
return BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, state) {
final calculatedForeground =
foreground ?? state.theme.menuGreyLight.foreground;
final calculatedBackground =
background ?? state.theme.menuGreyLight.background;
return LayoutBuilder(
builder: (context, constraints) {
final sizeConstraint =
min(constraints.maxHeight, constraints.maxWidth);
final calculatedFontSize =
fontSize ?? sizeConstraint * fontSizeFactor;
final calculatedPadding =
oneSidePadding ?? (sizeConstraint * paddingSizeFactor) / 2;
return Container(
padding: EdgeInsets.all(calculatedPadding),
alignment: Alignment.center,
width: size,
height: size,
decoration: BoxDecoration(
color: calculatedBackground,
borderRadius: BorderRadius.circular(borderRadius),
),
child: FittedBox(
child: Text(
kanji,
textScaleFactor: 1,
style: TextStyle(
color: calculatedForeground,
fontSize: calculatedFontSize,
).merge(japaneseFont.textStyle),
),
),
);
},
);
},
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:signature/signature.dart';
import '../../bloc/theme/theme_bloc.dart';
import '../../services/handwriting.dart';
import '../../services/snackbar.dart';
import '../../settings.dart';
class DrawingBoard extends StatefulWidget {
@ -182,10 +183,9 @@ class _DrawingBoardState extends State<DrawingBoard> {
),
if (!widget.onlyOneCharacterSuggestions)
IconButton(
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('TODO: implement scrolling page feature!'),
),
onPressed: () => showSnackbar(
context,
'TODO: implement scrolling page feature!',
),
icon: const Icon(Icons.arrow_forward),
),

View File

@ -4,18 +4,17 @@ import 'package:flutter_slidable/flutter_slidable.dart';
import '../../models/history/history_entry.dart';
import '../../routing/routes.dart';
import '../../services/datetime.dart';
import '../../services/snackbar.dart';
import '../../settings.dart';
import '../common/kanji_box.dart';
import '../common/loading.dart';
import 'kanji_box.dart';
class HistoryEntryItem extends StatelessWidget {
class HistoryEntryTile extends StatelessWidget {
final HistoryEntry entry;
final int objectKey;
final void Function()? onDelete;
final void Function()? onFavourite;
const HistoryEntryItem({
const HistoryEntryTile({
required this.entry,
required this.objectKey,
this.onDelete,
@ -23,10 +22,6 @@ class HistoryEntryItem extends StatelessWidget {
Key? key,
}) : super(key: key);
Widget get _child => (entry.isKanji)
? KanjiBox(kanji: entry.kanji!)
: Text(entry.word!);
void Function() _onTap(context) => entry.isKanji
? () => Navigator.pushNamed(
context,
@ -46,8 +41,7 @@ class HistoryEntryItem extends StatelessWidget {
future: entry.timestamps,
builder: (context, snapshot) {
// TODO: provide proper error handling
if (snapshot.hasError)
return ErrorWidget(snapshot.error!);
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
if (!snapshot.hasData) return const LoadingScreen();
return ListView(
children: snapshot.data!
@ -69,14 +63,6 @@ class HistoryEntryItem extends StatelessWidget {
icon: Icons.access_time,
onPressed: (_) => Navigator.push(context, timestamps),
),
SlidableAction(
backgroundColor: Colors.yellow,
icon: Icons.star,
onPressed: (_) {
showSnackbar(context, 'TODO: implement favourites');
onFavourite?.call();
},
),
SlidableAction(
backgroundColor: Colors.red,
icon: Icons.delete,
@ -107,7 +93,12 @@ class HistoryEntryItem extends StatelessWidget {
),
DefaultTextStyle.merge(
style: japaneseFont.textStyle,
child: _child,
child: entry.isKanji
? KanjiBox.headline4(
context: context,
kanji: entry.kanji!,
)
: Expanded(child: Text(entry.word!)),
),
],
),

View File

@ -1,41 +0,0 @@
import 'package:flutter/material.dart';
import '../../bloc/theme/theme_bloc.dart';
class KanjiBox extends StatelessWidget {
final String kanji;
const KanjiBox({
Key? key,
required this.kanji,
}) : super(key: key);
@override
Widget build(BuildContext context) => IntrinsicHeight(
child: AspectRatio(
aspectRatio: 1,
child: BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, state) {
final colors = state.theme.menuGreyLight;
return Container(
padding: const EdgeInsets.all(5),
alignment: Alignment.center,
decoration: BoxDecoration(
color: colors.background,
borderRadius: BorderRadius.circular(10.0),
),
child: FittedBox(
child: Text(
kanji,
style: TextStyle(
color: colors.foreground,
fontSize: 25,
),
),
),
);
},
),
),
);
}

View File

@ -3,13 +3,14 @@ import 'package:unofficial_jisho_api/api.dart' as jisho;
import './kanji_result_body/examples.dart';
import './kanji_result_body/grade.dart';
import './kanji_result_body/header.dart';
import './kanji_result_body/jlpt_level.dart';
import './kanji_result_body/radical.dart';
import './kanji_result_body/rank.dart';
import './kanji_result_body/stroke_order_gif.dart';
import './kanji_result_body/yomi_chips.dart';
import '../../bloc/theme/theme_bloc.dart';
import '../../services/kanji_grade_conversion.dart';
import '../common/kanji_box.dart';
class KanjiResultBody extends StatelessWidget {
late final String query;
@ -36,9 +37,22 @@ class KanjiResultBody extends StatelessWidget {
child: SizedBox(),
),
Flexible(
fit: FlexFit.tight,
child: Center(child: Header(kanji: query)),
),
child: AspectRatio(
aspectRatio: 1,
child: BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, state) {
final colors = state.theme.kanjiResultColor;
return KanjiBox.expanded(
kanji: query,
ratio: 40,
foreground: colors.foreground,
background: colors.background,
);
},
),
),
),
Flexible(
fit: FlexFit.tight,
child: Center(
@ -81,6 +95,7 @@ class KanjiResultBody extends StatelessWidget {
return ListView(
children: [
headerRow,
// TODO: handle case where meaning is empty. See for example
YomiChips(yomi: resultData.meaning.split(', '), type: YomiType.meaning),
(resultData.onyomi.isNotEmpty)
? YomiChips(yomi: resultData.onyomi, type: YomiType.onyomi)
@ -101,6 +116,7 @@ class KanjiResultBody extends StatelessWidget {
onyomi: resultData.onyomiExamples,
kunyomi: resultData.kunyomiExamples,
),
// TODO: Add unicode information
],
);
}

View File

@ -1,36 +0,0 @@
import 'package:flutter/material.dart';
import '../../../bloc/theme/theme_bloc.dart';
import '../../../settings.dart';
class Header extends StatelessWidget {
final String kanji;
const Header({
required this.kanji,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) => AspectRatio(
aspectRatio: 1,
child: BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, state) {
final colors = state.theme.kanjiResultColor;
return Container(
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.0),
color: colors.background,
),
child: Text(
kanji,
style: TextStyle(fontSize: 70.0, color: colors.foreground)
.merge(japaneseFont.textStyle),
),
);
},
),
);
}

View File

@ -19,7 +19,11 @@ class Radical extends StatelessWidget {
final colors = state.theme.kanjiResultColor;
return InkWell(
onTap: () => Navigator.pushNamed(context, Routes.kanjiSearchRadicals, arguments: radical.symbol),
onTap: () => Navigator.pushNamed(
context,
Routes.kanjiSearchRadicals,
arguments: radical.symbol,
),
child: Container(
padding: const EdgeInsets.all(15.0),
decoration: BoxDecoration(

View File

@ -22,6 +22,7 @@ class StrokeOrderGif extends StatelessWidget {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10.0),
// TODO: show some kind of default icon if GIF is missing.
child: Image.network(uri),
),
);

View File

@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:ruby_text/ruby_text.dart';
import '../../models/library/library_list.dart';
import '../common/kanji_box.dart';
import '../common/loading.dart';
Future<void> showAddToLibraryDialog({
required BuildContext context,
required String entryText,
String? furigana,
bool isKanji = false,
}) =>
showDialog(
context: context,
barrierDismissible: true,
builder: (_) => AddToLibraryDialog(
furigana: furigana,
entryText: entryText,
isKanji: isKanji,
),
);
class AddToLibraryDialog extends StatefulWidget {
final String? furigana;
final String entryText;
final bool isKanji;
const AddToLibraryDialog({
Key? key,
required this.entryText,
required this.isKanji,
this.furigana,
}) : super(key: key);
@override
State<AddToLibraryDialog> createState() => _AddToLibraryDialogState();
}
class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
Map<LibraryList, bool>? librariesContainEntry;
/// A lock to make sure that the local data and the database doesn't
/// get out of sync.
bool toggleLock = false;
@override
void initState() {
super.initState();
LibraryList.allListsContains(
entryText: widget.entryText,
isKanji: widget.isKanji,
).then((data) => setState(() => librariesContainEntry = data));
}
Future<void> toggleEntry({required LibraryList lib}) async {
if (toggleLock) return;
setState(() => toggleLock = true);
await lib.toggleEntry(
entryText: widget.entryText,
isKanji: widget.isKanji,
);
setState(() {
toggleLock = false;
librariesContainEntry![lib] = !librariesContainEntry![lib]!;
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add to library'),
contentPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 12),
content: Column(
children: [
ListTile(
title: Center(
child: widget.isKanji
? Row(
children: [
const Expanded(child: SizedBox()),
KanjiBox.headline4(
context: context,
kanji: widget.entryText,
),
const Expanded(child: SizedBox()),
],
)
: RubySpanWidget(
RubyTextData(
widget.entryText,
ruby: widget.furigana,
),
),
),
),
const Divider(thickness: 3),
Expanded(
child: SizedBox(
width: double.maxFinite,
child: librariesContainEntry == null
? const LoadingScreen()
: ListView(
children: librariesContainEntry!.entries.map((e) {
final lib = e.key;
final checked = e.value;
return ListTile(
onTap: () => toggleEntry(lib: lib),
contentPadding:
const EdgeInsets.symmetric(vertical: 5),
title: Row(
children: [
Checkbox(
value: checked,
onChanged: (_) => toggleEntry(lib: lib),
),
Text(lib.name),
],
),
);
}).toList(),
),
),
),
],
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
);
}
}

View File

@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import '../../models/library/library_entry.dart';
import '../../models/library/library_list.dart';
import '../../routing/routes.dart';
import '../../settings.dart';
import '../common/kanji_box.dart';
class LibraryListEntryTile extends StatelessWidget {
final int? index;
final LibraryList library;
final LibraryEntry entry;
final void Function()? onDelete;
final void Function()? onUpdate;
const LibraryListEntryTile({
Key? key,
required this.entry,
required this.library,
this.index,
this.onDelete,
this.onUpdate,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Slidable(
endActionPane: ActionPane(
motion: const ScrollMotion(),
children: [
SlidableAction(
backgroundColor: Colors.red,
icon: Icons.delete,
onPressed: (_) async {
await library.deleteEntry(
entryText: entry.entryText,
isKanji: entry.isKanji,
);
onDelete?.call();
},
),
],
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 10),
onTap: () async {
await Navigator.pushNamed(
context,
entry.isKanji ? Routes.kanjiSearch : Routes.search,
arguments: entry.entryText,
);
onUpdate?.call();
},
title: Row(
children: [
if (index != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
(index! + 1).toString(),
style: Theme.of(context)
.textTheme
.titleMedium!
.merge(japaneseFont.textStyle),
),
),
entry.isKanji
? KanjiBox.headline4(context: context, kanji: entry.entryText)
: Expanded(child: Text(entry.entryText)),
],
),
),
);
}
}

View File

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import '../../models/library/library_list.dart';
import '../../routing/routes.dart';
import '../common/loading.dart';
class LibraryListTile extends StatelessWidget {
final Widget? leading;
final LibraryList library;
final void Function()? onDelete;
final void Function()? onUpdate;
final bool isEditable;
const LibraryListTile({
Key? key,
required this.library,
this.leading,
this.onDelete,
this.onUpdate,
this.isEditable = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Slidable(
endActionPane: ActionPane(
motion: const ScrollMotion(),
children: !isEditable
? []
: [
SlidableAction(
backgroundColor: Colors.blue,
icon: Icons.edit,
onPressed: (_) async {
// TODO: update name
onUpdate?.call();
},
),
SlidableAction(
backgroundColor: Colors.red,
icon: Icons.delete,
onPressed: (_) async {
await library.delete();
onDelete?.call();
},
),
],
),
child: ListTile(
leading: leading,
onTap: () => Navigator.pushNamed(
context,
Routes.libraryContent,
arguments: library,
),
title: Row(
children: [
Expanded(child: Text(library.name)),
FutureBuilder<int>(
future: library.length,
builder: (context, snapshot) {
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
if (!snapshot.hasData) return const LoadingScreen();
return Text('${snapshot.data} items');
},
),
],
),
),
);
}
}

View File

@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import '../../models/library/library_list.dart';
void Function() showNewLibraryDialog(context) => () async {
final String? listName = await showDialog<String>(
context: context,
barrierDismissible: true,
builder: (_) => const NewLibraryDialog(),
);
if (listName == null) return;
LibraryList.insert(listName);
};
class NewLibraryDialog extends StatefulWidget {
const NewLibraryDialog({Key? key}) : super(key: key);
@override
State<NewLibraryDialog> createState() => _NewLibraryDialogState();
}
enum _NameState {
initial,
currentlyChecking,
invalid,
alreadyExists,
valid,
}
class _NewLibraryDialogState extends State<NewLibraryDialog> {
final controller = TextEditingController();
_NameState nameState = _NameState.initial;
Future<void> onNameUpdate(proposedListName) async {
setState(() => nameState = _NameState.currentlyChecking);
if (proposedListName == '') {
setState(() => nameState = _NameState.invalid);
return;
}
final nameAlreadyExists = await LibraryList.exists(proposedListName);
if (nameAlreadyExists) {
setState(() => nameState = _NameState.alreadyExists);
} else {
setState(() => nameState = _NameState.valid);
}
}
bool get errorStatus =>
nameState == _NameState.invalid || nameState == _NameState.alreadyExists;
String? get statusLabel => {
_NameState.invalid: 'Invalid Name',
_NameState.alreadyExists: 'Already Exists',
}[nameState];
bool get confirmButtonActive => nameState == _NameState.valid;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add new library'),
content: TextField(
decoration: InputDecoration(
hintText: 'Library name',
errorText: statusLabel,
),
controller: controller,
onChanged: onNameUpdate,
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
style: confirmButtonActive
? null
: ElevatedButton.styleFrom(
primary: Colors.grey,
),
onPressed: confirmButtonActive
? () => Navigator.pop(context, controller.text)
: () {},
child: const Text('Add'),
),
],
);
}
}

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:ruby_text/ruby_text.dart';
import 'package:unofficial_jisho_api/api.dart';
import '../../../../services/jisho_api/kanji_furigana_separation.dart';
import '../../../../services/romaji_transliteration.dart';
import '../../../../settings.dart';
@ -25,30 +27,13 @@ class JapaneseHeader extends StatelessWidget {
return Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 10.0),
child: Column(
children: [
// Both wordReading and word.word being present implies that the word has furigana.
// If that's not the case, then the word is usually present in wordReading.
// However, there are some exceptions where the reading is placed in word.
// I have no clue why this might be the case.
hasFurigana
? Text(
wordReading!,
style: romajiEnabled ? null : japaneseFont.textStyle,
)
: const Text(''),
hasFurigana
? Text(
word.word!,
style: japaneseFont.textStyle,
)
: Text(
wordReading ?? word.word!,
style: wordReading != null && romajiEnabled
? null
: japaneseFont.textStyle,
),
],
child: RubySpanWidget(
RubyTextData(
word.kanji,
ruby: word.furigana,
style: romajiEnabled ? null : japaneseFont.textStyle,
rubyStyle: romajiEnabled ? null : japaneseFont.textStyle,
),
),
);
}

View File

@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
import '../../../../bloc/theme/theme_bloc.dart';
import '../../../../routing/routes.dart';
import '../../../../settings.dart';
import '../../../common/kanji_box.dart';
class KanjiRow extends StatelessWidget {
final List<String> kanji;
@ -13,36 +12,6 @@ class KanjiRow extends StatelessWidget {
this.fontSize = 20,
}) : super(key: key);
Widget _kanjiBox(String kanji) => UnconstrainedBox(
child: IntrinsicHeight(
child: AspectRatio(
aspectRatio: 1,
child: BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, state) {
final colors = state.theme.menuGreyLight;
return Container(
padding: const EdgeInsets.all(10),
alignment: Alignment.center,
decoration: BoxDecoration(
color: colors.background,
borderRadius: BorderRadius.circular(10),
),
child: FittedBox(
child: Text(
kanji,
style: TextStyle(
color: colors.foreground,
fontSize: fontSize,
).merge(japaneseFont.textStyle),
),
),
);
},
),
),
),
);
@override
Widget build(BuildContext context) {
return Column(
@ -64,7 +33,10 @@ class KanjiRow extends StatelessWidget {
Routes.kanjiSearch,
arguments: k,
),
child: _kanjiBox(k),
child: KanjiBox.headline4(
context: context,
kanji: k,
),
)
],
),

View File

@ -1,18 +1,22 @@
import 'package:flutter/material.dart';
import 'package:jisho_study_tool/services/kanji_regex.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:unofficial_jisho_api/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/wanikani_badge.dart';
import '../../../models/library/library_list.dart';
import '../../../services/jisho_api/kanji_furigana_separation.dart';
import '../../../services/kanji_regex.dart';
import '../../../settings.dart';
import '../../library/add_to_library_dialog.dart';
import 'parts/audio_player.dart';
import 'parts/common_badge.dart';
import 'parts/header.dart';
import 'parts/jlpt_badge.dart';
import 'parts/kanji.dart';
import 'parts/links.dart';
import 'parts/notes.dart';
import 'parts/other_forms.dart';
import 'parts/senses.dart';
import 'parts/wanikani_badge.dart';
class SearchResultCard extends StatefulWidget {
final JishoResult result;
@ -31,20 +35,11 @@ class SearchResultCard extends StatefulWidget {
}
class _SearchResultCardState extends State<SearchResultCard> {
static const _margin = SizedBox(height: 20);
PhrasePageScrapeResultData? extraData;
bool? extraDataSearchFailed;
Future<PhrasePageScrapeResult?> _scrape(JishoResult result) =>
(!(result.japanese[0].word == null && result.japanese[0].reading == null))
? scrapeForPhrase(
widget.result.japanese[0].word ??
widget.result.japanese[0].reading!,
)
: Future(() => null);
List<JishoSenseLink> get links =>
[for (final sense in widget.result.senses) ...sense.links];
bool get hasAttribution =>
widget.result.attribution.jmdict ||
widget.result.attribution.jmnedict ||
@ -67,30 +62,77 @@ class _SearchResultCardState extends State<SearchResultCard> {
.toSet()
.toList();
Widget get _header => IntrinsicWidth(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
JapaneseHeader(word: widget.mainWord),
Row(
children: [
WKBadge(
level: widget.result.tags.firstWhere(
(tag) => tag.contains('wanikani'),
orElse: () => '',
),
),
JLPTBadge(jlptLevel: jlptLevel),
CommonBadge(isCommon: widget.result.isCommon ?? false)
],
)
],
),
List<JishoSenseLink> get links =>
[for (final sense in widget.result.senses) ...sense.links];
Widget get _header => Row(
children: [
Expanded(child: JapaneseHeader(word: widget.mainWord)),
WKBadge(
level: widget.result.tags.firstWhere(
(tag) => tag.contains('wanikani'),
orElse: () => '',
),
),
JLPTBadge(jlptLevel: jlptLevel),
CommonBadge(isCommon: widget.result.isCommon ?? false)
],
);
static const _margin = SizedBox(height: 20);
@override
Widget build(BuildContext context) {
final backgroundColor = Theme.of(context).scaffoldBackgroundColor;
List<Widget> _withMargin(Widget w) => [_margin, w];
return Slidable(
endActionPane: ActionPane(
motion: const ScrollMotion(),
children: [
SlidableAction(
backgroundColor: Colors.yellow,
icon: Icons.star,
onPressed: (_) => LibraryList.favourites.toggleEntry(
entryText: widget.result.slug,
isKanji: false,
),
),
SlidableAction(
backgroundColor: Colors.blue,
icon: Icons.bookmark,
onPressed: (context) => showAddToLibraryDialog(
context: context,
entryText: widget.result.japanese.first.kanji,
furigana: widget.result.japanese.first.furigana
),
),
],
),
child: ExpansionTile(
collapsedBackgroundColor: backgroundColor,
backgroundColor: backgroundColor,
onExpansionChanged: (b) async {
if (extensiveSearchEnabled && extraData == null) {
final data = await _scrape(widget.result);
setState(() {
extraDataSearchFailed = !(data?.found ?? false);
extraData = !extraDataSearchFailed! ? data!.data : null;
});
}
},
title: _header,
children: [
if (extensiveSearchEnabled && extraDataSearchFailed == null)
const Padding(
padding: EdgeInsets.symmetric(vertical: 10),
child: Center(child: CircularProgressIndicator()),
)
else if (!extraDataSearchFailed!)
_body(extendedData: extraData)
else
_body()
],
),
);
}
Widget _body({PhrasePageScrapeResultData? extendedData}) => Container(
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 10),
@ -123,34 +165,13 @@ class _SearchResultCardState extends State<SearchResultCard> {
),
);
@override
Widget build(BuildContext context) {
final backgroundColor = Theme.of(context).scaffoldBackgroundColor;
Future<PhrasePageScrapeResult?> _scrape(JishoResult result) =>
(!(result.japanese[0].word == null && result.japanese[0].reading == null))
? scrapeForPhrase(
widget.result.japanese[0].word ??
widget.result.japanese[0].reading!,
)
: Future(() => null);
return ExpansionTile(
collapsedBackgroundColor: backgroundColor,
backgroundColor: backgroundColor,
onExpansionChanged: (b) async {
if (extensiveSearchEnabled && extraData == null) {
final data = await _scrape(widget.result);
setState(() {
extraDataSearchFailed = !(data?.found ?? false);
extraData = !extraDataSearchFailed! ? data!.data : null;
});
}
},
title: _header,
children: [
if (extensiveSearchEnabled && extraDataSearchFailed == null)
const Padding(
padding: EdgeInsets.symmetric(vertical: 10),
child: Center(child: CircularProgressIndicator()),
)
else if (!extraDataSearchFailed!)
_body(extendedData: extraData)
else
_body()
],
);
}
List<Widget> _withMargin(Widget w) => [_margin, w];
}

View File

@ -1,18 +1,36 @@
import 'dart:io';
import 'dart:math';
// Example file Structure:
// jisho_data_22.01.01_1
// jisho_data_2022.01.01_1
// - history.json
// - library/
// - lista.json
// - listb.json
extension ArchiveFormat on Directory {
// TODO: make the export dir dependent on date
Directory get exportDirectory {
final dir = Directory(uri.resolve('export').path);
dir.createSync(recursive: true);
return dir;
final DateTime today = DateTime.now();
final String formattedDate = '${today.year}'
'.${today.month.toString().padLeft(2, '0')}'
'.${today.day.toString().padLeft(2, '0')}';
final List<int> takenNumbers = dir
.listSync()
.map((f) => f.uri.pathSegments[f.uri.pathSegments.length - 2])
.where((p) => RegExp('^jisho_data_${formattedDate}_(\\d+)').hasMatch(p))
.map((p) => int.tryParse(p.substring('jisho_data_0000.00.00_'.length)))
.whereType<int>()
.toList();
final int nextNum = takenNumbers.fold(0, max) + 1;
return Directory(
dir.uri.resolve('jisho_data_${formattedDate}_$nextNum').path,
)..createSync();
}
File get historyFile => File(uri.resolve('history.json').path);

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:get_it/get_it.dart';
import 'package:path/path.dart';
@ -41,7 +42,8 @@ Future<void> migrate(Database db, int oldVersion, int newVersion) async {
log(
'Migrating database from v$i to v${i + 1} with File(${migrations[i - 1]})',
);
final migrationContent = await rootBundle.loadString(migrations[i - 1], cache: false);
final migrationContent =
await rootBundle.loadString(migrations[i - 1], cache: false);
migrationContent
.split(';')
@ -58,24 +60,25 @@ Future<void> migrate(Database db, int oldVersion, int newVersion) async {
}
Future<void> setupDatabase() async {
databaseFactory.debugSetLogLevel(sqfliteLogLevelSql);
if (kDebugMode) {
databaseFactory.debugSetLogLevel(sqfliteLogLevelSql);
}
final Database database = await openDatabase(
await databasePath(),
version: 1,
onCreate: (db, version) => migrate(db, 0, version),
onUpgrade: migrate,
onOpen: (db) => Future.wait([
db.execute('PRAGMA foreign_keys=ON')
]),
onOpen: (db) => Future.wait([db.execute('PRAGMA foreign_keys=ON')]),
);
GetIt.instance.registerSingleton<Database>(database);
}
Future<void> resetDatabase() async {
await db().close();
File(await databasePath()).deleteSync();
GetIt.instance.unregister<Database>();
await setupDatabase();
await db().close();
File(await databasePath()).deleteSync();
GetIt.instance.unregister<Database>();
await setupDatabase();
}
class TableNames {
@ -101,7 +104,7 @@ class TableNames {
/// Attributes:
/// - name TEXT
/// - nextList TEXT
/// - prevList TEXT
static const String libraryList = 'JST_LibraryList';
/// Attributes:
@ -109,12 +112,17 @@ class TableNames {
/// - entryText TEXT
/// - isKanji BOOLEAN
/// - lastModified TIMESTAMP
/// - nextEntry TEXT
/// - prevEntryText TEXT
/// - prevEntryIsKanji BOOLEAN
static const String libraryListEntry = 'JST_LibraryListEntry';
///////////
// VIEWS //
///////////
/// Attributes:
/// - name TEXT
static const String libraryListOrdered = 'JST_LibraryListOrdered';
/// Attributes:
/// - entryId INTEGER

View File

@ -0,0 +1,74 @@
abstract class DatabaseError implements ArgumentError {
final String? tableName;
final Map<String, dynamic>? illegalArguments;
const DatabaseError({
this.tableName,
this.illegalArguments,
});
@override
dynamic get invalidValue => illegalArguments;
@override
StackTrace? get stackTrace => null;
}
class DataAlreadyExistsError extends DatabaseError {
const DataAlreadyExistsError({
String? tableName,
Map<String, dynamic>? illegalArguments,
}) : super(
tableName: tableName,
illegalArguments: illegalArguments,
);
@override
String? get name => illegalArguments?.keys.join(', ');
String get _inTableName => tableName != null ? ' in "$tableName"' : '';
String get _invalidArgs => illegalArguments != null ? ': ($name)' : '';
@override
String get message => 'Data already exists$_inTableName$_invalidArgs';
}
class DataNotFoundError extends DatabaseError {
const DataNotFoundError({
String? tableName,
Map<String, dynamic>? illegalArguments,
}) : super(
tableName: tableName,
illegalArguments: illegalArguments,
);
@override
String? get name => illegalArguments?.keys.join(', ');
String get _inTableName => tableName != null ? ' in "$tableName"' : '';
String get _invalidArgs => illegalArguments != null ? ': ($name)' : '';
@override
String get message => 'Data not found$_inTableName$_invalidArgs';
}
class IllegalDeletionError extends DatabaseError {
const IllegalDeletionError({
String? tableName,
Map<String, dynamic>? illegalArguments,
}) : super(
tableName: tableName,
illegalArguments: illegalArguments,
);
@override
String? get name => illegalArguments?.keys.join(', ');
String get _fromTableName => tableName != null ? ' from "$tableName"' : '';
String get _args => illegalArguments != null ? '($name)' : '';
@override
String get message => 'Deleting $_args$_fromTableName is not allowed.';
}
// class IllegalInsertionError extends DatabaseError {}

View File

@ -4,15 +4,12 @@ import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../models/history/history_entry.dart';
import '../models/library/library_list.dart';
import 'archive_format.dart';
import 'database.dart';
Future<Directory> exportDirectory() async {
final basedir = (await getExternalStorageDirectory())!;
final dir = basedir.exportDirectory;
dir.createSync(recursive: true);
return dir;
}
Future<Directory> exportDirectory() async =>
(await getExternalStorageDirectory())!.exportDirectory;
/// Returns the path to which the data was saved.
Future<String> exportData() async {
@ -33,13 +30,21 @@ Future<void> exportHistoryTo(Directory dir) async {
final query = await db().query(TableNames.historyEntryOrderedByTimestamp);
final List<HistoryEntry> entries =
query.map((e) => HistoryEntry.fromDBMap(e)).toList();
/// TODO: This creates a ton of sql statements. Ideally, the whole export
/// should be done in only one query.
///
/// On second thought, is that even possible? It's a doubly nested list structure.
final List<Map<String, Object?>> jsonEntries =
await Future.wait(entries.map((he) async => he.toJson()));
file.writeAsStringSync(jsonEncode(jsonEntries));
}
Future<void> exportLibraryListsTo(Directory dir) async {
// TODO:
// final query = db().query(TableNames.libraryList);
print('TODO: implement exportLibraryLists');
}
Future<void> exportLibraryListsTo(Directory dir) async => Future.wait(
(await LibraryList.allLibraries).map((lib) async {
final file = File(dir.uri.resolve('${lib.name}.json').path);
file.createSync();
final entries = await lib.entries;
file.writeAsStringSync(jsonEncode(entries));
}),
);

View File

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import '../models/history/history_entry.dart';
@ -13,11 +14,11 @@ Future<void> importData(Directory dir) async {
Future<void> importHistoryFrom(File file) async {
final String content = file.readAsStringSync();
await HistoryEntry.insertJsonEntries(
(jsonDecode(content) as List)
.map((h) => h as Map<String, Object?>)
.toList(),
);
final List<Map<String, Object?>> json = (jsonDecode(content) as List)
.map((h) => h as Map<String, Object?>)
.toList();
log('Importing ${json.length} entries from ${file.path}');
await HistoryEntry.insertJsonEntries(json);
}
Future<void> importLibraryListsFrom(Directory libraryListsDir) async {

View File

@ -267,7 +267,7 @@ class HistoryEntry {
/// An efficient implementation of [insertJsonEntry] for multiple
/// entries.
///
///
/// This assumes that there are no duplicates within the elements
/// in the json.
static Future<List<HistoryEntry>> insertJsonEntries(
@ -348,6 +348,14 @@ class HistoryEntry {
return entries;
});
static Future<int> amountOfEntries() async {
final query = await db().query(
TableNames.historyEntry,
columns: ['COUNT(*) AS count'],
);
return query.first['count']! as int;
}
static Future<List<HistoryEntry>> get fromDB async =>
(await db().query(TableNames.historyEntryOrderedByTimestamp))
.map((e) => HistoryEntry.fromDBMap(e))

View File

@ -0,0 +1,64 @@
class LibraryEntry {
DateTime lastModified;
String? kanji;
String? word;
bool get isKanji => word == null;
String get title => isKanji ? kanji! : word!;
String get entryText => isKanji ? kanji! : word!;
LibraryEntry({
DateTime? lastModified,
this.kanji,
this.word,
}) : lastModified = lastModified ?? DateTime.now(),
assert(kanji != null || word != null, "Library entry can't be empty");
LibraryEntry.fromWord({
required word,
DateTime? lastModified,
// ignore: prefer_initializing_formals
}) : word = word,
lastModified = lastModified ?? DateTime.now();
LibraryEntry.fromKanji({
required String kanji,
DateTime? lastModified,
// ignore: prefer_initializing_formals
}) : kanji = kanji,
lastModified = lastModified ?? DateTime.now();
Map<String, Object?> toJson() => {
'kanji': kanji,
'word': word,
'lastModified': lastModified.millisecondsSinceEpoch,
};
factory LibraryEntry.fromJson(Map<String, Object?> json) => json['kanji'] !=
null
? LibraryEntry.fromKanji(
kanji: json['kanji']! as String,
lastModified:
DateTime.fromMillisecondsSinceEpoch(json['lastModified']! as int),
)
: LibraryEntry.fromWord(
word: json['word']! as String,
lastModified:
DateTime.fromMillisecondsSinceEpoch(json['lastModified']! as int),
);
factory LibraryEntry.fromDBMap(Map<String, Object?> dbObject) =>
dbObject['isKanji']! == 1
? LibraryEntry.fromKanji(
kanji: dbObject['entryText']! as String,
lastModified: DateTime.fromMillisecondsSinceEpoch(
dbObject['lastModified']! as int,
),
)
: LibraryEntry.fromWord(
word: dbObject['entryText']! as String,
lastModified: DateTime.fromMillisecondsSinceEpoch(
dbObject['lastModified']! as int,
),
);
}

View File

@ -0,0 +1,360 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import '../../data/database.dart';
import '../../data/database_errors.dart';
import 'library_entry.dart';
class LibraryList {
final String name;
const LibraryList._byName(this.name);
static const LibraryList favourites = LibraryList._byName('favourites');
/// Get all entries within the library, in their custom order
Future<List<LibraryEntry>> get entries async {
const columns = ['entryText', 'isKanji', 'lastModified'];
final query = await db().rawQuery(
'''
WITH RECURSIVE "RecursionTable"(${columns.map((c) => '"$c"').join(', ')}) AS (
SELECT ${columns.map((c) => '"$c"').join(', ')}
FROM "${TableNames.libraryListEntry}" "I"
WHERE "I"."listName" = ? AND "I"."prevEntryText" IS NULL
UNION ALL
SELECT ${columns.map((c) => '"R"."$c"').join(', ')}
FROM "${TableNames.libraryListEntry}" "R"
JOIN "RecursionTable" ON (
"R"."prevEntryText" = "RecursionTable"."entryText"
AND "R"."prevEntryIsKanji" = "RecursionTable"."isKanji"
)
WHERE "R"."listName" = ?
)
SELECT * FROM "RecursionTable";
''',
[name, name],
);
return query.map((e) => LibraryEntry.fromDBMap(e)).toList();
}
/// Get all existing libraries in their custom order.
static Future<List<LibraryList>> get allLibraries async {
final query = await db().query(TableNames.libraryListOrdered);
return query
.map((lib) => LibraryList._byName(lib['name']! as String))
.toList();
}
/// Generates a map of all the libraries, with the value being
/// whether or not the specified entry is within the library.
static Future<Map<LibraryList, bool>> allListsContains({
required String entryText,
required bool isKanji,
}) async {
final query = await db().rawQuery(
'''
SELECT
*,
EXISTS(
SELECT * FROM "${TableNames.libraryListEntry}"
WHERE "listName" = "name" AND "entryText" = ? AND "isKanji" = ?
) AS "exists"
FROM "${TableNames.libraryListOrdered}"
''',
[entryText, isKanji ? 1 : 0],
);
return Map.fromEntries(
query.map(
(lib) => MapEntry(
LibraryList._byName(lib['name']! as String),
lib['exists']! as int == 1,
),
),
);
}
/// Whether a library contains a specific entry
Future<bool> contains({
required String entryText,
required bool isKanji,
}) async {
final query = await db().rawQuery(
'''
SELECT EXISTS(
SELECT *
FROM "${TableNames.libraryListEntry}"
WHERE "listName" = ? AND "entryText" = ? AND "isKanji" = ?
) AS "exists"
''',
[name, entryText, isKanji ? 1 : 0],
);
return query.first['exists']! as int == 1;
}
/// Whether a library contains a specific word entry
Future<bool> containsWord(String word) => contains(
entryText: word,
isKanji: false,
);
/// Whether a library contains a specific kanjientry
Future<bool> containsKanji(String kanji) => contains(
entryText: kanji,
isKanji: true,
);
/// Whether a library exists in the database
static Future<bool> exists(String libraryName) async {
final query = await db().rawQuery(
'''
SELECT EXISTS(
SELECT *
FROM "${TableNames.libraryList}"
WHERE "name" = ?
) AS "exists"
''',
[libraryName],
);
return query.first['exists']! as int == 1;
}
static Future<int> amountOfLibraries() async {
final query = await db().query(
TableNames.libraryList,
columns: ['COUNT(*) AS count'],
);
return query.first['count']! as int;
}
/// The amount of items within this library.
Future<int> get length async {
final query = await db().query(
TableNames.libraryListEntry,
columns: ['COUNT(*) AS count'],
where: 'listName = ?',
whereArgs: [name],
);
return query.first['count']! as int;
}
/// Swaps two entries within a list
/// Will throw an exception if the entry is already in the library
Future<void> insertEntry({
required String entryText,
required bool isKanji,
int? position,
DateTime? lastModified,
}) async {
// TODO: set up lastModified insertion
if (await contains(entryText: entryText, isKanji: isKanji)) {
throw DataAlreadyExistsError(
tableName: TableNames.libraryListEntry,
illegalArguments: {
'entryText': entryText,
'isKanji': isKanji,
},
);
}
if (position != null) {
final len = await length;
if (0 > position || position > len) {
throw IndexError(
position,
this,
'position',
'Data insertion position ($position) can not be between 0 and length ($len).',
len,
);
} else if (position == len) {
insertEntry(
entryText: entryText,
isKanji: isKanji,
lastModified: lastModified,
);
return;
} else {
log('Adding ${isKanji ? 'kanji ' : ''}"$entryText" to library "$name" at $position');
final b = db().batch();
final entriess = await entries;
final prevEntry = entriess[position - 1];
final nextEntry = entriess[position];
b.insert(TableNames.libraryListEntry, {
'listName': name,
'entryText': entryText,
'isKanji': isKanji ? 1 : 0,
'prevEntryText': prevEntry.word,
'prevEntryIsKanji': prevEntry.isKanji ? 1 : 0,
});
b.update(
TableNames.libraryListEntry,
{
'prevEntryText': entryText,
'prevEntryIsKanji': isKanji ? 1 : 0,
},
where: '"listName" = ? AND "entryText" = ? AND "isKanji" = ?',
whereArgs: [name, nextEntry.entryText, nextEntry.isKanji ? 1 : 0],
);
await b.commit();
return;
}
}
log('Adding ${isKanji ? 'kanji ' : ''}"$entryText" to library "$name"');
final LibraryEntry? prevEntry = (await entries).lastOrNull;
await db().insert(TableNames.libraryListEntry, {
'listName': name,
'entryText': entryText,
'isKanji': isKanji ? 1 : 0,
'prevEntryText': prevEntry?.word,
'prevEntryIsKanji': (prevEntry?.isKanji ?? false) ? 1 : 0,
});
}
/// Deletes an entry within a list
/// Will throw an exception if the entry is not in the library
Future<void> deleteEntry({
required String entryText,
required bool isKanji,
}) async {
if (!await contains(entryText: entryText, isKanji: isKanji)) {
throw DataNotFoundError(
tableName: TableNames.libraryListEntry,
illegalArguments: {
'entryText': entryText,
'isKanji': isKanji,
},
);
}
log('Deleting ${isKanji ? 'kanji ' : ''}"$entryText" from library "$name"');
// TODO: these queries might be combined into one
final entryQuery = await db().query(
TableNames.libraryListEntry,
where: '"listName" = ? AND "entryText" = ? AND "isKanji" = ?',
whereArgs: [name, entryText, isKanji],
);
final nextEntryQuery = await db().query(
TableNames.libraryListEntry,
where:
'"listName" = ? AND "prevEntryText" = ? AND "prevEntryIsKanji" = ?',
whereArgs: [name, entryText, isKanji],
);
// final LibraryEntry entry = LibraryEntry.fromDBMap(entryQuery.first);
final LibraryEntry? nextEntry =
nextEntryQuery.map((e) => LibraryEntry.fromDBMap(e)).firstOrNull;
final b = db().batch();
if (nextEntry != null) {
b.update(
TableNames.libraryListEntry,
{
'prevEntryText': entryQuery.first['prevEntryText'],
'prevEntryIsKanji': entryQuery.first['prevEntryIsKanji'],
},
where: '"listName" = ? AND "entryText" = ? AND "isKanji" = ?',
whereArgs: [name, nextEntry.entryText, nextEntry.isKanji],
);
}
b.delete(
TableNames.libraryListEntry,
where: '"listName" = ? AND "entryText" = ? AND "isKanji" = ?',
whereArgs: [name, entryText, isKanji],
);
b.commit();
}
/// Swaps two entries within a list
/// Will throw an error if both of the entries doesn't exist
Future<void> swapEntries({
required String entryText1,
required bool isKanji1,
required String entryText2,
required bool isKanji2,
}) async {
// TODO: implement function.
throw UnimplementedError();
}
/// Toggle whether an entry is in the library or not.
/// If [overrideToggleOn] is given true or false, it will specifically insert or
/// delete the entry respectively. Else, it will figure out whether the entry
/// is in the library already automatically.
Future<bool> toggleEntry({
required String entryText,
required bool isKanji,
bool? overrideToggleOn,
}) async {
overrideToggleOn ??=
!(await contains(entryText: entryText, isKanji: isKanji));
if (overrideToggleOn) {
await insertEntry(entryText: entryText, isKanji: isKanji);
} else {
await deleteEntry(entryText: entryText, isKanji: isKanji);
}
return overrideToggleOn;
}
Future<void> deleteAllEntries() => db().delete(
TableNames.libraryListEntry,
where: 'listName = ?',
whereArgs: [name],
);
/// Insert a new library list into the database
static Future<LibraryList> insert(String libraryName) async {
if (await exists(libraryName)) {
throw DataAlreadyExistsError(
tableName: TableNames.libraryList,
illegalArguments: {
'libraryName': libraryName,
},
);
}
// This is ok, because "favourites" should always exist.
final prevList = (await allLibraries).last;
await db().insert(TableNames.libraryList, {
'name': libraryName,
'prevList': prevList.name,
});
return LibraryList._byName(libraryName);
}
/// Delete this library from the database
Future<void> delete() async {
if (name == 'favourites') {
throw IllegalDeletionError(
tableName: TableNames.libraryList,
illegalArguments: {'name': name},
);
}
await db().delete(
TableNames.libraryList,
where: 'name = ?',
whereArgs: [name],
);
}
}

View File

@ -61,7 +61,14 @@ class DarkTheme extends AppTheme {
ThemeData getMaterialTheme() {
return ThemeData(
brightness: Brightness.dark,
primarySwatch: createMaterialColor(AppTheme.jishoGreen.background),
colorScheme: ColorScheme.fromSwatch(
primarySwatch: createMaterialColor(AppTheme.jishoGreen.background),
accentColor: AppTheme.jishoGreen.background,
brightness: Brightness.dark,
),
toggleableActiveColor: AppTheme.jishoGreen.background,
// elevatedButtonTheme: ElevatedButtonThemeData(style: )
);
}
}

View File

@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import '../models/library/library_list.dart';
import '../screens/home.dart';
import '../screens/info/about.dart';
import '../screens/info/licenses.dart';
import '../screens/library/library_content_view.dart';
import '../screens/search/result_page.dart';
import '../screens/search/search_mechanisms/drawing.dart';
import '../screens/search/search_mechanisms/grade_list.dart';
@ -40,6 +42,12 @@ Route<Widget> generateRoute(RouteSettings settings) {
builder: (_) => KanjiRadicalSearch(prechosenRadical: prechosenRadical),
);
case Routes.libraryContent:
final library = args! as LibraryList;
return MaterialPageRoute(
builder: (_) => LibraryContentView(library: library),
);
case Routes.about:
return MaterialPageRoute(builder: (_) => const AboutView());
case Routes.aboutLicenses:

View File

@ -5,6 +5,7 @@ class Routes {
static const String kanjiSearchDraw = '/kanjiSearch/draw';
static const String kanjiSearchGrade = '/kanjiSearch/grade';
static const String kanjiSearchRadicals = '/kanjiSearch/radicals';
static const String libraryContent = '/library';
static const String about = '/info/about';
static const String aboutLicenses = '/info/licenses';
static const String errorNotFound = '/error/404';

View File

@ -1,27 +1,58 @@
import 'package:flutter/material.dart';
import '../components/drawing_board/drawing_board.dart';
import '../components/common/kanji_box.dart';
// import '../components/drawing_board/drawing_board.dart';
// import '../components/library/add_to_library_dialog.dart';
class DebugView extends StatelessWidget {
const DebugView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
// return const Center(child: KanjiBox(kanji: ''));
return ListView(
children: [
DrawingBoard(
allowHiragana: true,
allowKatakana: true,
allowOther: true,
onSuggestionChosen: (s) => ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Chose: $s'),
duration: const Duration(milliseconds: 600),
Row(
children: [
KanjiBox.withPadding(kanji: '', padding: 5),
KanjiBox.withFontSize(kanji: '', fontSize: 20),
const KanjiBox.withFontSizeAndPadding(
kanji: '',
fontSize: 40,
padding: 10,
),
),
KanjiBox.withFontSize(kanji: '', fontSize: 40),
KanjiBox.withPadding(kanji: '', padding: 10),
],
),
const Divider(),
KanjiBox.expanded(kanji: '', ratio: 1),
const Divider(),
KanjiBox.expanded(kanji: '')
],
);
// return Column(
// mainAxisAlignment: MainAxisAlignment.end,
// children: const [
// ElevatedButton(
// onPressed: () => showAddToLibraryDialog(
// context: context,
// entryText: 'lol',
// ),
// child: const Text('Add entry to list'),
// ),
// DrawingBoard(
// allowHiragana: true,
// allowKatakana: true,
// allowOther: true,
// onSuggestionChosen: (s) => ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(
// content: Text('Chose: $s'),
// duration: const Duration(milliseconds: 600),
// ),
// ),
// ),
// ],
// );
}
}

View File

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import '../components/common/loading.dart';
import '../components/common/opaque_box.dart';
import '../components/history/date_divider.dart';
import '../components/history/history_entry_item.dart';
import '../components/history/history_entry_tile.dart';
import '../models/history/history_entry.dart';
import '../services/datetime.dart';
@ -45,10 +45,15 @@ class HistoryView extends StatelessWidget {
final HistoryEntry search = data[index];
final DateTime searchDate = search.lastTimestamp;
if (index == 0 || !dateIsEqual(data[index - 1].lastTimestamp, searchDate))
if (index == 0 ||
!dateIsEqual(data[index - 1].lastTimestamp, searchDate))
return TextDivider(text: formatDate(roundToDay(searchDate)));
return const Divider(height: 0);
return const Divider(
height: 0,
indent: 10,
endIndent: 10,
);
};
Widget Function(BuildContext, int) historyEntryWithData(
@ -56,7 +61,7 @@ class HistoryView extends StatelessWidget {
) =>
(context, index) => (index == 0)
? const SizedBox.shrink()
: HistoryEntryItem(
: HistoryEntryTile(
entry: data.values.toList()[index - 1],
objectKey: data.keys.toList()[index - 1],
onDelete: () => build(context),

View File

@ -4,8 +4,10 @@ import 'package:mdi/mdi.dart';
import '../bloc/theme/theme_bloc.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 'search/kanji_view.dart';
import 'search/search_view.dart';
import 'settings.dart';
@ -20,25 +22,35 @@ class Home extends StatefulWidget {
class _HomeState extends State<Home> {
int pageNum = 0;
_Page get page => pages[pageNum];
@override
Widget build(BuildContext context) {
return BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, themeState) {
return Scaffold(
appBar: AppBar(
title: pages[pageNum].titleBar,
title: Text(page.titleBar),
centerTitle: true,
backgroundColor: AppTheme.jishoGreen.background,
foregroundColor: AppTheme.jishoGreen.foreground,
actions: page.actions,
),
body: DenshiJishoBackground(child: pages[pageNum].content),
body: DenshiJishoBackground(child: page.content),
bottomNavigationBar: BottomNavigationBar(
fixedColor: AppTheme.jishoGreen.background,
currentIndex: pageNum,
onTap: (index) => setState(() {
pageNum = index;
}),
items: pages.map((p) => p.item).toList(),
items: pages
.map(
(p) => BottomNavigationBarItem(
label: p.titleBar,
icon: p.icon,
),
)
.toList(),
showSelectedLabels: false,
showUnselectedLabels: false,
unselectedItemColor: themeState.theme.menuGreyDark.background,
@ -51,52 +63,40 @@ class _HomeState extends State<Home> {
List<_Page> get pages => [
const _Page(
content: SearchView(),
titleBar: Text('Search'),
item: BottomNavigationBarItem(
label: 'Search',
icon: Icon(Icons.search),
),
titleBar: 'Search',
icon: Icon(Icons.search),
),
const _Page(
content: KanjiView(),
titleBar: Text('Kanji Search'),
item: BottomNavigationBarItem(
label: 'Kanji',
icon: Icon(Mdi.ideogramCjk, size: 30),
),
titleBar: 'Kanji Search',
icon: Icon(Mdi.ideogramCjk, size: 30),
),
const _Page(
content: HistoryView(),
titleBar: Text('History'),
item: BottomNavigationBarItem(
label: 'History',
icon: Icon(Icons.history),
),
titleBar: 'History',
icon: Icon(Icons.history),
),
_Page(
content: Container(),
titleBar: const Text('Library'),
item: const BottomNavigationBarItem(
label: 'Library',
icon: Icon(Icons.bookmark),
),
content: const LibraryView(),
titleBar: 'Library',
icon: const Icon(Icons.bookmark),
actions: [
IconButton(
onPressed: showNewLibraryDialog(context),
icon: const Icon(Icons.add),
)
],
),
const _Page(
content: SettingsView(),
titleBar: Text('Settings'),
item: BottomNavigationBarItem(
label: 'Settings',
icon: Icon(Icons.settings),
),
titleBar: 'Settings',
icon: Icon(Icons.settings),
),
if (kDebugMode) ...[
const _Page(
content: DebugView(),
titleBar: Text('Debug Page'),
item: BottomNavigationBarItem(
label: 'Debug',
icon: Icon(Icons.biotech),
),
titleBar: 'Debug Page',
icon: Icon(Icons.biotech),
)
],
];
@ -104,12 +104,14 @@ class _HomeState extends State<Home> {
class _Page {
final Widget content;
final Widget titleBar;
final BottomNavigationBarItem item;
final String titleBar;
final Icon icon;
final List<Widget> actions;
const _Page({
required this.content,
required this.titleBar,
required this.item,
required this.icon,
this.actions = const [],
});
}

View File

@ -0,0 +1,78 @@
import 'package:confirm_dialog/confirm_dialog.dart';
import 'package:flutter/material.dart';
import '../../components/common/loading.dart';
import '../../components/library/library_list_entry_tile.dart';
import '../../models/library/library_entry.dart';
import '../../models/library/library_list.dart';
class LibraryContentView extends StatefulWidget {
final LibraryList library;
const LibraryContentView({
Key? key,
required this.library,
}) : super(key: key);
@override
State<LibraryContentView> createState() => _LibraryContentViewState();
}
class _LibraryContentViewState extends State<LibraryContentView> {
List<LibraryEntry>? entries;
Future<void> getEntriesFromDatabase() =>
widget.library.entries.then((es) => setState(() => entries = es));
@override
void initState() {
super.initState();
getEntriesFromDatabase();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.library.name),
actions: [
IconButton(
onPressed: () async {
final entryCount = await widget.library.length;
if (!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 widget.library.deleteAllEntries();
await getEntriesFromDatabase();
},
icon: const Icon(Icons.delete),
),
],
),
body: entries == null
? const LoadingScreen()
: ListView.separated(
itemCount: entries!.length,
itemBuilder: (context, index) => LibraryListEntryTile(
index: index,
entry: entries![index],
library: widget.library,
onDelete: () => setState(() {
entries!.removeAt(index);
}),
onUpdate: () => getEntriesFromDatabase(),
),
separatorBuilder: (context, index) => const Divider(
height: 0,
indent: 10,
endIndent: 10,
),
),
);
}
}

View File

@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../../components/common/loading.dart';
import '../../components/library/library_list_tile.dart';
import '../../models/library/library_list.dart';
class LibraryView extends StatefulWidget {
const LibraryView({Key? key}) : super(key: key);
@override
State<LibraryView> createState() => _LibraryViewState();
}
class _LibraryViewState extends State<LibraryView> {
List<LibraryList>? libraries;
Future<void> getEntriesFromDatabase() =>
LibraryList.allLibraries.then((libs) => setState(() => libraries = libs));
@override
void initState() {
super.initState();
getEntriesFromDatabase();
}
@override
Widget build(BuildContext context) {
if (libraries == null) return const LoadingScreen();
return Column(
children: [
LibraryListTile(
library: LibraryList.favourites,
leading: const Icon(Icons.star),
onDelete: getEntriesFromDatabase,
onUpdate: getEntriesFromDatabase,
isEditable: false,
),
Expanded(
child: ListView(
children: libraries!
// Skip favourites
.skip(1)
.map(
(e) => LibraryListTile(
library: e,
onDelete: getEntriesFromDatabase,
onUpdate: getEntriesFromDatabase,
),
)
.toList(),
),
),
],
);
}
}

View File

@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
import '../../components/common/loading.dart';
import '../../components/kanji/kanji_result_body.dart';
import '../../components/library/add_to_library_dialog.dart';
import '../../components/search/search_result_body.dart';
import '../../models/history/history_entry.dart';
import '../../models/library/library_list.dart';
import '../../services/jisho_api/jisho_search.dart';
import '../../services/jisho_api/kanji_search.dart';
@ -23,11 +25,52 @@ class ResultPage extends StatefulWidget {
class _ResultPageState extends State<ResultPage> {
bool addedToDatabase = false;
bool isFavourite = false;
@override
void initState() {
super.initState();
if (!widget.isKanji) return;
LibraryList.favourites
.containsKanji(widget.searchTerm)
.then((b) => setState(() => isFavourite = b));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
appBar: AppBar(
actions: !widget.isKanji
? []
: [
IconButton(
onPressed: () async {
await showAddToLibraryDialog(
context: context,
entryText: widget.searchTerm,
isKanji: true,
);
final updatedFavouriteStatus = await LibraryList.favourites
.containsKanji(widget.searchTerm);
setState(() => isFavourite = updatedFavouriteStatus);
},
icon: const Icon(Icons.bookmark),
),
IconButton(
onPressed: () async {
await LibraryList.favourites.toggleEntry(
entryText: widget.searchTerm,
isKanji: true,
overrideToggleOn: !isFavourite,
);
setState(() => isFavourite = !isFavourite);
},
icon: isFavourite
? const Icon(Icons.star, color: Colors.yellow)
: const Icon(Icons.star_border),
)
],
),
body: FutureBuilder(
future: widget.isKanji
? fetchKanji(widget.searchTerm)

View File

@ -11,6 +11,8 @@ import '../components/common/denshi_jisho_background.dart';
import '../data/database.dart';
import '../data/export.dart';
import '../data/import.dart';
import '../models/history/history_entry.dart';
import '../models/library/library_list.dart';
import '../routing/routes.dart';
import '../services/open_webpage.dart';
import '../services/snackbar.dart';
@ -28,15 +30,12 @@ class _SettingsViewState extends State<SettingsView> {
bool dataImportIsLoading = false;
Future<void> clearHistory(context) async {
final historyCount = (await db().query(
TableNames.historyEntry,
columns: ['COUNT(*) AS count'],
))[0]['count']! as int;
final historyCount = await HistoryEntry.amountOfEntries();
final bool userIsSure = await confirm(
context,
content: Text(
'Are you sure that you want to delete $historyCount entries?',
'Are you sure that you want to clear all $historyCount entries in history?',
),
);
if (!userIsSure) return;
@ -45,10 +44,33 @@ class _SettingsViewState extends State<SettingsView> {
showSnackbar(context, 'Cleared history');
}
Future<void> clearAll(context) async {
final bool userIsSure = await confirm(context);
Future<void> clearFavourites(context) async {
final favouritesCount = await LibraryList.favourites.length;
final bool userIsSure = await confirm(
context,
content: Text(
'Are you sure that you want to clear all $favouritesCount entries in favourites?',
),
);
if (!userIsSure) return;
await LibraryList.favourites.deleteAllEntries();
showSnackbar(context, 'Cleared favourites');
}
Future<void> clearAll(context) async {
final historyCount = await HistoryEntry.amountOfEntries();
final libraryCount = await LibraryList.amountOfLibraries();
final bool userIsSure = await confirm(
context,
content: Text(
'Are you sure you want to delete $historyCount history entries '
'and $libraryCount libraries?',
),
);
if (!userIsSure) return;
await resetDatabase();
showSnackbar(context, 'Cleared everything');
}
@ -56,7 +78,7 @@ class _SettingsViewState extends State<SettingsView> {
// ignore: avoid_positional_boolean_parameters
void toggleAutoTheme(bool b) {
final bool newThemeIsDark = b
? WidgetsBinding.instance.window.platformBrightness == Brightness.dark
? WidgetsBinding.instance?.window.platformBrightness == Brightness.dark
: darkThemeEnabled;
BlocProvider.of<ThemeBloc>(context)
@ -274,9 +296,8 @@ class _SettingsViewState extends State<SettingsView> {
SettingsTile(
leading: const Icon(Icons.delete),
title: 'Clear Favourites',
onPressed: (c) {},
onPressed: clearFavourites,
titleTextStyle: const TextStyle(color: Colors.red),
enabled: false,
),
SettingsTile(
leading: const Icon(Icons.delete),

View File

@ -1,5 +1,5 @@
import 'package:unofficial_jisho_api/api.dart' as jisho;
import 'package:unofficial_jisho_api/api.dart';
export 'package:unofficial_jisho_api/api.dart' show JishoAPIResult;
Future<jisho.JishoAPIResult> fetchJishoResults(searchTerm) =>
jisho.searchForPhrase(searchTerm);
Future<JishoAPIResult> fetchJishoResults(searchTerm) =>
searchForPhrase(searchTerm);

View File

@ -0,0 +1,12 @@
import 'package:unofficial_jisho_api/parser.dart';
// TODO: This should be moved to the api.
extension KanjiFurigana on JishoJapaneseWord {
// Both wordReading and word.word being present implies that the word has furigana.
// If that's not the case, then the word is usually present in wordReading.
// However, there are some exceptions where the reading is placed in word.
bool get hasFurigana => word != null && reading != null;
String get kanji => word ?? reading!;
String? get furigana => hasFurigana ? reading! : null;
}

File diff suppressed because it is too large Load Diff

View File

@ -3,19 +3,20 @@ description: A dictionary app for studying japanese
version: 1.0.0+1
environment:
sdk: ">=2.12.0 <3.0.0"
sdk: ">=2.18.4 <3.0.0"
dependencies:
animated_size_and_fade: ^3.0.0
auto_size_text: ^3.0.0
collection: ^1.15.0
confirm_dialog: ^1.0.0
division: ^0.9.0
file_picker: ^4.5.1
file_picker: ^5.2.5
flutter:
sdk: flutter
flutter_bloc: ^8.0.0
flutter_settings_ui: ^2.0.1
flutter_slidable: ^1.1.0
flutter_slidable: ^2.0.0
flutter_svg: ^1.0.2
get_it: ^7.2.0
http: ^0.13.4
@ -23,13 +24,16 @@ dependencies:
mdi: ^5.0.0-nullsafety.0
path: ^1.8.0
path_provider: ^2.0.2
share_plus: ^4.0.4
ruby_text: ^3.0.1
share_plus: ^6.3.1
shared_preferences: ^2.0.6
signature: ^5.0.0
sqflite: ^2.0.2
sqflite_common_ffi: ^2.1.1
test: ^1.19.5
unofficial_jisho_api: ^3.0.0
# unofficial_jisho_api:
# path: /home/h7x4/git/unofficial-jisho-api-dart
url_launcher: ^6.0.9
dev_dependencies:
@ -37,7 +41,7 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_native_splash: ^2.1.6
flutter_launcher_icons: "^0.9.2"
flutter_launcher_icons: ^0.11.0
flutter_icons:
android: "launcher_icon"

4
tools/extractDB.sh Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
mkdir -p android-data
adb shell run-as app.jishostudytool.jisho_study_tool cat app_flutter/jisho.sqlite > android-data/jisho_extract.db

4
tools/extractExports.sh Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
mkdir -p android-data
adb pull /storage/emulated/0/Android/data/app.jishostudytool.jisho_study_tool/files/export android-data