diff --git a/lib/core/presentation/pages/startup_page.dart b/lib/core/presentation/pages/startup_page.dart index f6b594cbc..862de0f76 100644 --- a/lib/core/presentation/pages/startup_page.dart +++ b/lib/core/presentation/pages/startup_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite3; import 'package:submersion/core/database/database.dart'; import 'package:submersion/core/database/database_version_exception.dart'; @@ -43,6 +44,9 @@ enum _StartupState { backingUp, migrating, backupFailed, + recoveryRequired, + recovering, + recoveryFailed, ready, error, } @@ -99,6 +103,7 @@ class _StartupWrapperState extends State int _dbVersion = 0; int _appVersion = 0; BackupFailedException? _backupError; + sqlite3.SqliteException? _readonlyError; /// Drives the dissolve of the splash layer over the mounted app beneath. /// Forward-only; starts when _state first reaches ready. @@ -199,6 +204,27 @@ class _StartupWrapperState extends State _appVersion = e.appVersion; }); } + } on sqlite3.SqliteException catch (e) { + if (DatabaseService.isRecoverableReadonlyError(e)) { + debugPrint( + 'Startup hit SQLITE_READONLY (code ${e.extendedResultCode}); ' + 'offering hot-journal recovery.', + ); + if (mounted) { + setState(() { + _state = _StartupState.recoveryRequired; + _readonlyError = e; + }); + } + } else { + debugPrint('FATAL: App initialization failed: $e'); + if (mounted) { + setState(() { + _state = _StartupState.error; + _errorMessage = '$e'; + }); + } + } } catch (e) { debugPrint('FATAL: App initialization failed: $e'); if (mounted) { @@ -210,6 +236,42 @@ class _StartupWrapperState extends State } } + /// Attempt to recover the database from a hot-journal-readonly error by + /// reopening in read-write mode (which forces SQLite to finish the + /// rollback), then retry initialization from the top. + Future _runRecovery() async { + if (!mounted) return; + setState(() { + _state = _StartupState.recovering; + // Clear any stale text from a prior failed attempt so the + // recoveryFailed UI reflects only the current reason. + _errorMessage = ''; + }); + try { + final dbPath = await widget.locationService.getDatabasePath(); + final recovered = DatabaseService.recoverHotJournal(dbPath); + if (!recovered) { + if (mounted) { + setState(() { + _state = _StartupState.recoveryFailed; + _errorMessage = + 'SQLite could not reopen the database to roll back the ' + 'interrupted transaction.'; + }); + } + return; + } + await _runInitialization(); + } catch (e) { + if (mounted) { + setState(() { + _state = _StartupState.recoveryFailed; + _errorMessage = '$e'; + }); + } + } + } + Future _initializeServices() async { void onProgress(int currentStep, int totalSteps) { if (mounted) { @@ -390,7 +452,10 @@ class _StartupWrapperState extends State debugShowCheckedModeBanner: false, home: (_state == _StartupState.error || - _state == _StartupState.backupFailed) + _state == _StartupState.backupFailed || + _state == _StartupState.recoveryRequired || + _state == _StartupState.recovering || + _state == _StartupState.recoveryFailed) ? Scaffold( key: const ValueKey('error'), backgroundColor: backgroundColor, @@ -497,6 +562,12 @@ class _StartupWrapperState extends State ); } + if (_state == _StartupState.recoveryRequired || + _state == _StartupState.recovering || + _state == _StartupState.recoveryFailed) { + return _buildRecoveryContent(textColor, subtitleColor); + } + if (_isVersionMismatch) { return Padding( padding: const EdgeInsets.all(24), @@ -561,7 +632,8 @@ class _StartupWrapperState extends State const SizedBox(height: 16), Text( 'Try restarting the app. If this persists, ' - 'reinstall or contact support.', + 'contact support — your data is still on disk and does not ' + 'require a reinstall.', style: TextStyle(fontSize: 14, color: subtitleColor), textAlign: TextAlign.center, ), @@ -571,4 +643,147 @@ class _StartupWrapperState extends State ), ); } + + Widget _buildRecoveryContent(Color textColor, Color subtitleColor) { + if (_state == _StartupState.recovering) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 64, + height: 64, + child: CircularProgressIndicator(), + ), + const SizedBox(height: 24), + Text( + 'Recovering database...', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: textColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Rolling back the interrupted transaction. This usually ' + 'takes a few seconds.', + style: TextStyle(fontSize: 14, color: subtitleColor), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + if (_state == _StartupState.recoveryFailed) { + final details = _errorMessage.isNotEmpty + ? _errorMessage + : (_readonlyError?.toString() ?? ''); + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 64, color: Colors.orange), + const SizedBox(height: 24), + Text( + 'Recovery did not complete', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: textColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'The database could not be rolled back automatically. Your ' + 'data is still on disk; contact support before reinstalling ' + 'so we can help you recover it.', + style: TextStyle(fontSize: 14, color: subtitleColor), + textAlign: TextAlign.center, + ), + if (details.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + details, + style: TextStyle( + fontSize: 12, + color: subtitleColor, + fontFamily: 'monospace', + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 24), + Wrap( + spacing: 12, + children: [ + OutlinedButton( + onPressed: _runRecovery, + child: const Text('Try again'), + ), + FilledButton(onPressed: _closeApp, child: const Text('Close')), + ], + ), + ], + ), + ); + } + + // recoveryRequired + final code = _readonlyError?.extendedResultCode; + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.build_circle_outlined, size: 64, color: Colors.blue), + const SizedBox(height: 24), + Text( + 'Database needs recovery', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: textColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'A previous session was interrupted while writing to the ' + 'database. Your data is still on disk; we just need to finish ' + 'rolling back the cancelled change before the app can open.', + style: TextStyle(fontSize: 14, color: subtitleColor), + textAlign: TextAlign.center, + ), + if (code != null) ...[ + const SizedBox(height: 12), + Text( + 'SQLite code $code', + style: TextStyle( + fontSize: 12, + color: subtitleColor, + fontFamily: 'monospace', + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: _runRecovery, + child: const Text('Recover database'), + ), + const SizedBox(height: 8), + TextButton( + onPressed: _closeApp, + child: const Text('Close without recovering'), + ), + ], + ), + ); + } } diff --git a/lib/core/services/database_service.dart b/lib/core/services/database_service.dart index 2f563f334..109b08608 100644 --- a/lib/core/services/database_service.dart +++ b/lib/core/services/database_service.dart @@ -196,11 +196,16 @@ class DatabaseService { /// Reads the stored schema version from a database file without opening it /// through Drift. Returns null if the file does not exist, or the integer /// PRAGMA user_version value otherwise. + /// + /// Opens in read-write mode (not read-only) so SQLite can automatically + /// roll back any hot journal left behind by a previous crash. A read-only + /// open on a db with a pending rollback throws SQLITE_READONLY_ROLLBACK + /// (extended code 776) before even the first PRAGMA can execute. static int? getStoredSchemaVersion(String dbPath) { final file = File(dbPath); if (!file.existsSync()) return null; - final db = sqlite3.sqlite3.open(dbPath, mode: sqlite3.OpenMode.readOnly); + final db = sqlite3.sqlite3.open(dbPath, mode: sqlite3.OpenMode.readWrite); try { final result = db.select('PRAGMA user_version'); if (result.isEmpty) return null; @@ -210,6 +215,38 @@ class DatabaseService { } } + /// Force SQLite to complete any pending hot-journal rollback on [dbPath]. + /// + /// Opens the file in read-write mode — the very act of opening triggers + /// SQLite's automatic recovery of a hot journal. Returns true if the file + /// opened cleanly (recovery either wasn't needed or succeeded), false if + /// the journal could not be rolled back (file still read-only, on a + /// read-only volume, etc.). + /// + /// Safe to call on a file without a hot journal: it simply no-ops. + static bool recoverHotJournal(String dbPath) { + final file = File(dbPath); + if (!file.existsSync()) return true; + try { + final db = sqlite3.sqlite3.open(dbPath, mode: sqlite3.OpenMode.readWrite); + try { + db.select('PRAGMA user_version'); + } finally { + db.dispose(); + } + return true; + } on sqlite3.SqliteException { + return false; + } + } + + /// True if [error] is a [sqlite3.SqliteException] in the SQLITE_READONLY + /// family (primary result code 8) — typically SQLITE_READONLY_ROLLBACK + /// (776) after a cancelled transaction left a hot journal behind. + static bool isRecoverableReadonlyError(Object error) { + return error is sqlite3.SqliteException && error.resultCode == 8; + } + /// Resolve the database path using location service or default Future _resolveDatabasePath() async { if (_locationService != null) { diff --git a/lib/features/dive_import/data/services/uddf_entity_importer.dart b/lib/features/dive_import/data/services/uddf_entity_importer.dart index ac6eecadd..02142a360 100644 --- a/lib/features/dive_import/data/services/uddf_entity_importer.dart +++ b/lib/features/dive_import/data/services/uddf_entity_importer.dart @@ -28,6 +28,7 @@ import 'package:submersion/features/equipment/data/repositories/equipment_reposi import 'package:submersion/features/equipment/data/repositories/equipment_set_repository_impl.dart'; import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; import 'package:submersion/features/equipment/domain/entities/equipment_set.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/tags/data/repositories/tag_repository.dart'; import 'package:submersion/features/tags/domain/entities/tag.dart'; @@ -216,6 +217,10 @@ class UddfEntityImporter { /// /// Only entities at indices present in [selections] are imported. /// Reports progress via [onProgress] callback. + /// + /// If [cancelToken] is non-null, the dive-import loop polls + /// [ImportCancellationToken.isCancelled] between each dive and returns the + /// partial result already persisted when cancellation is observed. Future import({ required UddfImportResult data, required UddfImportSelections selections, @@ -223,6 +228,7 @@ class UddfEntityImporter { required String diverId, bool retainSourceDiveNumbers = false, ImportProgressCallback? onProgress, + ImportCancellationToken? cancelToken, }) async { final now = DateTime.now(); @@ -350,6 +356,7 @@ class UddfEntityImporter { retainSourceDiveNumbers: retainSourceDiveNumbers, now: now, onProgress: onProgress, + cancelToken: cancelToken, ); return UddfEntityImportResult( @@ -943,6 +950,7 @@ class UddfEntityImporter { bool retainSourceDiveNumbers = false, required DateTime now, ImportProgressCallback? onProgress, + ImportCancellationToken? cancelToken, }) async { if (selected.isEmpty) return const _DiveImportResult(0, 0); onProgress?.call(ImportPhase.dives, 0, selected.length); @@ -965,6 +973,8 @@ class UddfEntityImporter { : await repos.diveRepository.getNextDiveNumber(diverId: diverId); for (final i in sortedSelected) { + if (cancelToken?.isCancelled ?? false) break; + final diveData = items[i]; // Build profile (include setpoint/ppO2 sensor readings) diff --git a/lib/features/import_wizard/data/adapters/dive_computer_adapter.dart b/lib/features/import_wizard/data/adapters/dive_computer_adapter.dart index f01f7406b..f7c184d66 100644 --- a/lib/features/import_wizard/data/adapters/dive_computer_adapter.dart +++ b/lib/features/import_wizard/data/adapters/dive_computer_adapter.dart @@ -22,6 +22,7 @@ import 'package:submersion/features/dive_log/data/repositories/dive_repository_i import 'package:submersion/features/dive_log/domain/entities/dive_computer.dart'; import 'package:submersion/features/import_wizard/domain/adapters/import_source_adapter.dart'; import 'package:submersion/features/import_wizard/domain/models/duplicate_action.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; @@ -365,6 +366,7 @@ class DiveComputerAdapter implements ImportSourceAdapter { Map> duplicateActions, { bool retainSourceDiveNumbers = false, ImportProgressCallback? onProgress, + ImportCancellationToken? cancelToken, }) async { final comp = computer; if (comp == null) { @@ -431,10 +433,12 @@ class DiveComputerAdapter implements ImportSourceAdapter { var imported = 0; var consolidated = 0; var updated = 0; - final importedDives = []; + final processedDives = []; final importedDiveIds = []; for (var i = 0; i < allIndices.length; i++) { + if (cancelToken?.isCancelled ?? false) break; + final index = allIndices[i]; if (index >= _downloadedDives.length) continue; @@ -489,16 +493,25 @@ class DiveComputerAdapter implements ImportSourceAdapter { libdivecomputerVersion: _libdivecomputerVersion, ); imported++; - importedDives.add(dive); importedDiveIds.add(diveId); } + processedDives.add(dive); onProgress?.call(ImportPhase.dives, i + 1, total); } - // Update computer metadata. Use ALL downloaded dives for the fingerprint - // so skipped/consolidated dives aren't re-downloaded next session. - await _updateComputerAfterImport(comp, imported, _downloadedDives); + // Update computer metadata. + // + // Normal completion uses ALL downloaded dives so skipped/consolidated + // dives aren't re-downloaded next session. On cancellation we only advance + // the fingerprint for the dives we actually processed, so the user can + // re-import the remainder next time. + final wasCancelled = cancelToken?.isCancelled ?? false; + await _updateComputerAfterImport( + comp, + imported, + wasCancelled ? processedDives : _downloadedDives, + ); return UnifiedImportResult( importedCounts: {ImportEntityType.dives: imported}, diff --git a/lib/features/import_wizard/data/adapters/healthkit_adapter.dart b/lib/features/import_wizard/data/adapters/healthkit_adapter.dart index b68aff3be..e99722e98 100644 --- a/lib/features/import_wizard/data/adapters/healthkit_adapter.dart +++ b/lib/features/import_wizard/data/adapters/healthkit_adapter.dart @@ -12,6 +12,7 @@ import 'package:submersion/features/dive_log/data/repositories/dive_repository_i import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/import_wizard/domain/adapters/import_source_adapter.dart'; import 'package:submersion/features/import_wizard/domain/models/duplicate_action.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; @@ -218,6 +219,7 @@ class HealthKitAdapter implements ImportSourceAdapter { Map> duplicateActions, { bool retainSourceDiveNumbers = false, ImportProgressCallback? onProgress, + ImportCancellationToken? cancelToken, }) async { final baseSelections = Set.from( selections[ImportEntityType.dives] ?? {}, @@ -256,6 +258,8 @@ class HealthKitAdapter implements ImportSourceAdapter { final importedDiveIds = []; for (var i = 0; i < sortedIndices.length; i++) { + if (cancelToken?.isCancelled ?? false) break; + final index = sortedIndices[i]; if (index >= _parsedDives.length) continue; diff --git a/lib/features/import_wizard/data/adapters/universal_adapter.dart b/lib/features/import_wizard/data/adapters/universal_adapter.dart index 4fe9c6729..8e827108a 100644 --- a/lib/features/import_wizard/data/adapters/universal_adapter.dart +++ b/lib/features/import_wizard/data/adapters/universal_adapter.dart @@ -20,6 +20,7 @@ import 'package:submersion/features/equipment/presentation/providers/equipment_p import 'package:submersion/features/equipment/presentation/providers/equipment_set_providers.dart'; import 'package:submersion/features/import_wizard/domain/adapters/import_source_adapter.dart'; import 'package:submersion/features/import_wizard/domain/models/duplicate_action.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/entity_match_result.dart'; // Import wizard bundle types: hide ImportEntityType to avoid name clash with @@ -379,6 +380,7 @@ class UniversalAdapter implements ImportSourceAdapter { Map> duplicateActions, { bool retainSourceDiveNumbers = false, ImportProgressCallback? onProgress, + ImportCancellationToken? cancelToken, }) async { final notifierState = _ref.read(universalImportNotifierProvider); final payload = notifierState.payload; @@ -459,6 +461,7 @@ class UniversalAdapter implements ImportSourceAdapter { diverId: currentDiver.id, retainSourceDiveNumbers: retainSourceDiveNumbers, onProgress: onProgress, + cancelToken: cancelToken, ); return UnifiedImportResult( diff --git a/lib/features/import_wizard/domain/adapters/import_source_adapter.dart b/lib/features/import_wizard/domain/adapters/import_source_adapter.dart index 961a746db..63f8a37e9 100644 --- a/lib/features/import_wizard/domain/adapters/import_source_adapter.dart +++ b/lib/features/import_wizard/domain/adapters/import_source_adapter.dart @@ -1,5 +1,6 @@ import 'package:submersion/features/import_wizard/domain/models/duplicate_action.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; @@ -67,11 +68,16 @@ abstract class ImportSourceAdapter { /// The [selections] map contains which items the user selected per entity /// type. The [duplicateActions] map contains the user's chosen action for /// each duplicate item per entity type. + /// + /// If [cancelToken] is non-null, implementations should poll + /// [ImportCancellationToken.isCancelled] between work items and return a + /// partial result rather than throw when cancellation is observed. Future performImport( ImportBundle bundle, Map> selections, Map> duplicateActions, { bool retainSourceDiveNumbers, ImportProgressCallback? onProgress, + ImportCancellationToken? cancelToken, }); } diff --git a/lib/features/import_wizard/domain/models/import_cancellation_token.dart b/lib/features/import_wizard/domain/models/import_cancellation_token.dart new file mode 100644 index 000000000..8832b1561 --- /dev/null +++ b/lib/features/import_wizard/domain/models/import_cancellation_token.dart @@ -0,0 +1,25 @@ +/// A cooperative cancellation signal passed through an import pipeline. +/// +/// Long-running import adapters check [isCancelled] between work items (for +/// example, between dives in a batched download) and return a partial result +/// when true. Cancellation is cooperative, not preemptive: the in-flight +/// transaction finishes cleanly before the loop exits. +/// +/// Cooperative semantics matter because SQLite cannot recover from a +/// transaction that gets killed mid-write without a subsequent read-write +/// open to roll back the hot journal — the exact failure mode that blocks +/// app startup with SQLITE_READONLY_ROLLBACK (extended code 776). +class ImportCancellationToken { + bool _cancelled = false; + + /// True once [cancel] has been called. Adapters should check this between + /// work items and break out of the loop cleanly when true, returning a + /// partial result for whatever was already committed. + bool get isCancelled => _cancelled; + + /// Signal cancellation. Idempotent — calling twice has no additional + /// effect. + void cancel() { + _cancelled = true; + } +} diff --git a/lib/features/import_wizard/presentation/pages/unified_import_wizard.dart b/lib/features/import_wizard/presentation/pages/unified_import_wizard.dart index a88e72fac..c0dd5720f 100644 --- a/lib/features/import_wizard/presentation/pages/unified_import_wizard.dart +++ b/lib/features/import_wizard/presentation/pages/unified_import_wizard.dart @@ -32,30 +32,57 @@ import 'package:submersion/features/import_wizard/presentation/widgets/wizard_st /// Accepts an [ImportSourceAdapter] and orchestrates the full import flow: /// acquisition steps (source-specific), review, import progress, and summary. class UnifiedImportWizard extends StatelessWidget { - const UnifiedImportWizard({super.key, required this.adapter}); + const UnifiedImportWizard({ + super.key, + required this.adapter, + this.initialPageOverride, + this.notifierFactoryOverride, + }); final ImportSourceAdapter adapter; + /// Optional starting page for widget tests that need to exercise behavior + /// on pages past the acquisition/review flow (e.g. the cancel dialog on + /// the import-progress page) without driving the full adapter through + /// [ImportSourceAdapter.buildBundle] and [performImport]. + @visibleForTesting + final int? initialPageOverride; + + /// Optional notifier factory for tests that need to inject a pre-configured + /// [ImportWizardNotifier] (e.g. one whose state already has + /// `isCancellationRequested: true` so the "already cancelling" dialog + /// branch can be exercised). + @visibleForTesting + final ImportWizardNotifier Function(Ref ref)? notifierFactoryOverride; + @override Widget build(BuildContext context) { return ProviderScope( overrides: [ importWizardNotifierProvider.overrideWith( - (ref) => ImportWizardNotifier( - adapter, - tagRepository: ref.read(tagRepositoryProvider), - ), + notifierFactoryOverride ?? + (ref) => ImportWizardNotifier( + adapter, + tagRepository: ref.read(tagRepositoryProvider), + ), ), ], - child: _UnifiedImportWizardBody(adapter: adapter), + child: _UnifiedImportWizardBody( + adapter: adapter, + initialPageOverride: initialPageOverride, + ), ); } } class _UnifiedImportWizardBody extends ConsumerStatefulWidget { - const _UnifiedImportWizardBody({required this.adapter}); + const _UnifiedImportWizardBody({ + required this.adapter, + this.initialPageOverride, + }); final ImportSourceAdapter adapter; + final int? initialPageOverride; @override ConsumerState<_UnifiedImportWizardBody> createState() => @@ -110,7 +137,16 @@ class _UnifiedImportWizardBodyState widget.adapter.resetState(); } WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _resetComplete = true); + if (!mounted) return; + setState(() { + _resetComplete = true; + if (widget.initialPageOverride != null) { + _currentPage = widget.initialPageOverride!; + } + }); + if (widget.initialPageOverride != null && _pageController.hasClients) { + _pageController.jumpToPage(widget.initialPageOverride!); + } }); }); } @@ -276,19 +312,54 @@ class _UnifiedImportWizardBodyState } if (_currentPage >= _importIndex) { - await showDialog( + final notifier = ref.read(importWizardNotifierProvider.notifier); + final state = ref.read(importWizardNotifierProvider); + + // Already cancelling — show a waiting notice. + if (state.isCancellationRequested) { + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Cancelling'), + content: const Text( + 'Finishing the current dive before stopping. ' + 'Already-imported dives are kept.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); + return; + } + + final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( - title: const Text('Import in progress'), - content: const Text('Import is in progress and cannot be cancelled.'), + title: const Text('Cancel import?'), + content: const Text( + 'Stop after the current dive finishes. ' + 'Already-imported dives will be kept.', + ), actions: [ TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('OK'), + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Keep importing'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Cancel import'), ), ], ), ); + + if (confirmed == true) { + notifier.cancelImport(); + } return; } diff --git a/lib/features/import_wizard/presentation/providers/import_wizard_providers.dart b/lib/features/import_wizard/presentation/providers/import_wizard_providers.dart index 0c51f5329..92518f48a 100644 --- a/lib/features/import_wizard/presentation/providers/import_wizard_providers.dart +++ b/lib/features/import_wizard/presentation/providers/import_wizard_providers.dart @@ -4,6 +4,7 @@ import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/features/import_wizard/domain/adapters/import_source_adapter.dart'; import 'package:submersion/features/import_wizard/domain/models/duplicate_action.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/tag_selection.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; @@ -39,6 +40,7 @@ class ImportWizardState { this.importTotal = 0, this.importResult, this.isImporting = false, + this.isCancellationRequested = false, this.error, }); @@ -85,6 +87,12 @@ class ImportWizardState { /// True while the adapter's [performImport] is running. final bool isImporting; + /// True once the user has requested cancellation of the running import. + /// The adapter is notified via its cancellation token and returns a partial + /// result; this flag lets the UI render a "Cancelling..." state until that + /// return happens. + final bool isCancellationRequested; + /// Non-null when an error has occurred. final String? error; @@ -104,6 +112,7 @@ class ImportWizardState { UnifiedImportResult? importResult, bool clearImportResult = false, bool? isImporting, + bool? isCancellationRequested, String? error, bool clearError = false, }) { @@ -124,6 +133,8 @@ class ImportWizardState { ? null : (importResult ?? this.importResult), isImporting: isImporting ?? this.isImporting, + isCancellationRequested: + isCancellationRequested ?? this.isCancellationRequested, error: clearError ? null : (error ?? this.error), ); } @@ -165,11 +176,29 @@ class ImportWizardNotifier extends StateNotifier { final TagRepository? _tagRepository; String? _diverId; + /// Active cancellation token for the currently-running import, or null + /// when no import is in progress. The notifier owns the lifecycle: it's + /// allocated fresh in [performImport] and cleared once the adapter returns. + ImportCancellationToken? _cancelToken; + /// Set the validated diver ID for tag association during import. void setDiverId(String? diverId) { _diverId = diverId; } + /// Request cancellation of the running import. Cooperative — the adapter + /// finishes the current work item (e.g. the current dive's transaction) + /// before exiting the loop with a partial result. + /// + /// Safe to call when no import is in progress: it just no-ops. + /// Safe to call repeatedly: the token's own `cancel()` is idempotent. + void cancelImport() { + final token = _cancelToken; + if (token == null || token.isCancelled) return; + token.cancel(); + state = state.copyWith(isCancellationRequested: true); + } + /// The duplicate actions supported by the underlying adapter. Set get supportedDuplicateActions => _adapter.supportedDuplicateActions; @@ -478,7 +507,14 @@ class ImportWizardNotifier extends StateNotifier { return; } - state = state.copyWith(isImporting: true, clearError: true); + final token = ImportCancellationToken(); + _cancelToken = token; + + state = state.copyWith( + isImporting: true, + isCancellationRequested: false, + clearError: true, + ); try { final result = await _adapter.performImport( @@ -493,13 +529,17 @@ class ImportWizardNotifier extends StateNotifier { importTotal: total, ); }, + cancelToken: token, ); // Apply import tags to all imported dives. // Tag application is non-fatal: dives are already imported, so we // keep the result and advance to summary even if tagging fails. + // Skip it entirely if cancellation was requested — the user asked + // us to stop, not to do one more round of DB work. String? tagWarning; - if (state.importTags.isNotEmpty && + if (!token.isCancelled && + state.importTags.isNotEmpty && result.importedDiveIds.isNotEmpty && _tagRepository != null) { try { @@ -527,6 +567,11 @@ class ImportWizardNotifier extends StateNotifier { errorMessage: 'Import failed: $e', ), ); + } finally { + _cancelToken = null; + if (state.isCancellationRequested) { + state = state.copyWith(isCancellationRequested: false); + } } } diff --git a/lib/features/import_wizard/presentation/widgets/import_progress_step.dart b/lib/features/import_wizard/presentation/widgets/import_progress_step.dart index 5ce71ed54..71413177e 100644 --- a/lib/features/import_wizard/presentation/widgets/import_progress_step.dart +++ b/lib/features/import_wizard/presentation/widgets/import_progress_step.dart @@ -16,13 +16,17 @@ class ImportProgressStep extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(importWizardNotifierProvider); final theme = Theme.of(context); + final l10n = context.l10n; final phase = state.importPhase; final current = state.importCurrent; final total = state.importTotal; + final isCancelling = state.isCancellationRequested; final fraction = (total > 0) ? current / total : null; - final phaseText = _resolvePhaseText(context, phase); + final phaseText = isCancelling + ? l10n.settings_import_cancelling + : _resolvePhaseText(context, phase); return Center( child: Padding( @@ -71,6 +75,21 @@ class ImportProgressStep extends ConsumerWidget { key: const Key('import_progress_linear'), value: fraction, ), + const SizedBox(height: 32), + TextButton.icon( + key: const Key('import_progress_cancel_button'), + onPressed: isCancelling + ? null + : () => ref + .read(importWizardNotifierProvider.notifier) + .cancelImport(), + icon: const Icon(Icons.cancel_outlined), + label: Text( + isCancelling + ? l10n.settings_import_cancelling + : l10n.settings_import_cancelButton, + ), + ), ], ), ), diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index db9bcbac4..4283857ba 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -3262,6 +3262,8 @@ "settings_gfPreset_low_name": "منخفض", "settings_gfPreset_medium_description": "نهج متوازن", "settings_gfPreset_medium_name": "متوسط", + "settings_import_cancelButton": "إلغاء الاستيراد", + "settings_import_cancelling": "جارٍ الإلغاء...", "settings_import_dialog_title": "جارٍ استيراد البيانات", "settings_import_doNotClose": "يرجى عدم إغلاق التطبيق", "settings_import_itemCount": "{current} من {total}", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index cf7cd1609..ce3ca2485 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3262,6 +3262,8 @@ "settings_gfPreset_low_name": "Niedrig", "settings_gfPreset_medium_description": "Ausgewogener Ansatz", "settings_gfPreset_medium_name": "Mittel", + "settings_import_cancelButton": "Import abbrechen", + "settings_import_cancelling": "Wird abgebrochen...", "settings_import_dialog_title": "Daten werden importiert", "settings_import_doNotClose": "Bitte schliessen Sie die App nicht", "settings_import_itemCount": "{current} von {total}", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 6f29e441a..d5affccaa 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5581,6 +5581,8 @@ "settings_gfPreset_low_name": "Low", "settings_gfPreset_medium_description": "Balanced approach", "settings_gfPreset_medium_name": "Medium", + "settings_import_cancelButton": "Cancel import", + "settings_import_cancelling": "Cancelling...", "settings_import_dialog_title": "Importing Data", "settings_import_doNotClose": "Please do not close the app", "settings_import_itemCount": "{current} of {total}", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 40f3be7d3..d0dc4afd4 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -3262,6 +3262,8 @@ "settings_gfPreset_low_name": "Bajo", "settings_gfPreset_medium_description": "Enfoque equilibrado", "settings_gfPreset_medium_name": "Medio", + "settings_import_cancelButton": "Cancelar importacion", + "settings_import_cancelling": "Cancelando...", "settings_import_dialog_title": "Importando datos", "settings_import_doNotClose": "Por favor, no cierres la aplicacion", "settings_import_itemCount": "{current} de {total}", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 29b65a4df..bb41aae2d 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3228,6 +3228,8 @@ "settings_gfPreset_low_name": "Faible", "settings_gfPreset_medium_description": "Approche equilibree", "settings_gfPreset_medium_name": "Moyen", + "settings_import_cancelButton": "Annuler l'importation", + "settings_import_cancelling": "Annulation...", "settings_import_dialog_title": "Importation des donnees", "settings_import_doNotClose": "Veuillez ne pas fermer l'application", "settings_import_itemCount": "{current} sur {total}", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index abd74459c..009e37a7f 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -3262,6 +3262,8 @@ "settings_gfPreset_low_name": "נמוך", "settings_gfPreset_medium_description": "גישה מאוזנת", "settings_gfPreset_medium_name": "בינוני", + "settings_import_cancelButton": "ביטול ייבוא", + "settings_import_cancelling": "מבטל...", "settings_import_dialog_title": "ייבוא נתונים", "settings_import_doNotClose": "נא לא לסגור את האפליקציה", "settings_import_itemCount": "{current} מתוך {total}", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index a93ba80e7..198effd66 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -3228,6 +3228,8 @@ "settings_gfPreset_low_name": "Alacsony", "settings_gfPreset_medium_description": "Kiegyensulyozott megközelites", "settings_gfPreset_medium_name": "Közepes", + "settings_import_cancelButton": "Importalas megszakitasa", + "settings_import_cancelling": "Megszakitas...", "settings_import_dialog_title": "Adatok importalasa", "settings_import_doNotClose": "Kerem, ne zarja be az alkalmazast", "settings_import_itemCount": "{current} / {total}", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 640583ceb..2ce37acbe 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -3224,6 +3224,8 @@ "settings_gfPreset_low_name": "Basso", "settings_gfPreset_medium_description": "Approccio bilanciato", "settings_gfPreset_medium_name": "Medio", + "settings_import_cancelButton": "Annulla importazione", + "settings_import_cancelling": "Annullamento...", "settings_import_dialog_title": "Importazione dati", "settings_import_doNotClose": "Non chiudere l'app", "settings_import_itemCount": "{current} di {total}", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index ff2609761..839c2bf0a 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -18077,6 +18077,18 @@ abstract class AppLocalizations { /// **'Medium'** String get settings_gfPreset_medium_name; + /// No description provided for @settings_import_cancelButton. + /// + /// In en, this message translates to: + /// **'Cancel import'** + String get settings_import_cancelButton; + + /// No description provided for @settings_import_cancelling. + /// + /// In en, this message translates to: + /// **'Cancelling...'** + String get settings_import_cancelling; + /// No description provided for @settings_import_dialog_title. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index f4d1980be..f3cbca676 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10391,6 +10391,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String get settings_gfPreset_medium_name => 'متوسط'; + @override + String get settings_import_cancelButton => 'إلغاء الاستيراد'; + + @override + String get settings_import_cancelling => 'جارٍ الإلغاء...'; + @override String get settings_import_dialog_title => 'جارٍ استيراد البيانات'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index f65cb971b..938de7366 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10594,6 +10594,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_gfPreset_medium_name => 'Mittel'; + @override + String get settings_import_cancelButton => 'Import abbrechen'; + + @override + String get settings_import_cancelling => 'Wird abgebrochen...'; + @override String get settings_import_dialog_title => 'Daten werden importiert'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index faa7a6b46..5434cd224 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10421,6 +10421,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_gfPreset_medium_name => 'Medium'; + @override + String get settings_import_cancelButton => 'Cancel import'; + + @override + String get settings_import_cancelling => 'Cancelling...'; + @override String get settings_import_dialog_title => 'Importing Data'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 71e017bcc..2ffd44036 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10584,6 +10584,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_gfPreset_medium_name => 'Medio'; + @override + String get settings_import_cancelButton => 'Cancelar importacion'; + + @override + String get settings_import_cancelling => 'Cancelando...'; + @override String get settings_import_dialog_title => 'Importando datos'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 5dcdb7fe1..c33cfffea 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10634,6 +10634,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_gfPreset_medium_name => 'Moyen'; + @override + String get settings_import_cancelButton => 'Annuler l\'importation'; + + @override + String get settings_import_cancelling => 'Annulation...'; + @override String get settings_import_dialog_title => 'Importation des donnees'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 5ded72526..7d8a36bf4 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10314,6 +10314,12 @@ class AppLocalizationsHe extends AppLocalizations { @override String get settings_gfPreset_medium_name => 'בינוני'; + @override + String get settings_import_cancelButton => 'ביטול ייבוא'; + + @override + String get settings_import_cancelling => 'מבטל...'; + @override String get settings_import_dialog_title => 'ייבוא נתונים'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index dfb1cff09..9a49877bf 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10566,6 +10566,12 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_gfPreset_medium_name => 'Közepes'; + @override + String get settings_import_cancelButton => 'Importalas megszakitasa'; + + @override + String get settings_import_cancelling => 'Megszakitas...'; + @override String get settings_import_dialog_title => 'Adatok importalasa'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 7daadc4e4..ed27a9892 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10597,6 +10597,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_gfPreset_medium_name => 'Medio'; + @override + String get settings_import_cancelButton => 'Annulla importazione'; + + @override + String get settings_import_cancelling => 'Annullamento...'; + @override String get settings_import_dialog_title => 'Importazione dati'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 73d00e36a..6a9459a50 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10513,6 +10513,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_gfPreset_medium_name => 'Middel'; + @override + String get settings_import_cancelButton => 'Import annuleren'; + + @override + String get settings_import_cancelling => 'Bezig met annuleren...'; + @override String get settings_import_dialog_title => 'Gegevens importeren'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 2cf876c52..5f9aa6398 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10598,6 +10598,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_gfPreset_medium_name => 'Medio'; + @override + String get settings_import_cancelButton => 'Cancelar importacao'; + + @override + String get settings_import_cancelling => 'Cancelando...'; + @override String get settings_import_dialog_title => 'Importando Dados'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index b4a582830..64af7a495 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -10098,6 +10098,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_gfPreset_medium_name => '中等'; + @override + String get settings_import_cancelButton => '取消导入'; + + @override + String get settings_import_cancelling => '正在取消...'; + @override String get settings_import_dialog_title => '正在导入数据'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 90c56037f..adabd6014 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3262,6 +3262,8 @@ "settings_gfPreset_low_name": "Laag", "settings_gfPreset_medium_description": "Gebalanceerde aanpak", "settings_gfPreset_medium_name": "Middel", + "settings_import_cancelButton": "Import annuleren", + "settings_import_cancelling": "Bezig met annuleren...", "settings_import_dialog_title": "Gegevens importeren", "settings_import_doNotClose": "Sluit de app niet", "settings_import_itemCount": "{current} van {total}", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index b4fa6767a..3f841e065 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -3262,6 +3262,8 @@ "settings_gfPreset_low_name": "Baixo", "settings_gfPreset_medium_description": "Abordagem equilibrada", "settings_gfPreset_medium_name": "Medio", + "settings_import_cancelButton": "Cancelar importacao", + "settings_import_cancelling": "Cancelando...", "settings_import_dialog_title": "Importando Dados", "settings_import_doNotClose": "Por favor, nao feche o aplicativo", "settings_import_itemCount": "{current} de {total}", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 8a82d4a17..50af578ad 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -3428,6 +3428,8 @@ "settings_gfPreset_low_name": "低", "settings_gfPreset_medium_description": "平衡方案", "settings_gfPreset_medium_name": "中等", + "settings_import_cancelButton": "取消导入", + "settings_import_cancelling": "正在取消...", "settings_import_dialog_title": "正在导入数据", "settings_import_doNotClose": "请不要关闭应用", "settings_import_itemCount": "{current}/{total}", diff --git a/test/core/presentation/pages/startup_page_test.dart b/test/core/presentation/pages/startup_page_test.dart index 5c38353bd..37a482790 100644 --- a/test/core/presentation/pages/startup_page_test.dart +++ b/test/core/presentation/pages/startup_page_test.dart @@ -1,8 +1,11 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite3; import 'package:submersion/core/database/database_version_exception.dart'; import 'package:submersion/core/domain/entities/migration_progress.dart'; @@ -26,6 +29,43 @@ class _FakeLocationService extends DatabaseLocationService { Future getDatabasePath() async => _path; } +/// A fake [DatabaseLocationService] that returns a caller-provided path. +/// Used by recovery tests so each test has its own isolated db file path — +/// necessary because the recovery flow actually invokes sqlite3 against the +/// path (unlike most lifecycle tests where the path is never touched). +class _CustomPathLocationService extends DatabaseLocationService { + final String _path; + _CustomPathLocationService(super.prefs, this._path); + + @override + Future getDatabasePath() async => _path; +} + +/// A fake [DatabaseLocationService] that succeeds for the first [failAfter] +/// calls, then throws on every call thereafter. Used to drive the +/// `_runRecovery` catch block (which only fires when a non-SqliteException +/// escapes) without corrupting a real SQLite file. +class _FlakyLocationService extends DatabaseLocationService { + final String path; + final int failAfter; + int calls = 0; + + _FlakyLocationService( + super.prefs, { + required this.path, + required this.failAfter, + }); + + @override + Future getDatabasePath() async { + calls++; + if (calls > failAfter) { + throw StateError('simulated location failure'); + } + return path; + } +} + /// A synchronous no-op subclass of [PreMigrationBackupService] for tests that /// exercise the migration path without wanting real file I/O. class _NoOpBackupService extends PreMigrationBackupService { @@ -1170,6 +1210,394 @@ void main() { expect(quitCalled, 1); }); }); + + // --------------------------------------------------------------------------- + // Hot-journal recovery flow + // + // Triggered when initialization raises a SqliteException in the + // SQLITE_READONLY family (primary code 8) — typically code 776 + // (SQLITE_READONLY_ROLLBACK) left behind by a crashed or cancelled + // transaction. Rather than parroting "reinstall or contact support", the + // startup screen should offer a one-tap recovery path that reopens the + // database RW so SQLite can finish the rollback. + // --------------------------------------------------------------------------- + group('recovery flow', () { + late SharedPreferences prefs; + late LogFileService logFileService; + late DatabaseLocationService locationService; + late Directory tempDir; + void Function(FlutterErrorDetails)? originalOnError; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + logFileService = LogFileService(logDirectory: '/tmp/test-logs'); + tempDir = Directory.systemTemp.createTempSync('startup_recovery_test_'); + locationService = _CustomPathLocationService( + prefs, + p.join(tempDir.path, 'test.db'), + ); + // Suppress splash Image.asset decode noise (same as the lifecycle group). + originalOnError = FlutterError.onError; + FlutterError.onError = (details) { + final message = details.toString(); + if (message.contains('IMAGE RESOURCE SERVICE') || + message.contains('resolving an image') || + message.contains('Message corrupted')) { + return; + } + originalOnError?.call(details); + }; + }); + + tearDown(() { + FlutterError.onError = originalOnError; + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + testWidgets( + 'SqliteException code 776 routes to recovery UI, not generic error', + (tester) async { + await tester.pumpWidget( + _buildStartupWrapper( + prefs: prefs, + logFileService: logFileService, + locationService: locationService, + schemaVersionProbeOverride: (_) => + (needsMigration: false, totalSteps: 0), + initializerOverride: (_) async { + throw sqlite3.SqliteException( + 776, + 'attempt to write a readonly database', + ); + }, + ), + ); + + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + + // Recovery UI, not "Database upgrade failed". + expect(find.text('Database needs recovery'), findsOneWidget); + expect(find.byIcon(Icons.build_circle_outlined), findsOneWidget); + expect( + find.widgetWithText(FilledButton, 'Recover database'), + findsOneWidget, + ); + expect( + find.widgetWithText(TextButton, 'Close without recovering'), + findsOneWidget, + ); + expect(find.text('Database upgrade failed'), findsNothing); + expect(find.byKey(const ValueKey('error')), findsOneWidget); + }, + ); + + testWidgets( + 'recovery UI surfaces the SQLite extended result code for diagnostics', + (tester) async { + await tester.pumpWidget( + _buildStartupWrapper( + prefs: prefs, + logFileService: logFileService, + locationService: locationService, + schemaVersionProbeOverride: (_) => + (needsMigration: false, totalSteps: 0), + initializerOverride: (_) async { + throw sqlite3.SqliteException(776, 'readonly'); + }, + ), + ); + + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + + expect(find.textContaining('SQLite code 776'), findsOneWidget); + }, + ); + + testWidgets( + 'non-readonly SqliteException (e.g. SQLITE_BUSY) still shows generic error', + (tester) async { + await tester.pumpWidget( + _buildStartupWrapper( + prefs: prefs, + logFileService: logFileService, + locationService: locationService, + schemaVersionProbeOverride: (_) => + (needsMigration: false, totalSteps: 0), + initializerOverride: (_) async { + // SQLITE_BUSY — primary code 5; not in the READONLY family. + throw sqlite3.SqliteException(5, 'database is locked'); + }, + ), + ); + + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + + expect(find.text('Database upgrade failed'), findsOneWidget); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + expect(find.text('Database needs recovery'), findsNothing); + }, + ); + + testWidgets( + 'Close without recovering invokes closeAppOverride exactly once', + (tester) async { + var closeCalled = 0; + await tester.pumpWidget( + _buildStartupWrapper( + prefs: prefs, + logFileService: logFileService, + locationService: locationService, + schemaVersionProbeOverride: (_) => + (needsMigration: false, totalSteps: 0), + initializerOverride: (_) async { + throw sqlite3.SqliteException(776, 'readonly'); + }, + closeAppOverride: () => closeCalled++, + ), + ); + + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + + await tester.tap( + find.widgetWithText(TextButton, 'Close without recovering'), + ); + await tester.pump(); + + expect(closeCalled, 1); + }, + ); + + testWidgets( + 'recoverHotJournal returning false sets a specific error message on ' + 'the recoveryFailed UI', + (tester) async { + // Write bytes that aren't a valid SQLite header at the db path. + // sqlite3.open in readWrite mode will still create a handle, but the + // subsequent PRAGMA user_version probe inside recoverHotJournal will + // throw SQLITE_NOTADB — which recoverHotJournal catches and reports + // as `false`, driving us into the `!recovered` branch of _runRecovery. + final dbPath = p.join(tempDir.path, 'corrupt.db'); + File(dbPath).writeAsBytesSync(List.filled(4096, 0xAB)); + locationService = _CustomPathLocationService(prefs, dbPath); + + await tester.pumpWidget( + _buildStartupWrapper( + prefs: prefs, + logFileService: logFileService, + locationService: locationService, + schemaVersionProbeOverride: (_) => + (needsMigration: false, totalSteps: 0), + initializerOverride: (_) async { + throw sqlite3.SqliteException(776, 'readonly'); + }, + ), + ); + + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + expect(find.text('Database needs recovery'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Recover database')); + await tester.pump(); + await tester.pump(); + + expect(find.text('Recovery did not complete'), findsOneWidget); + // The specific message from the `!recovered` branch — NOT a stale + // SqliteException message and NOT an exception `.toString()`. + expect( + find.textContaining('could not reopen the database'), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'recovery catch block routes to recoveryFailed UI when getDatabasePath ' + 'throws on the retry attempt', + (tester) async { + // First call → succeeds (lets the initializer throw 776 → recoveryRequired). + // Second call (inside _runRecovery) → throws → the `catch` block in + // _runRecovery fires, which is the only path that sets _errorMessage + // alongside _state = recoveryFailed. + final flaky = _FlakyLocationService( + prefs, + path: p.join(tempDir.path, 'test.db'), + failAfter: 1, + ); + await tester.pumpWidget( + _buildStartupWrapper( + prefs: prefs, + logFileService: logFileService, + locationService: flaky, + schemaVersionProbeOverride: (_) => + (needsMigration: false, totalSteps: 0), + initializerOverride: (_) async { + throw sqlite3.SqliteException(776, 'readonly'); + }, + ), + ); + + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + + // We should now be in recoveryRequired. + expect(find.text('Database needs recovery'), findsOneWidget); + + // Tap Recover → _runRecovery calls getDatabasePath, which throws. + await tester.tap(find.widgetWithText(FilledButton, 'Recover database')); + await tester.pump(); + await tester.pump(); + + // The recoveryFailed UI is rendered with the error message. + expect(find.text('Recovery did not complete'), findsOneWidget); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + expect( + find.textContaining('simulated location failure'), + findsOneWidget, + ); + expect( + find.widgetWithText(OutlinedButton, 'Try again'), + findsOneWidget, + ); + expect(find.widgetWithText(FilledButton, 'Close'), findsOneWidget); + }, + ); + + testWidgets('Try again on recoveryFailed UI re-invokes _runRecovery', ( + tester, + ) async { + final flaky = _FlakyLocationService( + prefs, + path: p.join(tempDir.path, 'test.db'), + failAfter: 1, + ); + await tester.pumpWidget( + _buildStartupWrapper( + prefs: prefs, + logFileService: logFileService, + locationService: flaky, + schemaVersionProbeOverride: (_) => + (needsMigration: false, totalSteps: 0), + initializerOverride: (_) async { + throw sqlite3.SqliteException(776, 'readonly'); + }, + ), + ); + + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + + // Drive into recoveryFailed. + await tester.tap(find.widgetWithText(FilledButton, 'Recover database')); + await tester.pump(); + await tester.pump(); + expect(find.text('Recovery did not complete'), findsOneWidget); + + final callsBeforeRetry = flaky.calls; + + // Tap Try again — should run _runRecovery once more, which again + // calls getDatabasePath (throws) and lands back on recoveryFailed. + await tester.tap(find.widgetWithText(OutlinedButton, 'Try again')); + await tester.pump(); + await tester.pump(); + + // The location service was hit once more, confirming re-invocation. + expect(flaky.calls, greaterThan(callsBeforeRetry)); + expect(find.text('Recovery did not complete'), findsOneWidget); + }); + + testWidgets('Close on recoveryFailed UI invokes closeAppOverride', ( + tester, + ) async { + var closeCalled = 0; + final flaky = _FlakyLocationService( + prefs, + path: p.join(tempDir.path, 'test.db'), + failAfter: 1, + ); + await tester.pumpWidget( + _buildStartupWrapper( + prefs: prefs, + logFileService: logFileService, + locationService: flaky, + schemaVersionProbeOverride: (_) => + (needsMigration: false, totalSteps: 0), + initializerOverride: (_) async { + throw sqlite3.SqliteException(776, 'readonly'); + }, + closeAppOverride: () => closeCalled++, + ), + ); + + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Recover database')); + await tester.pump(); + await tester.pump(); + expect(find.text('Recovery did not complete'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Close')); + await tester.pump(); + + expect(closeCalled, 1); + }); + + testWidgets('tapping Recover database re-invokes the initializer and shows ' + 'recovering state while the second attempt is pending', (tester) async { + // First call throws the readonly-rollback exception that triggers + // recovery. The second call (the one that recovery re-runs) never + // completes, so the UI stays in the `recovering` state and we can + // assert it. Letting the second call succeed would mount the full + // app (which needs a real database) and fail the test harness. + var calls = 0; + await tester.pumpWidget( + _buildStartupWrapper( + prefs: prefs, + logFileService: logFileService, + locationService: locationService, + schemaVersionProbeOverride: (_) => + (needsMigration: false, totalSteps: 0), + initializerOverride: (_) async { + calls++; + if (calls == 1) { + throw sqlite3.SqliteException(776, 'readonly'); + } + await Completer().future; + }, + ), + ); + + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + + expect(find.text('Database needs recovery'), findsOneWidget); + expect(calls, 1); + + await tester.tap(find.widgetWithText(FilledButton, 'Recover database')); + // Drive the recovery microtasks: setState(recovering), + // getDatabasePath, recoverHotJournal (no-op for a nonexistent file), + // _runInitialization re-entry, probe, initializer (pends). + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(calls, 2); + expect(find.text('Recovering database...'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Database needs recovery'), findsNothing); + + // Drain the 1-second splash-delay timer started by _runInitialization. + await tester.pump(const Duration(seconds: 2)); + }); + }); } /// A backup service whose `backupIfMigrationPending` always throws. diff --git a/test/core/services/database_service_schema_version_test.dart b/test/core/services/database_service_schema_version_test.dart index b13123425..e814bc5aa 100644 --- a/test/core/services/database_service_schema_version_test.dart +++ b/test/core/services/database_service_schema_version_test.dart @@ -44,4 +44,72 @@ void main() { expect(version, 42); }); }); + + group('DatabaseService.recoverHotJournal', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('db_recovery_test_'); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + test('returns true for a file that does not exist', () { + final path = p.join(tempDir.path, 'nonexistent.db'); + expect(DatabaseService.recoverHotJournal(path), isTrue); + }); + + test('returns true for a healthy database (no recovery needed)', () { + final path = p.join(tempDir.path, 'healthy.db'); + final db = sqlite3.sqlite3.open(path); + db.execute('PRAGMA user_version = 69'); + db.execute('CREATE TABLE t (id INTEGER)'); + db.execute('INSERT INTO t VALUES (1), (2), (3)'); + db.dispose(); + + expect(DatabaseService.recoverHotJournal(path), isTrue); + }); + + test('leaves a healthy database readable at its original version', () { + final path = p.join(tempDir.path, 'readable.db'); + final db = sqlite3.sqlite3.open(path); + db.execute('PRAGMA user_version = 42'); + db.dispose(); + + DatabaseService.recoverHotJournal(path); + + expect(DatabaseService.getStoredSchemaVersion(path), 42); + }); + }); + + group('DatabaseService.isRecoverableReadonlyError', () { + test('true for SQLITE_READONLY primary code', () { + final e = sqlite3.SqliteException(8, 'attempt to write a readonly db'); + expect(DatabaseService.isRecoverableReadonlyError(e), isTrue); + }); + + test('true for SQLITE_READONLY_ROLLBACK extended code 776', () { + final e = sqlite3.SqliteException(776, 'attempt to write a readonly db'); + expect(DatabaseService.isRecoverableReadonlyError(e), isTrue); + }); + + test('true for SQLITE_READONLY_DIRECTORY extended code 1544', () { + final e = sqlite3.SqliteException(1544, 'readonly directory'); + expect(DatabaseService.isRecoverableReadonlyError(e), isTrue); + }); + + test('false for unrelated SQLite errors (e.g. SQLITE_BUSY)', () { + final e = sqlite3.SqliteException(5, 'database is locked'); + expect(DatabaseService.isRecoverableReadonlyError(e), isFalse); + }); + + test('false for non-SqliteException', () { + expect( + DatabaseService.isRecoverableReadonlyError(Exception('other')), + isFalse, + ); + }); + }); } diff --git a/test/features/import_wizard/data/adapters/dive_computer_adapter_test.dart b/test/features/import_wizard/data/adapters/dive_computer_adapter_test.dart index 9ce2a27a0..614d833e3 100644 --- a/test/features/import_wizard/data/adapters/dive_computer_adapter_test.dart +++ b/test/features/import_wizard/data/adapters/dive_computer_adapter_test.dart @@ -14,6 +14,7 @@ import 'package:submersion/features/dive_log/domain/entities/dive_computer.dart' import 'package:submersion/features/import_wizard/data/adapters/dive_computer_adapter.dart'; import 'package:submersion/features/import_wizard/domain/models/duplicate_action.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; @GenerateNiceMocks([ @@ -707,6 +708,122 @@ void main() { expect(result.errorMessage, isNotNull); }); + + test('breaks loop mid-import when cancelToken is cancelled', () async { + final dive1 = makeDownloadedDive( + startTime: DateTime(2026, 3, 15, 10, 0), + fingerprint: 'fp1', + ); + final dive2 = makeDownloadedDive( + startTime: DateTime(2026, 3, 15, 14, 0), + fingerprint: 'fp2', + ); + final dive3 = makeDownloadedDive( + startTime: DateTime(2026, 3, 15, 18, 0), + fingerprint: 'fp3', + ); + adapter.setDownloadedDives([dive1, dive2, dive3]); + final bundle = await adapter.buildBundle(); + + final cancelToken = ImportCancellationToken(); + + when( + mockImportService.importSingleDiveAsNew( + any, + computerId: anyNamed('computerId'), + diverId: anyNamed('diverId'), + descriptorVendor: anyNamed('descriptorVendor'), + descriptorProduct: anyNamed('descriptorProduct'), + descriptorModel: anyNamed('descriptorModel'), + libdivecomputerVersion: anyNamed('libdivecomputerVersion'), + ), + ).thenAnswer((invocation) async { + // Cancel the token after the FIRST dive is imported so the loop + // breaks before processing dives 2 and 3. + cancelToken.cancel(); + return 'imported-id'; + }); + + final result = await adapter.performImport( + bundle, + { + ImportEntityType.dives: {0, 1, 2}, + }, + {}, + cancelToken: cancelToken, + ); + + // Only the first dive was imported before cancellation kicked in. + expect(result.importedCounts[ImportEntityType.dives], equals(1)); + verify( + mockImportService.importSingleDiveAsNew( + any, + computerId: anyNamed('computerId'), + diverId: anyNamed('diverId'), + descriptorVendor: anyNamed('descriptorVendor'), + descriptorProduct: anyNamed('descriptorProduct'), + descriptorModel: anyNamed('descriptorModel'), + libdivecomputerVersion: anyNamed('libdivecomputerVersion'), + ), + ).called(1); + }); + + test( + 'cancelled import only advances fingerprint for processed dives', + () async { + // Three dives with incrementing startTimes. selectNewestFingerprint + // picks by latest startTime, so if ALL dives were used the fingerprint + // would be 'fp3'. With cancellation after dive1, it should be 'fp1'. + final dive1 = makeDownloadedDive( + startTime: DateTime(2026, 3, 15, 10, 0), + fingerprint: 'fp1', + ); + final dive2 = makeDownloadedDive( + startTime: DateTime(2026, 3, 15, 14, 0), + fingerprint: 'fp2', + ); + final dive3 = makeDownloadedDive( + startTime: DateTime(2026, 3, 15, 18, 0), + fingerprint: 'fp3', + ); + adapter.setDownloadedDives([dive1, dive2, dive3]); + final bundle = await adapter.buildBundle(); + + final cancelToken = ImportCancellationToken(); + + when( + mockImportService.importSingleDiveAsNew( + any, + computerId: anyNamed('computerId'), + diverId: anyNamed('diverId'), + descriptorVendor: anyNamed('descriptorVendor'), + descriptorProduct: anyNamed('descriptorProduct'), + descriptorModel: anyNamed('descriptorModel'), + libdivecomputerVersion: anyNamed('libdivecomputerVersion'), + ), + ).thenAnswer((_) async { + cancelToken.cancel(); + return 'imported-id'; + }); + + await adapter.performImport( + bundle, + { + ImportEntityType.dives: {0, 1, 2}, + }, + {}, + cancelToken: cancelToken, + ); + + // Fingerprint advances only to the processed dive's fingerprint, + // not to the newest downloaded dive's fingerprint. Next session can + // still pick up dive2 and dive3. + verify( + mockComputerRepo.updateLastFingerprint('computer-1', 'fp1'), + ).called(1); + verifyNever(mockComputerRepo.updateLastFingerprint(any, 'fp3')); + }, + ); }); // ------------------------------------------------------------------------- diff --git a/test/features/import_wizard/data/adapters/universal_adapter_test.mocks.dart b/test/features/import_wizard/data/adapters/universal_adapter_test.mocks.dart index 79f97da13..d74e75894 100644 --- a/test/features/import_wizard/data/adapters/universal_adapter_test.mocks.dart +++ b/test/features/import_wizard/data/adapters/universal_adapter_test.mocks.dart @@ -62,6 +62,8 @@ import 'package:submersion/features/equipment/domain/entities/equipment_item.dar as _i7; import 'package:submersion/features/equipment/domain/entities/equipment_set.dart' as _i8; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart' + as _i43; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart' as _i42; import 'package:submersion/features/tags/data/repositories/tag_repository.dart' @@ -2881,6 +2883,7 @@ class MockUddfEntityImporter extends _i1.Mock required String? diverId, bool? retainSourceDiveNumbers = false, _i42.ImportProgressCallback? onProgress, + _i43.ImportCancellationToken? cancelToken, }) => (super.noSuchMethod( Invocation.method(#import, [], { @@ -2890,6 +2893,7 @@ class MockUddfEntityImporter extends _i1.Mock #diverId: diverId, #retainSourceDiveNumbers: retainSourceDiveNumbers, #onProgress: onProgress, + #cancelToken: cancelToken, }), returnValue: _i18.Future<_i17.UddfEntityImportResult>.value( _FakeUddfEntityImportResult_18( @@ -2901,6 +2905,7 @@ class MockUddfEntityImporter extends _i1.Mock #diverId: diverId, #retainSourceDiveNumbers: retainSourceDiveNumbers, #onProgress: onProgress, + #cancelToken: cancelToken, }), ), ), @@ -2915,6 +2920,7 @@ class MockUddfEntityImporter extends _i1.Mock #diverId: diverId, #retainSourceDiveNumbers: retainSourceDiveNumbers, #onProgress: onProgress, + #cancelToken: cancelToken, }), ), ), diff --git a/test/features/import_wizard/domain/adapters/import_source_adapter_test.dart b/test/features/import_wizard/domain/adapters/import_source_adapter_test.dart index 4fe1544c2..824b44e97 100644 --- a/test/features/import_wizard/domain/adapters/import_source_adapter_test.dart +++ b/test/features/import_wizard/domain/adapters/import_source_adapter_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/features/import_wizard/domain/adapters/import_source_adapter.dart'; import 'package:submersion/features/import_wizard/domain/models/duplicate_action.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; @@ -44,6 +45,7 @@ class _TestAdapter extends ImportSourceAdapter { Map> duplicateActions, { bool retainSourceDiveNumbers = false, ImportProgressCallback? onProgress, + ImportCancellationToken? cancelToken, }) async => const UnifiedImportResult( importedCounts: {}, consolidatedCount: 0, diff --git a/test/features/import_wizard/domain/models/import_cancellation_token_test.dart b/test/features/import_wizard/domain/models/import_cancellation_token_test.dart new file mode 100644 index 000000000..7eed596a6 --- /dev/null +++ b/test/features/import_wizard/domain/models/import_cancellation_token_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; + +void main() { + group('ImportCancellationToken', () { + test('is not cancelled initially', () { + final token = ImportCancellationToken(); + expect(token.isCancelled, isFalse); + }); + + test('becomes cancelled after cancel()', () { + final token = ImportCancellationToken(); + token.cancel(); + expect(token.isCancelled, isTrue); + }); + + test('cancel() is idempotent', () { + final token = ImportCancellationToken(); + token.cancel(); + token.cancel(); + expect(token.isCancelled, isTrue); + }); + }); +} diff --git a/test/features/import_wizard/presentation/pages/unified_import_wizard_test.dart b/test/features/import_wizard/presentation/pages/unified_import_wizard_test.dart index c4b7f0a85..cc4555bb2 100644 --- a/test/features/import_wizard/presentation/pages/unified_import_wizard_test.dart +++ b/test/features/import_wizard/presentation/pages/unified_import_wizard_test.dart @@ -9,10 +9,12 @@ import 'package:submersion/features/import_wizard/data/adapters/universal_adapte import 'package:submersion/features/import_wizard/domain/adapters/import_source_adapter.dart'; import 'package:submersion/features/import_wizard/domain/models/duplicate_action.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; import 'package:submersion/features/import_wizard/presentation/pages/unified_import_wizard.dart'; +import 'package:submersion/features/import_wizard/presentation/providers/import_wizard_providers.dart'; import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; @@ -68,6 +70,7 @@ class _FakeAdapter implements ImportSourceAdapter { Map> duplicateActions, { bool retainSourceDiveNumbers = false, ImportProgressCallback? onProgress, + ImportCancellationToken? cancelToken, }) => throw UnimplementedError(); } @@ -177,4 +180,171 @@ void main() { expect(state.wasLoadedExternally, isFalse); }); }); + + // ------------------------------------------------------------------------- + // Cancel dialog flow on the import-progress page + // + // The wizard's close button (Icons.close in the AppBar) has two distinct + // behaviors when the user is on the import-progress page: + // 1. If no cancellation is pending, it confirms with a two-button dialog + // and only calls notifier.cancelImport() when the user picks "Cancel + // import". + // 2. If a cancellation is already in flight, it shows a read-only notice + // explaining that the current dive will finish before stopping. + // + // These tests use initialPageOverride to jump straight to _importIndex so + // we don't have to drive the adapter through buildBundle/performImport, + // and notifierFactoryOverride so the inner ProviderScope uses a spy we + // can assert `cancelImport` call-counts against. + // ------------------------------------------------------------------------- + group('UnifiedImportWizard cancel dialog', () { + Widget buildAt( + int page, { + required ImportSourceAdapter adapter, + ImportWizardNotifier Function(Ref ref)? notifierFactory, + }) { + return ProviderScope( + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: UnifiedImportWizard( + adapter: adapter, + initialPageOverride: page, + notifierFactoryOverride: notifierFactory, + ), + ), + ); + } + + _SpyNotifier makeSpy({bool alreadyCancelling = false}) { + final spy = _SpyNotifier(); + if (alreadyCancelling) { + spy.state = spy.state.copyWith(isCancellationRequested: true); + } + return spy; + } + + // Scope "Cancel import" matches to the dialog only — the import-progress + // page also renders a `TextButton.icon` with label "Cancel import", which + // otherwise matches and makes finders ambiguous. + Finder dialogCancelButton() => find.descendant( + of: find.byType(AlertDialog), + matching: find.widgetWithText(TextButton, 'Cancel import'), + ); + + testWidgets( + 'close button on import page opens Cancel import? confirm dialog', + (tester) async { + final adapter = _FakeAdapter(); + // 1 acquisition step → _importIndex = 2 (0=acq, 1=review, 2=import). + await tester.pumpWidget(buildAt(2, adapter: adapter)); + // Drain the two post-frame callbacks used by _resetComplete + + // initialPageOverride before the AppBar is clickable. + await tester.pump(); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.close)); + // Finite pump — ImportProgressStep renders indeterminate progress + // indicators that prevent pumpAndSettle from ever returning. + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Cancel import?'), findsOneWidget); + expect( + find.widgetWithText(TextButton, 'Keep importing'), + findsOneWidget, + ); + expect(dialogCancelButton(), findsOneWidget); + }, + ); + + testWidgets( + 'tapping Keep importing dismisses dialog without calling cancelImport', + (tester) async { + final spy = makeSpy(); + await tester.pumpWidget( + buildAt(2, adapter: _FakeAdapter(), notifierFactory: (_) => spy), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pump(const Duration(milliseconds: 300)); + + await tester.tap(find.widgetWithText(TextButton, 'Keep importing')); + await tester.pump(const Duration(milliseconds: 300)); + + expect(spy.cancelCalls, 0); + expect(find.text('Cancel import?'), findsNothing); + }, + ); + + testWidgets( + 'tapping Cancel import in confirm dialog calls notifier.cancelImport', + (tester) async { + final spy = makeSpy(); + await tester.pumpWidget( + buildAt(2, adapter: _FakeAdapter(), notifierFactory: (_) => spy), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pump(const Duration(milliseconds: 300)); + + await tester.tap(dialogCancelButton()); + await tester.pump(const Duration(milliseconds: 300)); + + expect(spy.cancelCalls, 1); + expect(find.text('Cancel import?'), findsNothing); + }, + ); + + testWidgets( + 'close button shows Cancelling notice when cancellation already pending', + (tester) async { + final spy = makeSpy(alreadyCancelling: true); + await tester.pumpWidget( + buildAt(2, adapter: _FakeAdapter(), notifierFactory: (_) => spy), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Cancelling'), findsOneWidget); + expect( + find.textContaining('Finishing the current dive'), + findsOneWidget, + ); + // No confirm buttons — just an OK dismiss. + expect(find.widgetWithText(TextButton, 'Keep importing'), findsNothing); + expect(find.widgetWithText(TextButton, 'OK'), findsOneWidget); + + await tester.tap(find.widgetWithText(TextButton, 'OK')); + await tester.pump(const Duration(milliseconds: 300)); + + // The already-cancelling path must not re-invoke cancelImport. + expect(spy.cancelCalls, 0); + }, + ); + }); +} + +/// Notifier spy so we can assert `cancelImport()` is (or isn't) called by +/// the wizard's confirm dialog without driving a real import. +class _SpyNotifier extends ImportWizardNotifier { + _SpyNotifier() : super(_FakeAdapter()); + + int cancelCalls = 0; + + @override + void cancelImport() { + cancelCalls++; + super.cancelImport(); + } } diff --git a/test/features/import_wizard/presentation/providers/import_wizard_notifier_test.dart b/test/features/import_wizard/presentation/providers/import_wizard_notifier_test.dart index e056c22ea..c365e3ca1 100644 --- a/test/features/import_wizard/presentation/providers/import_wizard_notifier_test.dart +++ b/test/features/import_wizard/presentation/providers/import_wizard_notifier_test.dart @@ -6,6 +6,7 @@ import 'package:submersion/features/dive_import/domain/services/dive_matcher.dar import 'package:submersion/features/import_wizard/domain/adapters/import_source_adapter.dart'; import 'package:submersion/features/import_wizard/domain/models/duplicate_action.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/tag_selection.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; @@ -540,6 +541,7 @@ void main() { any, retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), ), ).thenAnswer((_) async { // Capture during import — state should be importing @@ -582,6 +584,7 @@ void main() { any, any, onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), ), ).thenAnswer((_) async => importResult); @@ -594,6 +597,7 @@ void main() { captureAny, retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), ), ).captured; @@ -630,6 +634,7 @@ void main() { any, retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), ), ).thenAnswer((_) async => importResult); @@ -651,6 +656,7 @@ void main() { any, retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), ), ).thenThrow(Exception('DB error')); @@ -679,6 +685,7 @@ void main() { any, retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), ), ).thenAnswer((_) async => importResult); @@ -705,6 +712,7 @@ void main() { any, retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), ), ).thenAnswer((invocation) async { final onProgress = @@ -740,6 +748,7 @@ void main() { any, retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), ), ); expect(notifier.state.isImporting, isFalse); @@ -769,6 +778,7 @@ void main() { any, retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), ), ).thenThrow(Exception('Database connection lost')); @@ -800,6 +810,7 @@ void main() { any, retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), ), ).thenThrow(Exception('IO failure')); @@ -833,6 +844,7 @@ void main() { any, retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), ), ).thenAnswer((_) async => importResult); @@ -882,6 +894,7 @@ void main() { any, retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), ), ).thenAnswer((_) async => importResult); @@ -912,6 +925,7 @@ void main() { any, retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), ), ).thenAnswer((_) async => importResult); @@ -922,6 +936,119 @@ void main() { }); }); + // ------------------------------------------------------------------------- + // cancelImport + // ------------------------------------------------------------------------- + + group('cancelImport', () { + test('does nothing when no import is in progress', () { + // No-op when no token has been created (never started). + notifier.cancelImport(); + expect(notifier.state.isCancellationRequested, isFalse); + }); + + test('sets isCancellationRequested while import is running', () async { + final bundle = buildBundle(diveItems: [makeItem('Dive 1')]); + notifier.setBundle(bundle); + + const importResult = UnifiedImportResult( + importedCounts: {ImportEntityType.dives: 1}, + consolidatedCount: 0, + skippedCount: 0, + ); + + // Stub performImport to call cancel mid-flight and observe state. + var observedCancellationFlag = false; + when( + mockAdapter.performImport( + any, + any, + any, + retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), + onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), + ), + ).thenAnswer((_) async { + notifier.cancelImport(); + observedCancellationFlag = notifier.state.isCancellationRequested; + return importResult; + }); + + await notifier.performImport(); + + expect(observedCancellationFlag, isTrue); + }); + + test('passes a live cancelToken to the adapter', () async { + final bundle = buildBundle(diveItems: [makeItem('Dive 1')]); + notifier.setBundle(bundle); + + const importResult = UnifiedImportResult( + importedCounts: {ImportEntityType.dives: 0}, + consolidatedCount: 0, + skippedCount: 0, + ); + + ImportCancellationToken? capturedToken; + when( + mockAdapter.performImport( + any, + any, + any, + retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), + onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), + ), + ).thenAnswer((invocation) async { + capturedToken = + invocation.namedArguments[#cancelToken] + as ImportCancellationToken?; + notifier.cancelImport(); + return importResult; + }); + + await notifier.performImport(); + + expect(capturedToken, isNotNull); + expect(capturedToken!.isCancelled, isTrue); + }); + + test('clears isCancellationRequested after import completes', () async { + final bundle = buildBundle(diveItems: [makeItem('Dive 1')]); + notifier.setBundle(bundle); + + const importResult = UnifiedImportResult( + importedCounts: {ImportEntityType.dives: 1}, + consolidatedCount: 0, + skippedCount: 0, + ); + + when( + mockAdapter.performImport( + any, + any, + any, + retainSourceDiveNumbers: anyNamed('retainSourceDiveNumbers'), + onProgress: anyNamed('onProgress'), + cancelToken: anyNamed('cancelToken'), + ), + ).thenAnswer((_) async { + notifier.cancelImport(); + return importResult; + }); + + await notifier.performImport(); + + // The finally block clears both the live token and the + // isCancellationRequested flag so a subsequent import starts fresh. + expect(notifier.state.isCancellationRequested, isFalse); + + // A second cancelImport() is a no-op — no live token, no stale flag. + notifier.cancelImport(); + expect(notifier.state.isCancellationRequested, isFalse); + }); + }); + // ------------------------------------------------------------------------- // reset // ------------------------------------------------------------------------- diff --git a/test/features/import_wizard/presentation/providers/import_wizard_notifier_test.mocks.dart b/test/features/import_wizard/presentation/providers/import_wizard_notifier_test.mocks.dart index 69be2dd21..db9d465ea 100644 --- a/test/features/import_wizard/presentation/providers/import_wizard_notifier_test.mocks.dart +++ b/test/features/import_wizard/presentation/providers/import_wizard_notifier_test.mocks.dart @@ -13,6 +13,8 @@ import 'package:submersion/features/import_wizard/domain/models/duplicate_action as _i8; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart' as _i2; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart' + as _i11; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart' as _i10; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart' @@ -20,7 +22,7 @@ import 'package:submersion/features/import_wizard/domain/models/unified_import_r import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart' as _i7; import 'package:submersion/features/tags/data/repositories/tag_repository.dart' - as _i11; + as _i12; import 'package:submersion/features/tags/domain/entities/tag.dart' as _i4; // ignore_for_file: type=lint @@ -162,6 +164,7 @@ class MockImportSourceAdapter extends _i1.Mock duplicateActions, { bool? retainSourceDiveNumbers, _i10.ImportProgressCallback? onProgress, + _i11.ImportCancellationToken? cancelToken, }) => (super.noSuchMethod( Invocation.method( @@ -170,6 +173,7 @@ class MockImportSourceAdapter extends _i1.Mock { #retainSourceDiveNumbers: retainSourceDiveNumbers, #onProgress: onProgress, + #cancelToken: cancelToken, }, ), returnValue: _i9.Future<_i3.UnifiedImportResult>.value( @@ -181,6 +185,7 @@ class MockImportSourceAdapter extends _i1.Mock { #retainSourceDiveNumbers: retainSourceDiveNumbers, #onProgress: onProgress, + #cancelToken: cancelToken, }, ), ), @@ -195,6 +200,7 @@ class MockImportSourceAdapter extends _i1.Mock { #retainSourceDiveNumbers: retainSourceDiveNumbers, #onProgress: onProgress, + #cancelToken: cancelToken, }, ), ), @@ -206,7 +212,7 @@ class MockImportSourceAdapter extends _i1.Mock /// A class which mocks [TagRepository]. /// /// See the documentation for Mockito's code generation for more information. -class MockTagRepository extends _i1.Mock implements _i11.TagRepository { +class MockTagRepository extends _i1.Mock implements _i12.TagRepository { @override _i9.Future> getAllTags({String? diverId}) => (super.noSuchMethod( @@ -357,18 +363,18 @@ class MockTagRepository extends _i1.Mock implements _i11.TagRepository { as _i9.Future); @override - _i9.Future> getTagStatistics({String? diverId}) => + _i9.Future> getTagStatistics({String? diverId}) => (super.noSuchMethod( Invocation.method(#getTagStatistics, [], {#diverId: diverId}), - returnValue: _i9.Future>.value( - <_i11.TagStatistic>[], + returnValue: _i9.Future>.value( + <_i12.TagStatistic>[], ), returnValueForMissingStub: - _i9.Future>.value( - <_i11.TagStatistic>[], + _i9.Future>.value( + <_i12.TagStatistic>[], ), ) - as _i9.Future>); + as _i9.Future>); @override _i9.Future getTagUsageCount(String? tagId) => diff --git a/test/features/import_wizard/presentation/widgets/import_progress_step_test.dart b/test/features/import_wizard/presentation/widgets/import_progress_step_test.dart index 43dff3693..d20fcf634 100644 --- a/test/features/import_wizard/presentation/widgets/import_progress_step_test.dart +++ b/test/features/import_wizard/presentation/widgets/import_progress_step_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/features/import_wizard/domain/adapters/import_source_adapter.dart'; import 'package:submersion/features/import_wizard/domain/models/duplicate_action.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; @@ -52,6 +53,7 @@ class _FakeAdapter implements ImportSourceAdapter { Map> duplicateActions, { bool retainSourceDiveNumbers = false, ImportProgressCallback? onProgress, + ImportCancellationToken? cancelToken, }) => throw UnimplementedError(); } @@ -219,5 +221,77 @@ void main() { ); expect(indicator.value, closeTo(0.25, 0.001)); }); + + testWidgets('shows cancel button by default', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final notifier = _makeNotifier(); + + await tester.pumpWidget(_buildWidget(notifier)); + await tester.pump(); + + expect( + find.byKey(const Key('import_progress_cancel_button')), + findsOneWidget, + ); + expect(find.text('Cancel import'), findsOneWidget); + }); + + testWidgets('cancel button is disabled once cancellation requested', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final notifier = _makeNotifier(); + notifier.state = notifier.state.copyWith( + isCancellationRequested: true, + importPhase: ImportPhase.dives, + ); + + await tester.pumpWidget(_buildWidget(notifier)); + await tester.pump(); + + final button = tester.widget( + find.byKey(const Key('import_progress_cancel_button')), + ); + expect(button.onPressed, isNull); + expect(find.text('Cancelling...'), findsAtLeastNWidgets(1)); + }); + + testWidgets('tapping cancel button invokes notifier.cancelImport', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final notifier = _SpyNotifier(); + + await tester.pumpWidget(_buildWidget(notifier)); + await tester.pump(); + + expect(notifier.cancelCalls, 0); + + await tester.tap(find.byKey(const Key('import_progress_cancel_button'))); + await tester.pump(); + + expect(notifier.cancelCalls, 1); + }); }); } + +/// Notifier spy that counts `cancelImport` calls. The real notifier's +/// `cancelImport` short-circuits when no import is running; the spy lets us +/// assert the button actually wires through to it. +class _SpyNotifier extends ImportWizardNotifier { + _SpyNotifier() : super(_FakeAdapter()); + + int cancelCalls = 0; + + @override + void cancelImport() { + cancelCalls++; + super.cancelImport(); + } +} diff --git a/test/features/import_wizard/presentation/widgets/import_summary_step_test.dart b/test/features/import_wizard/presentation/widgets/import_summary_step_test.dart index be58b6e02..5ba0f7615 100644 --- a/test/features/import_wizard/presentation/widgets/import_summary_step_test.dart +++ b/test/features/import_wizard/presentation/widgets/import_summary_step_test.dart @@ -6,6 +6,7 @@ import 'package:submersion/l10n/arb/app_localizations.dart'; import 'package:submersion/features/import_wizard/domain/adapters/import_source_adapter.dart'; import 'package:submersion/features/import_wizard/domain/models/duplicate_action.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; @@ -52,6 +53,7 @@ class _FakeAdapter implements ImportSourceAdapter { Map> duplicateActions, { bool retainSourceDiveNumbers = false, ImportProgressCallback? onProgress, + ImportCancellationToken? cancelToken, }) => throw UnimplementedError(); } diff --git a/test/features/import_wizard/presentation/widgets/review_step_pending_test.dart b/test/features/import_wizard/presentation/widgets/review_step_pending_test.dart index 746d44434..48921572d 100644 --- a/test/features/import_wizard/presentation/widgets/review_step_pending_test.dart +++ b/test/features/import_wizard/presentation/widgets/review_step_pending_test.dart @@ -6,6 +6,7 @@ import 'package:submersion/features/dive_import/domain/services/dive_matcher.dar import 'package:submersion/features/import_wizard/domain/adapters/import_source_adapter.dart'; import 'package:submersion/features/import_wizard/domain/models/duplicate_action.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; @@ -54,6 +55,7 @@ class _TestAdapter implements ImportSourceAdapter { Map> duplicateActions, { bool retainSourceDiveNumbers = false, ImportProgressCallback? onProgress, + ImportCancellationToken? cancelToken, }) => throw UnimplementedError(); } diff --git a/test/features/import_wizard/presentation/widgets/review_step_test.dart b/test/features/import_wizard/presentation/widgets/review_step_test.dart index b9c3c30ed..20288ad83 100644 --- a/test/features/import_wizard/presentation/widgets/review_step_test.dart +++ b/test/features/import_wizard/presentation/widgets/review_step_test.dart @@ -8,6 +8,7 @@ import 'package:submersion/features/dive_log/presentation/providers/dive_provide import 'package:submersion/features/import_wizard/domain/adapters/import_source_adapter.dart'; import 'package:submersion/features/import_wizard/domain/models/duplicate_action.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; @@ -60,6 +61,7 @@ class _FakeAdapter implements ImportSourceAdapter { Map> duplicateActions, { bool retainSourceDiveNumbers = false, ImportProgressCallback? onProgress, + ImportCancellationToken? cancelToken, }) => throw UnimplementedError(); }