Implement mod troubleshooter

This commit is contained in:
2025-03-18 21:52:15 +01:00
parent 164e95fa54
commit a37b67873e
2 changed files with 814 additions and 29 deletions

View File

@@ -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<AppThemeExtension> {
@@ -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();
}
}

View File

@@ -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<ModTroubleshooterWidget> createState() => _ModTroubleshooterWidgetState();
}
class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
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<String> _checkedMods = {};
// Set of mod IDs that are suspected to cause issues
final Set<String> _problemMods = {};
// The currently selected mod IDs (for highlighting)
List<String> _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;
}
}