From a37b67873ebb15e98634bd5c6f30d5bde3a2954f Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Tue, 18 Mar 2025 21:52:15 +0100 Subject: [PATCH] Implement mod troubleshooter --- lib/main.dart | 31 +- lib/mod_troubleshooter_widget.dart | 812 +++++++++++++++++++++++++++++ 2 files changed, 814 insertions(+), 29 deletions(-) create mode 100644 lib/mod_troubleshooter_widget.dart diff --git a/lib/main.dart b/lib/main.dart index f1194f2..b8cdfb4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:rimworld_modman/logger.dart'; import 'package:rimworld_modman/mod.dart'; import 'package:rimworld_modman/mod_list.dart'; +import 'package:rimworld_modman/mod_troubleshooter_widget.dart'; // Theme extension to store app-specific constants class AppThemeExtension extends ThemeExtension { @@ -1551,34 +1552,6 @@ class TroubleshootingPage extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.build, size: 64), - const SizedBox(height: 16), - Text( - 'Troubleshooting', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: Text( - 'Find problematic mods with smart batch testing.', - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: () { - // TODO: Implement troubleshooting wizard - }, - child: const Text('Start Troubleshooting'), - ), - ], - ), - ); + return const ModTroubleshooterWidget(); } } diff --git a/lib/mod_troubleshooter_widget.dart b/lib/mod_troubleshooter_widget.dart new file mode 100644 index 0000000..2e07a08 --- /dev/null +++ b/lib/mod_troubleshooter_widget.dart @@ -0,0 +1,812 @@ +import 'package:flutter/material.dart'; +import 'package:rimworld_modman/mod.dart'; +import 'package:rimworld_modman/mod_list.dart'; +import 'package:rimworld_modman/mod_list_troubleshooter.dart'; + +import 'main.dart'; + +/// A widget that provides a user interface for the mod troubleshooter functionality. +/// +/// This allows users to: +/// - Toggle between binary and linear search modes +/// - Navigate forward and backward through mod sets +/// - Adjust step size for linear navigation +/// - Mark mods as checked/good or problematic +/// - Find specific mods causing issues in their load order +class ModTroubleshooterWidget extends StatefulWidget { + const ModTroubleshooterWidget({super.key}); + + @override + State createState() => _ModTroubleshooterWidgetState(); +} + +class _ModTroubleshooterWidgetState extends State { + late ModListTroubleshooter _troubleshooter; + + bool _isInitialized = false; + bool _isBinaryMode = false; + int _stepSize = 10; + + // Set of mod IDs that have been checked and confirmed to be good + final Set _checkedMods = {}; + + // Set of mod IDs that are suspected to cause issues + final Set _problemMods = {}; + + // The currently selected mod IDs (for highlighting) + List _selectedMods = []; + + // The next potential set of mods (from move calculation) + Move? _nextForwardMove; + Move? _nextBackwardMove; + + // Controller for step size input + late TextEditingController _stepSizeController; + + @override + void initState() { + super.initState(); + _stepSizeController = TextEditingController(text: _stepSize.toString()); + } + + @override + void dispose() { + _stepSizeController.dispose(); + super.dispose(); + } + + void _initialize() { + if (_isInitialized) return; + + // Initialize the troubleshooter with the global mod manager + _troubleshooter = ModListTroubleshooter(modManager); + + // Set initial active mods for highlighting + if (modManager.activeMods.isNotEmpty) { + // Initially select all active mods + _selectedMods = List.from(modManager.activeMods.keys); + } + + // Calculate initial moves + _updateNextMoves(); + + setState(() { + _isInitialized = true; + }); + } + + void _updateNextMoves() { + if (_isBinaryMode) { + _nextForwardMove = _troubleshooter.binaryForwardMove(); + _nextBackwardMove = _troubleshooter.binaryBackwardMove(); + } else { + _nextForwardMove = _troubleshooter.linearForwardMove(stepSize: _stepSize); + _nextBackwardMove = _troubleshooter.linearBackwardMove(stepSize: _stepSize); + } + } + + void _navigateForward() { + ModList result; + if (_isBinaryMode) { + result = _troubleshooter.binaryForward(); + } else { + result = _troubleshooter.linearForward(stepSize: _stepSize); + } + + // Load all required dependencies for the selected mods + final loadOrder = result.loadRequired(); + + // Use the mods from the load order result + setState(() { + _selectedMods = loadOrder.loadOrder; + _updateNextMoves(); + }); + } + + void _navigateBackward() { + ModList result; + if (_isBinaryMode) { + result = _troubleshooter.binaryBackward(); + } else { + result = _troubleshooter.linearBackward(stepSize: _stepSize); + } + + // Load all required dependencies for the selected mods + final loadOrder = result.loadRequired(); + + // Use the mods from the load order result + setState(() { + _selectedMods = loadOrder.loadOrder; + _updateNextMoves(); + }); + } + + void _toggleSearchMode() { + setState(() { + _isBinaryMode = !_isBinaryMode; + _updateNextMoves(); + }); + } + + void _markAsGood(String modId) { + setState(() { + _checkedMods.add(modId); + _problemMods.remove(modId); + }); + } + + void _markAsProblem(String modId) { + setState(() { + _problemMods.add(modId); + _checkedMods.remove(modId); + }); + } + + void _clearMarks(String modId) { + setState(() { + _checkedMods.remove(modId); + _problemMods.remove(modId); + }); + } + + void _resetTroubleshooter() { + setState(() { + _checkedMods.clear(); + _problemMods.clear(); + _isInitialized = false; + }); + _initialize(); + } + + void _saveTroubleshootingConfig() { + // Only save if we have a valid selection + if (_selectedMods.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No mods selected to save'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + // First disable all mods + modManager.disableAll(); + + // Then enable only the selected mods + modManager.enableMods(_selectedMods); + + // Save the configuration (we don't have direct access to save method, so show a message) + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${_selectedMods.length} mods prepared for testing. Please use Save button in the Mods tab to save config.'), + backgroundColor: Colors.orange, + duration: const Duration(seconds: 4), + ), + ); + } + + void _markSelectedAsGood() { + if (_selectedMods.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No mods selected to mark'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + setState(() { + for (final modId in _selectedMods) { + _checkedMods.add(modId); + _problemMods.remove(modId); + } + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Marked ${_selectedMods.length} mods as good'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } + + void _markSelectedAsProblem() { + if (_selectedMods.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No mods selected to mark'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + setState(() { + for (final modId in _selectedMods) { + _problemMods.add(modId); + _checkedMods.remove(modId); + } + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Marked ${_selectedMods.length} mods as problematic'), + backgroundColor: Colors.orange, + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + // Make sure we're initialized + if (!_isInitialized) { + _initialize(); + } + + if (!_isInitialized || modManager.mods.isEmpty) { + return _buildEmptyState(); + } + + return Column( + children: [ + _buildControlPanel(), + Expanded( + child: _buildModList(), + ), + ], + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.build, size: AppThemeExtension.of(context).iconSizeLarge * 2), + const SizedBox(height: 16), + Text( + 'Troubleshooting', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Text( + 'Load mods first to use the troubleshooting tools.', + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + // No direct access to the ModManagerHomePage state, so just show a message + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please go to the Mods tab to load mods first'), + duration: Duration(seconds: 3), + ), + ); + }, + child: const Text('Go to Mod Manager'), + ), + ], + ), + ); + } + + Widget _buildInstructionsPanel() { + return Card( + margin: AppThemeExtension.of(context).paddingRegular, + child: Padding( + padding: AppThemeExtension.of(context).paddingRegular, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Mod Troubleshooter', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'This tool helps you find problematic mods by testing different combinations. ' + 'Follow these steps:', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + '1. Start RimWorld with the highlighted mods active', + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + '2. If the game works correctly, mark those mods as "Good"', + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + '3. If the problem occurs, use "Forward" or "Backward" to narrow down the problem', + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + '4. Mods marked as "Problem" are more likely to be causing issues', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + if (_selectedMods.isNotEmpty) + Text( + 'Currently testing ${_selectedMods.length} mods', + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppThemeExtension.of(context).enabledModColor, + ), + ), + ], + ), + ), + ); + } + + Widget _buildControlPanel() { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Padding( + padding: AppThemeExtension.of(context).paddingSmall, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // Compact instruction + Expanded( + child: Text( + _selectedMods.isNotEmpty + ? 'Testing ${_selectedMods.length} mods. Tap highlighted mods to navigate. Mark results below:' + : 'Click highlighted mods to begin testing. Blue→forward, purple←backward.', + style: TextStyle( + fontSize: AppThemeExtension.of(context).textSizeRegular, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + + const SizedBox(height: 8), + + Row( + children: [ + // Binary/Linear mode toggle + Text( + 'Mode:', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(width: 8), + ToggleButtons( + isSelected: [!_isBinaryMode, _isBinaryMode], + onPressed: (index) { + setState(() { + _isBinaryMode = index == 1; + _updateNextMoves(); + }); + }, + children: const [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: Text('Linear'), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: Text('Binary'), + ), + ], + ), + + // Step size input field (only for linear mode) + if (!_isBinaryMode) ...[ + const SizedBox(width: 16), + Text( + 'Step:', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(width: 4), + SizedBox( + width: 60, + child: TextField( + keyboardType: TextInputType.number, + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 6, + vertical: 6, + ), + ), + controller: _stepSizeController, + onChanged: (value) { + final parsedValue = int.tryParse(value); + if (parsedValue != null && parsedValue > 0) { + setState(() { + _stepSize = parsedValue; + _updateNextMoves(); + }); + } + }, + ), + ), + ], + + const Spacer(), + + // Buttons to mark selected mods + if (_selectedMods.isNotEmpty) ...[ + OutlinedButton.icon( + icon: Icon(Icons.error, color: Colors.red.shade300, size: 16), + label: const Text('Problem'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), + ), + onPressed: _markSelectedAsProblem, + ), + const SizedBox(width: 4), + OutlinedButton.icon( + icon: Icon(Icons.check_circle, color: Colors.green.shade300, size: 16), + label: const Text('Good'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), + ), + onPressed: _markSelectedAsGood, + ), + const SizedBox(width: 4), + ], + + // Reset button + OutlinedButton.icon( + icon: const Icon(Icons.refresh, size: 16), + label: const Text('Reset'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), + ), + onPressed: _resetTroubleshooter, + ), + + if (_selectedMods.isNotEmpty) ...[ + const SizedBox(width: 4), + // Save config button + OutlinedButton.icon( + icon: const Icon(Icons.save, size: 16), + label: const Text('Save'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), + ), + onPressed: _saveTroubleshootingConfig, + ), + ], + ], + ), + ], + ), + ), + ); + } + + Widget _buildModList() { + // Get the original mod order from mod manager + final fullModList = modManager.activeMods.keys.toList(); + + return Card( + margin: AppThemeExtension.of(context).paddingRegular, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + color: Theme.of(context).primaryColor, + padding: AppThemeExtension.of(context).paddingRegular, + child: Column( + children: [ + Text( + 'Active Mods (${fullModList.length})', + style: const TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + if (_nextForwardMove != null || _nextBackwardMove != null) + Text( + 'Click ↓blue areas to move forward, ↑purple to move backward', + style: TextStyle( + fontSize: AppThemeExtension.of(context).textSizeSmall, + fontStyle: FontStyle.italic, + color: Colors.grey.shade300, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: fullModList.length, + itemBuilder: (context, index) { + final modId = fullModList[index]; + final mod = modManager.mods[modId]; + + if (mod == null) return const SizedBox.shrink(); + + // Determine if this mod is in the selection range for highlighted navigation + final bool isSelected = _selectedMods.contains(modId); + + // Check if this mod would be included in the next Forward/Backward move + bool isInNextForward = false; + bool isInNextBackward = false; + + if (_nextForwardMove != null && index >= _nextForwardMove!.startIndex && index < _nextForwardMove!.endIndex) { + isInNextForward = true; + } + + if (_nextBackwardMove != null && index >= _nextBackwardMove!.startIndex && index < _nextBackwardMove!.endIndex) { + isInNextBackward = true; + } + + // Determine mod status for coloring + final bool isChecked = _checkedMods.contains(modId); + final bool isProblem = _problemMods.contains(modId); + + return GestureDetector( + onTap: () { + // Navigation takes precedence if this mod is in a navigation range + if (isInNextForward) { + _navigateForward(); + } else if (isInNextBackward) { + _navigateBackward(); + } + // Otherwise toggle the status of this mod + else if (isChecked) { + _markAsProblem(modId); + } else if (isProblem) { + _clearMarks(modId); + } else { + _markAsGood(modId); + } + }, + child: Card( + margin: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 4.0, + ), + color: _getModCardColor( + isSelected: isSelected, + isChecked: isChecked, + isProblem: isProblem, + isInNextForward: isInNextForward, + isInNextBackward: isInNextBackward, + ), + child: ListTile( + leading: Text( + '${index + 1}', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isSelected ? Colors.white : Colors.grey, + ), + ), + title: Row( + children: [ + if (isSelected) + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), + margin: const EdgeInsets.only(right: 4), + decoration: BoxDecoration( + color: Colors.blue.shade800.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'TESTING', + style: TextStyle( + color: Colors.blue.shade200, + fontSize: AppThemeExtension.of(context).textSizeSmall, + fontWeight: FontWeight.bold, + ), + ), + ), + if (isChecked) + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), + margin: const EdgeInsets.only(right: 4), + decoration: BoxDecoration( + color: Colors.green.shade800.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'GOOD', + style: TextStyle( + color: Colors.green.shade200, + fontSize: AppThemeExtension.of(context).textSizeSmall, + fontWeight: FontWeight.bold, + ), + ), + ), + if (isProblem) + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), + margin: const EdgeInsets.only(right: 4), + decoration: BoxDecoration( + color: Colors.red.shade800.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'PROBLEM', + style: TextStyle( + color: Colors.red.shade200, + fontSize: AppThemeExtension.of(context).textSizeSmall, + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: Text( + mod.name, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ], + ), + subtitle: Text( + modId, + style: TextStyle( + fontSize: AppThemeExtension.of(context).textSizeSmall, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Display mod characteristics + if (mod.isBaseGame) + Tooltip( + message: 'Base Game', + child: Icon( + Icons.home, + color: AppThemeExtension.of(context).baseGameColor, + size: AppThemeExtension.of(context).iconSizeSmall, + ), + ), + if (mod.isExpansion) + Tooltip( + message: 'Expansion', + child: Icon( + Icons.star, + color: AppThemeExtension.of(context).expansionColor, + size: AppThemeExtension.of(context).iconSizeSmall, + ), + ), + if (mod.dependencies.isNotEmpty) + Tooltip( + message: 'Dependencies:\n${mod.dependencies.join('\n')}', + child: Icon( + Icons.link, + color: AppThemeExtension.of(context).linkColor, + size: AppThemeExtension.of(context).iconSizeSmall, + ), + ), + + // Display status icon + if (isChecked) + Tooltip( + message: 'Marked as working correctly', + child: Icon( + Icons.check_circle, + color: Colors.green.shade300, + ), + ) + else if (isProblem) + Tooltip( + message: 'Marked as problematic', + child: Icon( + Icons.error, + color: Colors.red.shade300, + ), + ), + + const SizedBox(width: 4), + + // Show navigation indicators + if (isInNextForward) + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Tooltip( + message: 'Click to move Forward (test this mod)', + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.arrow_forward, + color: Colors.blue.shade300, + size: AppThemeExtension.of(context).iconSizeSmall, + ), + const SizedBox(width: 2), + Text( + 'Forward', + style: TextStyle( + color: Colors.blue.shade300, + fontSize: AppThemeExtension.of(context).textSizeSmall, + ), + ), + ], + ), + ), + ), + + if (isInNextBackward) + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.purple.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Tooltip( + message: 'Click to move Backward (test this mod)', + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.arrow_back, + color: Colors.purple.shade300, + size: AppThemeExtension.of(context).iconSizeSmall, + ), + const SizedBox(width: 2), + Text( + 'Back', + style: TextStyle( + color: Colors.purple.shade300, + fontSize: AppThemeExtension.of(context).textSizeSmall, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + Color _getModCardColor({ + required bool isSelected, + required bool isChecked, + required bool isProblem, + required bool isInNextForward, + required bool isInNextBackward, + }) { + // Priority: 1. Selected, 2. Navigation areas, 3. Status + if (isSelected) { + return Colors.blue.shade800.withOpacity(0.3); + } else if (isInNextForward && isInNextBackward) { + // Both forward and backward - more obvious purple + return Colors.deepPurple.withOpacity(0.3); + } else if (isInNextForward) { + // Forward navigation - more obvious blue + return Colors.blue.withOpacity(0.25); + } else if (isInNextBackward) { + // Backward navigation - more obvious purple + return Colors.purple.withOpacity(0.25); + } else if (isChecked) { + return Colors.green.shade800.withOpacity(0.2); + } else if (isProblem) { + return Colors.red.shade800.withOpacity(0.2); + } + + return Colors.transparent; + } +} \ No newline at end of file