diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_api.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_api.dart new file mode 100644 index 000000000000..63b74003e40b --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_api.dart @@ -0,0 +1,92 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:win32/win32.dart'; + +import 'file_selector_dart/dialog_mode.dart'; +import 'file_selector_dart/dialog_wrapper.dart'; +import 'file_selector_dart/dialog_wrapper_factory.dart'; +import 'file_selector_dart/selection_options.dart'; + +/// File dialog handling for Open and Save operations. +class FileSelectorApi { + /// Creates a new instance of [FileSelectorApi]. + /// Allows Dependency Injection of a [DialogWrapperFactory] to handle dialog creation. + FileSelectorApi(this._dialogWrapperFactory) + : _foregroundWindow = GetForegroundWindow(); + + /// Creates a fake instance of [FileSelectorApi] for testing purpose where the [_foregroundWindow] handle is set + /// from the outside. + @visibleForTesting + FileSelectorApi.useFakeForegroundWindow( + this._dialogWrapperFactory, this._foregroundWindow); + + final DialogWrapperFactory _dialogWrapperFactory; + + final int _foregroundWindow; + + /// Displays a dialog window to open one or more files. + List showOpenDialog( + SelectionOptions options, + String? initialDirectory, + String? confirmButtonText, + ) => + _showDialog(_foregroundWindow, DialogMode.Open, options, initialDirectory, + null, confirmButtonText); + + /// Displays a dialog used to save a file. + List showSaveDialog( + SelectionOptions options, + String? initialDirectory, + String? suggestedName, + String? confirmButtonText, + ) => + _showDialog(_foregroundWindow, DialogMode.Save, options, initialDirectory, + suggestedName, confirmButtonText); + + List _showDialog( + int parentWindow, + DialogMode mode, + SelectionOptions options, + String? initialDirectory, + String? suggestedName, + String? confirmLabel) { + final DialogWrapper dialogWrapper = + _dialogWrapperFactory.createInstance(mode); + if (!SUCCEEDED(dialogWrapper.lastResult)) { + throw WindowsException(E_FAIL); + } + int dialogOptions = 0; + if (options.selectFolders) { + dialogOptions |= FILEOPENDIALOGOPTIONS.FOS_PICKFOLDERS; + } + if (options.allowMultiple) { + dialogOptions |= FILEOPENDIALOGOPTIONS.FOS_ALLOWMULTISELECT; + } + if (dialogOptions != 0) { + dialogWrapper.addOptions(dialogOptions); + } + + if (initialDirectory != null) { + dialogWrapper.setFolder(initialDirectory); + } + if (suggestedName != null) { + dialogWrapper.setFileName(suggestedName); + } + if (confirmLabel != null) { + dialogWrapper.setOkButtonLabel(confirmLabel); + } + + if (options.allowedTypes.isNotEmpty) { + dialogWrapper.setFileTypeFilters(options.allowedTypes); + } + + final List? files = dialogWrapper.show(parentWindow); + if (files != null) { + return files; + } + throw WindowsException(E_FAIL); + } +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_wrapper_factory.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_wrapper_factory.dart new file mode 100644 index 000000000000..cb75c7b432d4 --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_wrapper_factory.dart @@ -0,0 +1,26 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dialog_mode.dart'; +import 'dialog_wrapper.dart'; +import 'ifile_dialog_controller_factory.dart'; +import 'ifile_dialog_factory.dart'; + +/// Implementation of DialogWrapperFactory that provides [DialogWrapper] instances. +class DialogWrapperFactory { + /// Creates a [DialogWrapperFactory] that makes use of [IFileDialogControllerFactory] and [IFileDialogFactory] + /// to create [DialogWrapper] instances. + DialogWrapperFactory( + this._fileDialogControllerFactory, this._fileDialogFactory); + + final IFileDialogControllerFactory _fileDialogControllerFactory; + + final IFileDialogFactory _fileDialogFactory; + + /// Creates a [DialogWrapper] based on [dialogMode]. + DialogWrapper createInstance(DialogMode dialogMode) { + return DialogWrapper( + _fileDialogControllerFactory, _fileDialogFactory, dialogMode); + } +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/selection_options.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/selection_options.dart new file mode 100644 index 000000000000..d00e30198b1d --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/selection_options.dart @@ -0,0 +1,25 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; + +/// Options for Dialog window +class SelectionOptions { + /// Creates a new [SelectionOptions] instance with the specified values. + /// It defaults [allowMultiple] to false, [selectFolders] to false and no [allowedTypes] + SelectionOptions({ + this.allowMultiple = false, + this.selectFolders = false, + this.allowedTypes = const [], + }); + + /// Indicates whether the user is able to select multiple items at the same time or not. + bool allowMultiple; + + /// Indicates whether the user is able to select folders or not. + bool selectFolders; + + /// A list of file types that can be selected. + List allowedTypes; +} diff --git a/packages/file_selector/file_selector_windows/test/file_selector_api_test.dart b/packages/file_selector/file_selector_windows/test/file_selector_api_test.dart new file mode 100644 index 000000000000..509c6705bb96 --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_api_test.dart @@ -0,0 +1,185 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:file_selector_windows/src/file_selector_api.dart'; +import 'package:file_selector_windows/src/file_selector_dart/dialog_mode.dart'; +import 'package:file_selector_windows/src/file_selector_dart/dialog_wrapper.dart'; +import 'package:file_selector_windows/src/file_selector_dart/dialog_wrapper_factory.dart'; +import 'package:file_selector_windows/src/file_selector_dart/selection_options.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:win32/win32.dart'; + +import 'file_selector_api_test.mocks.dart'; + +@GenerateMocks([DialogWrapperFactory, DialogWrapper]) +void main() { + const int parentWindow = 1; + final MockDialogWrapperFactory mockDialogWrapperFactory = + MockDialogWrapperFactory(); + late MockDialogWrapper mockDialogWrapper; + final FileSelectorApi fileSelectorApi = + FileSelectorApi.useFakeForegroundWindow( + mockDialogWrapperFactory, parentWindow); + + const List expectedFileList = ['fileA', 'fileB']; + final SelectionOptions emptyOptions = SelectionOptions(); + + setUp(() { + mockDialogWrapper = MockDialogWrapper(); + when(mockDialogWrapper.lastResult).thenReturn(S_OK); + when(mockDialogWrapperFactory.createInstance(DialogMode.Save)) + .thenReturn(mockDialogWrapper); + when(mockDialogWrapperFactory.createInstance(DialogMode.Open)) + .thenReturn(mockDialogWrapper); + when(mockDialogWrapper.show(parentWindow)).thenReturn(expectedFileList); + }); + + tearDown(() { + reset(mockDialogWrapper); + reset(mockDialogWrapperFactory); + }); + + test('FileSelectorApi should not be null', () { + expect(fileSelectorApi, isNotNull); + }); + + group('showSaveDialog', () { + test('should call setFileName if a suggestedName is provided', () { + // Arrange + const String suggestedName = 'suggestedName'; + + // Act + fileSelectorApi.showSaveDialog(emptyOptions, null, suggestedName, null); + + // Assert + verify(mockDialogWrapper.setFileName(suggestedName)).called(1); + }); + + test('should create a DialogWrapper with DialogMode Save', () { + // Act + fileSelectorApi.showSaveDialog(emptyOptions, null, null, null); + + // Assert + verify(mockDialogWrapperFactory.createInstance(DialogMode.Save)) + .called(1); + }); + }); + group('showOpenDialog', () { + test('should create a DialogWrapper with DialogMode Open', () { + // Act + fileSelectorApi.showOpenDialog(emptyOptions, null, null); + + // Assert + verify(mockDialogWrapperFactory.createInstance(DialogMode.Open)) + .called(1); + }); + }); + group('Common behavior', () { + test('should throw a WindowsException is DialogWrapper can not be created', + () { + // Arrange + when(mockDialogWrapperFactory.createInstance(DialogMode.Open)) + .thenReturn(mockDialogWrapper); + when(mockDialogWrapper.lastResult).thenReturn(E_FAIL); + + // Act - Assert + expect(() => fileSelectorApi.showOpenDialog(emptyOptions, null, null), + throwsA(const TypeMatcher())); + }); + + test('should not call AddOptions if no options are configured', () { + // Act + fileSelectorApi.showOpenDialog(emptyOptions, null, null); + + // Assert + verifyNever(mockDialogWrapper.addOptions(any)); + }); + test('should call AddOptions with FOS_PICKFOLDERS configured', () { + // Arrange + final SelectionOptions options = SelectionOptions(selectFolders: true); + + // Act + fileSelectorApi.showOpenDialog(options, null, null); + + // Assert + verify(mockDialogWrapper + .addOptions(FILEOPENDIALOGOPTIONS.FOS_PICKFOLDERS)) + .called(1); + }); + + test('should call AddOptions with FOS_ALLOWMULTISELECT configured', () { + // Arrange + final SelectionOptions options = SelectionOptions(allowMultiple: true); + + // Act + fileSelectorApi.showOpenDialog(options, null, null); + + // Assert + verify(mockDialogWrapper + .addOptions(FILEOPENDIALOGOPTIONS.FOS_ALLOWMULTISELECT)) + .called(1); + }); + + test('should call setFolder if an initialDirectory is provided', () { + // Arrange + const String initialDirectory = 'path/to/dir'; + + // Act + fileSelectorApi.showOpenDialog(emptyOptions, initialDirectory, null); + + // Assert + verify(mockDialogWrapper.setFolder(initialDirectory)).called(1); + }); + + test('should call setOkButtonLabel if confirmButtonText is provided', () { + // Arrange + const String confirmButtonText = 'OK'; + + // Act + fileSelectorApi.showOpenDialog(emptyOptions, null, confirmButtonText); + + // Assert + verify(mockDialogWrapper.setOkButtonLabel(confirmButtonText)).called(1); + }); + + test('should call setFileTypeFilters with provided allowedTypes', + () { + // Arrange + final SelectionOptions options = + SelectionOptions(allowedTypes: [ + const XTypeGroup(extensions: ['jpg', 'png'], label: 'Images'), + const XTypeGroup(extensions: ['txt', 'json'], label: 'Text'), + ]); + + // Act + fileSelectorApi.showOpenDialog(options, null, null); + + // Assert + verify(mockDialogWrapper.setFileTypeFilters(options.allowedTypes)) + .called(1); + }); + + test('should return the file list on success', () { + // Act + final List result = + fileSelectorApi.showOpenDialog(emptyOptions, null, null); + + // Assert + expect(result.length, expectedFileList.length); + expect(result, expectedFileList); + }); + + test('should throw an exception if file list is null', () { + // Arrange + when(mockDialogWrapper.show(parentWindow)).thenReturn(null); + + // Act - Assert + expect(() => fileSelectorApi.showOpenDialog(emptyOptions, null, null), + throwsA(const TypeMatcher())); + }); + }); +} diff --git a/packages/file_selector/file_selector_windows/test/file_selector_api_test.mocks.dart b/packages/file_selector/file_selector_windows/test/file_selector_api_test.mocks.dart new file mode 100644 index 000000000000..e6544d2dda27 --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_api_test.mocks.dart @@ -0,0 +1,122 @@ +// Mocks generated by Mockito 5.3.2 from annotations +// in file_selector_windows/example/windows/flutter/ephemeral/.plugin_symlinks/file_selector_windows/test/file_selector_api_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart' + as _i5; +import 'package:file_selector_windows/src/file_selector_dart/dialog_mode.dart' + as _i4; +import 'package:file_selector_windows/src/file_selector_dart/dialog_wrapper.dart' + as _i2; +import 'package:file_selector_windows/src/file_selector_dart/dialog_wrapper_factory.dart' + as _i3; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeDialogWrapper_0 extends _i1.SmartFake implements _i2.DialogWrapper { + _FakeDialogWrapper_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [DialogWrapperFactory]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDialogWrapperFactory extends _i1.Mock + implements _i3.DialogWrapperFactory { + MockDialogWrapperFactory() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.DialogWrapper createInstance(_i4.DialogMode? dialogMode) => + (super.noSuchMethod( + Invocation.method( + #createInstance, + [dialogMode], + ), + returnValue: _FakeDialogWrapper_0( + this, + Invocation.method( + #createInstance, + [dialogMode], + ), + ), + ) as _i2.DialogWrapper); +} + +/// A class which mocks [DialogWrapper]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDialogWrapper extends _i1.Mock implements _i2.DialogWrapper { + MockDialogWrapper() { + _i1.throwOnMissingStub(this); + } + + @override + int get lastResult => (super.noSuchMethod( + Invocation.getter(#lastResult), + returnValue: 0, + ) as int); + @override + void setFolder(String? path) => super.noSuchMethod( + Invocation.method( + #setFolder, + [path], + ), + returnValueForMissingStub: null, + ); + @override + void setFileName(String? name) => super.noSuchMethod( + Invocation.method( + #setFileName, + [name], + ), + returnValueForMissingStub: null, + ); + @override + void setOkButtonLabel(String? label) => super.noSuchMethod( + Invocation.method( + #setOkButtonLabel, + [label], + ), + returnValueForMissingStub: null, + ); + @override + void addOptions(int? newOptions) => super.noSuchMethod( + Invocation.method( + #addOptions, + [newOptions], + ), + returnValueForMissingStub: null, + ); + @override + void setFileTypeFilters(List<_i5.XTypeGroup>? filters) => super.noSuchMethod( + Invocation.method( + #setFileTypeFilters, + [filters], + ), + returnValueForMissingStub: null, + ); + @override + List? show(int? parentWindow) => + (super.noSuchMethod(Invocation.method( + #show, + [parentWindow], + )) as List?); +}