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:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user