Compare commits
1 Commits
main
...
streaming-
| Author | SHA1 | Date | |
|---|---|---|---|
|
69016ad12e
|
@@ -2,11 +2,9 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:mugiten/routing/router.dart';
|
import 'package:mugiten/routing/router.dart';
|
||||||
import 'package:mugiten/screens/initialization.dart';
|
import 'package:mugiten/screens/initialization.dart';
|
||||||
import 'package:mugiten/services/archive/archive_controller.dart';
|
|
||||||
import 'package:mugiten/services/initialization/initialization_logic.dart';
|
import 'package:mugiten/services/initialization/initialization_logic.dart';
|
||||||
import 'package:mugiten/theme.dart';
|
import 'package:mugiten/theme.dart';
|
||||||
|
|
||||||
@@ -40,7 +38,6 @@ class MyApp extends StatefulWidget {
|
|||||||
|
|
||||||
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||||
final themeController = ThemeController.create();
|
final themeController = ThemeController.create();
|
||||||
final archiveController = ArchiveController();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -71,18 +68,16 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
Widget build(final BuildContext context) {
|
Widget build(final BuildContext context) {
|
||||||
return ValueListenableBuilder<AppThemeMode>(
|
return ValueListenableBuilder<AppThemeMode>(
|
||||||
valueListenable: themeController.themeMode,
|
valueListenable: themeController.themeMode,
|
||||||
builder: (final context, final themeMode, _) =>
|
builder: (final context, final themeMode, _) {
|
||||||
BlocProvider<ArchiveController>.value(
|
return MaterialApp(
|
||||||
value: archiveController,
|
title: '麦典',
|
||||||
child: MaterialApp(
|
theme: themeMode.lightThemeData,
|
||||||
title: '麦典',
|
darkTheme: themeMode.darkThemeData,
|
||||||
theme: themeMode.lightThemeData,
|
themeMode: themeMode.themeMode,
|
||||||
darkTheme: themeMode.darkThemeData,
|
initialRoute: '/',
|
||||||
themeMode: themeMode.themeMode,
|
onGenerateRoute: generateRoute,
|
||||||
initialRoute: '/',
|
);
|
||||||
onGenerateRoute: generateRoute,
|
},
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ extension HistoryEntryExt on DatabaseExecutor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: add a parameter flag to ignore existing id and assign a new one.
|
||||||
Future<void> historyEntryInsertEntry(
|
Future<void> historyEntryInsertEntry(
|
||||||
final HistoryEntry entry, {
|
final HistoryEntry entry, {
|
||||||
final bool assignNewId = false,
|
final bool assignNewId = false,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:mdi/mdi.dart';
|
import 'package:mdi/mdi.dart';
|
||||||
@@ -12,7 +11,6 @@ import 'package:mugiten/main.dart';
|
|||||||
import 'package:mugiten/models/history_entry.dart';
|
import 'package:mugiten/models/history_entry.dart';
|
||||||
import 'package:mugiten/models/library_list.dart';
|
import 'package:mugiten/models/library_list.dart';
|
||||||
import 'package:mugiten/routing/routes.dart';
|
import 'package:mugiten/routing/routes.dart';
|
||||||
import 'package:mugiten/services/archive/archive_controller.dart';
|
|
||||||
import 'package:mugiten/services/archive/v1/format.dart';
|
import 'package:mugiten/services/archive/v1/format.dart';
|
||||||
import 'package:mugiten/services/snackbar.dart';
|
import 'package:mugiten/services/snackbar.dart';
|
||||||
import 'package:mugiten/settings.dart';
|
import 'package:mugiten/settings.dart';
|
||||||
@@ -28,6 +26,10 @@ class SettingsView extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsViewState extends State<SettingsView> {
|
class _SettingsViewState extends State<SettingsView> {
|
||||||
|
final Database db = GetIt.instance.get<Database>();
|
||||||
|
bool dataExportIsLoading = false;
|
||||||
|
bool dataImportIsLoading = false;
|
||||||
|
|
||||||
Future<bool> confirm(
|
Future<bool> confirm(
|
||||||
final BuildContext context, {
|
final BuildContext context, {
|
||||||
required final Widget content,
|
required final Widget content,
|
||||||
@@ -108,22 +110,24 @@ class _SettingsViewState extends State<SettingsView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> exportHandler(final BuildContext context) async {
|
Future<void> exportHandler(final BuildContext context) async {
|
||||||
final tmpfile = File(
|
late final File zipfile;
|
||||||
Directory.systemTemp
|
try {
|
||||||
.createTempSync('mugiten_data_')
|
setState(() => dataExportIsLoading = true);
|
||||||
.uri
|
final db = GetIt.instance.get<Database>();
|
||||||
.resolve('mugiten_data.zip')
|
zipfile = await exportData(db);
|
||||||
.toFilePath(),
|
} catch (e) {
|
||||||
);
|
if (!context.mounted) return;
|
||||||
|
showSnackbar(context, 'Error exporting data: $e');
|
||||||
await BlocProvider.of<ArchiveController>(context).startExport(tmpfile);
|
} finally {
|
||||||
|
setState(() => dataExportIsLoading = false);
|
||||||
|
}
|
||||||
|
|
||||||
final saveFile = await FilePicker.saveFile(
|
final saveFile = await FilePicker.saveFile(
|
||||||
dialogTitle: 'Export data',
|
dialogTitle: 'Export data',
|
||||||
fileName: getExportFileNameNoSuffix(),
|
fileName: getExportFileNameNoSuffix(),
|
||||||
type: FileType.custom,
|
type: FileType.custom,
|
||||||
allowedExtensions: ['zip'],
|
allowedExtensions: ['zip'],
|
||||||
bytes: tmpfile.readAsBytesSync(),
|
bytes: zipfile.readAsBytesSync(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -149,11 +153,19 @@ class _SettingsViewState extends State<SettingsView> {
|
|||||||
|
|
||||||
final filepath = saveFile.files.first.path;
|
final filepath = saveFile.files.first.path;
|
||||||
|
|
||||||
if (!context.mounted) return;
|
final db = GetIt.instance.get<Database>();
|
||||||
|
|
||||||
await BlocProvider.of<ArchiveController>(
|
try {
|
||||||
context,
|
setState(() => dataImportIsLoading = true);
|
||||||
).startImport(File(filepath!));
|
await importData(db, File(filepath!));
|
||||||
|
if (!context.mounted) return;
|
||||||
|
showSnackbar(context, 'Data imported successfully');
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
showSnackbar(context, 'Error importing data: $e');
|
||||||
|
} finally {
|
||||||
|
setState(() => dataImportIsLoading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int?> Function(BuildContext) _chooseFromList({
|
Future<int?> Function(BuildContext) _chooseFromList({
|
||||||
@@ -183,25 +195,19 @@ class _SettingsViewState extends State<SettingsView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(final BuildContext context) =>
|
Widget build(final BuildContext context) => SettingsList(
|
||||||
BlocBuilder<ArchiveController, ArchiveState>(
|
lightTheme: SettingsThemeData(
|
||||||
builder: (final context, final archiveState) => SettingsList(
|
settingsListBackground: Theme.of(context).scaffoldBackgroundColor,
|
||||||
lightTheme: SettingsThemeData(
|
),
|
||||||
settingsListBackground: Theme.of(context).scaffoldBackgroundColor,
|
darkTheme: SettingsThemeData(
|
||||||
),
|
settingsListBackground: Theme.of(context).scaffoldBackgroundColor,
|
||||||
darkTheme: SettingsThemeData(
|
titleTextColor: mugitenWheatBackground,
|
||||||
settingsListBackground: Theme.of(context).scaffoldBackgroundColor,
|
),
|
||||||
titleTextColor: mugitenWheatBackground,
|
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
),
|
sections: _sections(context),
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
);
|
||||||
sections: _sections(context, archiveState),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
List<SettingsSection> _sections(
|
List<SettingsSection> _sections(final BuildContext context) => [
|
||||||
final BuildContext context,
|
|
||||||
final ArchiveState archiveState,
|
|
||||||
) => [
|
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title: const Text('Dictionary'),
|
title: const Text('Dictionary'),
|
||||||
tiles: <SettingsTile>[
|
tiles: <SettingsTile>[
|
||||||
@@ -266,41 +272,23 @@ class _SettingsViewState extends State<SettingsView> {
|
|||||||
title: const Text('Data'),
|
title: const Text('Data'),
|
||||||
tiles: <SettingsTile>[
|
tiles: <SettingsTile>[
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
enabled: archiveState is IdleState,
|
enabled: true,
|
||||||
leading: const Icon(Icons.file_upload),
|
leading: const Icon(Icons.file_upload),
|
||||||
title: const Text('Import Data'),
|
title: const Text('Import Data'),
|
||||||
description: const Text('Import user data from a file'),
|
description: const Text('Import user data from a file'),
|
||||||
onPressed: importHandler,
|
onPressed: importHandler,
|
||||||
trailing: archiveState is ImportingState
|
value: dataImportIsLoading ? const LinearProgressIndicator() : null,
|
||||||
? CircularProgressIndicator(
|
|
||||||
value: archiveState.total > 0
|
|
||||||
? archiveState.progress / archiveState.total
|
|
||||||
: null,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
value: archiveState is ImportingState
|
|
||||||
? Text(archiveState.status)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
enabled: archiveState is IdleState,
|
enabled: true,
|
||||||
leading: const Icon(Icons.file_download),
|
leading: const Icon(Icons.file_download),
|
||||||
title: const Text('Export Data'),
|
title: const Text('Export Data'),
|
||||||
description: const Text('Export user data to a file'),
|
description: const Text('Export user data to a file'),
|
||||||
onPressed: exportHandler,
|
onPressed: exportHandler,
|
||||||
trailing: archiveState is ExportingState
|
value: dataExportIsLoading ? const LinearProgressIndicator() : null,
|
||||||
? CircularProgressIndicator(
|
|
||||||
value: archiveState.total > 0
|
|
||||||
? archiveState.progress / archiveState.total
|
|
||||||
: null,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
value: archiveState is ExportingState
|
|
||||||
? Text(archiveState.status)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
enabled: archiveState is IdleState,
|
enabled: true,
|
||||||
leading: const Icon(Icons.delete),
|
leading: const Icon(Icons.delete),
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'Clear History',
|
'Clear History',
|
||||||
@@ -336,7 +324,7 @@ class _SettingsViewState extends State<SettingsView> {
|
|||||||
activeSwitchColor: mugitenWheatBackground,
|
activeSwitchColor: mugitenWheatBackground,
|
||||||
),
|
),
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
enabled: archiveState is IdleState,
|
enabled: true,
|
||||||
leading: const Icon(Icons.cached),
|
leading: const Icon(Icons.cached),
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'Reinitialize application',
|
'Reinitialize application',
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import 'dart:core';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'package:mugiten/services/archive/v2/format.dart';
|
|
||||||
import 'package:sqflite/sqlite_api.dart';
|
|
||||||
|
|
||||||
/// The archive controller is a singleton-like class that keeps track of whether the
|
|
||||||
/// application is currently importing or exporting data. This is used to prevent
|
|
||||||
/// the user from starting multiple imports/exports at the same time, as well as
|
|
||||||
/// to keep the state available when the user navigates away from the widget that
|
|
||||||
/// started the import/export process.
|
|
||||||
class ArchiveController extends Cubit<ArchiveState> {
|
|
||||||
ArchiveController() : super(const IdleState());
|
|
||||||
|
|
||||||
Future<void> startImport(final File archive) async {
|
|
||||||
if (state is! IdleState) {
|
|
||||||
throw StateError('Already importing or exporting data');
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(
|
|
||||||
const ImportingState(progress: 0, total: 0, status: 'Counting data...'),
|
|
||||||
);
|
|
||||||
|
|
||||||
final database = GetIt.instance.get<Database>();
|
|
||||||
|
|
||||||
final totalChunks = totalAmountOfChunksFromArchive(archive);
|
|
||||||
|
|
||||||
emit(
|
|
||||||
ImportingState(
|
|
||||||
progress: 0,
|
|
||||||
total: totalChunks,
|
|
||||||
status: 'Starting import...',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
int i = 0;
|
|
||||||
await for (final event in importData(database, archive)) {
|
|
||||||
i += 1;
|
|
||||||
final status = switch (event) {
|
|
||||||
ArchiveV2StreamEvent(type: 'history') =>
|
|
||||||
'Importing history: ${event.progress}/${event.total}',
|
|
||||||
ArchiveV2StreamEvent(type: 'library') =>
|
|
||||||
'Importing library list "${event.name}": ${event.subProgress}/${event.subTotal}',
|
|
||||||
_ => 'Importing unknown data: ${event.progress}/${event.total}',
|
|
||||||
};
|
|
||||||
|
|
||||||
emit(ImportingState(progress: i, total: totalChunks, status: status));
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(const IdleState());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> startExport(final File archive) async {
|
|
||||||
if (state is! IdleState) {
|
|
||||||
throw StateError('Already importing or exporting data');
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(
|
|
||||||
const ExportingState(progress: 0, total: 0, status: 'Counting data...'),
|
|
||||||
);
|
|
||||||
|
|
||||||
final database = GetIt.instance.get<Database>();
|
|
||||||
|
|
||||||
final totalChunks = await totalAmountOfChunksFromDatabase(database);
|
|
||||||
|
|
||||||
emit(
|
|
||||||
ExportingState(
|
|
||||||
progress: 0,
|
|
||||||
total: totalChunks,
|
|
||||||
status: 'Starting export...',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
int i = 0;
|
|
||||||
await for (final event in exportData(database, archive)) {
|
|
||||||
i += 1;
|
|
||||||
final status = switch (event) {
|
|
||||||
ArchiveV2StreamEvent(type: 'history') =>
|
|
||||||
'Exporting history: ${event.progress}/${event.total}',
|
|
||||||
ArchiveV2StreamEvent(type: 'library') =>
|
|
||||||
'Exporting library list "${event.name}": ${event.subProgress}/${event.subTotal}',
|
|
||||||
_ => 'Exporting unknown data: ${event.progress}/${event.total}',
|
|
||||||
};
|
|
||||||
|
|
||||||
emit(ExportingState(progress: i, total: totalChunks, status: status));
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(const IdleState());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class ArchiveState {
|
|
||||||
const ArchiveState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class IdleState extends ArchiveState {
|
|
||||||
const IdleState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class ImportingState extends ArchiveState {
|
|
||||||
final int progress;
|
|
||||||
final int total;
|
|
||||||
final String status;
|
|
||||||
|
|
||||||
const ImportingState({
|
|
||||||
required this.progress,
|
|
||||||
required this.total,
|
|
||||||
required this.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class ExportingState extends ArchiveState {
|
|
||||||
final int progress;
|
|
||||||
final int total;
|
|
||||||
final String status;
|
|
||||||
|
|
||||||
const ExportingState({
|
|
||||||
required this.progress,
|
|
||||||
required this.total,
|
|
||||||
required this.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import 'dart:convert';
|
|||||||
import 'dart:core';
|
import 'dart:core';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:archive/archive.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:mugiten/models/history_entry.dart';
|
import 'package:mugiten/models/history_entry.dart';
|
||||||
import 'package:mugiten/models/library_list.dart';
|
import 'package:mugiten/models/library_list.dart';
|
||||||
@@ -113,32 +112,6 @@ extension ArchiveFormatV2 on Directory {
|
|||||||
String slugifyLibraryListFileName(final String name) =>
|
String slugifyLibraryListFileName(final String name) =>
|
||||||
name.toLowerCase().replaceAll(RegExp(r'\s+'), '_');
|
name.toLowerCase().replaceAll(RegExp(r'\s+'), '_');
|
||||||
|
|
||||||
Future<int> totalAmountOfChunksFromDatabase(final DatabaseExecutor db) async {
|
|
||||||
final historyCount = await db.historyEntryAmount();
|
|
||||||
final libraryListCounts = (await db.libraryListGetLists())
|
|
||||||
.map((final list) => (list.totalCount / libraryListChunkSize).ceil())
|
|
||||||
.sum;
|
|
||||||
|
|
||||||
return (historyCount / historyChunkSize).ceil() + libraryListCounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: skip counting chunks where the library list already exists
|
|
||||||
// and has a non-zero entry count.
|
|
||||||
int totalAmountOfChunksFromArchive(final File archiveFile) {
|
|
||||||
final Archive archive = ZipDecoder().decodeStream(
|
|
||||||
InputFileStream(archiveFile.path),
|
|
||||||
);
|
|
||||||
int result = 0;
|
|
||||||
for (final file in archive) {
|
|
||||||
if (file.isFile &&
|
|
||||||
file.name != 'metadata.json' &&
|
|
||||||
file.name.endsWith('.json')) {
|
|
||||||
result++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ArchiveV2StreamEvent {
|
class ArchiveV2StreamEvent {
|
||||||
final String type;
|
final String type;
|
||||||
final int progress;
|
final int progress;
|
||||||
@@ -182,15 +155,6 @@ class ArchiveV2StreamEvent {
|
|||||||
subProgress <= subTotal),
|
subProgress <= subTotal),
|
||||||
'subProgress and subTotal must both be null or both be positive integers with subProgress <= subTotal',
|
'subProgress and subTotal must both be null or both be positive integers with subProgress <= subTotal',
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
if (type == 'history') {
|
|
||||||
return 'ArchiveV2StreamEvent.History($progress/$total)';
|
|
||||||
} else {
|
|
||||||
return 'ArchiveV2StreamEvent.Library("$name", $progress/$total, $subProgress/$subTotal)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<ArchiveV2StreamEvent> exportData(
|
Stream<ArchiveV2StreamEvent> exportData(
|
||||||
|
|||||||
Reference in New Issue
Block a user