1 Commits

Author SHA1 Message Date
69016ad12e WIP: services/archive/v2: init 2026-04-15 00:22:44 +09:00
5 changed files with 57 additions and 233 deletions

View File

@@ -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,
);
},
);
}
}

View File

@@ -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,

View File

@@ -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',

View File

@@ -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,
});
}

View File

@@ -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(