diff --git a/gui/lib/app.dart b/gui/lib/app.dart index 66d1038..ca83d04 100644 --- a/gui/lib/app.dart +++ b/gui/lib/app.dart @@ -11,6 +11,7 @@ import 'screens/about_screen.dart'; import 'screens/dreamcast_screen.dart'; import 'screens/home_screen.dart'; import 'screens/patcher_screen.dart'; +import 'screens/plugins_screen.dart'; import 'screens/settings_screen.dart'; import 'screens/theme_editor_screen.dart'; import 'screens/tools_screen.dart'; @@ -44,7 +45,7 @@ class _OpenROMAppState extends State { String _outputDir = ''; String _audioCodec = 'flac'; - static const List _mobileScreenIndices = [0, 1, 2, 4]; + static const List _mobileScreenIndices = [0, 1, 2, 4, 5]; @override void initState() { @@ -143,6 +144,7 @@ class _OpenROMAppState extends State { PatcherScreen(theme: theme), ToolsScreen(theme: theme), DreamcastScreen(theme: theme), + PluginsScreen(theme: theme), settingsWidget, AboutScreen(theme: theme), ThemeEditorScreen( @@ -221,6 +223,11 @@ class _OpenROMAppState extends State { activeIcon: Icon(Icons.construction), label: 'Tools', ), + BottomNavigationBarItem( + icon: Icon(Icons.extension_outlined), + activeIcon: Icon(Icons.extension), + label: 'Plugins', + ), BottomNavigationBarItem( icon: Icon(Icons.settings_outlined), activeIcon: Icon(Icons.settings), diff --git a/gui/lib/controllers/plugin_controller.dart b/gui/lib/controllers/plugin_controller.dart new file mode 100644 index 0000000..3ea8baf --- /dev/null +++ b/gui/lib/controllers/plugin_controller.dart @@ -0,0 +1,146 @@ +// OpenROM — Universal ROM Compression Suite +// M5 Dev | GPL v3 + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:openrom_core/openrom_core.dart'; + +import '../models/store_plugin.dart'; +import '../services/plugin_service.dart'; + +class PluginController extends ChangeNotifier { + final PluginService _service; + + List _installedPlugins = []; + List _storeCatalog = []; + bool _isLoading = false; + String? _errorMessage; + String? _statusMessage; + + PluginController({PluginService? service}) + : _service = service ?? PluginService(); + + List get installedPlugins => + List.unmodifiable(_installedPlugins); + List get storeCatalog => List.unmodifiable(_storeCatalog); + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + String? get statusMessage => _statusMessage; + + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + /// Refreshes the list of locally installed plugins. + Future loadInstalledPlugins() async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + _installedPlugins = await _service.loadInstalledPlugins(); + } catch (e) { + _errorMessage = 'Failed to load installed plugins: $e'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// Fetches available store catalog items. + Future fetchStoreCatalog({String? endpointUrl}) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + _storeCatalog = await _service.fetchStoreCatalog( + endpointUrl: endpointUrl, + ); + } catch (e) { + _errorMessage = 'Failed to fetch plugin store catalog: $e'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// Installs a `.orplug` package from a file path. + Future installPluginFromPath(String orplugPath) async { + _isLoading = true; + _statusMessage = 'Installing plugin...'; + _errorMessage = null; + notifyListeners(); + + try { + final success = await _service.installPlugin(orplugPath); + if (success) { + _statusMessage = 'Plugin installed successfully!'; + await loadInstalledPlugins(); + return true; + } else { + _errorMessage = + 'Failed to install plugin. Check manifest and SHA256 integrity.'; + return false; + } + } catch (e) { + _errorMessage = 'Error during plugin installation: $e'; + return false; + } finally { + _isLoading = false; + _statusMessage = null; + notifyListeners(); + } + } + + /// Opens file picker for `.orplug` files and installs the selected file. + Future pickAndInstallPlugin() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['orplug', 'zip'], + ); + + if (result != null && result.files.single.path != null) { + return await installPluginFromPath(result.files.single.path!); + } + } catch (e) { + _errorMessage = 'Failed to pick plugin file: $e'; + notifyListeners(); + } + return false; + } + + /// Uninstalls an installed plugin by ID. + Future uninstallPlugin(String pluginId) async { + _isLoading = true; + _statusMessage = 'Uninstalling plugin...'; + _errorMessage = null; + notifyListeners(); + + try { + final success = await _service.uninstallPlugin(pluginId); + if (success) { + _statusMessage = 'Plugin uninstalled successfully.'; + await loadInstalledPlugins(); + return true; + } else { + _errorMessage = 'Failed to uninstall plugin "$pluginId".'; + return false; + } + } catch (e) { + _errorMessage = 'Error during plugin uninstallation: $e'; + return false; + } finally { + _isLoading = false; + _statusMessage = null; + notifyListeners(); + } + } + + /// Helper to check if a store plugin ID is already installed. + bool isPluginInstalled(String pluginId) { + return _installedPlugins.any((p) => p.id == pluginId); + } +} diff --git a/gui/lib/models/store_plugin.dart b/gui/lib/models/store_plugin.dart new file mode 100644 index 0000000..da714f8 --- /dev/null +++ b/gui/lib/models/store_plugin.dart @@ -0,0 +1,51 @@ +// OpenROM — Universal ROM Compression Suite +// M5 Dev | GPL v3 + +class StorePlugin { + final String id; + final String name; + final String version; + final String author; + final String description; + final bool isVerified; + final String? downloadUrl; + final List tags; + + const StorePlugin({ + required this.id, + required this.name, + required this.version, + required this.author, + required this.description, + this.isVerified = false, + this.downloadUrl, + this.tags = const [], + }); + + factory StorePlugin.fromJson(Map json) { + final rawTags = json['tags'] as List? ?? []; + return StorePlugin( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + version: json['version'] as String? ?? '', + author: json['author'] as String? ?? '', + description: json['description'] as String? ?? '', + isVerified: json['isVerified'] as bool? ?? false, + downloadUrl: json['downloadUrl'] as String?, + tags: rawTags.map((e) => e.toString()).toList(), + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'version': version, + 'author': author, + 'description': description, + 'isVerified': isVerified, + if (downloadUrl != null) 'downloadUrl': downloadUrl, + 'tags': tags, + }; + } +} diff --git a/gui/lib/screens/plugins_screen.dart b/gui/lib/screens/plugins_screen.dart new file mode 100644 index 0000000..72fade5 --- /dev/null +++ b/gui/lib/screens/plugins_screen.dart @@ -0,0 +1,656 @@ +// OpenROM — Universal ROM Compression Suite +// M5 Dev | GPL v3 + +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flutter/material.dart'; +import 'package:openrom_core/openrom_core.dart'; + +import '../controllers/plugin_controller.dart'; +import '../models/store_plugin.dart'; +import '../models/theme_config.dart'; + +class PluginsScreen extends StatefulWidget { + final ThemeConfig theme; + final PluginController? controller; + + const PluginsScreen({ + super.key, + required this.theme, + this.controller, + }); + + @override + State createState() => _PluginsScreenState(); +} + +class _PluginsScreenState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + late final PluginController _controller; + bool _isDragging = false; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _controller = widget.controller ?? PluginController(); + _controller.addListener(_onControllerChanged); + _controller.loadInstalledPlugins(); + _controller.fetchStoreCatalog(); + } + + @override + void dispose() { + _tabController.dispose(); + _controller.removeListener(_onControllerChanged); + if (widget.controller == null) { + _controller.dispose(); + } + super.dispose(); + } + + void _onControllerChanged() { + if (mounted) { + setState(() {}); + } + } + + Color _parseHexColor(String hex, Color fallback) { + try { + var str = hex.replaceAll('#', ''); + if (str.length == 6) { + str = 'FF$str'; + } + return Color(int.parse(str, radix: 16)); + } catch (_) { + return fallback; + } + } + + @override + Widget build(BuildContext context) { + final theme = widget.theme; + + return Column( + children: [ + if (_controller.isLoading) + LinearProgressIndicator( + backgroundColor: theme.surface, + color: theme.accent, + minHeight: 3, + ), + Container( + color: theme.surface, + child: TabBar( + controller: _tabController, + indicatorColor: theme.accent, + labelColor: theme.accent, + unselectedLabelColor: theme.textSecondary, + tabs: const [ + Tab( + icon: Icon(Icons.extension_outlined), + text: 'Installed Plugins 📦', + ), + Tab( + icon: Icon(Icons.storefront_outlined), + text: 'Plugin Store 🌐🛒', + ), + ], + ), + ), + if (_controller.errorMessage != null) + Container( + width: double.infinity, + color: Colors.red.shade900.withValues(alpha: 0.8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.white, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + _controller.errorMessage!, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white, size: 18), + onPressed: () => _controller.clearError(), + ), + ], + ), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildInstalledTab(theme), + _buildStoreTab(theme), + ], + ), + ), + ], + ); + } + + Widget _buildInstalledTab(ThemeConfig theme) { + return DropTarget( + onDragEntered: (details) => setState(() => _isDragging = true), + onDragExited: (details) => setState(() => _isDragging = false), + onDragDone: (details) async { + setState(() => _isDragging = false); + for (final file in details.files) { + if (file.path.endsWith('.orplug') || file.path.endsWith('.zip')) { + await _controller.installPluginFromPath(file.path); + } + } + }, + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + // Drag & Drop Box + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + decoration: BoxDecoration( + color: _isDragging + ? theme.accent.withValues(alpha: 0.15) + : theme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _isDragging ? theme.accent : theme.cardBg, + width: 2, + ), + ), + child: Column( + children: [ + Icon( + Icons.cloud_upload_outlined, + size: 44, + color: _isDragging ? theme.accent : theme.textSecondary, + ), + const SizedBox(height: 10), + Text( + 'Drag & Drop .orplug Archive Here', + style: TextStyle( + color: theme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Install custom ROM tools, extensions, and formats', + style: TextStyle( + color: theme.textSecondary, + fontSize: 13, + ), + ), + const SizedBox(height: 14), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: theme.accent, + foregroundColor: Colors.black, + minimumSize: const Size(160, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () => _controller.pickAndInstallPlugin(), + icon: const Icon(Icons.folder_open), + label: const Text( + 'Browse .orplug File', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + Text( + 'Installed Plugins (${_controller.installedPlugins.length})', + style: TextStyle( + color: theme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + + if (_controller.installedPlugins.isEmpty) + _buildEmptyState(theme) + else + ..._controller.installedPlugins.map( + (plugin) => _buildInstalledCard(plugin, theme), + ), + ], + ), + ); + } + + Widget _buildEmptyState(ThemeConfig theme) { + return Container( + padding: const EdgeInsets.all(32), + margin: const EdgeInsets.only(top: 10), + decoration: BoxDecoration( + color: theme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Icon( + Icons.extension_off_outlined, + size: 56, + color: theme.textSecondary.withValues(alpha: 0.5), + ), + const SizedBox(height: 12), + Text( + 'No Plugins Installed Yet', + style: TextStyle( + color: theme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 6), + Text( + 'Drop an .orplug package above or explore community plugins in the Plugin Store.', + textAlign: TextAlign.center, + style: TextStyle( + color: theme.textSecondary, + fontSize: 13, + ), + ), + ], + ), + ); + } + + Widget _buildInstalledCard(PluginManifest plugin, ThemeConfig theme) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: theme.cardBg, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.accent.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon( + Icons.extension, + color: theme.accent, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + plugin.name, + style: TextStyle( + color: theme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: theme.cardBg, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'v${plugin.version}', + style: TextStyle( + color: theme.textSecondary, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + 'by ${plugin.author}', + style: TextStyle( + color: theme.textSecondary, + fontSize: 12, + ), + ), + ], + ), + ), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.redAccent, + side: const BorderSide(color: Colors.redAccent), + minimumSize: const Size(100, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () => _confirmUninstall(plugin, theme), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Uninstall'), + ), + ], + ), + if (plugin.description.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + plugin.description, + style: TextStyle( + color: theme.textPrimary.withValues(alpha: 0.9), + fontSize: 13, + ), + ), + ], + if (plugin.extensions.isNotEmpty) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 6, + children: plugin.extensions.map((ext) { + final badgeColor = _parseHexColor(ext.color, theme.accent); + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: badgeColor.withValues(alpha: 0.5), + ), + ), + child: Text( + ext.ext, + style: TextStyle( + color: badgeColor, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ); + } + + void _confirmUninstall(PluginManifest plugin, ThemeConfig theme) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: theme.surface, + title: Text( + 'Uninstall Plugin', + style: TextStyle(color: theme.textPrimary), + ), + content: Text( + 'Are you sure you want to uninstall "${plugin.name}" (${plugin.id})?', + style: TextStyle(color: theme.textSecondary), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text( + 'Cancel', + style: TextStyle(color: theme.textSecondary), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + ), + onPressed: () async { + Navigator.of(ctx).pop(); + await _controller.uninstallPlugin(plugin.id); + }, + child: const Text('Uninstall'), + ), + ], + ), + ); + } + + Widget _buildStoreTab(ThemeConfig theme) { + final storeCatalog = _controller.storeCatalog; + + return ListView( + padding: const EdgeInsets.all(20), + children: [ + Row( + children: [ + Icon( + Icons.explore, + color: theme.accent, + size: 24, + ), + const SizedBox(width: 8), + Text( + 'Community & Official Plugins', + style: TextStyle( + color: theme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Discover plugins created by official developers and the retro community.', + style: TextStyle( + color: theme.textSecondary, + fontSize: 13, + ), + ), + const SizedBox(height: 16), + ...storeCatalog.map( + (item) => _buildStoreCard(item, theme), + ), + ], + ); + } + + Widget _buildStoreCard(StorePlugin storeItem, ThemeConfig theme) { + final isInstalled = _controller.isPluginInstalled(storeItem.id); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: theme.cardBg, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + storeItem.name, + style: TextStyle( + color: theme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: theme.cardBg, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'v${storeItem.version}', + style: TextStyle( + color: theme.textSecondary, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + 'by ${storeItem.author}', + style: TextStyle( + color: theme.textSecondary, + fontSize: 12, + ), + ), + if (storeItem.isVerified || + storeItem.author == 'M5 Dev') ...[ + const SizedBox(width: 4), + Tooltip( + message: 'Verified Publisher 💙', + child: Icon( + Icons.verified, + color: const Color(0xFF00E5FF), + size: 16, + ), + ), + ], + ], + ), + ], + ), + ), + if (isInstalled) + Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.center, + decoration: BoxDecoration( + color: theme.accent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.accent.withValues(alpha: 0.4), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle, + color: theme.accent, + size: 18, + ), + const SizedBox(width: 6), + Text( + 'Installed', + style: TextStyle( + color: theme.accent, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ], + ), + ) + else + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: theme.accent, + foregroundColor: Colors.black, + minimumSize: const Size(110, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () => _controller.pickAndInstallPlugin(), + icon: const Icon(Icons.download, size: 18), + label: const Text( + 'Install', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + storeItem.description, + style: TextStyle( + color: theme.textPrimary.withValues(alpha: 0.9), + fontSize: 13, + ), + ), + if (storeItem.tags.isNotEmpty) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 6, + children: storeItem.tags.map((tag) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: theme.cardBg, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + tag, + style: TextStyle( + color: theme.textSecondary, + fontSize: 11, + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ); + } +} diff --git a/gui/lib/services/plugin_service.dart b/gui/lib/services/plugin_service.dart new file mode 100644 index 0000000..ca3f89a --- /dev/null +++ b/gui/lib/services/plugin_service.dart @@ -0,0 +1,102 @@ +// OpenROM — Universal ROM Compression Suite +// M5 Dev | GPL v3 + +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:openrom_core/openrom_core.dart'; + +import '../models/store_plugin.dart'; + +class PluginService { + final PluginManager _pluginManager; + + PluginService({PluginManager? pluginManager}) + : _pluginManager = pluginManager ?? PluginManager.instance; + + /// Loads all installed plugins and returns active manifests. + Future> loadInstalledPlugins() async { + await _pluginManager.loadInstalledPlugins(); + return _pluginManager.installedPlugins; + } + + /// Installs a plugin from a local `.orplug` ZIP archive path. + Future installPlugin(String orplugPath) async { + return await _pluginManager.installPlugin(orplugPath); + } + + /// Uninstalls a plugin by its ID. + Future uninstallPlugin(String pluginId) async { + return await _pluginManager.uninstallPlugin(pluginId); + } + + /// Fetches available plugins for the Plugin Store catalog. + /// Attempts to fetch from optional remote endpoint, falling back to default store plugins. + Future> fetchStoreCatalog({String? endpointUrl}) async { + if (endpointUrl != null && endpointUrl.isNotEmpty) { + try { + final response = await http.get(Uri.parse(endpointUrl)); + if (response.statusCode == 200) { + final List jsonList = jsonDecode(response.body); + return jsonList + .map((e) => StorePlugin.fromJson(e as Map)) + .toList(); + } + } catch (_) {} + } + + return _defaultStoreCatalog; + } + + static const List _defaultStoreCatalog = [ + StorePlugin( + id: 'xgdtool_360', + name: 'Xbox 360 XGDTool Engine', + version: '1.2.0', + author: 'M5 Dev', + description: + 'Adds full conversion support for Xbox 360 compressed disc formats (.zar, .cci) to standard ISO.', + isVerified: true, + tags: ['.zar', '.cci', '.iso'], + ), + StorePlugin( + id: 'saturn_ssp', + name: 'Sega Saturn SSP Patcher', + version: '1.0.4', + author: 'M5 Dev', + description: + 'Enables native creation and application of Sega Saturn SSP patch files with Mode-1 EDC/ECC recalculation.', + isVerified: true, + tags: ['.ssp', '.iso'], + ), + StorePlugin( + id: 'nkit_gc_wii', + name: 'NKit GameCube & Wii Suite', + version: '2.1.0', + author: 'M5 Dev', + description: + 'Supports raw disc image restoration and conversion for NKit (.nkit.iso, .wud, .wux, .wua) formats.', + isVerified: true, + tags: ['.nkit.iso', '.wud', '.wux', '.wua'], + ), + StorePlugin( + id: 'ps2_gz_compressor', + name: 'PS2 GZ ISO Tool', + version: '0.8.2', + author: 'RetroCommunity', + description: + 'Fast block gzip compression and decompression plugin for PlayStation 2 DVD ISOs.', + isVerified: false, + tags: ['.gz', '.iso'], + ), + StorePlugin( + id: 'chd_lossless_flac', + name: 'CHD FLAC Audio Extractor', + version: '1.1.0', + author: 'SonicHacker', + description: + 'Extracts and decodes lossless CD audio tracks directly from CHD images.', + isVerified: false, + tags: ['.chd', '.flac', '.wav'], + ), + ]; +} diff --git a/gui/lib/widgets/sidebar.dart b/gui/lib/widgets/sidebar.dart index e7c28dc..22c2e42 100644 --- a/gui/lib/widgets/sidebar.dart +++ b/gui/lib/widgets/sidebar.dart @@ -26,6 +26,7 @@ class Sidebar extends StatelessWidget { {'icon': Icons.build_outlined, 'tooltip': l10n.patcherTitle}, {'icon': Icons.construction_outlined, 'tooltip': l10n.toolsTitle}, {'icon': Icons.sports_esports_outlined, 'tooltip': l10n.dreamcastTitle}, + {'icon': Icons.extension_outlined, 'tooltip': 'Plugins'}, {'icon': Icons.settings, 'tooltip': l10n.settingsScreen}, {'icon': Icons.info_outline, 'tooltip': l10n.aboutScreen}, {'icon': Icons.palette, 'tooltip': l10n.themeScreen}, diff --git a/gui/test/plugins_screen_test.dart b/gui/test/plugins_screen_test.dart new file mode 100644 index 0000000..254abbb --- /dev/null +++ b/gui/test/plugins_screen_test.dart @@ -0,0 +1,163 @@ +// OpenROM — Universal ROM Compression Suite +// M5 Dev | GPL v3 + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gui/controllers/plugin_controller.dart'; +import 'package:gui/models/store_plugin.dart'; +import 'package:gui/models/theme_config.dart'; +import 'package:gui/screens/plugins_screen.dart'; +import 'package:gui/services/plugin_service.dart'; +import 'package:openrom_core/openrom_core.dart'; + +class FakePluginService extends PluginService { + final List installed; + final List catalog; + + FakePluginService({ + this.installed = const [], + this.catalog = const [ + StorePlugin( + id: 'xgdtool_360', + name: 'Xbox 360 XGDTool Engine', + version: '1.2.0', + author: 'M5 Dev', + description: 'Xbox 360 compressed ISO conversion plugin.', + isVerified: true, + tags: ['.zar', '.cci'], + ), + StorePlugin( + id: 'ps2_gz_compressor', + name: 'PS2 GZ ISO Tool', + version: '0.8.2', + author: 'RetroCommunity', + description: 'PS2 ISO gzip tool.', + isVerified: false, + tags: ['.gz'], + ), + ], + }); + + @override + Future> loadInstalledPlugins() async { + return installed; + } + + @override + Future> fetchStoreCatalog({String? endpointUrl}) async { + return catalog; + } + + @override + Future installPlugin(String orplugPath) async { + return true; + } + + @override + Future uninstallPlugin(String pluginId) async { + return true; + } +} + +void main() { + final testTheme = ThemeConfig.defaultTheme(); + + group('PluginController Unit Tests', () { + test('loadInstalledPlugins updates controller state', () async { + final fakeManifest = PluginManifest( + id: 'test_plugin', + name: 'Test Plugin', + version: '1.0.0', + author: 'Test Author', + description: 'A test plugin.', + minCoreVersion: '3.0.0', + binaries: {}, + extensions: [ + PluginExtension( + ext: '.test', + format: 'TEST', + platform: 'Test Platform', + color: '#00e5ff', + ) + ], + conversions: [], + ); + + final service = FakePluginService(installed: [fakeManifest]); + final controller = PluginController(service: service); + + await controller.loadInstalledPlugins(); + + expect(controller.installedPlugins.length, 1); + expect(controller.installedPlugins.first.name, 'Test Plugin'); + expect(controller.isPluginInstalled('test_plugin'), isTrue); + expect(controller.isPluginInstalled('non_existent'), isFalse); + }); + + test('fetchStoreCatalog populates store catalog', () async { + final service = FakePluginService(); + final controller = PluginController(service: service); + + await controller.fetchStoreCatalog(); + + expect(controller.storeCatalog.length, 2); + expect(controller.storeCatalog.first.isVerified, isTrue); + expect(controller.storeCatalog.first.author, 'M5 Dev'); + }); + }); + + group('PluginsScreen Widget Tests', () { + testWidgets('Renders tabs and empty state when no plugins are installed', + (WidgetTester tester) async { + final service = FakePluginService(installed: []); + final controller = PluginController(service: service); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PluginsScreen( + theme: testTheme, + controller: controller, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Installed Plugins 📦'), findsOneWidget); + expect(find.text('Plugin Store 🌐🛒'), findsOneWidget); + expect(find.text('Drag & Drop .orplug Archive Here'), findsOneWidget); + expect(find.text('Browse .orplug File'), findsOneWidget); + expect(find.text('No Plugins Installed Yet'), findsOneWidget); + }); + + testWidgets('Switching to Plugin Store displays verified badge for M5 Dev', + (WidgetTester tester) async { + final service = FakePluginService(installed: []); + final controller = PluginController(service: service); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PluginsScreen( + theme: testTheme, + controller: controller, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Tap on Plugin Store Tab + await tester.tap(find.text('Plugin Store 🌐🛒')); + await tester.pumpAndSettle(); + + expect(find.text('Xbox 360 XGDTool Engine'), findsOneWidget); + expect(find.text('by M5 Dev'), findsOneWidget); + expect(find.byIcon(Icons.verified), findsOneWidget); + expect(find.text('PS2 GZ ISO Tool'), findsOneWidget); + }); + }); +}