feat: Enhance GameDataPickerManager and Wolf3dAppManager with improved directory and file picking capabilities

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 19:41:08 +01:00
parent 1394c20134
commit 6158a92fb0
4 changed files with 259 additions and 7 deletions
@@ -5,18 +5,47 @@ import 'dart:collection';
import 'package:file_selector/file_selector.dart'; import 'package:file_selector/file_selector.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
/// Callback used to open a directory picker.
typedef PickDirectoryCallback =
Future<String?> Function({
String? confirmButtonText,
});
/// Callback used to open a multi-file picker.
typedef PickFilesCallback = Future<List<XFile>> Function();
/// Coordinates game-data selection flows for the no-data setup screen. /// Coordinates game-data selection flows for the no-data setup screen.
class GameDataPickerManager { class GameDataPickerManager {
GameDataPickerManager({required this.engine}); /// Creates a picker workflow manager bound to [engine].
///
/// [pickDirectory] and [pickFiles] are injectable test seams. Production
/// usage should rely on their defaults from `file_selector`.
GameDataPickerManager({
required this.engine,
PickDirectoryCallback? pickDirectory,
PickFilesCallback? pickFiles,
}) : _pickDirectory = pickDirectory ?? getDirectoryPath,
_pickFiles = pickFiles ?? openFiles;
/// Engine facade reloaded when users choose new data locations.
final Wolf3dFlutterEngine engine; final Wolf3dFlutterEngine engine;
final PickDirectoryCallback _pickDirectory;
final PickFilesCallback _pickFiles;
bool _isLoadingGameData = false; bool _isLoadingGameData = false;
String? _pickerError; String? _pickerError;
/// Whether a picker/reload operation is currently in progress.
bool get isLoadingGameData => _isLoadingGameData; bool get isLoadingGameData => _isLoadingGameData;
/// Last picker/reload error, if any.
String? get pickerError => _pickerError; String? get pickerError => _pickerError;
/// Prompts the user for a game-data directory and reloads discovery.
///
/// Calls [notifyChanged] before and after the async workflow so UIs can
/// update loading/error state.
Future<void> pickGameDataDirectory({ Future<void> pickGameDataDirectory({
required void Function() notifyChanged, required void Function() notifyChanged,
}) async { }) async {
@@ -25,7 +54,7 @@ class GameDataPickerManager {
notifyChanged(); notifyChanged();
try { try {
final String? directoryPath = await getDirectoryPath( final String? directoryPath = await _pickDirectory(
confirmButtonText: 'Use this folder', confirmButtonText: 'Use this folder',
); );
@@ -42,6 +71,11 @@ class GameDataPickerManager {
} }
} }
/// Prompts the user for one or more game-data files and reloads discovery.
///
/// Selected file paths are collapsed into unique parent directories, then
/// passed to [Wolf3dFlutterEngine.init] as one primary directory plus
/// [Wolf3dFlutterEngine.init] `additionalDirectories`.
Future<void> pickGameDataFiles({ Future<void> pickGameDataFiles({
required void Function() notifyChanged, required void Function() notifyChanged,
}) async { }) async {
@@ -50,7 +84,7 @@ class GameDataPickerManager {
notifyChanged(); notifyChanged();
try { try {
final List<XFile> selectedFiles = await openFiles(); final List<XFile> selectedFiles = await _pickFiles();
if (selectedFiles.isEmpty) { if (selectedFiles.isEmpty) {
return; return;
@@ -84,6 +118,7 @@ class GameDataPickerManager {
} }
} }
/// Returns the parent directory component from [path].
String _directoryFromFilePath(String path) { String _directoryFromFilePath(String path) {
final String trimmedPath = path.trim(); final String trimmedPath = path.trim();
if (trimmedPath.isEmpty) { if (trimmedPath.isEmpty) {
@@ -6,17 +6,26 @@ import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
/// Coordinates app-shell setup actions and shutdown behavior. /// Coordinates app-shell setup actions and shutdown behavior.
class Wolf3dAppManager { class Wolf3dAppManager {
Wolf3dAppManager({required this.engine}); /// Creates an app-shell manager bound to [engine].
///
/// [pickerManager] can be provided in tests to control picker behavior.
Wolf3dAppManager({
required this.engine,
GameDataPickerManager? pickerManager,
}) : _pickerManager = pickerManager ?? GameDataPickerManager(engine: engine);
/// Engine facade managed by the app shell.
final Wolf3dFlutterEngine engine; final Wolf3dFlutterEngine engine;
late final GameDataPickerManager _pickerManager = GameDataPickerManager( final GameDataPickerManager _pickerManager;
engine: engine,
);
Future<void>? _shutdownFuture; Future<void>? _shutdownFuture;
/// Forwarded picker loading flag for UI binding.
bool get isLoadingGameData => _pickerManager.isLoadingGameData; bool get isLoadingGameData => _pickerManager.isLoadingGameData;
/// Forwarded picker error text for UI binding.
String? get pickerError => _pickerManager.pickerError; String? get pickerError => _pickerManager.pickerError;
/// Shuts down engine audio exactly once and reuses in-flight shutdown calls.
Future<void> ensureAudioShutdown() { Future<void> ensureAudioShutdown() {
final existing = _shutdownFuture; final existing = _shutdownFuture;
if (existing != null) { if (existing != null) {
@@ -28,12 +37,14 @@ class Wolf3dAppManager {
return shutdown; return shutdown;
} }
/// Delegates directory picker + reload behavior.
Future<void> pickGameDataDirectory({ Future<void> pickGameDataDirectory({
required void Function() notifyChanged, required void Function() notifyChanged,
}) async { }) async {
await _pickerManager.pickGameDataDirectory(notifyChanged: notifyChanged); await _pickerManager.pickGameDataDirectory(notifyChanged: notifyChanged);
} }
/// Delegates file picker + reload behavior.
Future<void> pickGameDataFiles({ Future<void> pickGameDataFiles({
required void Function() notifyChanged, required void Function() notifyChanged,
}) async { }) async {
@@ -83,6 +83,14 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
} }
/// Initializes the engine by loading available game data. /// Initializes the engine by loading available game data.
///
/// If [directory] is provided, it is persisted and treated as the primary
/// external search root. If omitted, a previously persisted directory is
/// used when available. [additionalDirectories] are scanned after the
/// primary directory and are not persisted.
///
/// This method merges bundled package data (when present) with discovered
/// external directories, deduplicating versions by [GameVersion].
Future<Wolf3dFlutterEngine> init({ Future<Wolf3dFlutterEngine> init({
String? directory, String? directory,
Iterable<String>? additionalDirectories, Iterable<String>? additionalDirectories,
@@ -0,0 +1,198 @@
import 'package:file_selector/file_selector.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
class _NoopAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
int stopAllAudioCallCount = 0;
int disposeCallCount = 0;
@override
Future<void> debugSoundTest() async {}
@override
Future<void> init() async {}
@override
void playLevelMusic(Music music) {}
@override
void playMenuMusic() {}
@override
void playSoundEffect(SoundEffect effect) {}
@override
void playSoundEffectId(int sfxId) {}
@override
Future<void> stopAllAudio() async {
stopAllAudioCallCount++;
}
@override
void stopMusic() {}
@override
void dispose() {
disposeCallCount++;
}
}
class _RecordingEngine extends Wolf3dFlutterEngine {
_RecordingEngine({required this.audio}) : super(audioBackend: audio);
@override
final _NoopAudio audio;
int initCallCount = 0;
String? lastDirectory;
List<String> lastAdditionalDirectories = <String>[];
@override
Future<Wolf3dFlutterEngine> init({
String? directory,
Iterable<String>? additionalDirectories,
}) async {
initCallCount++;
lastDirectory = directory;
lastAdditionalDirectories =
additionalDirectories?.toList(growable: false) ?? <String>[];
return this;
}
}
void main() {
group('GameDataPickerManager', () {
test('pickGameDataDirectory reloads selected directory', () async {
final engine = _RecordingEngine(audio: _NoopAudio());
int notifyCount = 0;
final manager = GameDataPickerManager(
engine: engine,
pickDirectory: ({String? confirmButtonText}) async => ' /tmp/wolf ',
);
await manager.pickGameDataDirectory(
notifyChanged: () => notifyCount++,
);
expect(notifyCount, 2);
expect(manager.isLoadingGameData, isFalse);
expect(manager.pickerError, isNull);
expect(engine.initCallCount, 1);
expect(engine.lastDirectory, '/tmp/wolf');
expect(engine.lastAdditionalDirectories, isEmpty);
});
test('pickGameDataDirectory ignores empty selection', () async {
final engine = _RecordingEngine(audio: _NoopAudio());
final manager = GameDataPickerManager(
engine: engine,
pickDirectory: ({String? confirmButtonText}) async => ' ',
);
await manager.pickGameDataDirectory(notifyChanged: () {});
expect(engine.initCallCount, 0);
expect(manager.pickerError, isNull);
});
test(
'pickGameDataFiles deduplicates directories and forwards extras',
() async {
final engine = _RecordingEngine(audio: _NoopAudio());
final manager = GameDataPickerManager(
engine: engine,
pickFiles: () async => <XFile>[
XFile('/a/one/VSWAP.WL6'),
XFile('/a/one/MAPHEAD.WL6'),
XFile('/a/two/VSWAP.WL1'),
],
);
await manager.pickGameDataFiles(notifyChanged: () {});
expect(engine.initCallCount, 1);
expect(engine.lastDirectory, '/a/one');
expect(engine.lastAdditionalDirectories, <String>['/a/two']);
expect(manager.pickerError, isNull);
},
);
test('pickGameDataFiles reports missing directory paths', () async {
final engine = _RecordingEngine(audio: _NoopAudio());
final manager = GameDataPickerManager(
engine: engine,
pickFiles: () async => <XFile>[
XFile('VSWAP.WL6'),
XFile('MAPHEAD.WL6'),
],
);
await manager.pickGameDataFiles(notifyChanged: () {});
expect(engine.initCallCount, 0);
expect(
manager.pickerError,
'Selected files do not expose local filesystem paths.',
);
});
test('pickGameDataDirectory captures thrown picker error', () async {
final engine = _RecordingEngine(audio: _NoopAudio());
final manager = GameDataPickerManager(
engine: engine,
pickDirectory: ({String? confirmButtonText}) async {
throw StateError('picker failed');
},
);
await manager.pickGameDataDirectory(notifyChanged: () {});
expect(engine.initCallCount, 0);
expect(
manager.pickerError,
contains('Unable to load selected directory'),
);
expect(manager.isLoadingGameData, isFalse);
});
});
group('Wolf3dAppManager', () {
test('ensureAudioShutdown only executes once', () async {
final audio = _NoopAudio();
final engine = _RecordingEngine(audio: audio);
final manager = Wolf3dAppManager(engine: engine);
await Future.wait<void>(<Future<void>>[
manager.ensureAudioShutdown(),
manager.ensureAudioShutdown(),
]);
expect(audio.stopAllAudioCallCount, 1);
expect(audio.disposeCallCount, 1);
});
test('forwards picker state from injected picker manager', () async {
final engine = _RecordingEngine(audio: _NoopAudio());
final pickerManager = GameDataPickerManager(
engine: engine,
pickDirectory: ({String? confirmButtonText}) async => '/tmp/wolf',
);
final manager = Wolf3dAppManager(
engine: engine,
pickerManager: pickerManager,
);
await manager.pickGameDataDirectory(notifyChanged: () {});
expect(manager.isLoadingGameData, isFalse);
expect(manager.pickerError, isNull);
expect(engine.lastDirectory, '/tmp/wolf');
});
});
}