Files
flutter-rimworld-modman/lib/main.dart
2025-03-16 12:52:49 +01:00

894 lines
32 KiB
Dart

import 'package:flutter/material.dart';
import 'package:rimworld_modman/logger.dart';
import 'package:rimworld_modman/mod.dart';
import 'package:rimworld_modman/modloader.dart';
// Global variable to store loaded mods for access across the app
late ModList modManager;
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Get a reference to the logger (now auto-initializes)
final logger = Logger.instance;
logger.info('RimWorld Mod Manager starting...');
// Initialize the mod manager
modManager = ModList(path: modsRoot);
// Start the app
runApp(const RimWorldModManager());
}
class RimWorldModManager extends StatelessWidget {
const RimWorldModManager({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'RimWorld Mod Manager',
theme: ThemeData.dark().copyWith(
primaryColor: const Color(0xFF3D4A59),
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF3D4A59),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(0xFF1E262F),
cardColor: const Color(0xFF2A3440),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF2A3440),
foregroundColor: Colors.white,
),
),
home: const ModManagerHomePage(),
);
}
}
class ModManagerHomePage extends StatefulWidget {
const ModManagerHomePage({super.key});
@override
State<ModManagerHomePage> createState() => _ModManagerHomePageState();
}
class _ModManagerHomePageState extends State<ModManagerHomePage> {
int _selectedIndex = 0;
final List<Widget> _pages = [
const ModManagerPage(),
const TroubleshootingPage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('RimWorld Mod Manager')),
body: _pages[_selectedIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) {
setState(() {
_selectedIndex = index;
});
},
items: const [
BottomNavigationBarItem(icon: Icon(Icons.extension), label: 'Mods'),
BottomNavigationBarItem(
icon: Icon(Icons.build),
label: 'Troubleshoot',
),
],
),
);
}
}
// Combined page for mod management with two-panel layout
class ModManagerPage extends StatefulWidget {
const ModManagerPage({super.key});
@override
State<ModManagerPage> createState() => _ModManagerPageState();
}
class _ModManagerPageState extends State<ModManagerPage> {
// For all available mods (left panel)
List<Mod> _availableMods = [];
// For active mods (right panel)
List<Mod> _activeMods = [];
bool _isLoading = false;
String _statusMessage = '';
int _totalModsFound = 0;
bool _skipFileCount = false;
bool _hasCycles = false;
List<String>? _cycleInfo;
List<List<String>> _incompatibleMods = [];
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void initState() {
super.initState();
// Check if mods are already loaded in the global modManager
if (modManager.modsLoaded) {
_loadModsFromGlobalState();
}
_searchController.addListener(() {
setState(() {
_searchQuery = _searchController.text.toLowerCase();
});
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _loadModsFromGlobalState() {
setState(() {
// Get all mods for the left panel (sorted alphabetically)
_availableMods = modManager.mods.values.toList();
_availableMods.sort((a, b) => a.name.compareTo(b.name));
// Get active mods for the right panel (in load order)
_activeMods = modManager.mods.values.where((m) => m.enabled).toList();
_isLoading = false;
_statusMessage = modManager.loadingStatus;
_totalModsFound = modManager.totalModsFound;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body:
_isLoading && _availableMods.isEmpty
? _buildLoadingView()
: _availableMods.isEmpty
? _buildEmptyState()
: _buildSplitView(),
);
}
Widget _buildLoadingView() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
_statusMessage,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.extension, size: 64),
const SizedBox(height: 16),
Text(
'Mod Manager',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Text(
'Ready to scan for RimWorld mods.',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 12),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: _skipFileCount,
onChanged: (value) {
setState(() {
_skipFileCount = value ?? true;
});
},
),
const Text('Skip file counting (faster loading)'),
],
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _startLoadingMods,
child: const Text('Scan for Mods'),
),
],
),
);
}
Widget _buildSplitView() {
// Filter both available and active mods based on search query
final filteredAvailableMods =
_searchQuery.isEmpty
? _availableMods
: _availableMods
.where(
(mod) =>
mod.name.toLowerCase().contains(_searchQuery) ||
mod.id.toLowerCase().contains(_searchQuery),
)
.toList();
final filteredActiveMods =
_searchQuery.isEmpty
? _activeMods
: _activeMods
.where(
(mod) =>
mod.name.toLowerCase().contains(_searchQuery) ||
mod.id.toLowerCase().contains(_searchQuery),
)
.toList();
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
// Search field
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search mods by name or ID...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
),
suffixIcon:
_searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
),
),
),
const SizedBox(width: 8),
// Reload button
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Reload mods',
onPressed: _startLoadingMods,
),
const SizedBox(width: 8),
// Auto-sort button
ElevatedButton.icon(
icon: const Icon(Icons.sort),
label: const Text('Auto-Sort'),
onPressed: _sortActiveMods,
),
const SizedBox(width: 8),
// Save button
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('Save'),
onPressed: _saveModOrder,
),
],
),
),
// Status message
if (!_isLoading && _statusMessage.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: Text(
_statusMessage,
style: TextStyle(
color:
_hasCycles || _incompatibleMods.isNotEmpty
? Colors.orange
: Colors.green,
),
),
),
// Cycle warnings
if (_hasCycles && _cycleInfo != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
'Dependency cycle detected: ${_cycleInfo!.join(" -> ")}',
style: const TextStyle(color: Colors.orange),
),
),
// Incompatible mod warnings
if (_incompatibleMods.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
children: [
const Icon(Icons.warning, color: Colors.orange, size: 16),
const SizedBox(width: 4),
Text(
'${_incompatibleMods.length} incompatible mod pairs found',
style: const TextStyle(color: Colors.orange),
),
],
),
),
// Main split view
Expanded(
child: Row(
children: [
// LEFT PANEL - All available mods (alphabetical)
Expanded(
child: Card(
margin: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
color: Theme.of(context).primaryColor,
padding: const EdgeInsets.all(8.0),
child: Text(
'Available Mods (${filteredAvailableMods.length}${_searchQuery.isNotEmpty ? '/${_availableMods.length}' : ''})',
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
if (_searchQuery.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4.0),
child: Text(
'Searching: "$_searchQuery"',
style: TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,
color: Colors.grey.shade400,
),
textAlign: TextAlign.center,
),
),
Expanded(
child: ListView.builder(
itemCount: filteredAvailableMods.length,
itemBuilder: (context, index) {
final mod = filteredAvailableMods[index];
final isActive = mod.enabled;
return GestureDetector(
onDoubleTap: () => _toggleModActive(mod),
child: ListTile(
title: Text(
mod.name,
style: TextStyle(
color:
isActive ? Colors.green : Colors.white,
),
),
subtitle: Text(
'ID: ${mod.id}\nSize: ${mod.size} files',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (mod.isBaseGame)
const Tooltip(
message: 'Base Game',
child: Icon(
Icons.home,
color: Colors.blue,
size: 24,
),
),
if (mod.isExpansion)
const Tooltip(
message: 'Expansion',
child: Icon(
Icons.star,
color: Colors.yellow,
size: 24,
),
),
const SizedBox(width: 4),
if (mod.dependencies.isNotEmpty)
Tooltip(
message:
'Dependencies:\n${mod.dependencies.join('\n')}',
child: const Icon(
Icons.link,
color: Colors.orange,
size: 24,
),
),
if (mod.loadAfter.isNotEmpty)
Tooltip(
message:
'Loads after:\n${mod.loadAfter.join('\n')}',
child: const Icon(
Icons.arrow_downward,
color: Colors.blue,
size: 24,
),
),
if (mod.loadBefore.isNotEmpty)
Tooltip(
message:
'Loads before:\n${mod.loadBefore.join('\n')}',
child: const Icon(
Icons.arrow_upward,
color: Colors.green,
size: 24,
),
),
],
),
onTap: () {
// Show mod details in future
},
),
);
},
),
),
],
),
),
),
// RIGHT PANEL - Active mods (load order)
Expanded(
child: Card(
margin: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
color: Theme.of(context).primaryColor,
padding: const EdgeInsets.all(8.0),
child: Text(
'Active Mods (${filteredActiveMods.length}${_searchQuery.isNotEmpty ? '/${_activeMods.length}' : ''})',
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
_searchQuery.isNotEmpty
? 'Searching: "$_searchQuery"'
: 'Larger mods are prioritized during auto-sorting.',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade400,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
),
Expanded(
child: ListView.builder(
itemCount: filteredActiveMods.length,
itemBuilder: (context, index) {
final mod = filteredActiveMods[index];
// Find the actual position in the complete active mods list (for correct load order)
final actualLoadOrderPosition =
_activeMods.indexWhere((m) => m.id == mod.id) +
1;
return GestureDetector(
onDoubleTap: () {
// Don't allow deactivating base game or expansions
if (!mod.isBaseGame && !mod.isExpansion) {
_toggleModActive(mod);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Core game and expansions cannot be deactivated',
),
backgroundColor: Colors.orange,
),
);
}
},
child: Card(
margin: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 4.0,
),
child: ListTile(
leading: SizedBox(
width: 50,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment:
MainAxisAlignment.center,
children: [
SizedBox(
height: 24,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
),
decoration: BoxDecoration(
color:
_searchQuery.isNotEmpty
? Colors.blue.withOpacity(
0.2,
)
: null,
borderRadius:
BorderRadius.circular(4),
),
child: Tooltip(
message:
'Load position: $actualLoadOrderPosition of ${_activeMods.length}${_searchQuery.isNotEmpty ? " (preserved when filtering)" : ""}',
child: Text(
'$actualLoadOrderPosition',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color:
_searchQuery.isNotEmpty
? Colors.blue.shade300
: null,
),
),
),
),
),
SizedBox(
height: 24,
child: Center(
child:
mod.size > 0
? Tooltip(
message:
'This mod contains ${mod.size} files.',
child: Text(
'${mod.size}',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
)
: const SizedBox(),
),
),
],
),
),
title: Text(mod.name),
subtitle: Text(
mod.id,
style: const TextStyle(fontSize: 12),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (mod.isBaseGame)
const Tooltip(
message: 'Base Game',
child: Icon(
Icons.home,
color: Colors.blue,
size: 24,
),
),
if (mod.isExpansion)
const Tooltip(
message: 'Expansion',
child: Icon(
Icons.star,
color: Colors.yellow,
size: 24,
),
),
if (mod.dependencies.isNotEmpty)
Tooltip(
message:
'Dependencies:\n${mod.dependencies.join('\n')}',
child: const Icon(
Icons.link,
color: Colors.orange,
size: 24,
),
),
const SizedBox(width: 4),
if (mod.loadAfter.isNotEmpty)
Tooltip(
message:
'Loads after other mods:\n${mod.loadAfter.join('\n')}',
child: const Icon(
Icons.arrow_downward,
color: Colors.blue,
size: 24,
),
),
const SizedBox(width: 4),
if (mod.loadBefore.isNotEmpty)
Tooltip(
message:
'Loads before other mods:\n${mod.loadBefore.join('\n')}',
child: const Icon(
Icons.arrow_upward,
color: Colors.green,
size: 24,
),
),
],
),
onTap: () {
// Show mod details in future
},
),
),
);
},
),
),
],
),
),
),
],
),
),
],
);
}
void _startLoadingMods() {
setState(() {
_availableMods.clear();
_activeMods.clear();
_isLoading = true;
_statusMessage = 'Scanning for mods...';
_hasCycles = false;
_cycleInfo = null;
_incompatibleMods = [];
});
// Load mods
modManager
.loadWithConfig(skipFileCount: _skipFileCount)
.then((_) {
_loadModsFromGlobalState();
})
.catchError((error) {
setState(() {
_isLoading = false;
_statusMessage = 'Error loading mods: $error';
});
});
}
void _toggleModActive(Mod mod) {
// Cannot deactivate base game or expansions
if ((mod.isBaseGame || mod.isExpansion) && mod.enabled) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Core game and expansions cannot be deactivated'),
backgroundColor: Colors.orange,
),
);
return;
}
// Update the mod's enabled status in the global mod manager
final updatedMod = Mod(
name: mod.name,
id: mod.id,
path: mod.path,
versions: mod.versions,
description: mod.description,
dependencies: mod.dependencies,
loadAfter: mod.loadAfter,
loadBefore: mod.loadBefore,
incompatibilities: mod.incompatibilities,
enabled: !mod.enabled,
size: mod.size,
isBaseGame: mod.isBaseGame,
isExpansion: mod.isExpansion,
);
// Update in the global mod manager
modManager.mods[mod.id] = updatedMod;
// Update the UI
setState(() {
// Update in the available mods list
final index = _availableMods.indexWhere((m) => m.id == mod.id);
if (index >= 0) {
_availableMods[index] = updatedMod;
}
// Update the active mods list
if (updatedMod.enabled) {
// Add to active mods if not already there
if (!_activeMods.any((m) => m.id == updatedMod.id)) {
_activeMods.add(updatedMod);
}
} else {
// Remove from active mods
_activeMods.removeWhere((m) => m.id == updatedMod.id);
}
_statusMessage =
'Mod "${mod.name}" ${updatedMod.enabled ? 'activated' : 'deactivated'}';
});
}
void _sortActiveMods() async {
if (_activeMods.isEmpty) {
setState(() {
_statusMessage = 'No active mods to sort';
});
return;
}
setState(() {
_isLoading = true;
_statusMessage = 'Sorting mods based on dependencies...';
_hasCycles = false;
_cycleInfo = null;
_incompatibleMods = [];
});
// Use a Future.delayed to allow the UI to update
await Future.delayed(Duration.zero);
try {
// Check for cycles first
final hardGraph = modManager.buildDependencyGraph();
final cycle = modManager.detectCycle(hardGraph);
if (cycle != null) {
setState(() {
_hasCycles = true;
_cycleInfo = cycle;
});
}
// Get incompatibilities
_incompatibleMods = modManager.findIncompatibilities();
// Get the sorted mods
final sortedMods = modManager.getModsInLoadOrder();
setState(() {
_activeMods = sortedMods;
_isLoading = false;
_statusMessage = 'Sorting complete! ${sortedMods.length} mods sorted.';
if (_hasCycles) {
_statusMessage += ' Warning: Dependency cycles were found and fixed.';
}
if (_incompatibleMods.isNotEmpty) {
_statusMessage +=
' Warning: ${_incompatibleMods.length} incompatible mod pairs found.';
}
});
} catch (e) {
setState(() {
_isLoading = false;
_statusMessage = 'Error sorting mods: $e';
});
}
}
void _saveModOrder() async {
if (_activeMods.isEmpty) {
setState(() {
_statusMessage = 'No active mods to save';
});
return;
}
setState(() {
_isLoading = true;
_statusMessage = 'Saving mod load order...';
});
try {
// Create a ConfigFile instance
final configFile = ConfigFile(path: configPath);
// Load the current config
await configFile.load();
// Replace the mods with our active mods list
configFile.mods.clear();
for (final mod in _activeMods) {
configFile.mods.add(mod);
}
// Save the updated config
configFile.save();
setState(() {
_isLoading = false;
_statusMessage = 'Mod load order saved successfully!';
});
// Show success message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mod order saved to config'),
backgroundColor: Colors.green,
),
);
} catch (e) {
setState(() {
_isLoading = false;
_statusMessage = 'Error saving mod load order: $e';
});
// Show error message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error saving mod order: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
// Page for troubleshooting problematic mods
class TroubleshootingPage extends StatelessWidget {
const TroubleshootingPage({super.key});
@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'),
),
],
),
);
}
}