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/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/routing/router.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/theme.dart';
|
||||
|
||||
@@ -40,7 +38,6 @@ class MyApp extends StatefulWidget {
|
||||
|
||||
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
final themeController = ThemeController.create();
|
||||
final archiveController = ArchiveController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -71,18 +68,16 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
Widget build(final BuildContext context) {
|
||||
return ValueListenableBuilder<AppThemeMode>(
|
||||
valueListenable: themeController.themeMode,
|
||||
builder: (final context, final themeMode, _) =>
|
||||
BlocProvider<ArchiveController>.value(
|
||||
value: archiveController,
|
||||
child: MaterialApp(
|
||||
title: '麦典',
|
||||
theme: themeMode.lightThemeData,
|
||||
darkTheme: themeMode.darkThemeData,
|
||||
themeMode: themeMode.themeMode,
|
||||
initialRoute: '/',
|
||||
onGenerateRoute: generateRoute,
|
||||
),
|
||||
),
|
||||
builder: (final context, final themeMode, _) {
|
||||
return MaterialApp(
|
||||
title: '麦典',
|
||||
theme: themeMode.lightThemeData,
|
||||
darkTheme: themeMode.darkThemeData,
|
||||
themeMode: themeMode.themeMode,
|
||||
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(
|
||||
final HistoryEntry entry, {
|
||||
final bool assignNewId = false,
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
||||
import 'package:get_it/get_it.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/library_list.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/snackbar.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
@@ -28,6 +26,10 @@ class SettingsView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SettingsViewState extends State<SettingsView> {
|
||||
final Database db = GetIt.instance.get<Database>();
|
||||
bool dataExportIsLoading = false;
|
||||
bool dataImportIsLoading = false;
|
||||
|
||||
Future<bool> confirm(
|
||||
final BuildContext context, {
|
||||
required final Widget content,
|
||||
@@ -108,22 +110,24 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
}
|
||||
|
||||
Future<void> exportHandler(final BuildContext context) async {
|
||||
final tmpfile = File(
|
||||
Directory.systemTemp
|
||||
.createTempSync('mugiten_data_')
|
||||
.uri
|
||||
.resolve('mugiten_data.zip')
|
||||
.toFilePath(),
|
||||
);
|
||||
|
||||
await BlocProvider.of<ArchiveController>(context).startExport(tmpfile);
|
||||
late final File zipfile;
|
||||
try {
|
||||
setState(() => dataExportIsLoading = true);
|
||||
final db = GetIt.instance.get<Database>();
|
||||
zipfile = await exportData(db);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showSnackbar(context, 'Error exporting data: $e');
|
||||
} finally {
|
||||
setState(() => dataExportIsLoading = false);
|
||||
}
|
||||
|
||||
final saveFile = await FilePicker.saveFile(
|
||||
dialogTitle: 'Export data',
|
||||
fileName: getExportFileNameNoSuffix(),
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['zip'],
|
||||
bytes: tmpfile.readAsBytesSync(),
|
||||
bytes: zipfile.readAsBytesSync(),
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
@@ -149,11 +153,19 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
|
||||
final filepath = saveFile.files.first.path;
|
||||
|
||||
if (!context.mounted) return;
|
||||
final db = GetIt.instance.get<Database>();
|
||||
|
||||
await BlocProvider.of<ArchiveController>(
|
||||
context,
|
||||
).startImport(File(filepath!));
|
||||
try {
|
||||
setState(() => dataImportIsLoading = true);
|
||||
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({
|
||||
@@ -183,25 +195,19 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) =>
|
||||
BlocBuilder<ArchiveController, ArchiveState>(
|
||||
builder: (final context, final archiveState) => SettingsList(
|
||||
lightTheme: SettingsThemeData(
|
||||
settingsListBackground: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
darkTheme: SettingsThemeData(
|
||||
settingsListBackground: Theme.of(context).scaffoldBackgroundColor,
|
||||
titleTextColor: mugitenWheatBackground,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||
sections: _sections(context, archiveState),
|
||||
),
|
||||
);
|
||||
Widget build(final BuildContext context) => SettingsList(
|
||||
lightTheme: SettingsThemeData(
|
||||
settingsListBackground: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
darkTheme: SettingsThemeData(
|
||||
settingsListBackground: Theme.of(context).scaffoldBackgroundColor,
|
||||
titleTextColor: mugitenWheatBackground,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||
sections: _sections(context),
|
||||
);
|
||||
|
||||
List<SettingsSection> _sections(
|
||||
final BuildContext context,
|
||||
final ArchiveState archiveState,
|
||||
) => [
|
||||
List<SettingsSection> _sections(final BuildContext context) => [
|
||||
SettingsSection(
|
||||
title: const Text('Dictionary'),
|
||||
tiles: <SettingsTile>[
|
||||
@@ -266,41 +272,23 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
title: const Text('Data'),
|
||||
tiles: <SettingsTile>[
|
||||
SettingsTile(
|
||||
enabled: archiveState is IdleState,
|
||||
enabled: true,
|
||||
leading: const Icon(Icons.file_upload),
|
||||
title: const Text('Import Data'),
|
||||
description: const Text('Import user data from a file'),
|
||||
onPressed: importHandler,
|
||||
trailing: archiveState is ImportingState
|
||||
? CircularProgressIndicator(
|
||||
value: archiveState.total > 0
|
||||
? archiveState.progress / archiveState.total
|
||||
: null,
|
||||
)
|
||||
: null,
|
||||
value: archiveState is ImportingState
|
||||
? Text(archiveState.status)
|
||||
: null,
|
||||
value: dataImportIsLoading ? const LinearProgressIndicator() : null,
|
||||
),
|
||||
SettingsTile(
|
||||
enabled: archiveState is IdleState,
|
||||
enabled: true,
|
||||
leading: const Icon(Icons.file_download),
|
||||
title: const Text('Export Data'),
|
||||
description: const Text('Export user data to a file'),
|
||||
onPressed: exportHandler,
|
||||
trailing: archiveState is ExportingState
|
||||
? CircularProgressIndicator(
|
||||
value: archiveState.total > 0
|
||||
? archiveState.progress / archiveState.total
|
||||
: null,
|
||||
)
|
||||
: null,
|
||||
value: archiveState is ExportingState
|
||||
? Text(archiveState.status)
|
||||
: null,
|
||||
value: dataExportIsLoading ? const LinearProgressIndicator() : null,
|
||||
),
|
||||
SettingsTile(
|
||||
enabled: archiveState is IdleState,
|
||||
enabled: true,
|
||||
leading: const Icon(Icons.delete),
|
||||
title: const Text(
|
||||
'Clear History',
|
||||
@@ -336,7 +324,7 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
activeSwitchColor: mugitenWheatBackground,
|
||||
),
|
||||
SettingsTile(
|
||||
enabled: archiveState is IdleState,
|
||||
enabled: true,
|
||||
leading: const Icon(Icons.cached),
|
||||
title: const Text(
|
||||
'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:io';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
@@ -113,32 +112,6 @@ extension ArchiveFormatV2 on Directory {
|
||||
String slugifyLibraryListFileName(final String name) =>
|
||||
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 {
|
||||
final String type;
|
||||
final int progress;
|
||||
@@ -182,15 +155,6 @@ class ArchiveV2StreamEvent {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user