Compare commits

...

29 Commits

Author SHA1 Message Date
5f829fea12 Fix reinitialization, so it deletes and remigrates the database 2025-07-09 22:57:20 +02:00
0dad84c603 library_list: fix list deletion 2025-07-09 17:33:11 +02:00
26fe8ce4e1 word_search: don't throw error on empty results 2025-07-09 15:00:59 +02:00
62bc0d3d71 import/export: fix file name encoding in zip 2025-07-08 22:35:45 +02:00
15996b9b77 flake.lock: bump 2025-07-08 20:41:23 +02:00
a326712794 Fix table verification warning 2025-07-08 20:29:09 +02:00
60d5ecd25b Add initializtion screen, make initialization and updates more robust 2025-07-08 20:19:33 +02:00
23793f72a1 word_search: add kanji from drawing board despite input field not focused 2025-07-08 10:48:01 +02:00
ec73851f1b settings: add descriptions and clean some titles 2025-07-08 00:04:26 +02:00
09d96254d8 settings: remove dead code 2025-07-07 23:50:36 +02:00
96182be5c7 settings: remove 'Clear Favourites' button 2025-07-07 23:50:21 +02:00
2120d4e54a Capitalize application name 2025-07-07 23:39:02 +02:00
ae5bbf8702 Add setting for shrinking kanji drawing board size 2025-07-07 23:32:03 +02:00
696a0cd3ca Ensure kanji drawing board is above nav buttons 2025-07-07 23:31:17 +02:00
c9ec88d3ca settings: add a link to the repository 2025-07-07 23:04:36 +02:00
4dcfb15db8 history: show search counts in a side bubble 2025-07-07 22:35:19 +02:00
83f15718c1 screens/changelog: fix typo 2025-07-07 21:10:22 +02:00
2531b2bbab screens/changelog: use direct dependency on markdown package 2025-07-07 21:09:39 +02:00
4c49874a01 screens/changelog: init 2025-07-07 20:39:48 +02:00
124b98c342 docs/changelog: init 2025-07-07 17:40:01 +02:00
2d158079d1 screens/info: fix version number 2025-07-07 17:32:42 +02:00
e2a20fa731 Add setting for disabling history tracking 2025-07-07 16:41:12 +02:00
04cb44c39c pubspec.lock: update jadb + more 2025-07-07 16:11:25 +02:00
48cfa7f1c6 history: show history entry count on top 2025-07-07 16:01:45 +02:00
908891ea2f word_search: respect cursor position when drawing kanji 2025-07-07 15:35:21 +02:00
45a5810de1 Import library lists 2025-07-07 15:21:26 +02:00
923f5e48f6 screens/settings: allow resetting database despite broken condition 2025-06-26 23:56:09 +02:00
353d5c4c39 services/data_export_import: fix async import bug 2025-06-26 14:48:58 +02:00
a280c9e77a lib/database: verify mugiten tables 2025-06-26 13:43:45 +02:00
37 changed files with 1153 additions and 242 deletions

View File

@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="mugiten"
android:label="Mugiten"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity

17
docs/changelog/v0.1.0.md Normal file
View File

@@ -0,0 +1,17 @@
# v0.1.0
Initial beta version of the project.
This version denotes the first version after the complete rewrite from JST.
In this version, we are no longer depending on jisho.org for any data - everything
is managed with sqlite through `jadb`. This comes at a cost of bigger size, less accurate
search, temporarily disabling existing features such as library lists and history tracking,
and several missing pieces of data. But it is a step towards more control, and offline performant
search.
## Current features ✨
- Word search by input
- Kanji search, including search by radical, drawing, grade and input
- Font selection
- Romaji mode

13
docs/changelog/v0.2.0.md Normal file
View File

@@ -0,0 +1,13 @@
# v0.2.0
Major update readding some big features from JST, plus some more
## New features ✨
- Readd history tracking
- Readd library lists
- Add kanji drawing input for word search
## Bugfixes 🐞
- Fix kanji filtering sometimes suggesting invalid kanji.

34
docs/changelog/v0.3.0.md Normal file
View File

@@ -0,0 +1,34 @@
# v0.3.0
A large update with some significant additions and fixes.
## New features ✨
- Added offline kanji drawing search via Google ML Kit 🔥
- Allow users to import and export their data
- Added a toggle in settings for enabling/disabling history tracking
- Display the number of history searches on the history page
- Embed the changelog in the app
- Added search count bubbles to the history entries
- Added a toggle in settings to shrink the kanji drawing board
- Added an initialization view to perform updates and large downloads on launch
## Changes 🔧
- Removed the "Clear Favourites" button from settings in favour of using the delete button on the favourites page.
- Removed the "Reset database" button from settings in favour of "Clear History" and deleting individual library lists.
## Bugfixes 🐞
- Always show exact matches first in the word search results.
- Respect the cursor position when inserting a kanji into the search bar from the kanji drawing page.
- Remove word search duplicates
- Fix version number in the info page.
- Fix a broken query for adding items to library lists.
- Ensure that the kanji drawing search is located above navigation buttons
- Capitalize app name
## Other 📝
- Significant performance improvements for word search.
- A small size reduction of the database.

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1744463964,
"narHash": "sha256-LWqduOgLHCFxiTNYi3Uj5Lgz0SR+Xhw3kr/3Xd0GPTM=",
"lastModified": 1751792365,
"narHash": "sha256-J1kI6oAj25IG4EdVlg2hQz8NZTBNYvIS0l4wpr9KcUo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2631b0b7abcea6e640ce31cd78ea58910d31e650",
"rev": "1fd8bada0b6117e6c7eb54aad5813023eed37ccb",
"type": "github"
},
"original": {

View File

@@ -237,22 +237,32 @@ class _DrawingBoardState extends State<DrawingBoard> {
),
);
Widget drawingPanel() => AspectRatio(
aspectRatio: 1.2,
child: Stack(
alignment: Alignment.bottomRight,
children: [
ClipRect(
child: Signature(
key: signatureW,
controller: controller,
backgroundColor: panelColor.background,
),
Widget drawingPanel() {
final board = AspectRatio(
aspectRatio: 1.0,
child: Stack(
alignment: Alignment.bottomRight,
children: [
ClipRect(
child: Signature(
key: signatureW,
controller: controller,
backgroundColor: panelColor.background,
),
buttonRow(),
],
),
),
buttonRow(),
],
),
);
if (reduceKanjiDrawingBoardSize) {
return Padding(
padding: const EdgeInsets.fromLTRB(50, 0, 50, 30),
child: board,
);
}
return board;
}
@override
Widget build(BuildContext context) {

View File

@@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:mugiten/bloc/theme/theme_bloc.dart';
import 'package:mugiten/components/search/search_results_body/parts/circle_badge.dart';
import '../../models/history/history_entry.dart';
import '../../routing/routes.dart';
@@ -92,14 +95,24 @@ class HistoryEntryTile extends StatelessWidget {
child: Text(formatTime(entry.lastTimestamp)),
),
DefaultTextStyle.merge(
style: japaneseFont.textStyle,
child: entry.isKanji
? KanjiBox.headline4(
context: context,
kanji: entry.kanji!,
)
: Expanded(child: Text(entry.word!)),
),
style: japaneseFont.textStyle,
child: entry.isKanji
? KanjiBox.headline4(
context: context,
kanji: entry.kanji!,
)
: Expanded(child: Text(entry.word!))),
if (entry.isKanji) Expanded(child: SizedBox.shrink()),
if ((entry.timestampCount ?? 0) > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, themeState) => CircleBadge(
color: themeState.theme.menuGreyNormal.background,
child: Text('${entry.timestampCount}'),
),
),
),
],
),
),

View File

@@ -7,7 +7,8 @@ import '../../settings.dart';
import 'language_selector.dart';
class GlobalSearchBar extends StatelessWidget {
final TextEditingController controller = TextEditingController();
final TextEditingController textController = TextEditingController();
final FocusNode textFocus = FocusNode();
GlobalSearchBar({super.key});
@@ -25,7 +26,8 @@ class GlobalSearchBar extends StatelessWidget {
children: [
TextField(
onSubmitted: (text) => _search(context, text),
controller: controller,
controller: textController,
focusNode: textFocus,
style: japaneseFont.textStyle,
decoration: InputDecoration(
labelText: 'Search',
@@ -41,8 +43,8 @@ class GlobalSearchBar extends StatelessWidget {
color: AppTheme.mugitenWheat.background,
child: IconButton(
onPressed: () {
if (controller.text.isNotEmpty) {
_search(context, controller.text);
if (textController.text.isNotEmpty) {
_search(context, textController.text);
}
},
icon: const Icon(
@@ -63,7 +65,21 @@ class GlobalSearchBar extends StatelessWidget {
onPressed: () async {
final result = await _drawKanji()(context);
if (result != null && result.isNotEmpty) {
controller.text += result;
if (textController.selection.isValid) {
final pos = textController.selection.baseOffset;
textController.text = textController.text.substring(0, pos) +
result +
textController.text.substring(pos);
textController.selection = TextSelection.fromPosition(
TextPosition(offset: pos + result.length),
);
} else {
textController.text += result;
textController.selection = TextSelection.fromPosition(
TextPosition(offset: textController.text.length),
);
textFocus.requestFocus();
}
}
},
)
@@ -78,17 +94,19 @@ class GlobalSearchBar extends StatelessWidget {
final MaterialPageRoute<String> route = MaterialPageRoute(
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('Draw a kanji')),
body: Column(
children: [
const Expanded(child: Column()),
DrawingBoard(
onlyOneCharacterSuggestions: true,
onSuggestionChosen: (suggestion) => Navigator.pop(
context,
suggestion,
body: SafeArea(
child: Column(
children: [
const Expanded(child: Column()),
DrawingBoard(
onlyOneCharacterSuggestions: true,
onSuggestionChosen: (suggestion) => Navigator.pop(
context,
suggestion,
),
),
),
],
],
),
),
),
);

View File

@@ -5,12 +5,15 @@ import 'dart:io';
import 'package:flutter/services.dart';
import 'package:get_it/get_it.dart';
import 'package:jadb/search.dart';
import 'package:mugiten/models/verify_tables.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
export 'package:sqflite/sqlite_api.dart';
const int expectedDatabaseVersion = 2;
Database db() => GetIt.instance.get<Database>();
/// Returns the directory where mugiten's database file is stored.
@@ -25,6 +28,35 @@ Future<String> databasePath() async {
return join((await _databaseDir()).path, 'mugiten.sqlite');
}
Future<bool> databaseNeedsInitialization() async {
final String dbPath = await databasePath();
if (!await File(dbPath).exists()) {
return true;
}
final Database database = await openDatabase(
dbPath,
readOnly: true,
singleInstance: true,
);
final databaseVersion = await database.getVersion();
await database.close();
if (databaseVersion < expectedDatabaseVersion) {
return true;
}
return false;
}
Future<void> quickInitializeDatabase() async {
// TODO: Create more lightweight solution
await setupDatabase();
}
/// Migration logic and heavy initialization
class DatabaseMigration {
final String path;
final String content;
@@ -95,6 +127,35 @@ Future<void> migrate(Database db, List<DatabaseMigration> migrations) async {
}
}
Future<Database> openDatabaseWithoutMigrations(
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: (db) async {
// Enable foreign key constraints
await db.execute('PRAGMA foreign_keys=ON');
},
onOpen: (db) async {
if (verifyTables) {
log('Verifying jadb tables...');
db.jadbVerifyTables();
log('Verifying mugiten tables...');
verifyMugitenTablesWithDbConnection(db);
log('Database tables verified successfully');
}
},
);
return database;
}
Future<Database> openAndMigrateDatabase(
String dbPath,
List<DatabaseMigration> migrations,
@@ -102,7 +163,7 @@ Future<Database> openAndMigrateDatabase(
log('Opening database at $dbPath');
final Database database = await openDatabase(
dbPath,
version: 2,
version: expectedDatabaseVersion,
readOnly: false,
onUpgrade: (db, oldVersion, newVersion) async {
log('Migrating database from v$oldVersion to v$newVersion...');
@@ -119,10 +180,12 @@ Future<Database> openAndMigrateDatabase(
},
onOpen: (db) async {
log('Verifying jadb tables...');
db.jadbVerifyTables();
log('jadb opened successfully');
log('Verifying jadb tables...');
verifyMugitenTablesWithDbConnection(db);
log('Database tables verified successfully');
},
);
return database;
@@ -134,14 +197,17 @@ Future<void> setupDatabase() async {
final String dbPath = await databasePath();
if (!await File(dbPath).exists()) {
log('Extracting jadb.sqlite from assets...');
await extractJadbFromAssets(dbPath);
log('jadb.sqlite extracted to $dbPath');
}
assert(
await File(dbPath).exists(), 'Database file should exist at this point');
final List<DatabaseMigration> migrations = await readMigrationsFromAssets();
final Database database = await openAndMigrateDatabase(dbPath, migrations);
final database = await openDatabaseWithoutMigrations(
dbPath,
readOnly: false,
verifyTables: true,
);
assert(await database.getVersion() == expectedDatabaseVersion,
'Database version should be $expectedDatabaseVersion');
log('Registering database in GetIt...');
GetIt.instance.registerSingleton<Database>(database);

View File

@@ -31,4 +31,12 @@ abstract class HistoryTableNames {
/// - language CHAR(1)?
static const String historyEntryOrderedByTimestamp =
'Mugiten_HistoryEntry_orderedByTimestamp';
static Set<String> get allTables => {
historyEntry,
historyEntryKanji,
historyEntryTimestamp,
historyEntryWord,
historyEntryOrderedByTimestamp,
};
}

View File

@@ -20,4 +20,10 @@ abstract class LibraryListTableNames {
/// Attributes:
/// - name TEXT
static const String libraryListOrdered = 'Mugiten_LibraryList_Ordered';
static Set<String> get allTables => {
libraryList,
libraryListEntry,
libraryListOrdered,
};
}

View File

@@ -1,33 +1,30 @@
import 'package:flutter/material.dart';
import 'package:google_mlkit_digital_ink_recognition/google_mlkit_digital_ink_recognition.dart';
import 'package:mugiten/database/database.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:mugiten/screens/initialization.dart';
import 'package:mugiten/services/initialization/initialization_logic.dart';
import 'bloc/theme/theme_bloc.dart';
import 'routing/router.dart';
import 'services/licenses.dart';
import 'services/preferences.dart';
import 'settings.dart';
void runInitializationScreen(bool deleteDatabase) {
runApp(
InitializationView(
onInitializationComplete: () =>
quickInitialization().then((_) => runApp(const MyApp())),
deleteDatabase: deleteDatabase,
),
);
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
databaseFactory = databaseFactoryFfi;
await Future.wait([
setupDatabase(),
setupSharedPreferences(),
(() async {
final modelManager = DigitalInkRecognizerModelManager();
if (!await modelManager.isModelDownloaded('ja')) {
await modelManager.downloadModel('ja');
}
})()
]);
registerExtraLicenses();
runApp(const MyApp());
if (await needsInitialization()) {
runInitializationScreen(false);
} else {
await quickInitialization();
runApp(const MyApp());
}
}
class MyApp extends StatefulWidget {

View File

@@ -10,6 +10,7 @@ class HistoryEntry {
final String? kanji;
final String? word;
final DateTime lastTimestamp;
final int? timestampCount;
/// Whether this item is a kanji search or a word search
bool get isKanji => word == null;
@@ -18,12 +19,14 @@ class HistoryEntry {
required this.id,
required this.kanji,
required this.lastTimestamp,
this.timestampCount,
}) : word = null;
HistoryEntry.withWord({
required this.id,
required this.word,
required this.lastTimestamp,
this.timestampCount,
}) : kanji = null;
/// Reconstruct a HistoryEntry object with data from the database
@@ -43,6 +46,9 @@ class HistoryEntry {
lastTimestamp: DateTime.fromMillisecondsSinceEpoch(
dbObject['timestamp']! as int,
),
timestampCount: dbObject.containsKey('timestampCount')
? dbObject['timestampCount']! as int
: null,
)
: HistoryEntry.withKanji(
id: dbObject['entryId']! as int,
@@ -50,6 +56,9 @@ class HistoryEntry {
lastTimestamp: DateTime.fromMillisecondsSinceEpoch(
dbObject['timestamp']! as int,
),
timestampCount: dbObject.containsKey('timestampCount')
? dbObject['timestampCount']! as int
: null,
);
// TODO: There is a lot in common with

View File

@@ -1,6 +1,7 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:get_it/get_it.dart';
import 'package:mugiten/database/library_list/table_names.dart';
import '../../database/database.dart';
@@ -10,14 +11,14 @@ import 'library_entry.dart';
class LibraryList {
final String name;
const LibraryList._byName(this.name);
const LibraryList.byName(this.name);
static const LibraryList favourites = LibraryList._byName('favourites');
static const LibraryList favourites = LibraryList.byName('favourites');
/// Get all entries within the library, in their custom order
Future<List<LibraryEntry>> get entries async {
Future<List<LibraryEntry>> entries(DatabaseExecutor db) async {
const columns = ['jmdictEntryId', 'kanji', 'lastModified'];
final query = await db().rawQuery(
final query = await db.rawQuery(
'''
WITH RECURSIVE
"RecursionTable"(${columns.map((c) => '"$c"').join(', ')}) AS (
@@ -48,7 +49,7 @@ class LibraryList {
static Future<List<LibraryList>> get allLibraries async {
final query = await db().query(LibraryListTableNames.libraryListOrdered);
return query
.map((lib) => LibraryList._byName(lib['name']! as String))
.map((lib) => LibraryList.byName(lib['name']! as String))
.toList();
}
@@ -83,7 +84,7 @@ class LibraryList {
return Map.fromEntries(
query.map(
(lib) => MapEntry(
LibraryList._byName(lib['name']! as String),
LibraryList.byName(lib['name']! as String),
lib['exists']! as int == 1,
),
),
@@ -206,7 +207,7 @@ class LibraryList {
final b = db().batch();
final entries_ = await entries;
final entries_ = await entries(db());
final prevEntry = entries_[position - 1];
final nextEntry = entries_[position];
@@ -238,7 +239,7 @@ class LibraryList {
'Adding ${jmdictEntryId != null ? 'jmdict entry $jmdictEntryId' : 'kanji "$kanji"'} to library "$name"',
);
final LibraryEntry? prevEntry = (await entries).lastOrNull;
final LibraryEntry? prevEntry = (await entries(db())).lastOrNull;
await db().insert(LibraryListTableNames.libraryListEntry, {
'listName': name,
@@ -249,6 +250,32 @@ class LibraryList {
});
}
Future<void> insertJsonEntries(
List<Map<String, Object?>> jsonEntries,
) async {
List<LibraryEntry> entries =
jsonEntries.map((e) => LibraryEntry.fromJson(e)).toList();
// TODO: batch
for (final entry in entries) {
if (entry.kanji != null) {
await insertEntry(
kanji: entry.kanji,
jmdictEntryId: null,
position: null,
lastModified: entry.lastModified,
);
} else if (entry.jmdictEntryId != null) {
await insertEntry(
jmdictEntryId: entry.jmdictEntryId,
kanji: null,
position: null,
lastModified: entry.lastModified,
);
}
}
}
/// Deletes an entry within a list
/// Will throw an exception if the entry is not in the library
Future<void> deleteEntry({
@@ -418,7 +445,7 @@ class LibraryList {
'name': libraryName,
'prevList': prevList.name,
});
return LibraryList._byName(libraryName);
return LibraryList.byName(libraryName);
}
/// Delete this library from the database
@@ -429,10 +456,87 @@ class LibraryList {
illegalArguments: {'name': name},
);
}
await db().delete(
await db().transaction((txn) async {
await txn.delete(
LibraryListTableNames.libraryListEntry,
where: 'listName = ?',
whereArgs: [name],
);
final String prevName = await txn
.query(
LibraryListTableNames.libraryList,
columns: ['prevList'],
where: 'name = ?',
whereArgs: [name],
)
.then((rows) => rows.first['prevList']! as String);
final String? nextName = await txn
.query(
LibraryListTableNames.libraryList,
columns: ['name'],
where: 'prevList = ?',
whereArgs: [name],
)
.then((rows) => rows.firstOrNull?['name'] as String?);
await txn.delete(
LibraryListTableNames.libraryList,
where: 'name = ?',
whereArgs: [name],
);
if (nextName != null) {
await txn.update(
LibraryListTableNames.libraryList,
{'prevList': prevName},
where: 'name = ?',
whereArgs: [nextName],
);
}
if (!await verifyLibrariesLinkedList(txn)) {
print(
'Library list "$name" has a broken linked list after deletion, rolling back');
txn.execute('ROLLBACK');
}
});
}
Future<bool> verifyLibrariesLinkedList(
DatabaseExecutor db,
) async {
final int allItemsCount = await db.query(
LibraryListTableNames.libraryList,
where: 'name = ?',
whereArgs: [name],
);
columns: ['COUNT(*) AS count'],
).then((rows) => rows.first['count']! as int);
final int distinctPrevListCount = await db.query(
LibraryListTableNames.libraryList,
columns: ['COUNT(DISTINCT prevList) AS count'],
).then((rows) => (rows.first['count']! as int) + 1);
final int recursiveCount = await db.query(
LibraryListTableNames.libraryListOrdered,
columns: ['COUNT(*) AS count'],
).then((rows) => rows.first['count']! as int);
if (allItemsCount != distinctPrevListCount) {
log(
'Library list "$name" has a mismatch between all items count ($allItemsCount) and distinct prevList count ($distinctPrevListCount).',
);
return false;
}
if (recursiveCount != allItemsCount) {
log(
'Library list "$name" has a mismatch between recursive count ($recursiveCount) and all items count ($allItemsCount).',
);
return false;
}
return true;
}
}

View File

@@ -0,0 +1,35 @@
import 'package:mugiten/database/history/table_names.dart';
import 'package:mugiten/database/library_list/table_names.dart';
import 'package:sqflite/sqflite.dart';
Future<void> verifyMugitenTablesWithDbConnection(DatabaseExecutor db) async {
final Set<String> tables = await db
.query(
'sqlite_master',
columns: ['name'],
where: 'type IN (?, ?)',
whereArgs: ['table', 'view'],
)
.then((result) {
return result.map((row) => row['name'] as String).toSet();
});
final Set<String> expectedTables = {
...HistoryTableNames.allTables,
...LibraryListTableNames.allTables,
};
final missingTables = expectedTables.difference(tables);
if (missingTables.isNotEmpty) {
throw Exception([
'Missing tables:',
missingTables.map((table) => ' - $table').join('\n'),
'',
'Found tables:\n',
tables.map((table) => ' - $table').join('\n'),
'',
'Please ensure the database is correctly set up.',
].join('\n'));
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:mugiten/models/library/library_list.dart';
import 'package:mugiten/screens/info/changelog.dart';
import 'package:mugiten/screens/library/library_content_view.dart';
import 'package:mugiten/screens/search/kanji_search_result_page.dart';
import 'package:mugiten/screens/search/word_search_result_page.dart';
@@ -52,6 +53,8 @@ Route<Widget> generateRoute(RouteSettings settings) {
case Routes.aboutLicenses:
return MaterialPageRoute(builder: (_) => const LicensesView());
case Routes.aboutChangelog:
return MaterialPageRoute(builder: (_) => const ChangelogView());
// TODO: Add more specific error screens.
case Routes.errorNotFound:

View File

@@ -6,6 +6,7 @@ abstract class Routes {
static const String kanjiSearchGrade = '/kanjiSearch/grade';
static const String kanjiSearchRadicals = '/kanjiSearch/radicals';
static const String aboutLicenses = '/info/licenses';
static const String aboutChangelog = '/info/changelog';
static const String errorNotFound = '/error/404';
static const String errorNetwork = '/error/network';
static const String errorOther = '/error/other';

View File

@@ -29,7 +29,7 @@ class HistoryView extends StatelessWidget {
return OpaqueBox(
child: ListView.separated(
itemCount: data.length + 1,
itemCount: data.length + 2,
itemBuilder: historyEntryWithData(data),
separatorBuilder:
historyEntrySeparatorWithData(data.values.toList()),
@@ -46,8 +46,9 @@ 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 != 1 &&
(index == 0 ||
!dateIsEqual(data[index - 2].lastTimestamp, searchDate))) {
return TextDivider(text: formatDate(roundToDay(searchDate)));
}
@@ -60,12 +61,28 @@ class HistoryView extends StatelessWidget {
Widget Function(BuildContext, int) historyEntryWithData(
Map<int, HistoryEntry> data,
) =>
(context, index) => (index == 0)
? const SizedBox.shrink()
: HistoryEntryTile(
entry: data.values.toList()[index - 1],
objectKey: data.keys.toList()[index - 1],
onDelete: () => build(context),
);
) {
return (context, index) {
return switch (index) {
0 => const SizedBox.shrink(),
1 => Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Center(
child: Text(
'${data.length} distinct searches made',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
),
),
int i => HistoryEntryTile(
entry: data.values.toList()[i - 2],
objectKey: data.keys.toList()[i - 2],
onDelete: () => build(context),
),
};
};
}
}

View File

@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:mdi/mdi.dart';
import 'package:mugiten/screens/search/kanji_search_view.dart';
import 'package:mugiten/screens/search/word_search_view.dart';
import 'package:mugiten/services/snackbar.dart';
import 'package:mugiten/settings.dart';
import '../bloc/theme/theme_bloc.dart';
import '../components/common/denshi_jisho_background.dart';
@@ -61,16 +63,30 @@ class _HomeState extends State<Home> {
}
List<_Page> get pages => [
const _Page(
content: WordSearchView(),
titleBar: 'Search',
icon: Icon(Icons.search),
),
const _Page(
content: KanjiSearchView(),
titleBar: 'Kanji Search',
icon: Icon(Mdi.ideogramCjk, size: 30),
),
_Page(
content: WordSearchView(),
titleBar: 'Search',
icon: Icon(Icons.search),
actions: [
if (incognitoModeEnabled)
IconButton(
icon: const Icon(Mdi.incognito),
onPressed: () =>
showSnackbar(context, 'History tracking is disabled'),
),
]),
_Page(
content: KanjiSearchView(),
titleBar: 'Kanji Search',
icon: Icon(Mdi.ideogramCjk, size: 30),
actions: [
if (incognitoModeEnabled)
IconButton(
icon: const Icon(Mdi.incognito),
onPressed: () =>
showSnackbar(context, 'History tracking is disabled'),
),
]),
const _Page(
content: HistoryView(),
titleBar: 'History',

View File

@@ -0,0 +1,122 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:markdown/markdown.dart' show ExtensionSet;
class ChangelogView extends StatelessWidget {
const ChangelogView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Changelog'),
),
body: FutureBuilder<List<String>>(
future: _fetchChangelogs(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final versions = snapshot.data!;
return _buildChangelogList(versions);
},
),
);
}
Future<List<String>> _fetchChangelogs() async {
final String assetManifest =
await rootBundle.loadString('AssetManifest.json');
final List<String> changelogs =
(jsonDecode(assetManifest) as Map<String, Object?>)
.keys
.where(
(assetPath) =>
RegExp(r'^docs/changelog/v.*\.md$').hasMatch(assetPath),
)
.map((assetPath) => assetPath
.replaceFirst('docs/changelog/', '')
.replaceFirst('.md', ''))
.toList();
changelogs.sort((a, b) {
final aVersion =
a.replaceFirst(RegExp('^v'), '').split('.').map(int.parse).toList();
final bVersion =
b.replaceFirst(RegExp('^v'), '').split('.').map(int.parse).toList();
for (int i = 0; i < aVersion.length && i < bVersion.length; i++) {
if (aVersion[i] != bVersion[i]) {
return bVersion[i].compareTo(aVersion[i]);
}
}
return bVersion.length.compareTo(aVersion.length);
});
return changelogs;
}
Widget _buildChangelogList(List<String> versions) {
return ListView.builder(
itemCount: versions.length,
itemBuilder: (context, index) {
final version = versions[index];
return ListTile(
title: Text(version),
onTap: () {
Navigator.push(
context,
_buildChangelogDetailRoute(version),
);
},
);
},
);
}
String _removeHeaders(String markdown) {
final lines = markdown.split('\n');
final filteredLines = lines.where((line) => !line.startsWith('# '));
return filteredLines.join('\n');
}
MaterialPageRoute _buildChangelogDetailRoute(String version) {
return MaterialPageRoute(
builder: (context) => Scaffold(
appBar: AppBar(
title: Text(version),
),
body: FutureBuilder<String>(
future: rootBundle.loadString('docs/changelog/$version.md'),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 20.0),
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 100),
child: MarkdownBody(
data: _removeHeaders(snapshot.data!),
selectable: true,
extensionSet: ExtensionSet.gitHubFlavored,
),
),
);
},
),
),
);
}
}

View File

@@ -1,14 +1,28 @@
import 'package:flutter/material.dart';
import '../../settings.dart';
import 'package:package_info_plus/package_info_plus.dart';
class LicensesView extends StatelessWidget {
const LicensesView({super.key});
@override
Widget build(BuildContext context) => LicensePage(
Widget build(BuildContext context) => FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final packageInfo = snapshot.data!;
return _buildLicensePage(packageInfo);
},
);
Widget _buildLicensePage(PackageInfo packageInfo) => LicensePage(
applicationName: '麦典',
applicationVersion: 'Version: $appVersion',
applicationVersion: 'Version: ${packageInfo.version}',
applicationIcon: Padding(
padding: const EdgeInsets.symmetric(vertical: 30),
child: Row(

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mugiten/services/initialization/initialization_cubit.dart';
import 'package:mugiten/services/initialization/initialization_status.dart';
class InitializationView extends StatelessWidget {
final VoidCallback? onInitializationComplete;
final InitializationCubit cubit;
InitializationView({
super.key,
required this.onInitializationComplete,
required bool deleteDatabase,
}) : cubit = InitializationCubit(deleteDatabase);
@override
Widget build(BuildContext context) {
return MaterialApp(
darkTheme: ThemeData.dark(),
home: Scaffold(
body: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 100),
Image.asset(
'assets/images/logo/mugi.png',
height: 100,
),
const SizedBox(height: 20),
BlocBuilder<InitializationCubit, InitializationStatus>(
bloc: cubit,
builder: (context, state) {
switch (state) {
case InitializationNotStarted _:
cubit.start();
return const CircularProgressIndicator();
case InitializationPending _:
return const CircularProgressIndicator();
case CheckMLKitDigitalInkModel _:
return const Text('Checking for ML Kit updates...');
case DownloadMLKitDigitalInkModel _:
return const Text('Downloading ML Kit model...');
case FinishDownloadMLKitDigitalInkModel _:
return const Text('ML Kit model downloaded successfully');
case CheckDatabase _:
return const Text('Checking for database updates...');
case BackupUserData s:
return Column(
children: [
const Text('Backing up user data...'),
LinearProgressIndicator(value: s.progress / s.total),
],
);
case MigrateDatabase s:
return Column(
children: [
const Text('Performing database migrations...'),
LinearProgressIndicator(value: s.progress / s.total),
],
);
case RestoreUserData s:
return Column(
children: [
const Text('Restoring user data...'),
LinearProgressIndicator(value: s.progress / s.total),
],
);
case DatabaseUpdateFinished _:
return const Text('Database update finished');
case InitializationComplete _:
WidgetsBinding.instance.addPostFrameCallback((_) {
onInitializationComplete?.call();
});
return const Text('Initialization Complete');
default:
return const CircularProgressIndicator();
}
},
),
],
),
),
),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:confirm_dialog/confirm_dialog.dart';
import 'package:flutter/material.dart';
import 'package:mugiten/database/database.dart';
import '../../components/common/loading.dart';
import '../../components/library/library_list_entry_tile.dart';
@@ -21,7 +22,7 @@ class _LibraryContentViewState extends State<LibraryContentView> {
List<LibraryEntry>? entries;
Future<void> getEntriesFromDatabase() =>
widget.library.entries.then((es) => setState(() => entries = es));
widget.library.entries(db()).then((es) => setState(() => entries = es));
@override
void initState() {

View File

@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:jadb/models/kanji_search/kanji_search_result.dart';
import 'package:jadb/search.dart';
import 'package:mdi/mdi.dart';
import 'package:mugiten/components/library/add_to_library_dialog.dart';
import 'package:mugiten/models/history/history_entry.dart';
import 'package:mugiten/models/library/library_list.dart';
import 'package:mugiten/services/snackbar.dart';
import 'package:mugiten/settings.dart';
import 'package:sqflite/sqflite.dart';
// import './kanji_result_body/examples.dart';
@@ -101,6 +104,12 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
Widget _body(KanjiSearchResult result) {
return Scaffold(
appBar: AppBar(actions: [
if (incognitoModeEnabled)
IconButton(
icon: const Icon(Mdi.incognito),
onPressed: () =>
showSnackbar(context, 'History tracking is disabled'),
),
IconButton(
icon: const Icon(Icons.star),
color: isFavourite ? Colors.yellow : null,
@@ -171,7 +180,7 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
}
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
if (!addedToDatabase) {
if (!incognitoModeEnabled && !addedToDatabase) {
HistoryEntry.insertKanji(kanji: widget.kanji);
addedToDatabase = true;
}

View File

@@ -10,18 +10,20 @@ class KanjiDrawingSearch extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Draw a kanji')),
body: Column(
children: [
const Expanded(child: Column()),
DrawingBoard(
onlyOneCharacterSuggestions: true,
onSuggestionChosen: (suggestion) => Navigator.popAndPushNamed(
context,
Routes.kanjiSearch,
arguments: suggestion,
body: SafeArea(
child: Column(
children: [
const Expanded(child: Column()),
DrawingBoard(
onlyOneCharacterSuggestions: true,
onSuggestionChosen: (suggestion) => Navigator.popAndPushNamed(
context,
Routes.kanjiSearch,
arguments: suggestion,
),
),
),
],
],
),
),
);
}

View File

@@ -2,7 +2,10 @@ import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:jadb/models/word_search/word_search_result.dart';
import 'package:jadb/search.dart' show JaDBConnection;
import 'package:mdi/mdi.dart';
import 'package:mugiten/models/history/history_entry.dart';
import 'package:mugiten/services/snackbar.dart';
import 'package:mugiten/settings.dart';
import 'package:sqflite/sqflite.dart';
import '../../components/search/search_results_body/search_card.dart';
@@ -27,14 +30,28 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
appBar: AppBar(
title: Text('Search'),
actions: [
if (incognitoModeEnabled)
IconButton(
icon: const Icon(Mdi.incognito),
onPressed: () =>
showSnackbar(context, 'History tracking is disabled'),
),
],
),
body: FutureBuilder(
future: (() async {
final jadbConnection = GetIt.instance.get<Database>();
final results = await Future.wait([
jadbConnection.jadbSearchWordCount(widget.searchTerm),
jadbConnection.jadbSearchWord(widget.searchTerm),
jadbConnection
.jadbSearchWordCount(widget.searchTerm)
.then((v) => v ?? 0),
jadbConnection
.jadbSearchWord(widget.searchTerm)
.then((v) => v ?? <WordSearchResult>[]),
]);
return (results[0] as int, results[1] as List<WordSearchResult>);
@@ -45,7 +62,7 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
return const Center(child: CircularProgressIndicator());
}
if (!addedToDatabase) {
if (!incognitoModeEnabled && !addedToDatabase) {
HistoryEntry.insertWord(word: widget.searchTerm);
addedToDatabase = true;
}

View File

@@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:developer';
import 'package:confirm_dialog/confirm_dialog.dart';
import 'package:file_picker/file_picker.dart';
@@ -10,12 +11,14 @@ import 'package:mugiten/bloc/theme/theme_bloc.dart';
import 'package:mugiten/components/common/denshi_jisho_background.dart';
import 'package:mugiten/database/database.dart';
import 'package:mugiten/database/history/table_names.dart';
import 'package:mugiten/main.dart';
import 'package:mugiten/models/history/history_entry.dart';
import 'package:mugiten/models/library/library_list.dart';
import 'package:mugiten/routing/routes.dart';
import 'package:mugiten/services/data_export_import.dart';
import 'package:mugiten/services/snackbar.dart';
import 'package:mugiten/settings.dart';
import 'package:url_launcher/url_launcher.dart';
class SettingsView extends StatefulWidget {
const SettingsView({super.key});
@@ -29,16 +32,6 @@ class _SettingsViewState extends State<SettingsView> {
bool dataExportIsLoading = false;
bool dataImportIsLoading = false;
// Future<bool?> assertExternalStorePermissions() async {
// final permissionStatus = await Permission.storage.status;
// if (permissionStatus.isGranted) return null;
// final permissionResult = await Permission.storage.request();
// return permissionResult.isGranted;
// }
Future<void> clearHistory(context) async {
final historyCount = await HistoryEntry.amountOfEntries();
@@ -59,51 +52,6 @@ class _SettingsViewState extends State<SettingsView> {
showSnackbar(context, 'Cleared history');
}
Future<void> clearFavourites(context) async {
final favouritesCount = await LibraryList.favourites.length;
if (!context.mounted) return;
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();
if (!context.mounted) return;
showSnackbar(context, 'Cleared favourites');
}
Future<void> clearAll(context) async {
final historyCount = await HistoryEntry.amountOfEntries();
final libraryCount = await LibraryList.libraryCount();
if (!context.mounted) return;
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();
if (!context.mounted) return;
showSnackbar(
context,
'Database reset successfully.',
);
}
// ignore: avoid_positional_boolean_parameters
void toggleAutoTheme(bool b) {
final bool newThemeIsDark = b
@@ -226,7 +174,10 @@ class _SettingsViewState extends State<SettingsView> {
// titleTextStyle: _titleTextStyle,
tiles: <SettingsTile>[
SettingsTile.switchTile(
title: const Text('Use romaji'),
title: const Text('Romaji mode'),
description: const Text(
'Display romaji instead of kana for word readings',
),
leading: const Icon(Mdi.alphabetical),
onToggle: (b) => setState(() => romajiEnabled = b),
initialValue: romajiEnabled,
@@ -250,6 +201,8 @@ class _SettingsViewState extends State<SettingsView> {
tiles: <SettingsTile>[
SettingsTile.switchTile(
title: const Text('Automatic theme'),
description:
const Text('Let theme be determined by system'),
leading: const Icon(Icons.brightness_auto),
onToggle: toggleAutoTheme,
initialValue: autoThemeEnabled,
@@ -278,6 +231,7 @@ class _SettingsViewState extends State<SettingsView> {
enabled: true,
leading: const Icon(Icons.file_upload),
title: const Text('Import Data'),
description: const Text('Import user data from a file'),
onPressed: importHandler,
value: dataImportIsLoading
? const LinearProgressIndicator()
@@ -287,6 +241,7 @@ class _SettingsViewState extends State<SettingsView> {
enabled: true,
leading: const Icon(Icons.file_download),
title: const Text('Export Data'),
description: const Text('Export user data to a file'),
onPressed: exportHandler,
value: dataExportIsLoading
? const LinearProgressIndicator()
@@ -299,25 +254,59 @@ class _SettingsViewState extends State<SettingsView> {
'Clear History',
style: TextStyle(color: Colors.red),
),
description: const Text('Delete all search history'),
onPressed: clearHistory,
),
SettingsTile(
enabled: true,
leading: const Icon(Icons.delete),
title: const Text(
'Clear Favourites',
style: TextStyle(color: Colors.red),
],
),
SettingsSection(
title: Text('Misc', style: titleTextStyle),
tiles: <SettingsTile>[
SettingsTile.switchTile(
leading: const Icon(Mdi.incognito),
title: const Text('Disable history tracking'),
description: const Text(
'Useful for reviewing history for library lists without cluttering the order',
),
onPressed: clearFavourites,
onToggle: (b) => setState(() => incognitoModeEnabled = b),
initialValue: incognitoModeEnabled,
activeSwitchColor: AppTheme.mugitenWheat.background,
),
SettingsTile.switchTile(
leading: const Icon(Icons.close_fullscreen),
title: const Text('Shrink kanji drawing board'),
description: const Text(
'Useful if you keep accidentally activating system gestures',
),
onToggle: (b) =>
setState(() => reduceKanjiDrawingBoardSize = b),
initialValue: reduceKanjiDrawingBoardSize,
activeSwitchColor: AppTheme.mugitenWheat.background,
),
SettingsTile(
enabled: true,
leading: const Icon(Icons.delete),
leading: const Icon(Icons.cached),
title: const Text(
'Reset database',
'Reinitialize application',
style: TextStyle(color: Colors.red),
),
onPressed: clearAll,
description: const Text(
'Reinstall dictionary data and set up internal workings anew',
),
onPressed: (_) async {
if (!await confirm(
context,
content: const Text(
'Are you sure you want to reinitialize the application?',
),
)) {
return;
}
GetIt.instance.get<Database>().close();
GetIt.instance.reset();
runInitializationScreen(true);
},
),
],
),
@@ -326,10 +315,26 @@ class _SettingsViewState extends State<SettingsView> {
tiles: <SettingsTile>[
SettingsTile(
leading: const Icon(Icons.copyright),
title: const Text('Licenses'),
title: const Text('About'),
description: const Text(
'Information about Mugiten and licenses used'),
onPressed: (c) =>
Navigator.pushNamed(context, Routes.aboutLicenses),
),
SettingsTile(
leading: const Icon(Icons.notes),
title: const Text('Changelog'),
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: (c) => launchUrl(
Uri.parse('https://git.pvv.ntnu.no/mugiten'),
),
)
],
),
],

View File

@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:mugiten/database/history/table_names.dart';
import 'package:mugiten/database/library_list/table_names.dart';
import 'package:mugiten/models/history/history_entry.dart';
import 'package:mugiten/models/library/library_list.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
@@ -16,8 +17,8 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
// - listb.json
extension ArchiveFormat on Directory {
File get historyFile => File(uri.resolve('history.json').path);
Directory get libraryDir => Directory(uri.resolve('library').path);
File get historyFile => File(uri.resolve('history.json').toFilePath());
Directory get libraryDir => Directory(uri.resolve('library').toFilePath());
}
Future<Directory> tmpdir() async =>
@@ -25,7 +26,7 @@ Future<Directory> tmpdir() async =>
Future<Directory> unpackZipToTempDir(String zipFilePath) async {
final outputDir = await tmpdir();
extractFileToDisk(
await extractFileToDisk(
zipFilePath,
outputDir.path,
);
@@ -38,7 +39,7 @@ Future<File> packZip(
}) async {
if (outputFile == null || !outputFile.existsSync()) {
final outputDir = await tmpdir();
outputFile = File(outputDir.uri.resolve('mugiten_data.zip').path);
outputFile = File(outputDir.uri.resolve('mugiten_data.zip').toFilePath());
outputFile.createSync();
}
@@ -69,12 +70,12 @@ String getExportFileNameNoSuffix() {
Future<File> exportData(DatabaseExecutor db) async {
final dir = await tmpdir();
final libraryDir = Directory(dir.uri.resolve('library').path);
final libraryDir = Directory(dir.uri.resolve('library').toFilePath());
libraryDir.createSync();
await Future.wait([
exportHistoryTo(db, dir),
exportLibraryListsTo(libraryDir),
exportLibraryListsTo(db, libraryDir),
]);
final zipFile = await packZip(dir);
@@ -134,19 +135,64 @@ Future<void> importHistoryFrom(File file) async {
///////////////////
Future<void> exportLibraryListsTo(
// DatabaseExecutor db,
DatabaseExecutor db,
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));
}),
);
) 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();
final entries = (await LibraryList.byName(libraryName).entries(db))
.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(Directory libraryListsDir) async {
print('TODO: Implement importLibraryLists');
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 LibraryList.exists(libraryName)) {
if ((await LibraryList.byName(libraryName).length) > 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 {
LibraryList.insert(libraryName);
}
final content = await file.readAsString();
final List<Map<String, Object?>> jsonEntries = (jsonDecode(content) as List)
.map((e) => e as Map<String, Object?>)
.toList();
final libraryList = LibraryList.byName(libraryName);
await libraryList.insertJsonEntries(jsonEntries);
}
}

View File

@@ -0,0 +1,94 @@
import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:google_mlkit_digital_ink_recognition/google_mlkit_digital_ink_recognition.dart';
import 'package:mugiten/database/database.dart'
show
DatabaseMigration,
databaseNeedsInitialization,
databasePath,
extractJadbFromAssets,
openAndMigrateDatabase,
openDatabaseWithoutMigrations,
readMigrationsFromAssets;
import 'package:mugiten/services/data_export_import.dart';
import 'package:mugiten/services/initialization/initialization_status.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
class InitializationCubit extends Cubit<InitializationStatus> {
final bool deleteDatabase;
InitializationCubit(this.deleteDatabase) : super(InitializationNotStarted());
Future<void> start() async {
emit(InitializationPending());
emit(CheckMLKitDigitalInkModel());
final modelManager = DigitalInkRecognizerModelManager();
final isDownloaded = await modelManager.isModelDownloaded('ja');
if (!isDownloaded) {
emit(DownloadMLKitDigitalInkModel());
await modelManager.downloadModel('ja');
}
emit(FinishDownloadMLKitDigitalInkModel());
emit(CheckDatabase());
if (deleteDatabase || await databaseNeedsInitialization()) {
final String dbPath = await databasePath();
final databaseAlreadyExists = await File(dbPath).exists();
late final File? tmpdirDataDump;
if (!databaseAlreadyExists) {
await extractJadbFromAssets(dbPath);
} else {
emit(BackupUserData(total: 2, progress: 1));
final tempDir = await getTemporaryDirectory();
final database = await openDatabaseWithoutMigrations(dbPath);
GetIt.instance.registerSingleton<Database>(database);
final dataDump = await exportData(database);
GetIt.instance.unregister<Database>();
await database.close();
tmpdirDataDump =
await dataDump.copy('${tempDir.path}/mugiten_data_backup.zip');
emit(BackupUserData(total: 2, progress: 2));
}
if (deleteDatabase) {
await File(dbPath).delete();
await extractJadbFromAssets(dbPath);
}
emit(MigrateDatabase(total: 2, progress: 1));
final List<DatabaseMigration> migrations =
await readMigrationsFromAssets();
final database = await openAndMigrateDatabase(dbPath, migrations);
emit(MigrateDatabase(total: 2, progress: 2));
if (databaseAlreadyExists) {
emit(RestoreUserData(total: 2, progress: 1));
GetIt.instance.registerSingleton<Database>(database);
await importData(database, tmpdirDataDump!);
GetIt.instance.unregister<Database>();
emit(RestoreUserData(total: 2, progress: 2));
}
database.close();
}
emit(DatabaseUpdateFinished());
// Initialization complete
emit(InitializationComplete());
}
}

View File

@@ -0,0 +1,59 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:get_it/get_it.dart';
import 'package:google_mlkit_digital_ink_recognition/google_mlkit_digital_ink_recognition.dart';
import 'package:mugiten/database/database.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
/// Determine whether the application needs to show the initialization screen.
Future<bool> needsInitialization() async {
final modelManager = DigitalInkRecognizerModelManager();
if (!await modelManager.isModelDownloaded('ja')) {
return true;
}
if (await databaseNeedsInitialization()) {
return true;
}
return false;
}
/// Quick initialization used for normal startup without initialization screen.
Future<void> quickInitialization() async {
databaseFactory = databaseFactoryFfi;
await Future.wait([
quickInitializeDatabase(),
setupSharedPreferences(),
(() async {
final modelManager = DigitalInkRecognizerModelManager();
final isDownloaded = await modelManager.isModelDownloaded('ja');
assert(isDownloaded, 'Japanese model should be downloaded at this point');
})(),
]);
registerExtraLicenses();
}
// TODO: should this be deferred in case preferences are modified externally?
Future<void> setupSharedPreferences() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
GetIt.instance.registerSingleton<SharedPreferences>(prefs);
}
void registerExtraLicenses() => LicenseRegistry.addLicense(
() async* {
final jsonString = await rootBundle.loadString('assets/licenses.json');
final Map<String, dynamic> jsonData = jsonDecode(jsonString);
for (final license in jsonData.entries) {
yield LicenseEntryWithLineBreaks(
[license.key],
await rootBundle.loadString(license.value as String),
);
}
},
);

View File

@@ -0,0 +1,53 @@
abstract class InitializationStatus {}
class InitializationNotStarted extends InitializationStatus {}
class InitializationPending extends InitializationStatus {}
//
class CheckMLKitDigitalInkModel extends InitializationStatus {}
class DownloadMLKitDigitalInkModel extends InitializationStatus {}
class FinishDownloadMLKitDigitalInkModel extends InitializationStatus {}
//
class CheckDatabase extends InitializationStatus {}
class BackupUserData extends InitializationStatus {
final int progress;
final int total;
BackupUserData({
required this.progress,
required this.total,
});
}
class MigrateDatabase extends InitializationStatus {
final int progress;
final int total;
MigrateDatabase({
required this.progress,
required this.total,
});
}
class RestoreUserData extends InitializationStatus {
final int progress;
final int total;
RestoreUserData({
required this.progress,
required this.total,
});
}
class DatabaseUpdateFinished extends InitializationStatus {}
//
class InitializationComplete extends InitializationStatus {}

View File

@@ -1,15 +0,0 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
void registerExtraLicenses() => LicenseRegistry.addLicense(() async* {
final jsonString = await rootBundle.loadString('assets/licenses.json');
final Map<String, dynamic> jsonData = jsonDecode(jsonString);
for (final license in jsonData.entries) {
yield LicenseEntryWithLineBreaks(
[license.key],
await rootBundle.loadString(license.value as String),
);
}
});

View File

@@ -1,7 +0,0 @@
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<void> setupSharedPreferences() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
GetIt.instance.registerSingleton<SharedPreferences>(prefs);
}

View File

@@ -39,27 +39,34 @@ extension Methods on JapaneseFont {
'';
}
const String appVersion = '0.1 Beta';
const Map<String, dynamic> _defaults = {
'incognitoModeEnabled': false,
'romajiEnabled': false,
'darkThemeEnabled': false,
'autoThemeEnabled': false,
'japaneseFont': JapaneseFont.droidSansJapanese,
'reduceKanjiDrawingBoardSize': false,
};
bool _getSettingOrDefault(String settingName) =>
_prefs.getBool(settingName) ?? _defaults[settingName];
bool get incognitoModeEnabled =>
_getSettingOrDefault('incognitoModeEnabled');
bool get romajiEnabled => _getSettingOrDefault('romajiEnabled');
bool get darkThemeEnabled => _getSettingOrDefault('darkThemeEnabled');
bool get autoThemeEnabled => _getSettingOrDefault('autoThemeEnabled');
bool get reduceKanjiDrawingBoardSize =>
_getSettingOrDefault('reduceKanjiDrawingBoardSize');
JapaneseFont get japaneseFont {
final int? i = _prefs.getInt('japaneseFont');
return (i != null) ? JapaneseFont.values[i] : _defaults['japaneseFont'];
}
set incognitoModeEnabled(b) => _prefs.setBool('incognitoModeEnabled', b);
set romajiEnabled(b) => _prefs.setBool('romajiEnabled', b);
set darkThemeEnabled(b) => _prefs.setBool('darkThemeEnabled', b);
set autoThemeEnabled(b) => _prefs.setBool('autoThemeEnabled', b);
set reduceKanjiDrawingBoardSize(b) =>
_prefs.setBool('reduceKanjiDrawingBoardSize', b);
set japaneseFont(JapaneseFont jf) => _prefs.setInt('japaneseFont', jf.index);

View File

@@ -26,7 +26,10 @@ CREATE TABLE "Mugiten_HistoryEntryTimestamp" (
CREATE INDEX "Mugiten_HistoryEntryTimestamp_byTimestamp" ON "Mugiten_HistoryEntryTimestamp"("timestamp");
CREATE VIEW "Mugiten_HistoryEntry_orderedByTimestamp" AS
SELECT * FROM "Mugiten_HistoryEntryTimestamp"
SELECT
*,
COUNT("timestamp") AS "timestampCount"
FROM "Mugiten_HistoryEntryTimestamp"
LEFT JOIN "Mugiten_HistoryEntryWord" USING ("entryId")
LEFT JOIN "Mugiten_HistoryEntryKanji" USING ("entryId")
GROUP BY "entryId"

View File

@@ -198,6 +198,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_markdown_plus:
dependency: "direct main"
description:
name: flutter_markdown_plus
sha256: fe74214c5ac2f850d93efda290dcde3f18006e90a87caa9e3e6c13222a5db4de
url: "https://pub.dev"
source: hosted
version: "1.0.3"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -293,7 +301,7 @@ packages:
description:
path: "."
ref: HEAD
resolved-ref: "7978b74f8d125a404b42465d4dacd52de0224693"
resolved-ref: "1868c6fb41c781bb9c02cde172b39ab630f28421"
url: "https://git.pvv.ntnu.no/oysteikt/jadb.git"
source: git
version: "1.0.0"
@@ -329,6 +337,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
markdown:
dependency: "direct main"
description:
name: markdown
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
url: "https://pub.dev"
source: hosted
version: "7.3.0"
matcher:
dependency: transitive
description:
@@ -377,6 +393,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191"
url: "https://pub.dev"
source: hosted
version: "8.3.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
path:
dependency: "direct main"
description:
@@ -469,10 +501,10 @@ packages:
dependency: transitive
description:
name: posix
sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev"
source: hosted
version: "6.0.2"
version: "6.0.3"
provider:
dependency: transitive
description:
@@ -864,4 +896,4 @@ packages:
version: "6.5.0"
sdks:
dart: ">=3.6.2 <4.0.0"
flutter: ">=3.27.0"
flutter: ">=3.27.1"

View File

@@ -1,7 +1,7 @@
name: mugiten
description: "A new Flutter project."
publish_to: "none" # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1
version: 0.3.0+1
environment:
sdk: ^3.6.2
@@ -37,6 +37,9 @@ dependencies:
ruby_text: ^3.0.3
google_mlkit_digital_ink_recognition: ^0.14.1
archive: ^4.0.7
package_info_plus: ^8.3.0
flutter_markdown_plus: ^1.0.3
markdown: ^7.3.0
dev_dependencies:
flutter_test:
@@ -47,7 +50,6 @@ flutter:
uses-material-design: true
assets:
- migrations/
- assets/
- assets/fonts/
- assets/images/
@@ -55,6 +57,8 @@ flutter:
- assets/images/links/
- assets/images/logo/
- assets/licenses/
- docs/changelog/
- migrations/
fonts:
- family: Droid Sans Japanese