Move archival actions into a singleton controller

This commit is contained in:
2026-04-15 03:30:28 +09:00
parent 0f50750e24
commit 3b10ac1f06
4 changed files with 224 additions and 56 deletions

View File

@@ -2,9 +2,11 @@ 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';
@@ -38,6 +40,7 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
final themeController = ThemeController.create();
final archiveController = ArchiveController();
@override
void initState() {
@@ -68,16 +71,18 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
Widget build(final BuildContext context) {
return ValueListenableBuilder<AppThemeMode>(
valueListenable: themeController.themeMode,
builder: (final context, final themeMode, _) {
return MaterialApp(
title: '麦典',
theme: themeMode.lightThemeData,
darkTheme: themeMode.darkThemeData,
themeMode: themeMode.themeMode,
initialRoute: '/',
onGenerateRoute: generateRoute,
);
},
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,
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ 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';
@@ -11,6 +12,7 @@ import 'package:mugiten/main.dart';
import 'package:mugiten/models/history_entry.dart';
import 'package:mugiten/models/library_list.dart';
import 'package:mugiten/routing/routes.dart';
import 'package:mugiten/services/archive/archive_controller.dart';
import 'package:mugiten/services/archive/v1/format.dart';
import 'package:mugiten/services/snackbar.dart';
import 'package:mugiten/settings.dart';
@@ -26,10 +28,6 @@ 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,
@@ -110,24 +108,22 @@ class _SettingsViewState extends State<SettingsView> {
}
Future<void> exportHandler(final BuildContext context) async {
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 tmpfile = File(
Directory.systemTemp
.createTempSync('mugiten_data_')
.uri
.resolve('mugiten_data.zip')
.toFilePath(),
);
await BlocProvider.of<ArchiveController>(context).startExport(tmpfile);
final saveFile = await FilePicker.saveFile(
dialogTitle: 'Export data',
fileName: getExportFileNameNoSuffix(),
type: FileType.custom,
allowedExtensions: ['zip'],
bytes: zipfile.readAsBytesSync(),
bytes: tmpfile.readAsBytesSync(),
);
if (!context.mounted) return;
@@ -153,19 +149,11 @@ class _SettingsViewState extends State<SettingsView> {
final filepath = saveFile.files.first.path;
final db = GetIt.instance.get<Database>();
if (!context.mounted) return;
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);
}
await BlocProvider.of<ArchiveController>(
context,
).startImport(File(filepath!));
}
Future<int?> Function(BuildContext) _chooseFromList({
@@ -195,19 +183,25 @@ class _SettingsViewState extends State<SettingsView> {
);
@override
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),
);
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),
),
);
List<SettingsSection> _sections(final BuildContext context) => [
List<SettingsSection> _sections(
final BuildContext context,
final ArchiveState archiveState,
) => [
SettingsSection(
title: const Text('Dictionary'),
tiles: <SettingsTile>[
@@ -272,23 +266,41 @@ class _SettingsViewState extends State<SettingsView> {
title: const Text('Data'),
tiles: <SettingsTile>[
SettingsTile(
enabled: true,
enabled: archiveState is IdleState,
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() : null,
trailing: archiveState is ImportingState
? CircularProgressIndicator(
value: archiveState.total > 0
? archiveState.progress / archiveState.total
: null,
)
: null,
value: archiveState is ImportingState
? Text(archiveState.status)
: null,
),
SettingsTile(
enabled: true,
enabled: archiveState is IdleState,
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() : null,
trailing: archiveState is ExportingState
? CircularProgressIndicator(
value: archiveState.total > 0
? archiveState.progress / archiveState.total
: null,
)
: null,
value: archiveState is ExportingState
? Text(archiveState.status)
: null,
),
SettingsTile(
enabled: true,
enabled: archiveState is IdleState,
leading: const Icon(Icons.delete),
title: const Text(
'Clear History',
@@ -324,7 +336,7 @@ class _SettingsViewState extends State<SettingsView> {
activeSwitchColor: mugitenWheatBackground,
),
SettingsTile(
enabled: true,
enabled: archiveState is IdleState,
leading: const Icon(Icons.cached),
title: const Text(
'Reinitialize application',

View File

@@ -0,0 +1,124 @@
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,
});
}

View File

@@ -2,6 +2,7 @@ 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';
@@ -112,6 +113,32 @@ 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;