Files
flutter-rimworld-modman/lib/main.dart
2025-03-18 23:01:10 +01:00

1518 lines
58 KiB
Dart

import 'dart:io';
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> {
final double iconSizeSmall;
final double iconSizeRegular;
final double iconSizeLarge;
final double textSizeSmall;
final double textSizeRegular;
final double textSizeLarge;
final EdgeInsets paddingSmall;
final EdgeInsets paddingRegular;
final EdgeInsets paddingLarge;
final Color enabledModColor;
final Color enabledModBackgroundColor;
final Color errorBackgroundColor;
final Color warningColor;
final Color errorColor;
final Color baseGameColor;
final Color expansionColor;
final Color linkColor;
final Color loadAfterColor;
final Color loadBeforeColor;
AppThemeExtension({
required this.iconSizeSmall,
required this.iconSizeRegular,
required this.iconSizeLarge,
required this.textSizeSmall,
required this.textSizeRegular,
required this.textSizeLarge,
required this.paddingSmall,
required this.paddingRegular,
required this.paddingLarge,
required this.enabledModColor,
required this.enabledModBackgroundColor,
required this.errorBackgroundColor,
required this.warningColor,
required this.errorColor,
required this.baseGameColor,
required this.expansionColor,
required this.linkColor,
required this.loadAfterColor,
required this.loadBeforeColor,
});
static AppThemeExtension of(BuildContext context) {
return Theme.of(context).extension<AppThemeExtension>()!;
}
@override
ThemeExtension<AppThemeExtension> copyWith({
double? iconSizeSmall,
double? iconSizeRegular,
double? iconSizeLarge,
double? textSizeSmall,
double? textSizeRegular,
double? textSizeLarge,
EdgeInsets? paddingSmall,
EdgeInsets? paddingRegular,
EdgeInsets? paddingLarge,
Color? enabledModColor,
Color? enabledModBackgroundColor,
Color? errorBackgroundColor,
Color? warningColor,
Color? errorColor,
Color? baseGameColor,
Color? expansionColor,
Color? linkColor,
Color? loadAfterColor,
Color? loadBeforeColor,
}) {
return AppThemeExtension(
iconSizeSmall: iconSizeSmall ?? this.iconSizeSmall,
iconSizeRegular: iconSizeRegular ?? this.iconSizeRegular,
iconSizeLarge: iconSizeLarge ?? this.iconSizeLarge,
textSizeSmall: textSizeSmall ?? this.textSizeSmall,
textSizeRegular: textSizeRegular ?? this.textSizeRegular,
textSizeLarge: textSizeLarge ?? this.textSizeLarge,
paddingSmall: paddingSmall ?? this.paddingSmall,
paddingRegular: paddingRegular ?? this.paddingRegular,
paddingLarge: paddingLarge ?? this.paddingLarge,
enabledModColor: enabledModColor ?? this.enabledModColor,
enabledModBackgroundColor:
enabledModBackgroundColor ?? this.enabledModBackgroundColor,
errorBackgroundColor: errorBackgroundColor ?? this.errorBackgroundColor,
warningColor: warningColor ?? this.warningColor,
errorColor: errorColor ?? this.errorColor,
baseGameColor: baseGameColor ?? this.baseGameColor,
expansionColor: expansionColor ?? this.expansionColor,
linkColor: linkColor ?? this.linkColor,
loadAfterColor: loadAfterColor ?? this.loadAfterColor,
loadBeforeColor: loadBeforeColor ?? this.loadBeforeColor,
);
}
@override
ThemeExtension<AppThemeExtension> lerp(
covariant ThemeExtension<AppThemeExtension>? other,
double t,
) {
if (other is! AppThemeExtension) {
return this;
}
return AppThemeExtension(
iconSizeSmall: lerpDouble(iconSizeSmall, other.iconSizeSmall, t),
iconSizeRegular: lerpDouble(iconSizeRegular, other.iconSizeRegular, t),
iconSizeLarge: lerpDouble(iconSizeLarge, other.iconSizeLarge, t),
textSizeSmall: lerpDouble(textSizeSmall, other.textSizeSmall, t),
textSizeRegular: lerpDouble(textSizeRegular, other.textSizeRegular, t),
textSizeLarge: lerpDouble(textSizeLarge, other.textSizeLarge, t),
paddingSmall: EdgeInsets.lerp(paddingSmall, other.paddingSmall, t)!,
paddingRegular: EdgeInsets.lerp(paddingRegular, other.paddingRegular, t)!,
paddingLarge: EdgeInsets.lerp(paddingLarge, other.paddingLarge, t)!,
enabledModColor: Color.lerp(enabledModColor, other.enabledModColor, t)!,
enabledModBackgroundColor:
Color.lerp(
enabledModBackgroundColor,
other.enabledModBackgroundColor,
t,
)!,
errorBackgroundColor:
Color.lerp(errorBackgroundColor, other.errorBackgroundColor, t)!,
warningColor: Color.lerp(warningColor, other.warningColor, t)!,
errorColor: Color.lerp(errorColor, other.errorColor, t)!,
baseGameColor: Color.lerp(baseGameColor, other.baseGameColor, t)!,
expansionColor: Color.lerp(expansionColor, other.expansionColor, t)!,
linkColor: Color.lerp(linkColor, other.linkColor, t)!,
loadAfterColor: Color.lerp(loadAfterColor, other.loadAfterColor, t)!,
loadBeforeColor: Color.lerp(loadBeforeColor, other.loadBeforeColor, t)!,
);
}
static AppThemeExtension light() {
return AppThemeExtension(
iconSizeSmall: 16,
iconSizeRegular: 24,
iconSizeLarge: 32,
textSizeSmall: 10,
textSizeRegular: 14,
textSizeLarge: 18,
paddingSmall: const EdgeInsets.all(4.0),
paddingRegular: const EdgeInsets.all(8.0),
paddingLarge: const EdgeInsets.all(16.0),
enabledModColor: Colors.green,
enabledModBackgroundColor: const Color.fromRGBO(0, 128, 0, 0.1),
errorBackgroundColor: Color.fromRGBO(
Colors.red.shade900.red,
Colors.red.shade900.green,
Colors.red.shade900.blue,
0.3,
),
warningColor: Colors.orange,
errorColor: Colors.red,
baseGameColor: Colors.blue,
expansionColor: Colors.yellow,
linkColor: Colors.orange,
loadAfterColor: Colors.blue,
loadBeforeColor: Colors.green,
);
}
static AppThemeExtension dark() {
return light(); // For now, we use the same values for both light and dark
}
}
double lerpDouble(double a, double b, double t) {
return a + (b - a) * t;
}
// Constants for file paths
final String root =
Platform.isWindows
? r'C:/Users/Administrator/Seafile/Games-RimWorld'
: '~/Library/Application Support/RimWorld';
final String modsRoot = Platform.isWindows ? '$root/294100' : '$root/Mods';
final String configRoot =
Platform.isWindows
? '$root/AppData/RimWorld by Ludeon Studios/Config'
: '$root/Config';
final String configPath = '$configRoot/ModsConfig.xml';
final String logsPath = '$root/ModManager';
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(modsPath: modsRoot, configPath: configPath);
// 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,
),
extensions: [AppThemeExtension.dark()],
),
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 = '';
bool _skipFileCount = false;
bool _hasCycles = false;
List<String>? _cycleInfo;
List<List<String>> _incompatibleMods = [];
List<String>? _loadOrderErrors;
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void initState() {
super.initState();
// Check if mods are already loaded
if (modManager.mods.isNotEmpty) {
_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 = 'Loaded ${_availableMods.length} mods';
});
}
@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: AppThemeExtension.of(context).paddingRegular,
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),
// Load Dependencies button
Tooltip(
message:
'Automatically load missing dependencies for active mods',
child: ElevatedButton.icon(
icon: const Icon(Icons.download),
label: const Text('Load Deps'),
onPressed: _loadRequiredDependencies,
),
),
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,
),
),
),
// Error display section
if (_hasCycles ||
_incompatibleMods.isNotEmpty ||
(_loadOrderErrors?.isNotEmpty ?? false))
Container(
margin: EdgeInsets.symmetric(
horizontal:
AppThemeExtension.of(context).paddingRegular.horizontal,
vertical: AppThemeExtension.of(context).paddingSmall.vertical,
),
padding: AppThemeExtension.of(context).paddingRegular,
decoration: BoxDecoration(
color: AppThemeExtension.of(context).errorBackgroundColor,
borderRadius: BorderRadius.circular(4.0),
border: Border.all(color: Colors.red.shade800),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Cycle warnings
if (_hasCycles && _cycleInfo != null)
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Row(
children: [
Icon(
Icons.loop,
color: AppThemeExtension.of(context).warningColor,
size: AppThemeExtension.of(context).iconSizeSmall,
),
const SizedBox(width: 4),
Expanded(
child: Text(
'Dependency cycle detected: ${_cycleInfo!.join(" -> ")}',
style: TextStyle(
color: AppThemeExtension.of(context).warningColor,
),
),
),
],
),
),
// Incompatible mod warnings
if (_incompatibleMods.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.warning,
color: AppThemeExtension.of(context).warningColor,
size: AppThemeExtension.of(context).iconSizeSmall,
),
const SizedBox(width: 4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_incompatibleMods.length} incompatible mod pairs:',
style: TextStyle(
color:
AppThemeExtension.of(
context,
).warningColor,
),
),
if (_incompatibleMods.length <= 3)
...List.generate(_incompatibleMods.length, (
index,
) {
final pair = _incompatibleMods[index];
final mod1 =
modManager.mods[pair[0]]?.name ?? pair[0];
final mod2 =
modManager.mods[pair[1]]?.name ?? pair[1];
return Padding(
padding: const EdgeInsets.only(
left: 12.0,
top: 2.0,
),
child: Text(
'$mod1$mod2',
style: TextStyle(
color: Colors.orange.shade300,
fontSize:
AppThemeExtension.of(
context,
).textSizeSmall,
),
),
);
}),
],
),
),
],
),
),
// Other errors (missing dependencies, etc.)
if (_loadOrderErrors?.isNotEmpty ?? false)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.error_outline,
color: AppThemeExtension.of(context).errorColor,
size: AppThemeExtension.of(context).iconSizeSmall,
),
const SizedBox(width: 4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Dependency errors:',
style: TextStyle(
color: AppThemeExtension.of(context).errorColor,
),
),
...List.generate(
_loadOrderErrors!.length > 5
? 5
: _loadOrderErrors!.length,
(index) => Padding(
padding: const EdgeInsets.only(
left: 12.0,
top: 2.0,
),
child: Text(
'${_loadOrderErrors![index]}',
style: TextStyle(
color: Colors.red.shade300,
fontSize:
AppThemeExtension.of(
context,
).textSizeSmall,
),
),
),
),
if (_loadOrderErrors!.length > 5)
Padding(
padding: const EdgeInsets.only(
left: 12.0,
top: 4.0,
),
child: Text(
'(${_loadOrderErrors!.length - 5} more errors...)',
style: TextStyle(
color: Colors.red.shade300,
fontSize:
AppThemeExtension.of(
context,
).textSizeSmall,
fontStyle: FontStyle.italic,
),
),
),
],
),
),
],
),
],
),
),
// Main split view
Expanded(
child: Row(
children: [
// LEFT PANEL - All available mods (alphabetical)
Expanded(
child: Card(
margin: AppThemeExtension.of(context).paddingRegular,
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: AppThemeExtension.of(context).paddingSmall,
child: Text(
'Searching: "$_searchQuery"',
style: TextStyle(
fontSize:
AppThemeExtension.of(context).textSizeSmall,
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
? AppThemeExtension.of(
context,
).enabledModColor
: Colors.white,
),
),
subtitle: Text(
'ID: ${mod.id}\nSize: ${mod.size} files',
style: Theme.of(context).textTheme.bodySmall,
),
tileColor:
isActive
? AppThemeExtension.of(
context,
).enabledModBackgroundColor
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (mod.isBaseGame)
Tooltip(
message: 'Base Game',
child: Icon(
Icons.home,
color:
AppThemeExtension.of(
context,
).baseGameColor,
size:
AppThemeExtension.of(
context,
).iconSizeRegular,
),
),
if (mod.isExpansion)
Tooltip(
message: 'Expansion',
child: Icon(
Icons.star,
color:
AppThemeExtension.of(
context,
).expansionColor,
size:
AppThemeExtension.of(
context,
).iconSizeRegular,
),
),
const SizedBox(width: 4),
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,
).iconSizeRegular,
),
),
if (mod.loadAfter.isNotEmpty)
Tooltip(
message:
'Loads after:\n${mod.loadAfter.join('\n')}',
child: Icon(
Icons.arrow_downward,
color:
AppThemeExtension.of(
context,
).loadAfterColor,
size:
AppThemeExtension.of(
context,
).iconSizeRegular,
),
),
if (mod.loadBefore.isNotEmpty)
Tooltip(
message:
'Loads before:\n${mod.loadBefore.join('\n')}',
child: Icon(
Icons.arrow_upward,
color:
AppThemeExtension.of(
context,
).loadBeforeColor,
size:
AppThemeExtension.of(
context,
).iconSizeRegular,
),
),
],
),
onTap: () {
// Show mod details in future
},
),
);
},
),
),
],
),
),
),
// RIGHT PANEL - Active mods (load order)
Expanded(
child: Card(
margin: AppThemeExtension.of(context).paddingRegular,
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: AppThemeExtension.of(context).paddingRegular,
child: Text(
_searchQuery.isNotEmpty
? 'Searching: "$_searchQuery"'
: 'Larger mods are prioritized during auto-sorting.',
style: TextStyle(
fontSize:
AppThemeExtension.of(context).textSizeSmall,
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:
AppThemeExtension.of(
context,
).paddingRegular,
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
? const Color.fromRGBO(
0,
0,
255,
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:
AppThemeExtension.of(
context,
).textSizeSmall,
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: TextStyle(
fontSize:
AppThemeExtension.of(
context,
).textSizeSmall,
color: Colors.grey,
),
),
)
: const SizedBox(),
),
),
],
),
),
title: Text(mod.name),
subtitle: Text(
mod.id,
style: TextStyle(
fontSize:
AppThemeExtension.of(
context,
).textSizeSmall,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (mod.isBaseGame)
Tooltip(
message: 'Base Game',
child: Icon(
Icons.home,
color:
AppThemeExtension.of(
context,
).baseGameColor,
size:
AppThemeExtension.of(
context,
).iconSizeRegular,
),
),
if (mod.isExpansion)
Tooltip(
message: 'Expansion',
child: Icon(
Icons.star,
color:
AppThemeExtension.of(
context,
).expansionColor,
size:
AppThemeExtension.of(
context,
).iconSizeRegular,
),
),
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,
).iconSizeRegular,
),
),
const SizedBox(width: 4),
if (mod.loadAfter.isNotEmpty)
Tooltip(
message:
'Loads after other mods:\n${mod.loadAfter.join('\n')}',
child: Icon(
Icons.arrow_downward,
color:
AppThemeExtension.of(
context,
).loadAfterColor,
size:
AppThemeExtension.of(
context,
).iconSizeRegular,
),
),
const SizedBox(width: 4),
if (mod.loadBefore.isNotEmpty)
Tooltip(
message:
'Loads before other mods:\n${mod.loadBefore.join('\n')}',
child: Icon(
Icons.arrow_upward,
color:
AppThemeExtension.of(
context,
).loadBeforeColor,
size:
AppThemeExtension.of(
context,
).iconSizeRegular,
),
),
],
),
onTap: () {
// Show mod details in future
},
),
),
);
},
),
),
],
),
),
),
],
),
),
],
);
}
void _startLoadingMods() {
setState(() {
_availableMods.clear();
_activeMods.clear();
_isLoading = true;
_statusMessage = 'Scanning for mods...';
_hasCycles = false;
_cycleInfo = null;
_incompatibleMods = [];
});
// Create an async function to load mods
Future<void> loadMods() async {
try {
// First load available mods
await for (final mod in modManager.loadAvailable()) {
// Update UI for each mod loaded
if (mounted) {
setState(() {
_statusMessage = 'Loaded mod: ${mod.name}';
});
}
}
// Then load active mods from config
await for (final mod in modManager.loadActive()) {
// Update UI as active mods are loaded
if (mounted) {
setState(() {
_statusMessage = 'Loading active mod: ${mod.name}';
});
}
}
// Update the UI with all loaded mods
if (mounted) {
_loadModsFromGlobalState();
setState(() {
_statusMessage =
'Loaded ${_availableMods.length} mods, ${_activeMods.length} active';
});
}
} catch (error) {
if (mounted) {
setState(() {
_isLoading = false;
_statusMessage = 'Error loading mods: $error';
});
}
}
}
// Start the loading process
loadMods();
}
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;
}
// Get the current state before toggling
final bool wasEnabled = mod.enabled;
// Toggle the mod in the global mod manager
modManager.setEnabled(mod.id, !wasEnabled);
Logger.instance.info(
'Toggled mod ${mod.name} (${mod.id}) from ${wasEnabled ? 'enabled' : 'disabled'} to ${!wasEnabled ? 'enabled' : 'disabled'}',
);
// Update the UI
setState(() {
// Update in the available mods list
final index = _availableMods.indexWhere((m) => m.id == mod.id);
if (index >= 0) {
_availableMods[index] = modManager.mods[mod.id]!;
}
// Update the active mods list
_activeMods = modManager.mods.values.where((m) => m.enabled).toList();
_statusMessage =
'Mod "${mod.name}" ${!wasEnabled ? '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 = [];
_loadOrderErrors = null;
});
// Use a Future.delayed to allow the UI to update
await Future.delayed(Duration.zero);
try {
final logger = Logger.instance;
logger.info('Starting auto-sort of ${_activeMods.length} active mods');
// Generate a load order for active mods
final loadOrder = modManager.generateLoadOrder();
// Store all errors
if (loadOrder.hasErrors) {
setState(() {
_loadOrderErrors = List<String>.from(loadOrder.errors);
});
logger.warning(
'Found ${loadOrder.errors.length} errors during sorting',
);
for (final error in loadOrder.errors) {
logger.warning(' - $error');
}
setState(() {
_hasCycles = loadOrder.errors.any(
(e) => e.contains('Cyclic dependency'),
);
if (_hasCycles) {
// Extract cycle info from error message
final cycleError = loadOrder.errors.firstWhere(
(e) => e.contains('Cyclic dependency'),
orElse: () => '',
);
logger.warning('Detected dependency cycle: $cycleError');
if (cycleError.isNotEmpty) {
// Extract cycle path from error message
final startIndex = cycleError.indexOf(':');
if (startIndex != -1) {
final pathStr = cycleError.substring(startIndex + 1).trim();
_cycleInfo = pathStr.split(' -> ');
logger.info(
'Extracted cycle path: ${_cycleInfo!.join(" -> ")}',
);
}
}
}
});
} else {
_loadOrderErrors = null;
}
// Check for incompatibilities
_incompatibleMods = modManager.checkIncompatibilities(
modManager.activeMods.keys.toList(),
);
if (_incompatibleMods.isNotEmpty) {
logger.warning(
'Found ${_incompatibleMods.length} incompatible mod pairs:',
);
for (final pair in _incompatibleMods) {
final mod1 = modManager.mods[pair[0]]?.name ?? pair[0];
final mod2 = modManager.mods[pair[1]]?.name ?? pair[1];
logger.warning(' - $mod1 is incompatible with $mod2');
}
}
// Get sorted mods from the load order
final List<Mod> sortedMods = [];
for (final modId in loadOrder.loadOrder) {
if (modManager.mods.containsKey(modId)) {
sortedMods.add(modManager.mods[modId]!);
}
}
logger.info(
'Sorting complete. Arranged ${sortedMods.length} mods in load order',
);
if (sortedMods.isNotEmpty) {
logger.info(
'First 5 mods in order: ${sortedMods.take(5).map((m) => m.name).join(', ')}',
);
if (sortedMods.length > 5) {
logger.info('... (${sortedMods.length - 5} more) ...');
logger.info(
'Last 3 mods in order: ${sortedMods.reversed.take(3).map((m) => m.name).toList().reversed.join(', ')}',
);
}
}
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) {
Logger.instance.error('Error during auto-sort: $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 {
final logger = Logger.instance;
logger.info(
'Saving mod load order for ${_activeMods.length} active mods to $configPath',
);
modManager.saveToConfig(LoadOrder(_activeMods));
setState(() {
_isLoading = false;
_statusMessage = 'Mod load order saved successfully!';
});
// Show success message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Mod order saved to config. ${_activeMods.length} mods enabled.',
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
final logger = Logger.instance;
logger.error('Error saving mod load order: $e');
setState(() {
_isLoading = false;
_statusMessage = 'Error saving mod load order: $e';
});
// Show error message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error saving mod order: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
// Load all required dependencies for active mods
void _loadRequiredDependencies() async {
if (_activeMods.isEmpty) {
setState(() {
_statusMessage = 'No active mods to load dependencies for';
});
return;
}
setState(() {
_isLoading = true;
_statusMessage = 'Loading required dependencies...';
_hasCycles = false;
_cycleInfo = null;
_incompatibleMods = [];
_loadOrderErrors = null;
});
// Use a Future.delayed to allow the UI to update
await Future.delayed(Duration.zero);
try {
final logger = Logger.instance;
logger.info(
'Starting dependency resolution for ${_activeMods.length} active mods',
);
// Get current active mod count for comparison
final initialActiveCount = modManager.activeMods.length;
// Load required dependencies and get the load order
final loadOrder = modManager.loadRequired();
// Store any errors
if (loadOrder.hasErrors) {
setState(() {
_loadOrderErrors = List<String>.from(loadOrder.errors);
});
logger.warning(
'Found ${loadOrder.errors.length} errors during dependency loading',
);
for (final error in loadOrder.errors) {
logger.warning(' - $error');
}
// Check for cycles
setState(() {
_hasCycles = loadOrder.errors.any(
(e) => e.contains('Cyclic dependency'),
);
if (_hasCycles) {
// Extract cycle info from error message
final cycleError = loadOrder.errors.firstWhere(
(e) => e.contains('Cyclic dependency'),
orElse: () => '',
);
logger.warning('Detected dependency cycle: $cycleError');
if (cycleError.isNotEmpty) {
// Extract cycle path from error message
final startIndex = cycleError.indexOf(':');
if (startIndex != -1) {
final pathStr = cycleError.substring(startIndex + 1).trim();
_cycleInfo = pathStr.split(' -> ');
logger.info(
'Extracted cycle path: ${_cycleInfo!.join(" -> ")}',
);
}
}
}
});
} else {
_loadOrderErrors = null;
}
// Check for incompatibilities
_incompatibleMods = modManager.checkIncompatibilities(
modManager.activeMods.keys.toList(),
);
if (_incompatibleMods.isNotEmpty) {
logger.warning(
'Found ${_incompatibleMods.length} incompatible mod pairs:',
);
for (final pair in _incompatibleMods) {
final mod1 = modManager.mods[pair[0]]?.name ?? pair[0];
final mod2 = modManager.mods[pair[1]]?.name ?? pair[1];
logger.warning(' - $mod1 is incompatible with $mod2');
}
}
// Get sorted mods from the load order
final List<Mod> sortedMods = [];
for (final modId in loadOrder.loadOrder) {
if (modManager.mods.containsKey(modId)) {
sortedMods.add(modManager.mods[modId]!);
}
}
// Calculate how many dependencies were added
final newModsCount = modManager.activeMods.length - initialActiveCount;
logger.info(
'Dependency loading complete. Enabled $newModsCount new dependencies.',
);
if (newModsCount > 0) {
logger.info('Newly enabled dependencies:');
final activeModIds = modManager.activeMods.keys.toList();
for (int i = 0; i < newModsCount; i++) {
final modId = activeModIds[initialActiveCount + i];
logger.info(' - ${modManager.mods[modId]?.name ?? modId} ($modId)');
}
}
setState(() {
_activeMods = sortedMods;
_isLoading = false;
if (newModsCount > 0) {
_statusMessage = 'Added $newModsCount required dependencies!';
} else {
_statusMessage = 'All dependencies are already loaded.';
}
if (_hasCycles) {
_statusMessage += ' Warning: Dependency cycles were found and fixed.';
}
if (_incompatibleMods.isNotEmpty) {
_statusMessage +=
' Warning: ${_incompatibleMods.length} incompatible mod pairs found.';
}
});
} catch (e) {
Logger.instance.error('Error during dependency loading: $e');
setState(() {
_isLoading = false;
_statusMessage = 'Error loading dependencies: $e';
});
}
}
}
// Page for troubleshooting problematic mods
class TroubleshootingPage extends StatelessWidget {
const TroubleshootingPage({super.key});
@override
Widget build(BuildContext context) {
return const ModTroubleshooterWidget();
}
}