Compare commits

...

21 Commits

Author SHA1 Message Date
4c6e3b5ed5 Update mod list to include 'Odyssey' expansion and bump version to 1.6.4518 rev71 2025-07-14 23:33:52 +02:00
a662dffc7c Update dependencies in pubspec.lock and add new mod 'Odyssey' to mod list 2025-07-14 23:33:46 +02:00
753859fd3e Fix duplicate dependencies 2025-03-22 16:48:53 +01:00
43a5f63759 Fix issue with loading dependencies
141034 fucking formats jesus christ
2025-03-22 16:44:57 +01:00
07d81eca71 Add release script 2025-03-22 00:16:56 +01:00
2e6bfb84de Remove all log statements
Because THEY were causing the lag?????????
2025-03-22 00:14:14 +01:00
0384e8012e Make skip counting file size for existing mods 2025-03-22 00:09:21 +01:00
1bb8ed9084 Fix up some of the todos 2025-03-22 00:00:32 +01:00
573ad05514 TODO 2025-03-19 01:01:51 +01:00
9a8b7fd2d3 Center on startup 2025-03-19 00:50:31 +01:00
d00c20397f Clean up code 2025-03-19 00:48:46 +01:00
40d251f400 Jesus they're not BBCode... They're html and bbcode and markdown
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
2025-03-19 00:44:14 +01:00
09b7fe539e Fix up markdown rendering to be scrollable 2025-03-19 00:28:59 +01:00
5f20368fe2 The expansions must be loaded in correct order.... fuck 2025-03-19 00:17:45 +01:00
9eb71e94c1 Implement a popup card that renders description markdown 2025-03-19 00:03:32 +01:00
f90371109c Make errors larger 2025-03-18 23:41:56 +01:00
7f4b944101 Also parse forceLoadAfter 2025-03-18 23:40:19 +01:00
8f466420f2 Add special mods for the base game and expansions
Just for the metadata
2025-03-18 23:40:19 +01:00
160488849f Implement counting files only of the latest version 2025-03-18 23:12:13 +01:00
6826b272aa Indent xml for saving 2025-03-18 23:04:47 +01:00
1c6af27c7e Rework saving to config 2025-03-18 23:01:10 +01:00
17 changed files with 1619 additions and 262 deletions

View File

@@ -0,0 +1,344 @@
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:url_launcher/url_launcher.dart';
import '../format_converter.dart';
class HtmlTooltip extends StatefulWidget {
final Widget child;
final String content;
final double maxWidth;
final double maxHeight;
final EdgeInsets padding;
final Duration showDuration;
final Duration fadeDuration;
final Color backgroundColor;
final Color textColor;
final BorderRadius borderRadius;
final bool preferBelow;
final String? title;
const HtmlTooltip({
super.key,
required this.child,
required this.content,
this.maxWidth = 800.0,
this.maxHeight = 800.0,
this.padding = const EdgeInsets.all(8.0),
this.showDuration = const Duration(milliseconds: 0),
this.fadeDuration = const Duration(milliseconds: 200),
this.backgroundColor = const Color(0xFF232323),
this.textColor = Colors.white,
this.borderRadius = const BorderRadius.all(Radius.circular(4.0)),
this.preferBelow = true,
this.title = 'Mod Description',
});
@override
State<HtmlTooltip> createState() => _HtmlTooltipState();
}
class _HtmlTooltipState extends State<HtmlTooltip> {
final LayerLink _layerLink = LayerLink();
OverlayEntry? _overlayEntry;
bool _isTooltipVisible = false;
bool _isMouseInside = false;
bool _isMouseInsideTooltip = false;
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
_hideTooltip();
super.dispose();
}
// Launch a URL
Future<void> _launchUrl(String? urlString) async {
if (urlString == null || urlString.isEmpty) return;
final Uri url = Uri.parse(urlString);
try {
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
} catch (e) {
debugPrint('Error launching URL: $e');
}
}
void _showTooltip(BuildContext context) {
if (_overlayEntry != null) return;
// Get render box of the trigger widget
final RenderBox box = context.findRenderObject() as RenderBox;
final Size childSize = box.size;
// Use the specified maxWidth without adjusting based on content length
final double tooltipWidth = widget.maxWidth;
_overlayEntry = OverlayEntry(
builder: (context) {
return Positioned(
width: tooltipWidth,
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
offset: Offset(
(childSize.width / 2) - (tooltipWidth / 2),
widget.preferBelow ? childSize.height + 5 : -5,
),
child: Material(
color: Colors.transparent,
child: MouseRegion(
onEnter: (_) {
setState(() {
_isMouseInsideTooltip = true;
});
},
onExit: (_) {
setState(() {
_isMouseInsideTooltip = false;
// Slight delay to prevent flickering
Future.delayed(const Duration(milliseconds: 50), () {
if (!_isMouseInside && !_isMouseInsideTooltip) {
_hideTooltip();
}
});
});
},
child: FadeTransition(
opacity: const AlwaysStoppedAnimation(1.0),
child: Container(
constraints: BoxConstraints(
maxWidth: tooltipWidth,
maxHeight: widget.maxHeight,
),
decoration: BoxDecoration(
color: widget.backgroundColor,
borderRadius: widget.borderRadius,
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(
77,
), // Equivalent to 0.3 opacity
blurRadius: 10.0,
spreadRadius: 0.0,
),
],
),
child: ClipRRect(
borderRadius: widget.borderRadius,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Container(
color: const Color(0xFF3D4A59),
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title ?? 'Description',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14.0,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Scroll to top button
InkWell(
onTap: () {
if (_scrollController.hasClients) {
_scrollController.animateTo(
0.0,
duration: const Duration(
milliseconds: 300,
),
curve: Curves.easeOut,
);
}
},
child: const Padding(
padding: EdgeInsets.all(2.0),
child: Icon(
Icons.arrow_upward,
color: Colors.white,
size: 16.0,
),
),
),
const SizedBox(width: 4.0),
// Close button
InkWell(
onTap: () {
_hideTooltip();
},
child: const Padding(
padding: EdgeInsets.all(2.0),
child: Icon(
Icons.close,
color: Colors.white,
size: 16.0,
),
),
),
],
),
],
),
),
// Content
Flexible(
child: SingleChildScrollView(
controller: _scrollController,
child: Padding(
padding: widget.padding,
child: Html(
data: FormatConverter.toHtml(widget.content),
style: {
"body": Style(
color: widget.textColor,
margin: Margins.zero,
padding: HtmlPaddings.zero,
fontSize: FontSize(14.0),
),
"a": Style(
color: Colors.lightBlue,
textDecoration: TextDecoration.underline,
),
"blockquote": Style(
backgroundColor: Colors.grey.withAlpha(
26,
), // Approx 0.1 opacity
border: Border(
left: BorderSide(
color: Colors.grey.withAlpha(
128,
), // Approx 0.5 opacity
width: 4.0,
),
),
padding: HtmlPaddings.all(8.0),
margin: Margins.only(left: 0, right: 0),
),
"code": Style(
backgroundColor: Colors.grey.withAlpha(
51,
), // Approx 0.2 opacity
padding: HtmlPaddings.all(2.0),
fontFamily: 'monospace',
),
"pre": Style(
backgroundColor: Colors.grey.withAlpha(
51,
), // Approx 0.2 opacity
padding: HtmlPaddings.all(8.0),
fontFamily: 'monospace',
margin: Margins.only(
bottom: 8.0,
top: 8.0,
),
),
"table": Style(
border: Border.all(color: Colors.grey),
backgroundColor: Colors.transparent,
),
"td": Style(
border: Border.all(
color: Colors.grey.withAlpha(128),
), // Approx 0.5 opacity
padding: HtmlPaddings.all(4.0),
),
"th": Style(
border: Border.all(
color: Colors.grey.withAlpha(128),
), // Approx 0.5 opacity
padding: HtmlPaddings.all(4.0),
backgroundColor: Colors.grey.withAlpha(
51,
), // Approx 0.2 opacity
),
},
onAnchorTap: (url, _, __) {
_launchUrl(url);
},
),
),
),
),
],
),
),
),
),
),
),
),
);
},
);
// Use a check for mounted before inserting the overlay
if (mounted) {
Overlay.of(context).insert(_overlayEntry!);
_isTooltipVisible = true;
}
}
void _hideTooltip() {
_overlayEntry?.remove();
_overlayEntry = null;
_isTooltipVisible = false;
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _layerLink,
child: MouseRegion(
onEnter: (_) {
setState(() {
_isMouseInside = true;
// Show tooltip after a brief delay to prevent accidental triggers
Future.delayed(const Duration(milliseconds: 50), () {
if (mounted && _isMouseInside && !_isTooltipVisible) {
_showTooltip(context);
}
});
});
},
onExit: (_) {
setState(() {
_isMouseInside = false;
// Slight delay to prevent flickering
Future.delayed(const Duration(milliseconds: 50), () {
if (mounted && !_isMouseInside && !_isMouseInsideTooltip) {
_hideTooltip();
}
});
});
},
child: GestureDetector(
onTap: () {
// Toggle tooltip for touch devices
if (_isTooltipVisible) {
_hideTooltip();
} else {
_showTooltip(context);
}
},
child: widget.child,
),
),
);
}
}

308
lib/format_converter.dart Normal file
View File

@@ -0,0 +1,308 @@
import 'dart:math' as math;
/// Utility class to convert mixed format content (BBCode, Markdown, and HTML) to HTML
class FormatConverter {
/// Converts mixed format text (BBCode, Markdown, HTML) to pure HTML
static String toHtml(String content) {
if (content.isEmpty) return '';
// First, normalize line endings and escape any literal backslashes that aren't already escaped
String result = content.replaceAll('\r\n', '\n');
// Handle BBCode format
result = _convertBBCodeToHtml(result);
// Handle Markdown format
result = _convertMarkdownToHtml(result);
// Sanitize HTML
result = _sanitizeHtml(result);
// Wrap the final content in a container with styles
result =
'<div style="line-height: 1.5; word-wrap: break-word;">$result</div>';
return result;
}
/// Converts BBCode to HTML
static String _convertBBCodeToHtml(String bbcode) {
String result = bbcode;
// Fix unclosed tags - RimWorld descriptions often have unclosed BBCode tags
final List<String> tagTypes = [
'b',
'i',
'color',
'size',
'url',
'code',
'quote',
'list',
'table',
'tr',
'td',
];
for (final tag in tagTypes) {
final openCount = '[$tag'.allMatches(result).length;
final closeCount = '[/$tag]'.allMatches(result).length;
if (openCount > closeCount) {
result = result + '[/$tag]' * (openCount - closeCount);
}
}
// URLs
// [url=http://example.com]text[/url] -> <a href="http://example.com">text</a>
result = RegExp(
r'\[url=([^\]]+)\](.*?)\[/url\]',
dotAll: true,
).allMatches(result).fold(result, (prev, match) {
final url = match.group(1);
final text = match.group(2);
return prev.replaceFirst(
match.group(0)!,
'<a href="$url" target="_blank">$text</a>',
);
});
// Simple URL [url]http://example.com[/url] -> <a href="http://example.com">http://example.com</a>
result = result.replaceAllMapped(
RegExp(r'\[url\](.*?)\[/url\]', dotAll: true),
(match) =>
'<a href="${match.group(1)}" target="_blank">${match.group(1)}</a>',
);
// Bold
result = result
.replaceAll('[b]', '<strong>')
.replaceAll('[/b]', '</strong>');
// Italic
result = result.replaceAll('[i]', '<em>').replaceAll('[/i]', '</em>');
// Headers
result = result.replaceAllMapped(
RegExp(r'\[h1\](.*?)\[/h1\]', dotAll: true),
(match) =>
'<h1 style="margin-top: 16px; margin-bottom: 8px;">${match.group(1)?.trim()}</h1>',
);
result = result.replaceAllMapped(
RegExp(r'\[h2\](.*?)\[/h2\]', dotAll: true),
(match) =>
'<h2 style="margin-top: 12px; margin-bottom: 6px;">${match.group(1)?.trim()}</h2>',
);
result = result.replaceAllMapped(
RegExp(r'\[h3\](.*?)\[/h3\]', dotAll: true),
(match) =>
'<h3 style="margin-top: 10px; margin-bottom: 4px;">${match.group(1)?.trim()}</h3>',
);
// Lists
result = result
.replaceAll(
'[list]',
'<ul style="padding-left: 20px; margin-top: 8px; margin-bottom: 8px;">',
)
.replaceAll('[/list]', '</ul>');
// List items
result = result.replaceAllMapped(
RegExp(r'\[\*\](.*?)(?=\[\*\]|\[/list\]|$)', dotAll: true),
(match) {
final content = match.group(1)?.trim() ?? '';
return '<li style="margin-bottom: 4px;">$content</li>';
},
);
// Color
result = result.replaceAllMapped(
RegExp(r'\[color=([^\]]+)\](.*?)\[/color\]', dotAll: true),
(match) {
final color = match.group(1) ?? '';
final content = match.group(2) ?? '';
if (content.trim().isEmpty) return '';
return '<span style="color:$color">$content</span>';
},
);
// Images
result = result.replaceAllMapped(
RegExp(r'\[img\](.*?)\[/img\]', dotAll: true),
(match) =>
'<img src="${match.group(1)}" alt="Image" style="max-width: 100%;" />',
);
// Image with size
result = result.replaceAllMapped(
RegExp(r'\[img[^\]]*width=(\d+)[^\]]*\](.*?)\[/img\]', dotAll: true),
(match) {
final width = match.group(1) ?? '';
final url = match.group(2) ?? '';
return '<img src="$url" alt="Image" width="$width" style="max-width: 100%;" />';
},
);
// Tables
result = result
.replaceAll(
'[table]',
'<table border="1" style="border-collapse: collapse; width: 100%; margin: 10px 0;">',
)
.replaceAll('[/table]', '</table>');
result = result.replaceAll('[tr]', '<tr>').replaceAll('[/tr]', '</tr>');
result = result
.replaceAll('[td]', '<td style="padding: 8px;">')
.replaceAll('[/td]', '</td>');
// Size
result = result.replaceAllMapped(
RegExp(r'\[size=([^\]]+)\](.*?)\[/size\]', dotAll: true),
(match) {
final size = match.group(1) ?? '';
final content = match.group(2) ?? '';
return '<span style="font-size:${size}px">$content</span>';
},
);
// Code
result = result
.replaceAll(
'[code]',
'<pre style="background-color: rgba(0,0,0,0.1); padding: 8px; border-radius: 4px; overflow-x: auto;"><code>',
)
.replaceAll('[/code]', '</code></pre>');
// Quote
result = result.replaceAllMapped(
RegExp(r'\[quote\](.*?)\[/quote\]', dotAll: true),
(match) {
final content = match.group(1)?.trim() ?? '';
if (content.isEmpty) return '';
return '<blockquote style="border-left: 4px solid rgba(128,128,128,0.5); padding-left: 10px; margin: 10px 0; color: rgba(255,255,255,0.8);">$content</blockquote>';
},
);
// Handle any remaining custom BBCode tags
result = result.replaceAllMapped(
RegExp(r'\[([a-zA-Z0-9_]+)(?:=[^\]]+)?\](.*?)\[/\1\]', dotAll: true),
(match) => match.group(2) ?? '',
);
// Handle RimWorld-specific patterns
// [h1] without closing tag is common
result = result.replaceAllMapped(
RegExp(r'\[h1\]([^\[]+)'),
(match) =>
'<h1 style="margin-top: 16px; margin-bottom: 8px;">${match.group(1)?.trim()}</h1>',
);
return result;
}
/// Converts Markdown to HTML
static String _convertMarkdownToHtml(String markdown) {
String result = markdown;
// Headers
// Convert # Header to <h1>Header</h1>
result = result.replaceAllMapped(
RegExp(r'^#\s+(.*?)$', multiLine: true),
(match) =>
'<h1 style="margin-top: 16px; margin-bottom: 8px;">${match.group(1)?.trim()}</h1>',
);
// Convert ## Header to <h2>Header</h2>
result = result.replaceAllMapped(
RegExp(r'^##\s+(.*?)$', multiLine: true),
(match) =>
'<h2 style="margin-top: 12px; margin-bottom: 6px;">${match.group(1)?.trim()}</h2>',
);
// Convert ### Header to <h3>Header</h3>
result = result.replaceAllMapped(
RegExp(r'^###\s+(.*?)$', multiLine: true),
(match) =>
'<h3 style="margin-top: 10px; margin-bottom: 4px;">${match.group(1)?.trim()}</h3>',
);
// Bold - **text** to <strong>text</strong>
result = result.replaceAllMapped(
RegExp(r'\*\*(.*?)\*\*'),
(match) => '<strong>${match.group(1)}</strong>',
);
// Italic - *text* or _text_ to <em>text</em>
result = result.replaceAllMapped(
RegExp(r'\*(.*?)\*|_(.*?)_'),
(match) => '<em>${match.group(1) ?? match.group(2)}</em>',
);
// Inline code - `code` to <code>code</code>
result = result.replaceAllMapped(
RegExp(r'`(.*?)`'),
(match) =>
'<code style="background-color: rgba(0,0,0,0.1); padding: 2px 4px; border-radius: 3px;">${match.group(1)}</code>',
);
// Links - [text](url) to <a href="url">text</a>
result = result.replaceAllMapped(
RegExp(r'\[(.*?)\]\((.*?)\)'),
(match) =>
'<a href="${match.group(2)}" target="_blank">${match.group(1)}</a>',
);
// Images - ![alt](url) to <img src="url" alt="alt" />
result = result.replaceAllMapped(
RegExp(r'!\[(.*?)\]\((.*?)\)'),
(match) =>
'<img src="${match.group(2)}" alt="${match.group(1)}" style="max-width: 100%;" />',
);
// Lists - Convert Markdown bullet lists to HTML lists
// This is a simple implementation and might not handle all cases
result = result.replaceAllMapped(
RegExp(r'^(\s*)\*\s+(.*?)$', multiLine: true),
(match) => '<li style="margin-bottom: 4px;">${match.group(2)}</li>',
);
// Wrap adjacent list items in <ul> tags (simple approach)
result = result.replaceAll('</li>\n<li>', '</li><li>');
result = result.replaceAll(
'<li>',
'<ul style="padding-left: 20px; margin-top: 8px; margin-bottom: 8px;"><li>',
);
result = result.replaceAll('</li>', '</li></ul>');
// Remove duplicated </ul><ul> tags
result = result.replaceAll(
'</ul><ul style="padding-left: 20px; margin-top: 8px; margin-bottom: 8px;">',
'',
);
// Paragraphs - Convert newlines to <br>, but skipping where tags already exist
result = result.replaceAllMapped(
RegExp(r'(?<!>)\n(?!<)'),
(match) => '<br />',
);
return result;
}
/// Performs basic sanitization and fixes for the HTML
static String _sanitizeHtml(String html) {
// Remove potentially dangerous elements and attributes
final String result = html
// Remove any script tags
.replaceAll(RegExp(r'<script.*?>.*?</script>', dotAll: true), '')
// Remove on* event handlers
.replaceAll(RegExp(r'\son\w+=".*?"'), '')
// Ensure newlines are converted to <br /> if not already handled
.replaceAll(RegExp(r'(?<!>)\n(?!<)'), '<br />');
// Fix double paragraph or break issues
return result
.replaceAll('<br /><br />', '<br />')
.replaceAll('<br></br>', '<br />')
.replaceAll('<p></p>', '');
}
}

View File

@@ -1,7 +1,9 @@
import 'dart:io';
// TODO: Fix "load dependencies", it causes fake errors between expansions and base game
import 'package:flutter/material.dart';
import 'package:rimworld_modman/logger.dart';
import 'package:rimworld_modman/components/html_tooltip.dart';
import 'package:rimworld_modman/mod.dart';
import 'package:rimworld_modman/mod_list.dart';
import 'package:rimworld_modman/mod_troubleshooter_widget.dart';
@@ -27,6 +29,7 @@ class AppThemeExtension extends ThemeExtension<AppThemeExtension> {
final Color linkColor;
final Color loadAfterColor;
final Color loadBeforeColor;
final Color incompatibleColor;
AppThemeExtension({
required this.iconSizeSmall,
@@ -48,6 +51,7 @@ class AppThemeExtension extends ThemeExtension<AppThemeExtension> {
required this.linkColor,
required this.loadAfterColor,
required this.loadBeforeColor,
required this.incompatibleColor,
});
static AppThemeExtension of(BuildContext context) {
@@ -75,6 +79,7 @@ class AppThemeExtension extends ThemeExtension<AppThemeExtension> {
Color? linkColor,
Color? loadAfterColor,
Color? loadBeforeColor,
Color? incompatibleColor,
}) {
return AppThemeExtension(
iconSizeSmall: iconSizeSmall ?? this.iconSizeSmall,
@@ -97,6 +102,7 @@ class AppThemeExtension extends ThemeExtension<AppThemeExtension> {
linkColor: linkColor ?? this.linkColor,
loadAfterColor: loadAfterColor ?? this.loadAfterColor,
loadBeforeColor: loadBeforeColor ?? this.loadBeforeColor,
incompatibleColor: incompatibleColor ?? this.incompatibleColor,
);
}
@@ -134,6 +140,7 @@ class AppThemeExtension extends ThemeExtension<AppThemeExtension> {
linkColor: Color.lerp(linkColor, other.linkColor, t)!,
loadAfterColor: Color.lerp(loadAfterColor, other.loadAfterColor, t)!,
loadBeforeColor: Color.lerp(loadBeforeColor, other.loadBeforeColor, t)!,
incompatibleColor: Color.lerp(incompatibleColor, other.incompatibleColor, t)!,
);
}
@@ -163,6 +170,7 @@ class AppThemeExtension extends ThemeExtension<AppThemeExtension> {
linkColor: Colors.orange,
loadAfterColor: Colors.blue,
loadBeforeColor: Colors.green,
incompatibleColor: Colors.red.shade400,
);
}
@@ -294,6 +302,8 @@ class _ModManagerPageState extends State<ModManagerPage> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
bool _useRegex = false;
RegExp? _searchRegex;
@override
void initState() {
@@ -302,12 +312,6 @@ class _ModManagerPageState extends State<ModManagerPage> {
if (modManager.mods.isNotEmpty) {
_loadModsFromGlobalState();
}
_searchController.addListener(() {
setState(() {
_searchQuery = _searchController.text.toLowerCase();
});
});
}
@override
@@ -390,9 +394,19 @@ class _ModManagerPageState extends State<ModManagerPage> {
],
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _startLoadingMods,
child: const Text('Scan for Mods'),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: _startLoadingMods,
child: const Text('Full Scan'),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: _startQuickScan,
child: const Text('Quick Scan'),
),
],
),
],
),
@@ -401,27 +415,49 @@ class _ModManagerPageState extends State<ModManagerPage> {
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();
List<Mod> filteredAvailableMods;
List<Mod> filteredActiveMods;
final filteredActiveMods =
_searchQuery.isEmpty
? _activeMods
: _activeMods
.where(
(mod) =>
mod.name.toLowerCase().contains(_searchQuery) ||
mod.id.toLowerCase().contains(_searchQuery),
)
.toList();
if (_searchQuery.isEmpty) {
filteredAvailableMods = _availableMods;
filteredActiveMods = _activeMods;
} else {
if (_useRegex && _searchRegex != null) {
// Use regex pattern for filtering
filteredAvailableMods = _availableMods
.where(
(mod) =>
_searchRegex!.hasMatch(mod.name.toLowerCase()) ||
_searchRegex!.hasMatch(mod.id.toLowerCase()),
)
.toList();
filteredActiveMods = _activeMods
.where(
(mod) =>
_searchRegex!.hasMatch(mod.name.toLowerCase()) ||
_searchRegex!.hasMatch(mod.id.toLowerCase()),
)
.toList();
} else {
// Use simple string contains for filtering
filteredAvailableMods = _availableMods
.where(
(mod) =>
mod.name.toLowerCase().contains(_searchQuery) ||
mod.id.toLowerCase().contains(_searchQuery),
)
.toList();
filteredActiveMods = _activeMods
.where(
(mod) =>
mod.name.toLowerCase().contains(_searchQuery) ||
mod.id.toLowerCase().contains(_searchQuery),
)
.toList();
}
}
return Column(
children: [
@@ -449,14 +485,74 @@ class _ModManagerPageState extends State<ModManagerPage> {
)
: null,
),
onChanged: (value) {
setState(() {
_searchQuery = value.toLowerCase();
// Try to compile regex if regex mode is enabled
if (_useRegex && _searchQuery.isNotEmpty) {
try {
_searchRegex = RegExp(_searchQuery, caseSensitive: false);
} catch (e) {
// If regex is invalid, fallback to normal search
_searchRegex = null;
}
}
});
},
),
),
const SizedBox(width: 8),
// Regex toggle
Tooltip(
message: 'Use regex pattern matching',
child: Row(
children: [
Checkbox(
value: _useRegex,
onChanged: (value) {
setState(() {
_useRegex = value ?? false;
// Try to compile regex if toggled on
if (_useRegex && _searchQuery.isNotEmpty) {
try {
_searchRegex = RegExp(_searchQuery, caseSensitive: false);
} catch (e) {
// If regex fails, keep checkbox on but disable regex internally
_searchRegex = null;
}
} else {
_searchRegex = null;
}
});
},
),
const Text('Regex'),
],
),
),
const SizedBox(width: 8),
// Reload button
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Reload mods',
tooltip: 'Reload all mods (full scan)',
onPressed: _startLoadingMods,
style: IconButton.styleFrom(
backgroundColor: Colors.blueGrey.shade800,
foregroundColor: Colors.white,
),
),
const SizedBox(width: 8),
// Scan New button
IconButton(
icon: const Icon(Icons.update),
tooltip: 'Quick scan (skip existing mods)',
onPressed: _startQuickScan,
style: IconButton.styleFrom(
backgroundColor: Colors.green.shade800,
foregroundColor: Colors.white,
),
),
const SizedBox(width: 8),
// Load Dependencies button
@@ -641,7 +737,7 @@ class _ModManagerPageState extends State<ModManagerPage> {
fontSize:
AppThemeExtension.of(
context,
).textSizeSmall,
).textSizeRegular,
),
),
),
@@ -741,6 +837,20 @@ class _ModManagerPageState extends State<ModManagerPage> {
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Description tooltip
if (mod.description.isNotEmpty)
HtmlTooltip(
content: mod.description,
child: Icon(
Icons.description_outlined,
color: Colors.lightBlue.shade300,
size:
AppThemeExtension.of(
context,
).iconSizeRegular,
),
),
const SizedBox(width: 4),
if (mod.isBaseGame)
Tooltip(
message: 'Base Game',
@@ -771,7 +881,6 @@ class _ModManagerPageState extends State<ModManagerPage> {
).iconSizeRegular,
),
),
const SizedBox(width: 4),
if (mod.dependencies.isNotEmpty)
Tooltip(
message:
@@ -820,6 +929,22 @@ class _ModManagerPageState extends State<ModManagerPage> {
).iconSizeRegular,
),
),
if (mod.incompatibilities.isNotEmpty)
Tooltip(
message:
'Incompatible with:\n${mod.incompatibilities.join('\n')}',
child: Icon(
Icons.warning_amber_rounded,
color:
AppThemeExtension.of(
context,
).incompatibleColor,
size:
AppThemeExtension.of(
context,
).iconSizeRegular,
),
),
],
),
onTap: () {
@@ -982,6 +1107,20 @@ class _ModManagerPageState extends State<ModManagerPage> {
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Description tooltip
if (mod.description.isNotEmpty)
HtmlTooltip(
content: mod.description,
child: Icon(
Icons.description_outlined,
color: Colors.lightBlue.shade300,
size:
AppThemeExtension.of(
context,
).iconSizeRegular,
),
),
const SizedBox(width: 4),
if (mod.isBaseGame)
Tooltip(
message: 'Base Game',
@@ -1062,6 +1201,22 @@ class _ModManagerPageState extends State<ModManagerPage> {
).iconSizeRegular,
),
),
if (mod.incompatibilities.isNotEmpty)
Tooltip(
message:
'Incompatible with:\n${mod.incompatibilities.join('\n')}',
child: Icon(
Icons.warning_amber_rounded,
color:
AppThemeExtension.of(
context,
).incompatibleColor,
size:
AppThemeExtension.of(
context,
).iconSizeRegular,
),
),
],
),
onTap: () {
@@ -1140,6 +1295,59 @@ class _ModManagerPageState extends State<ModManagerPage> {
loadMods();
}
void _startQuickScan() {
setState(() {
_isLoading = true;
_statusMessage = 'Quick scanning for mods...';
_hasCycles = false;
_cycleInfo = null;
_incompatibleMods = [];
});
// Create an async function to load mods
Future<void> loadMods() async {
try {
// First load available mods with the quick option
await for (final mod in modManager.loadAvailable(skipExistingSizes: true)) {
// 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 = 'Quick scan complete: ${_availableMods.length} mods, ${_activeMods.length} active';
});
}
} catch (error) {
if (mounted) {
setState(() {
_isLoading = false;
_statusMessage = 'Error during quick scan: $error';
});
}
}
}
// Start the loading process
loadMods();
}
void _toggleModActive(Mod mod) {
// Cannot deactivate base game or expansions
if ((mod.isBaseGame || mod.isExpansion) && mod.enabled) {
@@ -1325,47 +1533,7 @@ class _ModManagerPageState extends State<ModManagerPage> {
'Saving mod load order for ${_activeMods.length} active mods to $configPath',
);
// Save the mod list to the XML config file
final file = File(configPath);
final buffer = StringBuffer();
buffer.writeln('<?xml version="1.0" encoding="utf-8"?>');
buffer.writeln('<ModsConfigData>');
buffer.writeln(' <version>1</version>');
// Write active mods
buffer.writeln(' <activeMods>');
for (final mod in _activeMods) {
buffer.writeln(' <li>${mod.id}</li>');
logger.info(' - Adding mod to config: ${mod.name} (${mod.id})');
}
buffer.writeln(' </activeMods>');
// Count expansions
final expansions = _availableMods.where((m) => m.isExpansion).toList();
logger.info('Found ${expansions.length} expansions to include in config');
// Add known expansions
buffer.writeln(' <knownExpansions>');
for (final mod in expansions) {
buffer.writeln(' <li>${mod.id}</li>');
logger.info(' - Adding expansion to config: ${mod.name} (${mod.id})');
}
buffer.writeln(' </knownExpansions>');
buffer.writeln('</ModsConfigData>');
// Ensure directory exists
final directory = Directory(configRoot);
if (!directory.existsSync()) {
logger.info('Creating config directory: $configRoot');
directory.createSync(recursive: true);
}
// Write to file
logger.info('Writing config file to $configPath');
await file.writeAsString(buffer.toString());
logger.info('Successfully saved mod configuration');
modManager.saveToConfig(LoadOrder(_activeMods));
setState(() {
_isLoading = false;

View File

@@ -1,6 +1,5 @@
import 'dart:io';
import 'package:rimworld_modman/logger.dart';
import 'package:xml/xml.dart';
XmlElement findCaseInsensitive(XmlElement element, String name) {
@@ -59,28 +58,28 @@ class Mod {
}
static Mod fromDirectory(String path, {bool skipFileCount = false}) {
final logger = Logger.instance;
final stopwatch = Stopwatch()..start();
// final logger = Logger.instance;
// final stopwatch = Stopwatch()..start();
logger.info('Attempting to load mod from directory: $path');
// logger.info('Attempting to load mod from directory: $path');
final aboutFile = File('$path/About/About.xml');
if (!aboutFile.existsSync()) {
logger.error('About.xml file does not exist in $aboutFile');
// logger.error('About.xml file does not exist in $aboutFile');
throw Exception('About.xml file does not exist in $aboutFile');
}
logger.info('Parsing About.xml file...');
// logger.info('Parsing About.xml file...');
final aboutXml = XmlDocument.parse(aboutFile.readAsStringSync());
final xmlTime = stopwatch.elapsedMilliseconds;
// final xmlTime = stopwatch.elapsedMilliseconds;
late final XmlElement metadata;
try {
metadata = findCaseInsensitiveDoc(aboutXml, 'ModMetaData');
logger.info('Successfully found ModMetaData in About.xml');
// logger.info('Successfully found ModMetaData in About.xml');
} catch (e) {
logger.error(
'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e',
);
// logger.error(
// 'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e',
// );
throw Exception(
'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e',
);
@@ -89,11 +88,11 @@ class Mod {
late final String name;
try {
name = metadata.findElements('name').first.innerText;
logger.info('Mod name found: $name');
// logger.info('Mod name found: $name');
} catch (e) {
logger.error(
'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e',
);
// logger.error(
// 'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e',
// );
throw Exception(
'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e',
);
@@ -102,11 +101,11 @@ class Mod {
late final String id;
try {
id = metadata.findElements('packageId').first.innerText.toLowerCase();
logger.info('Mod ID found: $id');
// logger.info('Mod ID found: $id');
} catch (e) {
logger.error(
'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e',
);
// logger.error(
// 'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e',
// );
throw Exception(
'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e',
);
@@ -121,11 +120,11 @@ class Mod {
.findElements('li')
.map((e) => e.innerText)
.toList();
logger.info('Supported versions found: ${versions.join(", ")}');
// logger.info('Supported versions found: ${versions.join(", ")}');
} catch (e) {
logger.error(
'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e',
);
// logger.error(
// 'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e',
// );
throw Exception(
'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e',
);
@@ -134,11 +133,11 @@ class Mod {
String description = '';
try {
description = metadata.findElements('description').first.innerText;
logger.info('Mod description found: $description');
// logger.info('Mod description found: $description');
} catch (e) {
logger.warning(
'Description element is missing in ModMetaData ($aboutFile).',
);
// logger.warning(
// 'Description element is missing in ModMetaData ($aboutFile).',
// );
}
List<String> dependencies = [];
@@ -156,11 +155,28 @@ class Mod {
e.findElements("packageId").first.innerText.toLowerCase(),
)
.toList();
logger.info('Dependencies found: ${dependencies.join(", ")}');
// logger.info('Dependencies found: ${dependencies.join(", ")}');
} catch (e) {
logger.warning(
'Dependencies element is missing in ModMetaData ($aboutFile).',
// logger.warning(
// 'Dependencies element is missing in ModMetaData ($aboutFile).',
// );
}
try {
dependencies.addAll(
metadata
.findElements('modDependencies')
.first
.findElements('li')
.map(
(e) => e.findElements("packageId").first.innerText.toLowerCase(),
)
.toList(),
);
// logger.info('Additional dependencies found: ${dependencies.join(", ")}');
} catch (e) {
// logger.warning(
// 'modDependencies element is missing in ModMetaData ($aboutFile). Original error: $e',
// );
}
List<String> loadAfter = [];
@@ -172,12 +188,32 @@ class Mod {
.findElements('li')
.map((e) => e.innerText.toLowerCase())
.toList();
logger.info('Load after dependencies found: ${loadAfter.join(", ")}');
// logger.info(
// 'Load after dependencies found: ${loadAfter.isNotEmpty ? loadAfter.join(", ") : "none"}',
// );
} catch (e) {
logger.warning(
'Load after element is missing in ModMetaData ($aboutFile).',
);
// logger.warning(
// 'Load after element is missing or empty in ModMetaData ($aboutFile). Original error: $e',
// );
}
List<String> loadAfterForce = [];
try {
loadAfterForce =
metadata
.findElements('forceLoadAfter')
.first
.findElements('li')
.map((e) => e.innerText.toLowerCase())
.toList();
// logger.info(
// 'Force load after dependencies found: ${loadAfterForce.isNotEmpty ? loadAfterForce.join(", ") : "none"}',
// );
} catch (e) {
// logger.warning(
// 'Force load after element is missing or empty in ModMetaData ($aboutFile). Original error: $e',
// );
}
dependencies.addAll(loadAfterForce);
List<String> loadBefore = [];
try {
@@ -188,11 +224,13 @@ class Mod {
.findElements('li')
.map((e) => e.innerText.toLowerCase())
.toList();
logger.info('Load before dependencies found: ${loadBefore.join(", ")}');
// logger.info(
// 'Load before dependencies found: ${loadBefore.isNotEmpty ? loadBefore.join(", ") : "none"}',
// );
} catch (e) {
logger.warning(
'Load before element is missing in ModMetaData ($aboutFile).',
);
// logger.warning(
// 'Load before element is missing or empty in ModMetaData ($aboutFile). Original error: $e ',
// );
}
List<String> incompatibilities = [];
@@ -204,52 +242,97 @@ class Mod {
.findElements('li')
.map((e) => e.innerText.toLowerCase())
.toList();
logger.info('Incompatibilities found: ${incompatibilities.join(", ")}');
// logger.info('Incompatibilities found: ${incompatibilities.join(", ")}');
} catch (e) {
logger.warning(
'Incompatibilities element is missing in ModMetaData ($aboutFile).',
);
// logger.warning(
// 'Incompatibilities element is missing in ModMetaData ($aboutFile).',
// );
}
final metadataTime = stopwatch.elapsedMilliseconds - xmlTime;
// final metadataTime = stopwatch.elapsedMilliseconds - xmlTime;
int size = 0;
if (!skipFileCount) {
size =
// Find all directories matching version pattern (like "1.0", "1.4", etc.)
final versionDirs =
Directory(path)
.listSync(recursive: true)
.listSync(recursive: false)
.whereType<Directory>()
.where(
(entity) =>
!entity.path
.split(Platform.pathSeparator)
.last
.startsWith('.'),
(dir) => RegExp(
r'^\d+\.\d+$',
).hasMatch(dir.path.split(Platform.pathSeparator).last),
)
.length;
logger.info('File count in mod directory: $size');
.toList();
// Find the latest version directory (if any)
Directory? latestVersionDir;
if (versionDirs.isNotEmpty) {
// Sort by version number
versionDirs.sort((a, b) {
final List<int> vA =
a.path
.split(Platform.pathSeparator)
.last
.split('.')
.map(int.parse)
.toList();
final List<int> vB =
b.path
.split(Platform.pathSeparator)
.last
.split('.')
.map(int.parse)
.toList();
return vA[0] != vB[0]
? vA[0] - vB[0]
: vA[1] - vB[1]; // Compare major, then minor version
});
latestVersionDir = versionDirs.last;
// logger.info(
// 'Latest version directory found: ${latestVersionDir.path.split(Platform.pathSeparator).last}',
// );
}
// Count all files, excluding older version directories
size =
Directory(path).listSync(recursive: true).where((entity) {
if (entity is! File ||
entity.path
.split(Platform.pathSeparator)
.any((part) => part.startsWith('.'))) {
return false;
}
// Skip files in version directories except for the latest
for (final verDir in versionDirs) {
if (verDir != latestVersionDir &&
entity.path.startsWith(verDir.path)) {
return false;
}
}
return true;
}).length;
// logger.info(
// 'File count in mod directory (with only latest version): $size',
// );
}
// Check if this is RimWorld base game or expansion
bool isBaseGame = id == 'ludeon.rimworld';
bool isExpansion = !isBaseGame && id.startsWith('ludeon.rimworld.');
// If this is an expansion, ensure it depends on the base game
if (isExpansion && !loadAfter.contains('ludeon.rimworld')) {
loadAfter.add('ludeon.rimworld');
logger.info(
'Added base game dependency for expansion mod: ludeon.rimworld',
);
}
final fileCountTime =
stopwatch.elapsedMilliseconds - metadataTime - xmlTime;
final totalTime = stopwatch.elapsedMilliseconds;
// final fileCountTime =
// stopwatch.elapsedMilliseconds - metadataTime - xmlTime;
// final totalTime = stopwatch.elapsedMilliseconds;
// Log detailed timing information
logger.info(
'Mod $name timing: XML=${xmlTime}ms, Metadata=${metadataTime}ms, FileCount=${fileCountTime}ms, Total=${totalTime}ms',
);
// logger.info(
// 'Mod $name timing: XML=${xmlTime}ms, Metadata=${metadataTime}ms, FileCount=${fileCountTime}ms, Total=${totalTime}ms',
// );
dependencies = dependencies.toSet().toList();
loadAfter = loadAfter.toSet().toList();
loadBefore = loadBefore.toSet().toList();
incompatibilities = incompatibilities.toSet().toList();
return Mod(
name: name,
id: id,
@@ -261,8 +344,9 @@ class Mod {
loadBefore: loadBefore,
incompatibilities: incompatibilities,
size: size,
isBaseGame: isBaseGame,
isExpansion: isExpansion,
// No mods loaded from workshop are ever base or expansion games
isBaseGame: false,
isExpansion: false,
);
}

View File

@@ -13,11 +13,114 @@ class LoadOrder {
return order.map((mod) => mod.id).toList();
}
LoadOrder();
LoadOrder([List<Mod>? order]) {
this.order = order ?? [];
}
bool get hasErrors => errors.isNotEmpty;
}
var specialMods = {
'ludeon.rimworld': Mod(
id: 'ludeon.rimworld',
name: 'RimWorld',
path: '',
versions: [],
description: 'RimWorld base game',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
isBaseGame: true,
size: 0,
isExpansion: false,
enabled: true,
),
'ludeon.rimworld.royalty': Mod(
id: 'ludeon.rimworld.royalty',
name: 'Royalty',
path: '',
versions: [],
description: 'RimWorld expansion - Royalty',
dependencies: ['ludeon.rimworld'],
loadAfter: [],
loadBefore: [
'ludeon.rimworld.anomaly',
'ludeon.rimworld.biotech',
'ludeon.rimworld.ideology',
],
incompatibilities: [],
isBaseGame: false,
size: 0,
isExpansion: true,
enabled: true,
),
'ludeon.rimworld.ideology': Mod(
id: 'ludeon.rimworld.ideology',
name: 'Ideology',
path: '',
versions: [],
description: 'RimWorld expansion - Ideology',
dependencies: ['ludeon.rimworld'],
loadAfter: ['ludeon.rimworld.royalty'],
loadBefore: ['ludeon.rimworld.anomaly', 'ludeon.rimworld.biotech'],
incompatibilities: [],
isBaseGame: false,
size: 0,
isExpansion: true,
enabled: true,
),
'ludeon.rimworld.biotech': Mod(
id: 'ludeon.rimworld.biotech',
name: 'Biotech',
path: '',
versions: [],
description: 'RimWorld expansion - Biotech',
dependencies: ['ludeon.rimworld'],
loadAfter: ['ludeon.rimworld.ideology', 'ludeon.rimworld.royalty'],
loadBefore: ['ludeon.rimworld.anomaly'],
incompatibilities: [],
isBaseGame: false,
size: 0,
isExpansion: true,
enabled: true,
),
'ludeon.rimworld.anomaly': Mod(
id: 'ludeon.rimworld.anomaly',
name: 'Anomaly',
path: '',
versions: [],
description: 'RimWorld expansion - Anomaly',
dependencies: ['ludeon.rimworld'],
loadAfter: [
'ludeon.rimworld.biotech',
'ludeon.rimworld.ideology',
'ludeon.rimworld.royalty',
],
loadBefore: [],
incompatibilities: [],
isBaseGame: false,
size: 0,
isExpansion: true,
enabled: true,
),
'ludeon.rimworld.odyssey': Mod(
id: 'ludeon.rimworld.odyssey',
name: 'Odyssey',
path: '',
versions: [],
description: 'RimWorld expansion - Odyssey',
dependencies: ['ludeon.rimworld'],
loadAfter: ['ludeon.rimworld.anomaly'],
loadBefore: [],
incompatibilities: [],
isBaseGame: false,
size: 0,
isExpansion: true,
enabled: true,
),
};
class ModList {
String configPath = '';
String modsPath = '';
@@ -42,44 +145,43 @@ class ModList {
return newModlist;
}
Stream<Mod> loadAvailable() async* {
final logger = Logger.instance;
final stopwatch = Stopwatch()..start();
Stream<Mod> loadAvailable({bool skipExistingSizes = false}) async* {
// final logger = Logger.instance;
// final stopwatch = Stopwatch()..start();
final directory = Directory(modsPath);
if (!directory.existsSync()) {
logger.error('Error: Mods root directory does not exist: $modsPath');
// logger.error('Error: Mods root directory does not exist: $modsPath');
return;
}
final List<FileSystemEntity> entities = directory.listSync();
// TODO: Count only the latest version of each mod and not all versions
final List<String> modDirectories =
entities.whereType<Directory>().map((dir) => dir.path).toList();
logger.info(
'Found ${modDirectories.length} mod directories (${stopwatch.elapsedMilliseconds}ms)',
);
// logger.info(
// 'Found ${modDirectories.length} mod directories (${stopwatch.elapsedMilliseconds}ms)',
// );
for (final modDir in modDirectories) {
try {
final modStart = stopwatch.elapsedMilliseconds;
// final modStart = stopwatch.elapsedMilliseconds;
// Check if this directory contains a valid mod
final aboutFile = File('$modDir/About/About.xml');
if (!aboutFile.existsSync()) {
logger.warning('No About.xml found in directory: $modDir');
// logger.warning('No About.xml found in directory: $modDir');
continue;
}
final mod = Mod.fromDirectory(modDir);
logger.info('Loaded mod from directory: ${mod.name} (ID: ${mod.id})');
final mod = Mod.fromDirectory(modDir, skipFileCount: skipExistingSizes);
// logger.info('Loaded mod from directory: ${mod.name} (ID: ${mod.id})');
if (mods.containsKey(mod.id)) {
logger.warning(
'Mod $mod.id already exists in mods list, overwriting',
);
// logger.warning(
// 'Mod ${mod.id} already exists in mods list, overwriting',
// );
final existingMod = mods[mod.id]!;
mods[mod.id] = Mod(
name: mod.name,
@@ -96,20 +198,20 @@ class ModList {
isBaseGame: existingMod.isBaseGame,
isExpansion: existingMod.isExpansion,
);
logger.info('Updated existing mod: ${mod.name} (ID: ${mod.id})');
// logger.info('Updated existing mod: ${mod.name} (ID: ${mod.id})');
} else {
mods[mod.id] = mod;
logger.info('Added new mod: ${mod.name} (ID: ${mod.id})');
// logger.info('Added new mod: ${mod.name} (ID: ${mod.id})');
}
final modTime = stopwatch.elapsedMilliseconds - modStart;
logger.info(
'Loaded mod from directory: ${mod.name} (ID: ${mod.id}) in $modTime ms',
);
// final modTime = stopwatch.elapsedMilliseconds - modStart;
// logger.info(
// 'Loaded mod from directory: ${mod.name} (ID: ${mod.id}) in $modTime ms',
// );
yield mod;
} catch (e) {
logger.error('Error loading mod from directory: $modDir');
logger.error('Error: $e');
// logger.error('Error loading mod from directory: $modDir');
// logger.error('Error: $e');
}
}
}
@@ -152,42 +254,30 @@ class ModList {
for (final modElement in modElements) {
final modId = modElement.innerText.toLowerCase();
// Check if this is a special Ludeon mod
final isBaseGame = modId == 'ludeon.rimworld';
final isExpansion =
!isBaseGame &&
modId.startsWith('ludeon.rimworld.') &&
knownExpansionIds.contains(modId);
if (specialMods.containsKey(modId)) {
logger.info('Loading special mod: $modId');
mods[modId] = specialMods[modId]!.copyWith();
setEnabled(modId, true);
logger.info('Enabled special mod: $modId');
yield mods[modId]!;
continue;
}
final existingMod = mods[modId];
final mod = Mod(
name:
existingMod?.name ??
(isBaseGame
? "RimWorld"
: isExpansion
? "RimWorld ${_expansionNameFromId(modId)}"
: modId),
name: existingMod?.name ?? modId,
id: existingMod?.id ?? modId,
path: existingMod?.path ?? '',
versions: existingMod?.versions ?? [],
description:
existingMod?.description ??
(isBaseGame
? "RimWorld base game"
: isExpansion
? "RimWorld expansion"
: ""),
description: existingMod?.description ?? '',
dependencies: existingMod?.dependencies ?? [],
loadAfter:
existingMod?.loadAfter ??
(isExpansion ? ['ludeon.rimworld'] : []),
loadAfter: existingMod?.loadAfter ?? [],
loadBefore: existingMod?.loadBefore ?? [],
incompatibilities: existingMod?.incompatibilities ?? [],
enabled: existingMod?.enabled ?? false,
size: existingMod?.size ?? 0,
isBaseGame: isBaseGame,
isExpansion: isExpansion,
isBaseGame: false,
isExpansion: false,
);
if (mods.containsKey(modId)) {
logger.warning('Mod $modId already exists in mods list, overwriting');
@@ -204,6 +294,73 @@ class ModList {
}
}
void saveToConfig(LoadOrder loadOrder) {
final file = File(configPath);
final logger = Logger.instance;
try {
// Create XML builder
final builder = XmlBuilder();
// Add XML declaration
builder.declaration(encoding: 'utf-8');
// Add root element
builder.element(
'ModsConfigData',
nest: () {
// Add version element
builder.element('version', nest: '1.6.4518 rev71');
// Add active mods element
builder.element(
'activeMods',
nest: () {
// Add each mod as a list item
for (final mod in loadOrder.order) {
builder.element('li', nest: mod.id);
logger.info('Adding mod to config: ${mod.name} (${mod.id})');
}
},
);
// Add known expansions element
final expansions = mods.values.where((m) => m.isExpansion).toList();
if (expansions.isNotEmpty) {
builder.element(
'knownExpansions',
nest: () {
for (final mod in expansions) {
builder.element('li', nest: mod.id);
logger.info(
'Adding expansion to config: ${mod.name} (${mod.id})',
);
}
},
);
}
},
);
// Build the XML document
final xmlDocument = builder.buildDocument();
// Convert to string with 2-space indentation
final prettyXml = xmlDocument.toXmlString(
pretty: true,
indent: ' ', // 2 spaces
newLine: '\n',
);
// Write the formatted XML document to file
file.writeAsStringSync(prettyXml);
logger.info('Successfully saved mod configuration to: $configPath');
} catch (e) {
logger.error('Error saving configuration file: $e');
throw Exception('Failed to save config file: $e');
}
}
void setEnabled(String modId, bool enabled) {
if (mods.containsKey(modId)) {
final mod = mods[modId]!;
@@ -244,9 +401,15 @@ class ModList {
loadOrder ??= LoadOrder();
final logger = Logger.instance;
logger.info('Generating load order...');
for (final mod in activeMods.values) {
logger.info('Checking mod: ${mod.id}');
if (specialMods.containsKey(mod.id)) {
logger.info('Special mod: ${mod.id}');
// Replace our fake base game mod with the chad one
// This is a bit of a hack, but it works
activeMods[mod.id] = specialMods[mod.id]!.copyWith();
mods[mod.id] = specialMods[mod.id]!.copyWith();
}
logger.info('Mod details: ${mod.toString()}');
for (final incomp in mod.incompatibilities) {
if (activeMods.containsKey(incomp)) {
@@ -554,13 +717,83 @@ class ModList {
LoadOrder loadRequired([LoadOrder? loadOrder]) {
loadOrder ??= LoadOrder();
final toEnable = <String>[];
final logger = Logger.instance;
// First, identify all base game and expansion mods
final baseGameIds = <String>{};
final expansionIds = <String>{};
for (final entry in mods.entries) {
if (entry.value.isBaseGame) {
baseGameIds.add(entry.key);
} else if (entry.value.isExpansion) {
expansionIds.add(entry.key);
}
}
logger.info("Base game mods: ${baseGameIds.join(', ')}");
logger.info("Expansion mods: ${expansionIds.join(', ')}");
// Load dependencies for all active mods
for (final modid in activeMods.keys) {
loadDependencies(modid, loadOrder, toEnable);
}
// Enable all required dependencies
for (final modid in toEnable) {
setEnabled(modid, true);
}
return generateLoadOrder(loadOrder);
// Generate the load order
final newLoadOrder = generateLoadOrder(loadOrder);
// Filter out any error messages related to incompatibilities between base game and expansions
if (newLoadOrder.hasErrors) {
final filteredErrors = <String>[];
for (final error in newLoadOrder.errors) {
// Check if the error is about incompatibility
if (error.contains('Incompatibility detected:')) {
// Extract the mod IDs from the error message
final parts = error.split(' is incompatible with ');
if (parts.length == 2) {
final firstModId = parts[0].replaceAll(
'Incompatibility detected: ',
'',
);
final secondModId = parts[1];
// Check if either mod is a base game or expansion
final isBaseGameOrExpansion =
baseGameIds.contains(firstModId) ||
baseGameIds.contains(secondModId) ||
expansionIds.contains(firstModId) ||
expansionIds.contains(secondModId);
// Only keep the error if it's not between base game/expansions
if (!isBaseGameOrExpansion) {
filteredErrors.add(error);
} else {
logger.info(
"Ignoring incompatibility between base game or expansion mods: $error",
);
}
} else {
// If we can't parse the error, keep it
filteredErrors.add(error);
}
} else {
// Keep non-incompatibility errors
filteredErrors.add(error);
}
}
// Replace the errors with the filtered list
newLoadOrder.errors.clear();
newLoadOrder.errors.addAll(filteredErrors);
}
return newLoadOrder;
}
LoadOrder loadRequiredBaseGame([LoadOrder? loadOrder]) {
@@ -584,12 +817,3 @@ class ModList {
return loadRequired(loadOrder);
}
}
String _expansionNameFromId(String id) {
final parts = id.split('.');
if (parts.length < 3) return id;
final expansionPart = parts[2];
return expansionPart.substring(0, 1).toUpperCase() +
expansionPart.substring(1);
}

View File

@@ -34,7 +34,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
final Set<String> _problemMods = {};
// The currently selected mod IDs (for highlighting)
List<String> _selectedMods = [];
LoadOrder _loadOrder = LoadOrder();
// The next potential set of mods (from move calculation)
Move? _nextForwardMove;
@@ -64,7 +64,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
// Set initial active mods for highlighting
if (modManager.activeMods.isNotEmpty) {
// Initially select all active mods
_selectedMods = List.from(modManager.activeMods.keys);
_loadOrder = LoadOrder(modManager.activeMods.values.toList());
}
// Calculate initial moves
@@ -100,7 +100,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
// Use the mods from the load order result
setState(() {
_selectedMods = loadOrder.loadOrder;
_loadOrder = loadOrder;
_updateNextMoves();
});
}
@@ -118,7 +118,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
// Use the mods from the load order result
setState(() {
_selectedMods = loadOrder.loadOrder;
_loadOrder = loadOrder;
_updateNextMoves();
});
}
@@ -155,7 +155,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
void _saveTroubleshootingConfig() {
// Only save if we have a valid selection
if (_selectedMods.isEmpty) {
if (_loadOrder.order.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No mods selected to save'),
@@ -165,26 +165,22 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
return;
}
// First disable all mods
modManager.disableAll();
// Then enable only the selected mods
modManager.enableMods(_selectedMods);
modManager.saveToConfig(_loadOrder);
// 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.',
'${_loadOrder.order.length} mods have been successfully saved to the configuration.',
),
backgroundColor: Colors.orange,
backgroundColor: Colors.green,
duration: const Duration(seconds: 4),
),
);
}
void _markSelectedAsGood() {
if (_selectedMods.isEmpty) {
if (_loadOrder.order.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No mods selected to mark'),
@@ -195,15 +191,15 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
}
setState(() {
for (final modId in _selectedMods) {
_checkedMods.add(modId);
_problemMods.remove(modId);
for (final mod in _loadOrder.order) {
_checkedMods.add(mod.id);
_problemMods.remove(mod.id);
}
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Marked ${_selectedMods.length} mods as good'),
content: Text('Marked ${_loadOrder.order.length} mods as good'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
@@ -211,7 +207,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
}
void _markSelectedAsProblem() {
if (_selectedMods.isEmpty) {
if (_loadOrder.order.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No mods selected to mark'),
@@ -222,15 +218,15 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
}
setState(() {
for (final modId in _selectedMods) {
_problemMods.add(modId);
_checkedMods.remove(modId);
for (final mod in _loadOrder.order) {
_problemMods.add(mod.id);
_checkedMods.remove(mod.id);
}
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Marked ${_selectedMods.length} mods as problematic'),
content: Text('Marked ${_loadOrder.order.length} mods as problematic'),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
),
@@ -307,8 +303,8 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
// Compact instruction
Expanded(
child: Text(
_selectedMods.isNotEmpty
? 'Testing ${_selectedMods.length} mods. Tap highlighted mods to navigate. Mark results below:'
_loadOrder.order.isNotEmpty
? 'Testing ${_loadOrder.order.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,
@@ -379,7 +375,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
const Spacer(),
// Buttons to mark selected mods
if (_selectedMods.isNotEmpty) ...[
if (_loadOrder.order.isNotEmpty) ...[
OutlinedButton.icon(
icon: Icon(
Icons.error,
@@ -427,7 +423,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
onPressed: _resetTroubleshooter,
),
if (_selectedMods.isNotEmpty) ...[
if (_loadOrder.order.isNotEmpty) ...[
const SizedBox(width: 4),
// Save config button
OutlinedButton.icon(
@@ -492,7 +488,9 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
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);
final bool isSelected = _loadOrder.order.any(
(m) => m.id == modId,
);
// Check if this mod would be included in the next Forward/Backward move
bool isInNextForward = false;

View File

@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -5,6 +5,8 @@
import FlutterMacOS
import Foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View File

@@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
source: hosted
version: "80.0.0"
version: "85.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e"
sha256: abf63d42450c7ad6d8188887d16eeba2f1ff92ea8d8dc673213e99fb3c02b194
url: "https://pub.dev"
source: hosted
version: "7.3.0"
version: "7.5.7"
args:
dependency: transitive
description:
@@ -29,10 +29,10 @@ packages:
dependency: transitive
description:
name: async
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.12.0"
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
@@ -49,6 +49,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
clock:
dependency: transitive
description:
@@ -77,10 +85,10 @@ packages:
dependency: transitive
description:
name: coverage
sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
version: "1.15.0"
crypto:
dependency: transitive
description:
@@ -89,6 +97,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.6"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@@ -101,10 +117,10 @@ packages:
dependency: transitive
description:
name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
version: "1.3.3"
file:
dependency: transitive
description:
@@ -118,6 +134,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_html:
dependency: "direct main"
description:
name: flutter_html
sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_lints:
dependency: "direct dev"
description:
@@ -126,11 +150,24 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_markdown:
dependency: "direct main"
description:
name: flutter_markdown
sha256: "04c4722cc36ec5af38acc38ece70d22d3c2123c61305d555750a091517bbe504"
url: "https://pub.dev"
source: hosted
version: "0.6.23"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
frontend_server_client:
dependency: transitive
description:
@@ -147,6 +184,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http_multi_server:
dependency: transitive
description:
@@ -191,10 +236,10 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev"
source: hosted
version: "10.0.8"
version: "10.0.9"
leak_tracker_flutter_testing:
dependency: transitive
description:
@@ -219,6 +264,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
list_counter:
dependency: transitive
description:
name: list_counter
sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237
url: "https://pub.dev"
source: hosted
version: "1.0.2"
logging:
dependency: transitive
description:
@@ -227,6 +280,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
markdown:
dependency: transitive
description:
name: markdown
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
url: "https://pub.dev"
source: hosted
version: "7.3.0"
matcher:
dependency: transitive
description:
@@ -291,6 +352,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
@@ -432,6 +501,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79"
url: "https://pub.dev"
source: hosted
version: "6.3.16"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb"
url: "https://pub.dev"
source: hosted
version: "6.3.3"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev"
source: hosted
version: "3.1.4"
vector_math:
dependency: transitive
description:
@@ -444,18 +577,18 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev"
source: hosted
version: "14.3.1"
version: "15.0.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104"
sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.1.2"
web:
dependency: transitive
description:
@@ -468,18 +601,18 @@ packages:
dependency: transitive
description:
name: web_socket
sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83"
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "0.1.6"
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5"
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.2"
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
@@ -506,4 +639,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.7.2 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.27.0"

View File

@@ -37,6 +37,9 @@ dependencies:
xml: ^6.5.0
intl: ^0.20.2
path: ^1.9.1
flutter_markdown: ^0.6.20
flutter_html: ^3.0.0-beta.2
url_launcher: ^6.3.1
dev_dependencies:
flutter_test:

51
release.sh Normal file
View File

@@ -0,0 +1,51 @@
#!/bin/bash
echo "Figuring out the tag..."
TAG=$(git describe --tags --exact-match 2>/dev/null || echo "")
if [ -z "$TAG" ]; then
# Get the latest tag
LATEST_TAG=$(git describe --tags $(git rev-list --tags --max-count=1))
# Increment the patch version
IFS='.' read -r -a VERSION_PARTS <<< "$LATEST_TAG"
VERSION_PARTS[2]=$((VERSION_PARTS[2]+1))
TAG="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}"
# Create a new tag
git tag $TAG
git push origin $TAG
fi
echo "Tag: $TAG"
echo "Building the thing..."
flutter build windows --release
echo "Creating a release..."
TOKEN="$GITEA_API_KEY"
GITEA="https://git.site.quack-lab.dev"
REPO="dave/flutter-rimworld-modman"
ZIP="rimworld-modman-${TAG}.zip"
# Create a release
RELEASE_RESPONSE=$(curl -s -X POST \
-H "Authorization: token $TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "'"$TAG"'",
"name": "'"$TAG"'",
"draft": false,
"prerelease": false
}' \
$GITEA/api/v1/repos/$REPO/releases)
# Extract the release ID
echo $RELEASE_RESPONSE
RELEASE_ID=$(echo $RELEASE_RESPONSE | awk -F'"id":' '{print $2+0; exit}')
echo "Release ID: $RELEASE_ID"
echo "Uploading the things..."
WINRELEASE="./build/windows/x64/runner/Release/"
7z a $WINRELEASE/$ZIP $WINRELEASE/*
curl -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@$WINRELEASE/$ZIP" \
"$GITEA/api/v1/repos/$REPO/releases/${RELEASE_ID}/assets?name=$ZIP"
rm $WINRELEASE/$ZIP

View File

@@ -200,10 +200,10 @@ void main() {
final expected = [
'brrainz.harmony',
'ludeon.rimworld',
'ludeon.rimworld.anomaly',
'ludeon.rimworld.biotech',
'ludeon.rimworld.ideology',
'ludeon.rimworld.royalty',
'ludeon.rimworld.ideology',
'ludeon.rimworld.biotech',
'ludeon.rimworld.anomaly',
'dubwise.rimatomics',
'jecrell.doorsexpanded',
'dubwise.rimefeller',
@@ -304,10 +304,10 @@ void main() {
'brrainz.harmony',
'ludeon.rimworld',
'bs.betterlog',
'ludeon.rimworld.anomaly',
'ludeon.rimworld.royalty',
'ludeon.rimworld.ideology',
'ludeon.rimworld.biotech',
'ludeon.rimworld.anomaly',
];
expect(order.loadOrder, equals(expected));
});
@@ -712,10 +712,10 @@ void main() {
'brrainz.harmony',
'ludeon.rimworld',
'bs.betterlog',
'ludeon.rimworld.anomaly',
'ludeon.rimworld.biotech',
'ludeon.rimworld.ideology',
'ludeon.rimworld.royalty',
'ludeon.rimworld.ideology',
'ludeon.rimworld.biotech',
'ludeon.rimworld.anomaly',
'rah.rbse',
'mlie.usethisinstead',
'dubwise.rimatomics',

View File

@@ -182,6 +182,38 @@ void main() {
expect(result.errors, isEmpty);
expect(result.loadOrder, equals(expected));
});
test('Expansions should load in the correct order', () {
final list = ModList();
// Intentionally left barren because that's how we get it out of the box
// It is up to generateLoadOrder to fill in the details
list.mods = {
'ludeon.rimworld': makeDummy().copyWith(id: 'ludeon.rimworld'),
'ludeon.rimworld.anomaly': makeDummy().copyWith(
id: 'ludeon.rimworld.anomaly',
),
'ludeon.rimworld.ideology': makeDummy().copyWith(
id: 'ludeon.rimworld.ideology',
),
'ludeon.rimworld.biotech': makeDummy().copyWith(
id: 'ludeon.rimworld.biotech',
),
'ludeon.rimworld.royalty': makeDummy().copyWith(
id: 'ludeon.rimworld.royalty',
),
};
list.enableAll();
final result = list.generateLoadOrder();
final expected = [
'ludeon.rimworld',
'ludeon.rimworld.royalty',
'ludeon.rimworld.ideology',
'ludeon.rimworld.biotech',
'ludeon.rimworld.anomaly',
];
expect(result.errors, isEmpty);
expect(result.loadOrder, equals(expected));
});
});
group('Test loadRequired', () {

View File

@@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h"
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -25,8 +25,9 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
FlutterWindow window(project);
Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720);
Win32Window::Size size(1920, 1080);
Win32Window::Point origin((GetSystemMetrics(SM_CXSCREEN) - size.width) / 2,
(GetSystemMetrics(SM_CYSCREEN) - size.height) / 2);
if (!window.Create(L"rimworld_modman", origin, size)) {
return EXIT_FAILURE;
}