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

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( Future<void> historyEntryInsertEntry(
final HistoryEntry entry, { final HistoryEntry entry, {
final bool assignNewId = false, final bool assignNewId = false,

View File

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

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