Compare commits
	
		
			17 Commits
		
	
	
		
			71ad392fb6
			...
			v1.0.0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 07d81eca71 | |||
| 2e6bfb84de | |||
| 0384e8012e | |||
| 1bb8ed9084 | |||
| 573ad05514 | |||
| 9a8b7fd2d3 | |||
| d00c20397f | |||
| 40d251f400 | |||
| 09b7fe539e | |||
| 5f20368fe2 | |||
| 9eb71e94c1 | |||
| f90371109c | |||
| 7f4b944101 | |||
| 8f466420f2 | |||
| 160488849f | |||
| 6826b272aa | |||
| 1c6af27c7e | 
							
								
								
									
										345
									
								
								lib/components/html_tooltip.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										345
									
								
								lib/components/html_tooltip.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,345 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_html/flutter_html.dart'; | ||||
| import 'package:url_launcher/url_launcher.dart'; | ||||
| import '../format_converter.dart'; | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| 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
									
								
							
							
						
						
									
										308
									
								
								lib/format_converter.dart
									
									
									
									
									
										Normal 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 -  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>', ''); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										314
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						
									
										314
									
								
								lib/main.dart
									
									
									
									
									
								
							| @@ -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; | ||||
|   | ||||
							
								
								
									
										208
									
								
								lib/mod.dart
									
									
									
									
									
								
							
							
						
						
									
										208
									
								
								lib/mod.dart
									
									
									
									
									
								
							| @@ -59,28 +59,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 +89,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 +102,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 +121,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 +134,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 +156,11 @@ 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).', | ||||
|       // ); | ||||
|     } | ||||
|  | ||||
|     List<String> loadAfter = []; | ||||
| @@ -172,12 +172,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 +208,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,51 +226,92 @@ 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', | ||||
|     // ); | ||||
|  | ||||
|     return Mod( | ||||
|       name: name, | ||||
| @@ -261,8 +324,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, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -13,11 +13,99 @@ 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, | ||||
|   ), | ||||
| }; | ||||
|  | ||||
| class ModList { | ||||
|   String configPath = ''; | ||||
|   String modsPath = ''; | ||||
| @@ -42,44 +130,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 +183,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 +239,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 +279,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.5.4297 rev994'); | ||||
|  | ||||
|           // 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 +386,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 +702,76 @@ 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 +795,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); | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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); | ||||
| } | ||||
|   | ||||
| @@ -3,6 +3,7 @@ | ||||
| # | ||||
|  | ||||
| list(APPEND FLUTTER_PLUGIN_LIST | ||||
|   url_launcher_linux | ||||
| ) | ||||
|  | ||||
| list(APPEND FLUTTER_FFI_PLUGIN_LIST | ||||
|   | ||||
| @@ -5,6 +5,8 @@ | ||||
| import FlutterMacOS | ||||
| import Foundation | ||||
|  | ||||
| import url_launcher_macos | ||||
|  | ||||
| func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { | ||||
|   UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) | ||||
| } | ||||
|   | ||||
							
								
								
									
										127
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										127
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -89,6 +89,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: | ||||
| @@ -118,6 +126,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 +142,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 +176,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.3" | ||||
|   html: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: html | ||||
|       sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.15.5" | ||||
|   http_multi_server: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -219,6 +256,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 +272,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 +344,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 +493,70 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.4.0" | ||||
|   url_launcher: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: url_launcher | ||||
|       sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.3.1" | ||||
|   url_launcher_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: url_launcher_android | ||||
|       sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.3.15" | ||||
|   url_launcher_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: url_launcher_ios | ||||
|       sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.3.2" | ||||
|   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: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.4.0" | ||||
|   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: | ||||
| @@ -506,4 +631,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" | ||||
|   | ||||
| @@ -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
									
								
							
							
						
						
									
										51
									
								
								release.sh
									
									
									
									
									
										Normal 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 | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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', () { | ||||
|   | ||||
| @@ -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")); | ||||
| } | ||||
|   | ||||
| @@ -3,6 +3,7 @@ | ||||
| # | ||||
|  | ||||
| list(APPEND FLUTTER_PLUGIN_LIST | ||||
|   url_launcher_windows | ||||
| ) | ||||
|  | ||||
| list(APPEND FLUTTER_FFI_PLUGIN_LIST | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user