From 0e6b8eee2d663a2ba38e5f17e7efdda043ab7ca7 Mon Sep 17 00:00:00 2001 From: MuZhou233 Date: Tue, 15 Oct 2024 19:02:18 +0800 Subject: [PATCH] feat(gebura): support scan local folder --- lib/bloc/gebura/gebura_bloc.dart | 51 ++ lib/bloc/gebura/gebura_event.dart | 10 + lib/bloc/gebura/gebura_state.dart | 12 + lib/common/app_scan/_native.dart | 219 ++++--- lib/common/app_scan/_web.dart | 4 +- lib/common/app_scan/model.dart | 54 +- lib/common/app_scan/model.freezed.dart | 362 +++++++++--- lib/common/app_scan/model.g.dart | 48 +- lib/view/form/form_field.dart | 16 + .../pages/gebura/gebura_library_settings.dart | 48 +- .../gebura_library_settings_common.dart | 544 +++++++++++++++++- .../gebura/gebura_library_settings_steam.dart | 5 +- pubspec.yaml | 2 + 13 files changed, 1186 insertions(+), 189 deletions(-) diff --git a/lib/bloc/gebura/gebura_bloc.dart b/lib/bloc/gebura/gebura_bloc.dart index de96c84..f958d32 100644 --- a/lib/bloc/gebura/gebura_bloc.dart +++ b/lib/bloc/gebura/gebura_bloc.dart @@ -9,6 +9,8 @@ import 'package:tuihub_protos/librarian/v1/common.pb.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:uuid/uuid.dart'; +import '../../common/app_scan/app_scan.dart'; +import '../../common/app_scan/model.dart'; import '../../common/bloc_event_status_mixin.dart'; import '../../common/platform.dart'; import '../../common/steam/steam.dart'; @@ -44,10 +46,17 @@ class GeburaBloc extends Bloc { .loadTrackedAppInsts() .asMap() .map((_, value) => MapEntry(value.uuid, value)); + final localCommonLibraryFolders = state.localCommonLibraryFolders ?? + _repo + .loadTrackedCommonAppFolders() + .asMap() + .map((_, value) => MapEntry(value.basePath, value)); + add(GeburaRefreshLibraryListEvent()); emit(state.copyWith( localTrackedApps: localTrackedApps, localTrackedAppInsts: localTrackedAppInsts, + localCommonLibraryFolders: localCommonLibraryFolders, )); }); @@ -229,6 +238,18 @@ class GeburaBloc extends Bloc { )); }); + on((event, emit) async { + final setting = event.setting; + await _repo.setTrackedCommonAppFolder(setting); + emit(state.copyWith( + localCommonLibraryFolders: { + ...state.localCommonLibraryFolders ?? {}, + setting.basePath: setting, + }, + )); + add(GeburaScanLocalLibraryEvent()); + }, transformer: droppable()); + on((event, emit) async { emit(GeburaSearchAppInfosState(state, EventStatus.processing)); final resp = await _api.doRequest( @@ -504,6 +525,36 @@ class GeburaBloc extends Bloc { }); on((event, emit) async { + add(GeburaScanLocalCommonLibraryEvent()); + add(GeburaScanLocalSteamLibraryEvent()); + }, transformer: droppable()); + + on((event, emit) async { + emit(state.copyWith( + localLibraryStateMessage: S.current.scanningLocalFiles)); + final folders = state.localCommonLibraryFolders ?? {}; + for (final folder in folders.values) { + final result = await scanCommonApps(folder); + final tracked = _repo.loadTrackedAppInsts(); + final unTracked = result.installedApps.where( + (element) => !tracked.any( + (t) => + t.type == LocalAppInstType.common && + t.path == element.installPath, + ), + ); + emit(state.copyWith( + localLibraryStateMessage: unTracked.isNotEmpty + ? S.current.newApplicationFound(unTracked.length) + : '', + localInstalledCommonAppInsts: result.installedApps + .asMap() + .map((_, value) => MapEntry(value.installPath, value)), + )); + } + }, transformer: droppable()); + + on((event, emit) async { if (!PlatformHelper.isWindowsApp()) { return; } diff --git a/lib/bloc/gebura/gebura_event.dart b/lib/bloc/gebura/gebura_event.dart index 4cb8542..db9e8e3 100644 --- a/lib/bloc/gebura/gebura_event.dart +++ b/lib/bloc/gebura/gebura_event.dart @@ -23,6 +23,10 @@ final class GeburaApplyLibraryFilterEvent extends GeburaEvent { final class GeburaScanLocalLibraryEvent extends GeburaEvent {} +final class GeburaScanLocalCommonLibraryEvent extends GeburaEvent {} + +final class GeburaScanLocalSteamLibraryEvent extends GeburaEvent {} + final class GeburaClearLocalLibraryStateEvent extends GeburaEvent {} final class GeburaTrackSteamAppsEvent extends GeburaEvent { @@ -61,6 +65,12 @@ final class GeburaLaunchLocalCommonAppInstEvent extends GeburaEvent { GeburaLaunchLocalCommonAppInstEvent(this.uuid); } +final class GeburaSaveLocalCommonAppFolderSettingEvent extends GeburaEvent { + final CommonAppFolderScanSetting setting; + + GeburaSaveLocalCommonAppFolderSettingEvent(this.setting); +} + // Events handle server data. // TODO: review diff --git a/lib/bloc/gebura/gebura_state.dart b/lib/bloc/gebura/gebura_state.dart index e642e09..d176e9f 100644 --- a/lib/bloc/gebura/gebura_state.dart +++ b/lib/bloc/gebura/gebura_state.dart @@ -18,7 +18,9 @@ class GeburaState { late LibrarySettings? librarySettings; late Map? localTrackedApps; late Map? localTrackedAppInsts; + late Map? localInstalledCommonAppInsts; late Map? localInstalledSteamAppInsts; + late Map? localCommonLibraryFolders; late List? localSteamLibraryFolders; Map> getAppInsts(Int64 id) { @@ -84,7 +86,9 @@ class GeburaState { this.librarySettings, this.localTrackedApps, this.localTrackedAppInsts, + this.localInstalledCommonAppInsts, this.localInstalledSteamAppInsts, + this.localCommonLibraryFolders, this.localSteamLibraryFolders, }); @@ -101,7 +105,9 @@ class GeburaState { LibrarySettings? librarySettings, Map? localTrackedApps, Map? localTrackedAppInsts, + Map? localInstalledCommonAppInsts, Map? localInstalledSteamAppInsts, + Map? localCommonLibraryFolders, List? localSteamLibraryFolders, }) { return GeburaState( @@ -118,8 +124,12 @@ class GeburaState { librarySettings: librarySettings ?? this.librarySettings, localTrackedApps: localTrackedApps ?? this.localTrackedApps, localTrackedAppInsts: localTrackedAppInsts ?? this.localTrackedAppInsts, + localInstalledCommonAppInsts: + localInstalledCommonAppInsts ?? this.localInstalledCommonAppInsts, localInstalledSteamAppInsts: localInstalledSteamAppInsts ?? this.localInstalledSteamAppInsts, + localCommonLibraryFolders: + localCommonLibraryFolders ?? this.localCommonLibraryFolders, localSteamLibraryFolders: localSteamLibraryFolders ?? this.localSteamLibraryFolders, ); @@ -140,7 +150,9 @@ class GeburaState { librarySettings = other.librarySettings; localTrackedApps = other.localTrackedApps; localTrackedAppInsts = other.localTrackedAppInsts; + localInstalledCommonAppInsts = other.localInstalledCommonAppInsts; localInstalledSteamAppInsts = other.localInstalledSteamAppInsts; + localCommonLibraryFolders = other.localCommonLibraryFolders; localSteamLibraryFolders = other.localSteamLibraryFolders; } } diff --git a/lib/common/app_scan/_native.dart b/lib/common/app_scan/_native.dart index 46745d5..88dc339 100644 --- a/lib/common/app_scan/_native.dart +++ b/lib/common/app_scan/_native.dart @@ -6,120 +6,191 @@ import 'package:universal_io/io.dart'; import '../platform.dart'; import 'model.dart'; -CommonAppFolderScanResult scanCommonApps(CommonAppFolderScanSetting setting) { +Future scanCommonApps( + CommonAppFolderScanSetting setting) async { if (!PlatformHelper.isWindowsApp()) { return const CommonAppFolderScanResult( installedApps: [], details: [], + code: CommonAppFolderScanResultCode.unavailable, ); } - final entries = _walkEntry(setting, setting.basePath); - if (entries == null) { - return const CommonAppFolderScanResult( + try { + final entries = await _walkEntry(setting, setting.basePath); + if (entries == null) { + return const CommonAppFolderScanResult( + installedApps: [], + details: [], + code: CommonAppFolderScanResultCode.baseFolderNotFound, + ); + } + final (installedApps, details) = _buildAppList(setting, entries); + return CommonAppFolderScanResult( + installedApps: installedApps, + details: details, + code: CommonAppFolderScanResultCode.success, + ); + } catch (e) { + return CommonAppFolderScanResult( installedApps: [], details: [], + code: CommonAppFolderScanResultCode.unknownError, + msg: e.toString(), ); } - final installedApps = _buildAppList(setting, entries); - return CommonAppFolderScanResult( - installedApps: installedApps, - details: entries, - ); } -List _buildAppList(CommonAppFolderScanSetting setting, - List entries) { +(List, List) + _buildAppList( + CommonAppFolderScanSetting setting, + List entries, +) { + entries.sort((a, b) => a.path.length.compareTo(b.path.length)); final Map entryMap = entries.asMap().map( (key, value) => MapEntry(value.path, value), ); final Map appMap = {}; + for (final entry in entryMap.values) { if (entry.status != CommonAppFolderScanEntryStatus.hit) { continue; } - final pathFields = entry.path.split(Platform.pathSeparator); - if (pathFields.length <= setting.pathFieldMatcher.length) { - continue; - } - final installPath = pathFields - .sublist( - 0, - pathFields.length - setting.pathFieldMatcher.length, - ) - .join(Platform.pathSeparator); + final pathFields = entry.path + .replaceFirst(setting.basePath + Platform.pathSeparator, '') + .split(Platform.pathSeparator); + for (var i = 0; i < pathFields.length; i++) { + if (i < setting.minInstallDirDepth) { + continue; + } + final currentPath = [ + setting.basePath, + pathFields.sublist(0, i).join(Platform.pathSeparator), + ].join(Platform.pathSeparator); + + if (appMap[currentPath] != null) { + if (setting.minExecutableDepth <= i && + i <= setting.maxExecutableDepth) { + appMap[currentPath]!.launcherPaths.add(entry.path); + } else { + entryMap[entry.path] = entry.copyWith( + status: CommonAppFolderScanEntryStatus.skipped, + ); + } + break; + } - var name = ''; - var version = ''; - for (var i = 0; - i < min(pathFields.length, setting.pathFieldMatcher.length); - i++) { - final fieldValue = pathFields[pathFields.length - i - 1]; - switch (setting.pathFieldMatcher[i]) { - case CommonAppFolderScanPathFieldMatcher.name: - name = fieldValue; - case CommonAppFolderScanPathFieldMatcher.version: - version = fieldValue; - case CommonAppFolderScanPathFieldMatcher.ignore: - continue; + if (i > setting.maxInstallDirDepth) { + break; + } + + if (i == pathFields.length - 1) { + appMap[currentPath] = InstalledCommonApps( + name: pathFields.last, + version: '', + installPath: currentPath, + launcherPaths: [entry.path], + ); + break; } } - if (appMap[installPath] != null) { - appMap[installPath]!.launcherPaths.add(entry.path); - } else { - appMap[installPath] = InstalledCommonApps( + + for (final app in appMap.values) { + final pathFields = app.installPath + .replaceFirst(setting.basePath, '') + .split(Platform.pathSeparator); + + var name = app.name; + var version = app.version; + late int endIndex; + switch (setting.pathFieldMatcherAlignment) { + case CommonAppFolderScanPathFieldMatcherAlignment.left: + endIndex = min(pathFields.length, setting.pathFieldMatcher.length); + case CommonAppFolderScanPathFieldMatcherAlignment.right: + endIndex = max(pathFields.length, setting.pathFieldMatcher.length); + } + for (var i = endIndex - pathFields.length; i < endIndex; i++) { + final fieldValue = pathFields[i]; + switch (setting.pathFieldMatcher[i]) { + case CommonAppFolderScanPathFieldMatcher.name: + name = fieldValue; + case CommonAppFolderScanPathFieldMatcher.version: + version = fieldValue; + case CommonAppFolderScanPathFieldMatcher.ignore: + continue; + } + } + appMap[app.installPath] = app.copyWith( name: name, version: version, - installPath: installPath, - launcherPaths: [entry.path], ); } } - return appMap.values.toList(); + final details = entryMap.values.toList(); + details.sort((a, b) => a.path.compareTo(b.path)); + return (appMap.values.toList(), details); } -List? _walkEntry( +Future?> _walkEntry( CommonAppFolderScanSetting setting, - String path, -) { - switch (FileSystemEntity.typeSync(path)) { - case FileSystemEntityType.directory: - return _walkDirectory(setting, path); - case FileSystemEntityType.file: - final result = _walkFile(setting, path); - if (result != null) { - return [result]; - } - return []; - case FileSystemEntityType.notFound: - return null; - case FileSystemEntityType.link: - case FileSystemEntityType.pipe: - case FileSystemEntityType.unixDomainSock: - } - return [ + String path, { + int? remainWalkDepth, +}) async { + final errRes = [ CommonAppFolderScanResultDetail( path: path, type: CommonAppFolderScanEntryType.unknown, status: CommonAppFolderScanEntryStatus.skipped, ) ]; + try { + switch (FileSystemEntity.typeSync(path)) { + case FileSystemEntityType.directory: + return _walkDirectory( + setting, + path, + remainWalkDepth ?? + setting.maxInstallDirDepth + setting.maxExecutableDepth, + ); + case FileSystemEntityType.file: + final result = await _walkFile( + setting, + path, + ); + if (result != null) { + return [result]; + } + return []; + case FileSystemEntityType.notFound: + return null; + case FileSystemEntityType.link: + case FileSystemEntityType.pipe: + case FileSystemEntityType.unixDomainSock: + } + return errRes; + } catch (e) { + return errRes; + } } -List? _walkDirectory( +Future?> _walkDirectory( CommonAppFolderScanSetting setting, String path, -) { - final skipped = CommonAppFolderScanResultDetail( - path: path, - type: CommonAppFolderScanEntryType.directory, - status: CommonAppFolderScanEntryStatus.skipped, - ); + int remainWalkDepth, +) async { try { final dir = Directory(path); if (!dir.existsSync()) { return null; } + final skipped = CommonAppFolderScanResultDetail( + path: path, + type: CommonAppFolderScanEntryType.directory, + status: CommonAppFolderScanEntryStatus.skipped, + ); + if (remainWalkDepth == 0) { + return [skipped]; + } final dirName = dir.path.split(Platform.pathSeparator).last; for (final matcher in setting.excludeDirectoryMatchers) { if (Glob(matcher).matches(dirName)) { @@ -135,7 +206,11 @@ List? _walkDirectory( ) ]; for (final entry in entries) { - final details = _walkEntry(setting, entry.path); + final details = await _walkEntry( + setting, + entry.path, + remainWalkDepth: remainWalkDepth - 1, + ); if (details != null) { result.addAll(details); } @@ -152,10 +227,10 @@ List? _walkDirectory( } } -CommonAppFolderScanResultDetail? _walkFile( +Future _walkFile( CommonAppFolderScanSetting setting, String path, -) { +) async { final skipped = CommonAppFolderScanResultDetail( path: path, type: CommonAppFolderScanEntryType.file, @@ -168,7 +243,7 @@ CommonAppFolderScanResultDetail? _walkFile( } final fileName = file.path.split(Platform.pathSeparator).last; bool isMatched = false; - for (final matcher in setting.targetFileMatchers) { + for (final matcher in setting.includeExecutableMatchers) { if (Glob(matcher).matches(fileName)) { isMatched = true; break; @@ -177,7 +252,7 @@ CommonAppFolderScanResultDetail? _walkFile( if (!isMatched) { return skipped; } - for (final matcher in setting.excludeFileMatchers) { + for (final matcher in setting.excludeExecutableMatchers) { if (Glob(matcher).matches(fileName)) { return skipped; } diff --git a/lib/common/app_scan/_web.dart b/lib/common/app_scan/_web.dart index 0f7d0cf..b4704cf 100644 --- a/lib/common/app_scan/_web.dart +++ b/lib/common/app_scan/_web.dart @@ -1,8 +1,10 @@ import 'model.dart'; -CommonAppFolderScanResult scanCommonApps(CommonAppFolderScanSetting setting) { +Future scanCommonApps( + CommonAppFolderScanSetting setting) async { return const CommonAppFolderScanResult( installedApps: [], details: [], + code: CommonAppFolderScanResultCode.unavailable, ); } diff --git a/lib/common/app_scan/model.dart b/lib/common/app_scan/model.dart index 3b0572b..bde89c4 100644 --- a/lib/common/app_scan/model.dart +++ b/lib/common/app_scan/model.dart @@ -52,6 +52,11 @@ enum CommonAppFolderScanPathFieldMatcher { version, } +enum CommonAppFolderScanPathFieldMatcherAlignment { + left, + right, +} + enum CommonAppFolderScanEntryType { file, directory, @@ -68,13 +73,43 @@ enum CommonAppFolderScanEntryStatus { @freezed class CommonAppFolderScanSetting with _$CommonAppFolderScanSetting { const factory CommonAppFolderScanSetting({ + // base path required String basePath, - required List targetFileMatchers, - required List excludeFileMatchers, - required List excludeDirectoryMatchers, - required List pathFieldMatcher, + // install dir matcher + required List excludeDirectoryMatchers, // walk + required int minInstallDirDepth, // build + required int maxInstallDirDepth, // build + required List + pathFieldMatcher, // build + required CommonAppFolderScanPathFieldMatcherAlignment + pathFieldMatcherAlignment, // build + required List includeExecutableMatchers, // walk + required List excludeExecutableMatchers, // walk + // extra executable file matcher + required int minExecutableDepth, // build + required int maxExecutableDepth, // build }) = _CommonAppFolderScanSetting; + factory CommonAppFolderScanSetting.empty() => + const CommonAppFolderScanSetting( + basePath: '', + excludeDirectoryMatchers: + defaultCommonAppFolderScanExcludeDirectoryMatchers, + minInstallDirDepth: 1, + maxInstallDirDepth: 1, + pathFieldMatcher: [ + CommonAppFolderScanPathFieldMatcher.name, + CommonAppFolderScanPathFieldMatcher.ignore, + ], + pathFieldMatcherAlignment: + CommonAppFolderScanPathFieldMatcherAlignment.left, + includeExecutableMatchers: defaultCommonAppFolderScanTargetFileMatchers, + excludeExecutableMatchers: + defaultCommonAppFolderScanExcludeFileMatchers, + minExecutableDepth: 1, + maxExecutableDepth: 2, + ); + factory CommonAppFolderScanSetting.fromJson(Map json) => _$CommonAppFolderScanSettingFromJson(json); } @@ -84,12 +119,21 @@ class CommonAppFolderScanResult with _$CommonAppFolderScanResult { const factory CommonAppFolderScanResult({ required List installedApps, required List details, + required CommonAppFolderScanResultCode code, + String? msg, }) = _CommonAppFolderScanResult; factory CommonAppFolderScanResult.fromJson(Map json) => _$CommonAppFolderScanResultFromJson(json); } +enum CommonAppFolderScanResultCode { + unavailable, + unknownError, + baseFolderNotFound, + success, +} + @freezed class CommonAppFolderScanResultDetail with _$CommonAppFolderScanResultDetail { const factory CommonAppFolderScanResultDetail({ @@ -103,7 +147,7 @@ class CommonAppFolderScanResultDetail with _$CommonAppFolderScanResultDetail { _$CommonAppFolderScanResultDetailFromJson(json); } -@freezed +@Freezed(makeCollectionsUnmodifiable: false) class InstalledCommonApps with _$InstalledCommonApps { const factory InstalledCommonApps({ required String name, diff --git a/lib/common/app_scan/model.freezed.dart b/lib/common/app_scan/model.freezed.dart index c1a4400..cc840b2 100644 --- a/lib/common/app_scan/model.freezed.dart +++ b/lib/common/app_scan/model.freezed.dart @@ -21,13 +21,24 @@ CommonAppFolderScanSetting _$CommonAppFolderScanSettingFromJson( /// @nodoc mixin _$CommonAppFolderScanSetting { - String get basePath => throw _privateConstructorUsedError; - List get targetFileMatchers => throw _privateConstructorUsedError; - List get excludeFileMatchers => throw _privateConstructorUsedError; +// base path + String get basePath => + throw _privateConstructorUsedError; // install dir matcher List get excludeDirectoryMatchers => - throw _privateConstructorUsedError; + throw _privateConstructorUsedError; // walk + int get minInstallDirDepth => throw _privateConstructorUsedError; // build + int get maxInstallDirDepth => throw _privateConstructorUsedError; // build List get pathFieldMatcher => - throw _privateConstructorUsedError; + throw _privateConstructorUsedError; // build + CommonAppFolderScanPathFieldMatcherAlignment get pathFieldMatcherAlignment => + throw _privateConstructorUsedError; // build + List get includeExecutableMatchers => + throw _privateConstructorUsedError; // walk + List get excludeExecutableMatchers => + throw _privateConstructorUsedError; // walk +// extra executable file matcher + int get minExecutableDepth => throw _privateConstructorUsedError; // build + int get maxExecutableDepth => throw _privateConstructorUsedError; /// Serializes this CommonAppFolderScanSetting to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -48,10 +59,15 @@ abstract class $CommonAppFolderScanSettingCopyWith<$Res> { @useResult $Res call( {String basePath, - List targetFileMatchers, - List excludeFileMatchers, List excludeDirectoryMatchers, - List pathFieldMatcher}); + int minInstallDirDepth, + int maxInstallDirDepth, + List pathFieldMatcher, + CommonAppFolderScanPathFieldMatcherAlignment pathFieldMatcherAlignment, + List includeExecutableMatchers, + List excludeExecutableMatchers, + int minExecutableDepth, + int maxExecutableDepth}); } /// @nodoc @@ -71,32 +87,57 @@ class _$CommonAppFolderScanSettingCopyWithImpl<$Res, @override $Res call({ Object? basePath = null, - Object? targetFileMatchers = null, - Object? excludeFileMatchers = null, Object? excludeDirectoryMatchers = null, + Object? minInstallDirDepth = null, + Object? maxInstallDirDepth = null, Object? pathFieldMatcher = null, + Object? pathFieldMatcherAlignment = null, + Object? includeExecutableMatchers = null, + Object? excludeExecutableMatchers = null, + Object? minExecutableDepth = null, + Object? maxExecutableDepth = null, }) { return _then(_value.copyWith( basePath: null == basePath ? _value.basePath : basePath // ignore: cast_nullable_to_non_nullable as String, - targetFileMatchers: null == targetFileMatchers - ? _value.targetFileMatchers - : targetFileMatchers // ignore: cast_nullable_to_non_nullable - as List, - excludeFileMatchers: null == excludeFileMatchers - ? _value.excludeFileMatchers - : excludeFileMatchers // ignore: cast_nullable_to_non_nullable - as List, excludeDirectoryMatchers: null == excludeDirectoryMatchers ? _value.excludeDirectoryMatchers : excludeDirectoryMatchers // ignore: cast_nullable_to_non_nullable as List, + minInstallDirDepth: null == minInstallDirDepth + ? _value.minInstallDirDepth + : minInstallDirDepth // ignore: cast_nullable_to_non_nullable + as int, + maxInstallDirDepth: null == maxInstallDirDepth + ? _value.maxInstallDirDepth + : maxInstallDirDepth // ignore: cast_nullable_to_non_nullable + as int, pathFieldMatcher: null == pathFieldMatcher ? _value.pathFieldMatcher : pathFieldMatcher // ignore: cast_nullable_to_non_nullable as List, + pathFieldMatcherAlignment: null == pathFieldMatcherAlignment + ? _value.pathFieldMatcherAlignment + : pathFieldMatcherAlignment // ignore: cast_nullable_to_non_nullable + as CommonAppFolderScanPathFieldMatcherAlignment, + includeExecutableMatchers: null == includeExecutableMatchers + ? _value.includeExecutableMatchers + : includeExecutableMatchers // ignore: cast_nullable_to_non_nullable + as List, + excludeExecutableMatchers: null == excludeExecutableMatchers + ? _value.excludeExecutableMatchers + : excludeExecutableMatchers // ignore: cast_nullable_to_non_nullable + as List, + minExecutableDepth: null == minExecutableDepth + ? _value.minExecutableDepth + : minExecutableDepth // ignore: cast_nullable_to_non_nullable + as int, + maxExecutableDepth: null == maxExecutableDepth + ? _value.maxExecutableDepth + : maxExecutableDepth // ignore: cast_nullable_to_non_nullable + as int, ) as $Val); } } @@ -112,10 +153,15 @@ abstract class _$$CommonAppFolderScanSettingImplCopyWith<$Res> @useResult $Res call( {String basePath, - List targetFileMatchers, - List excludeFileMatchers, List excludeDirectoryMatchers, - List pathFieldMatcher}); + int minInstallDirDepth, + int maxInstallDirDepth, + List pathFieldMatcher, + CommonAppFolderScanPathFieldMatcherAlignment pathFieldMatcherAlignment, + List includeExecutableMatchers, + List excludeExecutableMatchers, + int minExecutableDepth, + int maxExecutableDepth}); } /// @nodoc @@ -134,32 +180,57 @@ class __$$CommonAppFolderScanSettingImplCopyWithImpl<$Res> @override $Res call({ Object? basePath = null, - Object? targetFileMatchers = null, - Object? excludeFileMatchers = null, Object? excludeDirectoryMatchers = null, + Object? minInstallDirDepth = null, + Object? maxInstallDirDepth = null, Object? pathFieldMatcher = null, + Object? pathFieldMatcherAlignment = null, + Object? includeExecutableMatchers = null, + Object? excludeExecutableMatchers = null, + Object? minExecutableDepth = null, + Object? maxExecutableDepth = null, }) { return _then(_$CommonAppFolderScanSettingImpl( basePath: null == basePath ? _value.basePath : basePath // ignore: cast_nullable_to_non_nullable as String, - targetFileMatchers: null == targetFileMatchers - ? _value._targetFileMatchers - : targetFileMatchers // ignore: cast_nullable_to_non_nullable - as List, - excludeFileMatchers: null == excludeFileMatchers - ? _value._excludeFileMatchers - : excludeFileMatchers // ignore: cast_nullable_to_non_nullable - as List, excludeDirectoryMatchers: null == excludeDirectoryMatchers ? _value._excludeDirectoryMatchers : excludeDirectoryMatchers // ignore: cast_nullable_to_non_nullable as List, + minInstallDirDepth: null == minInstallDirDepth + ? _value.minInstallDirDepth + : minInstallDirDepth // ignore: cast_nullable_to_non_nullable + as int, + maxInstallDirDepth: null == maxInstallDirDepth + ? _value.maxInstallDirDepth + : maxInstallDirDepth // ignore: cast_nullable_to_non_nullable + as int, pathFieldMatcher: null == pathFieldMatcher ? _value._pathFieldMatcher : pathFieldMatcher // ignore: cast_nullable_to_non_nullable as List, + pathFieldMatcherAlignment: null == pathFieldMatcherAlignment + ? _value.pathFieldMatcherAlignment + : pathFieldMatcherAlignment // ignore: cast_nullable_to_non_nullable + as CommonAppFolderScanPathFieldMatcherAlignment, + includeExecutableMatchers: null == includeExecutableMatchers + ? _value._includeExecutableMatchers + : includeExecutableMatchers // ignore: cast_nullable_to_non_nullable + as List, + excludeExecutableMatchers: null == excludeExecutableMatchers + ? _value._excludeExecutableMatchers + : excludeExecutableMatchers // ignore: cast_nullable_to_non_nullable + as List, + minExecutableDepth: null == minExecutableDepth + ? _value.minExecutableDepth + : minExecutableDepth // ignore: cast_nullable_to_non_nullable + as int, + maxExecutableDepth: null == maxExecutableDepth + ? _value.maxExecutableDepth + : maxExecutableDepth // ignore: cast_nullable_to_non_nullable + as int, )); } } @@ -169,41 +240,30 @@ class __$$CommonAppFolderScanSettingImplCopyWithImpl<$Res> class _$CommonAppFolderScanSettingImpl implements _CommonAppFolderScanSetting { const _$CommonAppFolderScanSettingImpl( {required this.basePath, - required final List targetFileMatchers, - required final List excludeFileMatchers, required final List excludeDirectoryMatchers, - required final List - pathFieldMatcher}) - : _targetFileMatchers = targetFileMatchers, - _excludeFileMatchers = excludeFileMatchers, - _excludeDirectoryMatchers = excludeDirectoryMatchers, - _pathFieldMatcher = pathFieldMatcher; + required this.minInstallDirDepth, + required this.maxInstallDirDepth, + required final List pathFieldMatcher, + required this.pathFieldMatcherAlignment, + required final List includeExecutableMatchers, + required final List excludeExecutableMatchers, + required this.minExecutableDepth, + required this.maxExecutableDepth}) + : _excludeDirectoryMatchers = excludeDirectoryMatchers, + _pathFieldMatcher = pathFieldMatcher, + _includeExecutableMatchers = includeExecutableMatchers, + _excludeExecutableMatchers = excludeExecutableMatchers; factory _$CommonAppFolderScanSettingImpl.fromJson( Map json) => _$$CommonAppFolderScanSettingImplFromJson(json); +// base path @override final String basePath; - final List _targetFileMatchers; - @override - List get targetFileMatchers { - if (_targetFileMatchers is EqualUnmodifiableListView) - return _targetFileMatchers; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_targetFileMatchers); - } - - final List _excludeFileMatchers; - @override - List get excludeFileMatchers { - if (_excludeFileMatchers is EqualUnmodifiableListView) - return _excludeFileMatchers; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_excludeFileMatchers); - } - +// install dir matcher final List _excludeDirectoryMatchers; +// install dir matcher @override List get excludeDirectoryMatchers { if (_excludeDirectoryMatchers is EqualUnmodifiableListView) @@ -212,7 +272,15 @@ class _$CommonAppFolderScanSettingImpl implements _CommonAppFolderScanSetting { return EqualUnmodifiableListView(_excludeDirectoryMatchers); } +// walk + @override + final int minInstallDirDepth; +// build + @override + final int maxInstallDirDepth; +// build final List _pathFieldMatcher; +// build @override List get pathFieldMatcher { if (_pathFieldMatcher is EqualUnmodifiableListView) @@ -221,9 +289,42 @@ class _$CommonAppFolderScanSettingImpl implements _CommonAppFolderScanSetting { return EqualUnmodifiableListView(_pathFieldMatcher); } +// build + @override + final CommonAppFolderScanPathFieldMatcherAlignment pathFieldMatcherAlignment; +// build + final List _includeExecutableMatchers; +// build + @override + List get includeExecutableMatchers { + if (_includeExecutableMatchers is EqualUnmodifiableListView) + return _includeExecutableMatchers; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_includeExecutableMatchers); + } + +// walk + final List _excludeExecutableMatchers; +// walk + @override + List get excludeExecutableMatchers { + if (_excludeExecutableMatchers is EqualUnmodifiableListView) + return _excludeExecutableMatchers; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_excludeExecutableMatchers); + } + +// walk +// extra executable file matcher + @override + final int minExecutableDepth; +// build + @override + final int maxExecutableDepth; + @override String toString() { - return 'CommonAppFolderScanSetting(basePath: $basePath, targetFileMatchers: $targetFileMatchers, excludeFileMatchers: $excludeFileMatchers, excludeDirectoryMatchers: $excludeDirectoryMatchers, pathFieldMatcher: $pathFieldMatcher)'; + return 'CommonAppFolderScanSetting(basePath: $basePath, excludeDirectoryMatchers: $excludeDirectoryMatchers, minInstallDirDepth: $minInstallDirDepth, maxInstallDirDepth: $maxInstallDirDepth, pathFieldMatcher: $pathFieldMatcher, pathFieldMatcherAlignment: $pathFieldMatcherAlignment, includeExecutableMatchers: $includeExecutableMatchers, excludeExecutableMatchers: $excludeExecutableMatchers, minExecutableDepth: $minExecutableDepth, maxExecutableDepth: $maxExecutableDepth)'; } @override @@ -233,14 +334,25 @@ class _$CommonAppFolderScanSettingImpl implements _CommonAppFolderScanSetting { other is _$CommonAppFolderScanSettingImpl && (identical(other.basePath, basePath) || other.basePath == basePath) && - const DeepCollectionEquality() - .equals(other._targetFileMatchers, _targetFileMatchers) && - const DeepCollectionEquality() - .equals(other._excludeFileMatchers, _excludeFileMatchers) && const DeepCollectionEquality().equals( other._excludeDirectoryMatchers, _excludeDirectoryMatchers) && + (identical(other.minInstallDirDepth, minInstallDirDepth) || + other.minInstallDirDepth == minInstallDirDepth) && + (identical(other.maxInstallDirDepth, maxInstallDirDepth) || + other.maxInstallDirDepth == maxInstallDirDepth) && const DeepCollectionEquality() - .equals(other._pathFieldMatcher, _pathFieldMatcher)); + .equals(other._pathFieldMatcher, _pathFieldMatcher) && + (identical(other.pathFieldMatcherAlignment, + pathFieldMatcherAlignment) || + other.pathFieldMatcherAlignment == pathFieldMatcherAlignment) && + const DeepCollectionEquality().equals( + other._includeExecutableMatchers, _includeExecutableMatchers) && + const DeepCollectionEquality().equals( + other._excludeExecutableMatchers, _excludeExecutableMatchers) && + (identical(other.minExecutableDepth, minExecutableDepth) || + other.minExecutableDepth == minExecutableDepth) && + (identical(other.maxExecutableDepth, maxExecutableDepth) || + other.maxExecutableDepth == maxExecutableDepth)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -248,10 +360,15 @@ class _$CommonAppFolderScanSettingImpl implements _CommonAppFolderScanSetting { int get hashCode => Object.hash( runtimeType, basePath, - const DeepCollectionEquality().hash(_targetFileMatchers), - const DeepCollectionEquality().hash(_excludeFileMatchers), const DeepCollectionEquality().hash(_excludeDirectoryMatchers), - const DeepCollectionEquality().hash(_pathFieldMatcher)); + minInstallDirDepth, + maxInstallDirDepth, + const DeepCollectionEquality().hash(_pathFieldMatcher), + pathFieldMatcherAlignment, + const DeepCollectionEquality().hash(_includeExecutableMatchers), + const DeepCollectionEquality().hash(_excludeExecutableMatchers), + minExecutableDepth, + maxExecutableDepth); /// Create a copy of CommonAppFolderScanSetting /// with the given fields replaced by the non-null parameter values. @@ -274,25 +391,44 @@ abstract class _CommonAppFolderScanSetting implements CommonAppFolderScanSetting { const factory _CommonAppFolderScanSetting( {required final String basePath, - required final List targetFileMatchers, - required final List excludeFileMatchers, required final List excludeDirectoryMatchers, - required final List - pathFieldMatcher}) = _$CommonAppFolderScanSettingImpl; + required final int minInstallDirDepth, + required final int maxInstallDirDepth, + required final List pathFieldMatcher, + required final CommonAppFolderScanPathFieldMatcherAlignment + pathFieldMatcherAlignment, + required final List includeExecutableMatchers, + required final List excludeExecutableMatchers, + required final int minExecutableDepth, + required final int + maxExecutableDepth}) = _$CommonAppFolderScanSettingImpl; factory _CommonAppFolderScanSetting.fromJson(Map json) = _$CommonAppFolderScanSettingImpl.fromJson; +// base path + @override + String get basePath; // install dir matcher + @override + List get excludeDirectoryMatchers; // walk @override - String get basePath; + int get minInstallDirDepth; // build @override - List get targetFileMatchers; + int get maxInstallDirDepth; // build @override - List get excludeFileMatchers; + List get pathFieldMatcher; // build @override - List get excludeDirectoryMatchers; + CommonAppFolderScanPathFieldMatcherAlignment + get pathFieldMatcherAlignment; // build @override - List get pathFieldMatcher; + List get includeExecutableMatchers; // walk + @override + List get excludeExecutableMatchers; // walk +// extra executable file matcher + @override + int get minExecutableDepth; // build + @override + int get maxExecutableDepth; /// Create a copy of CommonAppFolderScanSetting /// with the given fields replaced by the non-null parameter values. @@ -313,6 +449,8 @@ mixin _$CommonAppFolderScanResult { throw _privateConstructorUsedError; List get details => throw _privateConstructorUsedError; + CommonAppFolderScanResultCode get code => throw _privateConstructorUsedError; + String? get msg => throw _privateConstructorUsedError; /// Serializes this CommonAppFolderScanResult to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -332,7 +470,9 @@ abstract class $CommonAppFolderScanResultCopyWith<$Res> { @useResult $Res call( {List installedApps, - List details}); + List details, + CommonAppFolderScanResultCode code, + String? msg}); } /// @nodoc @@ -353,6 +493,8 @@ class _$CommonAppFolderScanResultCopyWithImpl<$Res, $Res call({ Object? installedApps = null, Object? details = null, + Object? code = null, + Object? msg = freezed, }) { return _then(_value.copyWith( installedApps: null == installedApps @@ -363,6 +505,14 @@ class _$CommonAppFolderScanResultCopyWithImpl<$Res, ? _value.details : details // ignore: cast_nullable_to_non_nullable as List, + code: null == code + ? _value.code + : code // ignore: cast_nullable_to_non_nullable + as CommonAppFolderScanResultCode, + msg: freezed == msg + ? _value.msg + : msg // ignore: cast_nullable_to_non_nullable + as String?, ) as $Val); } } @@ -378,7 +528,9 @@ abstract class _$$CommonAppFolderScanResultImplCopyWith<$Res> @useResult $Res call( {List installedApps, - List details}); + List details, + CommonAppFolderScanResultCode code, + String? msg}); } /// @nodoc @@ -398,6 +550,8 @@ class __$$CommonAppFolderScanResultImplCopyWithImpl<$Res> $Res call({ Object? installedApps = null, Object? details = null, + Object? code = null, + Object? msg = freezed, }) { return _then(_$CommonAppFolderScanResultImpl( installedApps: null == installedApps @@ -408,6 +562,14 @@ class __$$CommonAppFolderScanResultImplCopyWithImpl<$Res> ? _value._details : details // ignore: cast_nullable_to_non_nullable as List, + code: null == code + ? _value.code + : code // ignore: cast_nullable_to_non_nullable + as CommonAppFolderScanResultCode, + msg: freezed == msg + ? _value.msg + : msg // ignore: cast_nullable_to_non_nullable + as String?, )); } } @@ -417,7 +579,9 @@ class __$$CommonAppFolderScanResultImplCopyWithImpl<$Res> class _$CommonAppFolderScanResultImpl implements _CommonAppFolderScanResult { const _$CommonAppFolderScanResultImpl( {required final List installedApps, - required final List details}) + required final List details, + required this.code, + this.msg}) : _installedApps = installedApps, _details = details; @@ -440,9 +604,14 @@ class _$CommonAppFolderScanResultImpl implements _CommonAppFolderScanResult { return EqualUnmodifiableListView(_details); } + @override + final CommonAppFolderScanResultCode code; + @override + final String? msg; + @override String toString() { - return 'CommonAppFolderScanResult(installedApps: $installedApps, details: $details)'; + return 'CommonAppFolderScanResult(installedApps: $installedApps, details: $details, code: $code, msg: $msg)'; } @override @@ -452,7 +621,9 @@ class _$CommonAppFolderScanResultImpl implements _CommonAppFolderScanResult { other is _$CommonAppFolderScanResultImpl && const DeepCollectionEquality() .equals(other._installedApps, _installedApps) && - const DeepCollectionEquality().equals(other._details, _details)); + const DeepCollectionEquality().equals(other._details, _details) && + (identical(other.code, code) || other.code == code) && + (identical(other.msg, msg) || other.msg == msg)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -460,7 +631,9 @@ class _$CommonAppFolderScanResultImpl implements _CommonAppFolderScanResult { int get hashCode => Object.hash( runtimeType, const DeepCollectionEquality().hash(_installedApps), - const DeepCollectionEquality().hash(_details)); + const DeepCollectionEquality().hash(_details), + code, + msg); /// Create a copy of CommonAppFolderScanResult /// with the given fields replaced by the non-null parameter values. @@ -481,9 +654,10 @@ class _$CommonAppFolderScanResultImpl implements _CommonAppFolderScanResult { abstract class _CommonAppFolderScanResult implements CommonAppFolderScanResult { const factory _CommonAppFolderScanResult( - {required final List installedApps, - required final List details}) = - _$CommonAppFolderScanResultImpl; + {required final List installedApps, + required final List details, + required final CommonAppFolderScanResultCode code, + final String? msg}) = _$CommonAppFolderScanResultImpl; factory _CommonAppFolderScanResult.fromJson(Map json) = _$CommonAppFolderScanResultImpl.fromJson; @@ -492,6 +666,10 @@ abstract class _CommonAppFolderScanResult implements CommonAppFolderScanResult { List get installedApps; @override List get details; + @override + CommonAppFolderScanResultCode get code; + @override + String? get msg; /// Create a copy of CommonAppFolderScanResult /// with the given fields replaced by the non-null parameter values. @@ -861,7 +1039,7 @@ class __$$InstalledCommonAppsImplCopyWithImpl<$Res> : installPath // ignore: cast_nullable_to_non_nullable as String, launcherPaths: null == launcherPaths - ? _value._launcherPaths + ? _value.launcherPaths : launcherPaths // ignore: cast_nullable_to_non_nullable as List, )); @@ -875,8 +1053,7 @@ class _$InstalledCommonAppsImpl implements _InstalledCommonApps { {required this.name, required this.version, required this.installPath, - required final List launcherPaths}) - : _launcherPaths = launcherPaths; + required this.launcherPaths}); factory _$InstalledCommonAppsImpl.fromJson(Map json) => _$$InstalledCommonAppsImplFromJson(json); @@ -887,13 +1064,8 @@ class _$InstalledCommonAppsImpl implements _InstalledCommonApps { final String version; @override final String installPath; - final List _launcherPaths; @override - List get launcherPaths { - if (_launcherPaths is EqualUnmodifiableListView) return _launcherPaths; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_launcherPaths); - } + final List launcherPaths; @override String toString() { @@ -910,13 +1082,13 @@ class _$InstalledCommonAppsImpl implements _InstalledCommonApps { (identical(other.installPath, installPath) || other.installPath == installPath) && const DeepCollectionEquality() - .equals(other._launcherPaths, _launcherPaths)); + .equals(other.launcherPaths, launcherPaths)); } @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, name, version, installPath, - const DeepCollectionEquality().hash(_launcherPaths)); + const DeepCollectionEquality().hash(launcherPaths)); /// Create a copy of InstalledCommonApps /// with the given fields replaced by the non-null parameter values. diff --git a/lib/common/app_scan/model.g.dart b/lib/common/app_scan/model.g.dart index f3a65ef..a26ef10 100644 --- a/lib/common/app_scan/model.g.dart +++ b/lib/common/app_scan/model.g.dart @@ -10,32 +10,48 @@ _$CommonAppFolderScanSettingImpl _$$CommonAppFolderScanSettingImplFromJson( Map json) => _$CommonAppFolderScanSettingImpl( basePath: json['basePath'] as String, - targetFileMatchers: (json['targetFileMatchers'] as List) - .map((e) => e as String) - .toList(), - excludeFileMatchers: (json['excludeFileMatchers'] as List) - .map((e) => e as String) - .toList(), excludeDirectoryMatchers: (json['excludeDirectoryMatchers'] as List) .map((e) => e as String) .toList(), + minInstallDirDepth: (json['minInstallDirDepth'] as num).toInt(), + maxInstallDirDepth: (json['maxInstallDirDepth'] as num).toInt(), pathFieldMatcher: (json['pathFieldMatcher'] as List) .map((e) => $enumDecode(_$CommonAppFolderScanPathFieldMatcherEnumMap, e)) .toList(), + pathFieldMatcherAlignment: $enumDecode( + _$CommonAppFolderScanPathFieldMatcherAlignmentEnumMap, + json['pathFieldMatcherAlignment']), + includeExecutableMatchers: + (json['includeExecutableMatchers'] as List) + .map((e) => e as String) + .toList(), + excludeExecutableMatchers: + (json['excludeExecutableMatchers'] as List) + .map((e) => e as String) + .toList(), + minExecutableDepth: (json['minExecutableDepth'] as num).toInt(), + maxExecutableDepth: (json['maxExecutableDepth'] as num).toInt(), ); Map _$$CommonAppFolderScanSettingImplToJson( _$CommonAppFolderScanSettingImpl instance) => { 'basePath': instance.basePath, - 'targetFileMatchers': instance.targetFileMatchers, - 'excludeFileMatchers': instance.excludeFileMatchers, 'excludeDirectoryMatchers': instance.excludeDirectoryMatchers, + 'minInstallDirDepth': instance.minInstallDirDepth, + 'maxInstallDirDepth': instance.maxInstallDirDepth, 'pathFieldMatcher': instance.pathFieldMatcher .map((e) => _$CommonAppFolderScanPathFieldMatcherEnumMap[e]!) .toList(), + 'pathFieldMatcherAlignment': + _$CommonAppFolderScanPathFieldMatcherAlignmentEnumMap[ + instance.pathFieldMatcherAlignment]!, + 'includeExecutableMatchers': instance.includeExecutableMatchers, + 'excludeExecutableMatchers': instance.excludeExecutableMatchers, + 'minExecutableDepth': instance.minExecutableDepth, + 'maxExecutableDepth': instance.maxExecutableDepth, }; const _$CommonAppFolderScanPathFieldMatcherEnumMap = { @@ -44,6 +60,11 @@ const _$CommonAppFolderScanPathFieldMatcherEnumMap = { CommonAppFolderScanPathFieldMatcher.version: 'version', }; +const _$CommonAppFolderScanPathFieldMatcherAlignmentEnumMap = { + CommonAppFolderScanPathFieldMatcherAlignment.left: 'left', + CommonAppFolderScanPathFieldMatcherAlignment.right: 'right', +}; + _$CommonAppFolderScanResultImpl _$$CommonAppFolderScanResultImplFromJson( Map json) => _$CommonAppFolderScanResultImpl( @@ -54,6 +75,8 @@ _$CommonAppFolderScanResultImpl _$$CommonAppFolderScanResultImplFromJson( .map((e) => CommonAppFolderScanResultDetail.fromJson( e as Map)) .toList(), + code: $enumDecode(_$CommonAppFolderScanResultCodeEnumMap, json['code']), + msg: json['msg'] as String?, ); Map _$$CommonAppFolderScanResultImplToJson( @@ -61,8 +84,17 @@ Map _$$CommonAppFolderScanResultImplToJson( { 'installedApps': instance.installedApps, 'details': instance.details, + 'code': _$CommonAppFolderScanResultCodeEnumMap[instance.code]!, + 'msg': instance.msg, }; +const _$CommonAppFolderScanResultCodeEnumMap = { + CommonAppFolderScanResultCode.unavailable: 'unavailable', + CommonAppFolderScanResultCode.unknownError: 'unknownError', + CommonAppFolderScanResultCode.baseFolderNotFound: 'baseFolderNotFound', + CommonAppFolderScanResultCode.success: 'success', +}; + _$CommonAppFolderScanResultDetailImpl _$$CommonAppFolderScanResultDetailImplFromJson(Map json) => _$CommonAppFolderScanResultDetailImpl( diff --git a/lib/view/form/form_field.dart b/lib/view/form/form_field.dart index 8c95962..11a5b07 100644 --- a/lib/view/form/form_field.dart +++ b/lib/view/form/form_field.dart @@ -1,5 +1,21 @@ import 'package:flutter/material.dart'; +class SectionDividerFormField extends FormField { + SectionDividerFormField({super.key, required Widget title}) + : super( + builder: (FormFieldState state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + title, + const Divider(), + ], + ); + }, + ); +} + class CheckboxFormField extends FormField { CheckboxFormField( {super.key, diff --git a/lib/view/pages/gebura/gebura_library_settings.dart b/lib/view/pages/gebura/gebura_library_settings.dart index d591e73..f9231a5 100644 --- a/lib/view/pages/gebura/gebura_library_settings.dart +++ b/lib/view/pages/gebura/gebura_library_settings.dart @@ -1,15 +1,28 @@ +import 'dart:convert'; + +import 'package:animated_tree_view/listenable_node/indexed_listenable_node.dart'; +import 'package:animated_tree_view/tree_view/tree_node.dart'; +import 'package:animated_tree_view/tree_view/tree_view.dart'; import 'package:data_table_2/data_table_2.dart'; import 'package:extended_image/extended_image.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:open_file/open_file.dart'; +import 'package:smooth_scroll_multiplatform/smooth_scroll_multiplatform.dart'; import 'package:universal_io/io.dart' as io; +import 'package:universal_io/io.dart'; import '../../../bloc/gebura/gebura_bloc.dart'; +import '../../../common/app_scan/_native.dart'; +import '../../../common/app_scan/model.dart'; import '../../../common/steam/steam.dart'; import '../../../l10n/l10n.dart'; +import '../../components/pop_alert.dart'; +import '../../form/form_field.dart'; import '../../helper/spacing.dart'; +import '../../layout/bootstrap_container.dart'; part 'gebura_library_settings_common.dart'; part 'gebura_library_settings_steam.dart'; @@ -88,7 +101,8 @@ class _OverviewCard extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder(builder: (context, state) { - final libraryFolders = state.localSteamLibraryFolders ?? []; + final commonLibraryFolders = state.localCommonLibraryFolders ?? {}; + final steamLibraryFolders = state.localSteamLibraryFolders ?? []; return Card( margin: EdgeInsets.zero, child: Container( @@ -111,7 +125,7 @@ class _OverviewCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final folder in libraryFolders) + for (final folder in steamLibraryFolders) InkWell( onTap: () async { await OpenFile.open(folder); @@ -149,6 +163,36 @@ class _OverviewCard extends StatelessWidget { ), ), ), + for (final folder in commonLibraryFolders.values) + InkWell( + onTap: () async { + await OpenFile.open(folder.basePath); + }, + child: Container( + width: 150, + padding: const EdgeInsets.all(16), + child: Ink( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Stack( + children: [ + Center( + child: Icon( + FontAwesomeIcons.folder, + size: 48, + ), + ), + ], + ), + const SizedBox(height: 8), + Text(folder.basePath), + ], + ), + ), + ), + ), ], ), ), diff --git a/lib/view/pages/gebura/gebura_library_settings_common.dart b/lib/view/pages/gebura/gebura_library_settings_common.dart index ca3179d..93ae5e5 100644 --- a/lib/view/pages/gebura/gebura_library_settings_common.dart +++ b/lib/view/pages/gebura/gebura_library_settings_common.dart @@ -6,7 +6,24 @@ class _CommonSettingCard extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder(builder: (context, state) { - final libraryFolders = state.localSteamLibraryFolders ?? []; + final localCommonApps = state.localInstalledCommonAppInsts ?? {}; + final localTrackedCommonAppInsts = state.localTrackedAppInsts?.values + .where((e) => e.commonLaunchSetting != null) + .toList() ?? + []; + final untrackedCommonApps = localCommonApps.values + .where( + (l) => localTrackedCommonAppInsts.every( + (i) => + i.commonLaunchSetting == null || + l.installPath != i.commonLaunchSetting!.installPath, + ), + ) + .toList(); + final uninstalledCommonApps = localTrackedCommonAppInsts + .where((e) => + localCommonApps[e.commonLaunchSetting!.installPath] == null) + .toList(); return Card( margin: EdgeInsets.zero, child: Container( @@ -19,7 +36,7 @@ class _CommonSettingCard extends StatelessWidget { children: SpacingHelper.listSpacing(width: 16, children: [ const Icon(Icons.folder), const Text('文件夹扫描'), - const Expanded(child: SizedBox()), + Expanded(child: Container()), // OutlinedButton.icon( // label: const Text('添加应用'), // icon: const Icon(FontAwesomeIcons.circlePlus), @@ -40,11 +57,43 @@ class _CommonSettingCard extends StatelessWidget { OutlinedButton.icon( label: const Text('添加文件夹'), icon: const Icon(FontAwesomeIcons.folderPlus), - onPressed: null, + onPressed: () async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (context) { + return const _CommonAppFolderScanSettingPage(); + }), + ); + }, + ), + OutlinedButton.icon( + onPressed: () { + context + .read() + .add(GeburaScanLocalCommonLibraryEvent()); + }, + icon: const Icon(Icons.refresh), + label: const Text('刷新'), ), ]), ), const SizedBox(height: 16), + Text( + '发现${untrackedCommonApps.length}个游戏(已导入${localTrackedCommonAppInsts.length}个${uninstalledCommonApps.isNotEmpty ? ',已卸载${uninstalledCommonApps.length}个' : ''})'), + if (untrackedCommonApps.isNotEmpty) + Container( + margin: const EdgeInsets.only(top: 16), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).dividerColor, + ), + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + ), + constraints: const BoxConstraints( + maxHeight: 400, + ), + child: _CommonGameList(apps: untrackedCommonApps), + ), ], ), ), @@ -52,3 +101,492 @@ class _CommonSettingCard extends StatelessWidget { }); } } + +class _CommonGameList extends StatefulWidget { + const _CommonGameList({ + required this.apps, + }); + + final List apps; + + @override + State<_CommonGameList> createState() => _CommonGameListState(); +} + +class _CommonGameListState extends State<_CommonGameList> { + @override + void initState() { + super.initState(); + selectedIndex = List.generate(widget.apps.length, (index) => false); + } + + late List selectedIndex; + + @override + Widget build(BuildContext context) { + return DataTable2( + onSelectAll: (isSelectedAll) { + if (isSelectedAll ?? false) { + setState(() { + selectedIndex = List.generate(widget.apps.length, (index) => true); + }); + } else { + setState(() { + selectedIndex = List.generate(widget.apps.length, (index) => false); + }); + } + }, + columns: [ + DataColumn2( + label: Text( + '游戏列表${selectedIndex.contains(true) ? '(已选${selectedIndex.where((element) => element).length}个)' : ''}'), + size: ColumnSize.L, + ), + DataColumn2( + label: Align( + alignment: Alignment.centerRight, + child: ElevatedButton( + onPressed: selectedIndex.contains(true) + ? () { + // context.read().add( + // GeburaTrackCommonAppsEvent( + // widget.apps + // .where((element) => selectedIndex[ + // widget.apps.indexOf(element)]) + // .map((e) => e.installPath) + // .toList(), + // ), + // ); + for (var i = 0; i < selectedIndex.length; i++) { + selectedIndex[i] = false; + } + } + : null, + child: const Text('导入选中'), + ), + ), + fixedWidth: 150, + ), + ], + rows: widget.apps.map( + (e) { + return DataRow( + selected: selectedIndex[widget.apps.indexOf(e)], + onSelectChanged: (isSelected) { + setState(() { + selectedIndex[widget.apps.indexOf(e)] = isSelected ?? false; + }); + }, + cells: [ + DataCell(Row( + children: [ + Text(e.name), + ], + )), + DataCell( + Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + onPressed: () async { + await OpenFile.open(e.installPath); + }, + icon: const Icon(Icons.folder, size: 16), + label: const Text('查看'), + )), + ), + ], + ); + }, + ).toList(), + ); + } +} + +class _CommonAppFolderScanSettingPage extends StatefulWidget { + const _CommonAppFolderScanSettingPage({this.initialValue}); + + final CommonAppFolderScanSetting? initialValue; + + @override + State<_CommonAppFolderScanSettingPage> createState() => + _CommonAppFolderScanSettingPageState(); +} + +class _CommonAppFolderScanSettingPageState + extends State<_CommonAppFolderScanSettingPage> { + @override + void initState() { + super.initState(); + setting = widget.initialValue ?? CommonAppFolderScanSetting.empty(); + + basePathController.text = setting.basePath; + excludeDirectoryMatchersController.text = + setting.excludeDirectoryMatchers.join('\n'); + targetFileMatchersController.text = + setting.includeExecutableMatchers.join('\n'); + excludeFileMatchersController.text = + setting.excludeExecutableMatchers.join('\n'); + } + + void _saveAndExit(BuildContext context) { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + context.read().add( + GeburaSaveLocalCommonAppFolderSettingEvent(setting), + ); + Navigator.of(context).pop(); + } + } + + late CommonAppFolderScanSetting setting; + CommonAppFolderScanResult? result; + final GlobalKey _formKey = GlobalKey(); + final TextEditingController basePathController = TextEditingController(); + final TextEditingController excludeDirectoryMatchersController = + TextEditingController(); + final TextEditingController targetFileMatchersController = + TextEditingController(); + final TextEditingController excludeFileMatchersController = + TextEditingController(); + + bool testRunning = false; + String testRunMsg = ''; + IndexedTreeNode tree = + IndexedTreeNode.root(); + + Future doTest() async { + setState(() { + testRunning = true; + testRunMsg = 'Scanning'; + }); + result = await scanCommonApps(setting); + setState(() { + testRunMsg = 'Indexing'; + }); + await Future.delayed(const Duration(seconds: 1), () { + tree = indexTree(); + }); + setState(() { + testRunning = false; + }); + } + + IndexedTreeNode indexTree() { + final IndexedTreeNode newTree = + IndexedTreeNode.root(); + if (result == null) { + testRunMsg = 'No result'; + return newTree; + } + String keyPath(List parts) { + final res = StringBuffer(); + for (final index in parts.asMap().keys) { + if (index > 0) { + res.write('.'); + } + res.write(parts + .take(index + 1) + .map((e) => base64Encode(utf8.encode(e))) + .join()); + } + return res.toString(); + } + + String key(List parts) { + return parts.map((e) => base64Encode(utf8.encode(e))).join(); + } + + try { + for (final detail in result!.details) { + final parts = detail.path + .replaceFirst(setting.basePath, '') + .split(Platform.pathSeparator); + + IndexedListenableNode node = newTree; + for (final index in parts.asMap().keys) { + final path = parts.take(index + 1).toList(); + try { + final child = newTree.elementAt(keyPath(path)); + node = child; + } catch (e) { + final newNode = IndexedTreeNode( + key: key(path), + data: detail, + ); + node.add(newNode); + node = newNode; + } + } + } + } catch (e) { + testRunMsg = 'Error: $e'; + return newTree; + } + testRunMsg = ''; + return newTree; + } + + IconData _entryTypeIcon(CommonAppFolderScanEntryType type) { + switch (type) { + case CommonAppFolderScanEntryType.file: + return Icons.file_copy; + case CommonAppFolderScanEntryType.directory: + return Icons.folder; + case CommonAppFolderScanEntryType.unknown: + return Icons.help; + } + } + + Color? _entryStatusColor( + BuildContext context, CommonAppFolderScanEntryStatus status) { + switch (status) { + case CommonAppFolderScanEntryStatus.error: + return Colors.red.shade400; + case CommonAppFolderScanEntryStatus.hit: + return Colors.green.shade400; + case CommonAppFolderScanEntryStatus.skipped: + return Theme.of(context).disabledColor; + case CommonAppFolderScanEntryStatus.accessed: + return null; + } + } + + @override + Widget build(BuildContext context) { + return PopAlert( + title: '确定退出', + content: '是否保存更改再退出', + onConfirm: () { + _saveAndExit(context); + }, + onDeny: () {}, + onCancel: () {}, + child: Scaffold( + appBar: AppBar( + title: const Text('文件夹扫描设置'), + actions: [ + IconButton( + icon: const Icon(Icons.check), + onPressed: () { + _saveAndExit(context); + }, + ), + ], + ), + body: BootstrapContainer(children: [ + BootstrapColumn( + xxs: 6, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + const SizedBox(height: 16), + Text('配置信息', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + Form( + key: _formKey, + child: Expanded( + child: DynMouseScroll( + builder: (context, controller, physics) { + return SingleChildScrollView( + controller: controller, + physics: physics, + child: Column( + children: SpacingHelper + .listSpacing(height: 8, children: [ + SectionDividerFormField( + title: const Text('基本'), + ), + TextFormField( + controller: basePathController, + decoration: InputDecoration( + labelText: '起始路径', + hintText: '请输入起始路径', + suffixIcon: TextButton( + onPressed: () async { + final path = await FilePicker.platform + .getDirectoryPath(); + if (path != null) { + basePathController.text = path; + setState(() { + setting = setting.copyWith( + basePath: path); + }); + } + }, + child: const Text('选择'), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return '请输入起始路径'; + } + return null; + }, + onSaved: (value) { + if (value != null) { + setState(() { + setting = + setting.copyWith(basePath: value); + }); + } + }, + ), + SectionDividerFormField( + title: const Text('文件夹扫描'), + ), + TextFormField( + controller: + excludeDirectoryMatchersController, + decoration: const InputDecoration( + labelText: '排除文件夹', + hintText: '请输入排除文件夹', + ), + keyboardType: TextInputType.multiline, + maxLines: null, + onSaved: (value) { + if (value != null) { + setState(() { + setting = setting.copyWith( + excludeDirectoryMatchers: + value.split('\n')); + }); + } + }, + ), + SectionDividerFormField( + title: const Text('启动文件定位'), + ), + TextFormField( + controller: targetFileMatchersController, + decoration: const InputDecoration( + labelText: '目标文件', + hintText: '请输入目标文件', + ), + keyboardType: TextInputType.multiline, + maxLines: null, + onSaved: (value) { + if (value != null) { + setState(() { + setting = setting.copyWith( + includeExecutableMatchers: + value.split('\n')); + }); + } + }, + ), + TextFormField( + controller: excludeFileMatchersController, + decoration: const InputDecoration( + labelText: '排除文件', + hintText: '请输入排除文件', + ), + keyboardType: TextInputType.multiline, + maxLines: null, + onSaved: (value) { + if (value != null) { + setState(() { + setting = setting.copyWith( + excludeExecutableMatchers: + value.split('\n')); + }); + } + }, + ), + Wrap( + spacing: 8, + children: [ + ElevatedButton( + onPressed: testRunning + ? null + : () async { + if (_formKey.currentState! + .validate()) { + _formKey.currentState!.save(); + await doTest(); + } + }, + child: const Text('测试'), + ), + ElevatedButton( + onPressed: () async { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + } + }, + child: const Text('提交'), + ), + ], + ), + const SizedBox(height: 16), + ]), + ), + ); + }), + ), + ), + ], + )), + ), + BootstrapColumn( + xxs: 6, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + const SizedBox(height: 16), + Text('扫描预览', style: Theme.of(context).textTheme.titleLarge), + Text(testRunMsg), + if (testRunning) const LinearProgressIndicator(), + Expanded( + child: TreeView.indexTyped>( + tree: tree, + expansionBehavior: + ExpansionBehavior.collapseOthersAndSnapToTop, + shrinkWrap: true, + showRootNode: false, + builder: (context, node) => ListTile( + leading: Icon( + _entryTypeIcon(node.data?.type ?? + CommonAppFolderScanEntryType.unknown), + ), + title: Text( + node.data?.path.split(Platform.pathSeparator).last ?? + '未知', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: node.data != null + ? TextStyle( + color: _entryStatusColor( + context, node.data!.status)) + : null, + ), + trailing: result?.installedApps.any( + (e) => e.installPath == node.data?.path) ?? + false + ? const Chip( + label: Text('安装目录'), + visualDensity: VisualDensity( + horizontal: VisualDensity.minimumDensity, + vertical: VisualDensity.minimumDensity, + ), + ) + : null, + visualDensity: const VisualDensity( + horizontal: VisualDensity.minimumDensity, + vertical: VisualDensity.minimumDensity, + ), + ), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ]), + ), + ); + } +} diff --git a/lib/view/pages/gebura/gebura_library_settings_steam.dart b/lib/view/pages/gebura/gebura_library_settings_steam.dart index 2dab040..0caa9ad 100644 --- a/lib/view/pages/gebura/gebura_library_settings_steam.dart +++ b/lib/view/pages/gebura/gebura_library_settings_steam.dart @@ -51,7 +51,7 @@ class _SteamSettingCard extends StatelessWidget { onPressed: () { context .read() - .add(GeburaScanLocalLibraryEvent()); + .add(GeburaScanLocalSteamLibraryEvent()); }, icon: const Icon(Icons.refresh), label: const Text('刷新'), @@ -189,8 +189,7 @@ class _SteamGameListState extends State<_SteamGameList> { .read() .showSteamAppDetails(e.appId); }, - icon: Icon(const FaIcon(FontAwesomeIcons.steam).icon, - size: 16), + icon: const Icon(FontAwesomeIcons.steam, size: 16), label: const Text('查看'), )), ), diff --git a/pubspec.yaml b/pubspec.yaml index 7d30d90..326ae6e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: sdk: flutter sentry_flutter: ^8.2.0 sentry_hive: ^8.2.0 + collection: ^1.18.0 # state management bloc: ^8.1.4 @@ -91,6 +92,7 @@ dependencies: git: url: https://github.com/tuihub/flutter_jsonschema_builder.git ref: main + animated_tree_view: ^2.1.0 # rust bridge ffi: ^2.0.1