Compare commits
	
		
			19 Commits
		
	
	
		
			71ad392fb6
			...
			v1.1.0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 753859fd3e | |||
| 43a5f63759 | |||
| 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'; | import 'dart:io'; | ||||||
|  |  | ||||||
|  | // TODO: Fix "load dependencies", it causes fake errors between expansions and base game | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:rimworld_modman/logger.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.dart'; | ||||||
| import 'package:rimworld_modman/mod_list.dart'; | import 'package:rimworld_modman/mod_list.dart'; | ||||||
| import 'package:rimworld_modman/mod_troubleshooter_widget.dart'; | import 'package:rimworld_modman/mod_troubleshooter_widget.dart'; | ||||||
| @@ -27,6 +29,7 @@ class AppThemeExtension extends ThemeExtension<AppThemeExtension> { | |||||||
|   final Color linkColor; |   final Color linkColor; | ||||||
|   final Color loadAfterColor; |   final Color loadAfterColor; | ||||||
|   final Color loadBeforeColor; |   final Color loadBeforeColor; | ||||||
|  |   final Color incompatibleColor; | ||||||
|  |  | ||||||
|   AppThemeExtension({ |   AppThemeExtension({ | ||||||
|     required this.iconSizeSmall, |     required this.iconSizeSmall, | ||||||
| @@ -48,6 +51,7 @@ class AppThemeExtension extends ThemeExtension<AppThemeExtension> { | |||||||
|     required this.linkColor, |     required this.linkColor, | ||||||
|     required this.loadAfterColor, |     required this.loadAfterColor, | ||||||
|     required this.loadBeforeColor, |     required this.loadBeforeColor, | ||||||
|  |     required this.incompatibleColor, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   static AppThemeExtension of(BuildContext context) { |   static AppThemeExtension of(BuildContext context) { | ||||||
| @@ -75,6 +79,7 @@ class AppThemeExtension extends ThemeExtension<AppThemeExtension> { | |||||||
|     Color? linkColor, |     Color? linkColor, | ||||||
|     Color? loadAfterColor, |     Color? loadAfterColor, | ||||||
|     Color? loadBeforeColor, |     Color? loadBeforeColor, | ||||||
|  |     Color? incompatibleColor, | ||||||
|   }) { |   }) { | ||||||
|     return AppThemeExtension( |     return AppThemeExtension( | ||||||
|       iconSizeSmall: iconSizeSmall ?? this.iconSizeSmall, |       iconSizeSmall: iconSizeSmall ?? this.iconSizeSmall, | ||||||
| @@ -97,6 +102,7 @@ class AppThemeExtension extends ThemeExtension<AppThemeExtension> { | |||||||
|       linkColor: linkColor ?? this.linkColor, |       linkColor: linkColor ?? this.linkColor, | ||||||
|       loadAfterColor: loadAfterColor ?? this.loadAfterColor, |       loadAfterColor: loadAfterColor ?? this.loadAfterColor, | ||||||
|       loadBeforeColor: loadBeforeColor ?? this.loadBeforeColor, |       loadBeforeColor: loadBeforeColor ?? this.loadBeforeColor, | ||||||
|  |       incompatibleColor: incompatibleColor ?? this.incompatibleColor, | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -134,6 +140,7 @@ class AppThemeExtension extends ThemeExtension<AppThemeExtension> { | |||||||
|       linkColor: Color.lerp(linkColor, other.linkColor, t)!, |       linkColor: Color.lerp(linkColor, other.linkColor, t)!, | ||||||
|       loadAfterColor: Color.lerp(loadAfterColor, other.loadAfterColor, t)!, |       loadAfterColor: Color.lerp(loadAfterColor, other.loadAfterColor, t)!, | ||||||
|       loadBeforeColor: Color.lerp(loadBeforeColor, other.loadBeforeColor, 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, |       linkColor: Colors.orange, | ||||||
|       loadAfterColor: Colors.blue, |       loadAfterColor: Colors.blue, | ||||||
|       loadBeforeColor: Colors.green, |       loadBeforeColor: Colors.green, | ||||||
|  |       incompatibleColor: Colors.red.shade400, | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -294,6 +302,8 @@ class _ModManagerPageState extends State<ModManagerPage> { | |||||||
|  |  | ||||||
|   final TextEditingController _searchController = TextEditingController(); |   final TextEditingController _searchController = TextEditingController(); | ||||||
|   String _searchQuery = ''; |   String _searchQuery = ''; | ||||||
|  |   bool _useRegex = false; | ||||||
|  |   RegExp? _searchRegex; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
| @@ -302,12 +312,6 @@ class _ModManagerPageState extends State<ModManagerPage> { | |||||||
|     if (modManager.mods.isNotEmpty) { |     if (modManager.mods.isNotEmpty) { | ||||||
|       _loadModsFromGlobalState(); |       _loadModsFromGlobalState(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     _searchController.addListener(() { |  | ||||||
|       setState(() { |  | ||||||
|         _searchQuery = _searchController.text.toLowerCase(); |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -390,9 +394,19 @@ class _ModManagerPageState extends State<ModManagerPage> { | |||||||
|             ], |             ], | ||||||
|           ), |           ), | ||||||
|           const SizedBox(height: 24), |           const SizedBox(height: 24), | ||||||
|           ElevatedButton( |           Row( | ||||||
|             onPressed: _startLoadingMods, |             mainAxisAlignment: MainAxisAlignment.center, | ||||||
|             child: const Text('Scan for Mods'), |             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() { |   Widget _buildSplitView() { | ||||||
|     // Filter both available and active mods based on search query |     // Filter both available and active mods based on search query | ||||||
|     final filteredAvailableMods = |     List<Mod> filteredAvailableMods; | ||||||
|         _searchQuery.isEmpty |     List<Mod> filteredActiveMods; | ||||||
|             ? _availableMods |  | ||||||
|             : _availableMods |  | ||||||
|                 .where( |  | ||||||
|                   (mod) => |  | ||||||
|                       mod.name.toLowerCase().contains(_searchQuery) || |  | ||||||
|                       mod.id.toLowerCase().contains(_searchQuery), |  | ||||||
|                 ) |  | ||||||
|                 .toList(); |  | ||||||
|  |  | ||||||
|     final filteredActiveMods = |     if (_searchQuery.isEmpty) { | ||||||
|         _searchQuery.isEmpty |       filteredAvailableMods = _availableMods; | ||||||
|             ? _activeMods |       filteredActiveMods = _activeMods; | ||||||
|             : _activeMods |     } else { | ||||||
|                 .where( |       if (_useRegex && _searchRegex != null) { | ||||||
|                   (mod) => |         // Use regex pattern for filtering | ||||||
|                       mod.name.toLowerCase().contains(_searchQuery) || |         filteredAvailableMods = _availableMods | ||||||
|                       mod.id.toLowerCase().contains(_searchQuery), |             .where( | ||||||
|                 ) |               (mod) => | ||||||
|                 .toList(); |                   _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( |     return Column( | ||||||
|       children: [ |       children: [ | ||||||
| @@ -449,14 +485,74 @@ class _ModManagerPageState extends State<ModManagerPage> { | |||||||
|                             ) |                             ) | ||||||
|                             : null, |                             : 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), |               const SizedBox(width: 8), | ||||||
|               // Reload button |               // Reload button | ||||||
|               IconButton( |               IconButton( | ||||||
|                 icon: const Icon(Icons.refresh), |                 icon: const Icon(Icons.refresh), | ||||||
|                 tooltip: 'Reload mods', |                 tooltip: 'Reload all mods (full scan)', | ||||||
|                 onPressed: _startLoadingMods, |                 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), |               const SizedBox(width: 8), | ||||||
|               // Load Dependencies button |               // Load Dependencies button | ||||||
| @@ -641,7 +737,7 @@ class _ModManagerPageState extends State<ModManagerPage> { | |||||||
|                                     fontSize: |                                     fontSize: | ||||||
|                                         AppThemeExtension.of( |                                         AppThemeExtension.of( | ||||||
|                                           context, |                                           context, | ||||||
|                                         ).textSizeSmall, |                                         ).textSizeRegular, | ||||||
|                                   ), |                                   ), | ||||||
|                                 ), |                                 ), | ||||||
|                               ), |                               ), | ||||||
| @@ -741,6 +837,20 @@ class _ModManagerPageState extends State<ModManagerPage> { | |||||||
|                                 trailing: Row( |                                 trailing: Row( | ||||||
|                                   mainAxisSize: MainAxisSize.min, |                                   mainAxisSize: MainAxisSize.min, | ||||||
|                                   children: [ |                                   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) |                                     if (mod.isBaseGame) | ||||||
|                                       Tooltip( |                                       Tooltip( | ||||||
|                                         message: 'Base Game', |                                         message: 'Base Game', | ||||||
| @@ -771,7 +881,6 @@ class _ModManagerPageState extends State<ModManagerPage> { | |||||||
|                                               ).iconSizeRegular, |                                               ).iconSizeRegular, | ||||||
|                                         ), |                                         ), | ||||||
|                                       ), |                                       ), | ||||||
|                                     const SizedBox(width: 4), |  | ||||||
|                                     if (mod.dependencies.isNotEmpty) |                                     if (mod.dependencies.isNotEmpty) | ||||||
|                                       Tooltip( |                                       Tooltip( | ||||||
|                                         message: |                                         message: | ||||||
| @@ -820,6 +929,22 @@ class _ModManagerPageState extends State<ModManagerPage> { | |||||||
|                                               ).iconSizeRegular, |                                               ).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: () { |                                 onTap: () { | ||||||
| @@ -982,6 +1107,20 @@ class _ModManagerPageState extends State<ModManagerPage> { | |||||||
|                                   trailing: Row( |                                   trailing: Row( | ||||||
|                                     mainAxisSize: MainAxisSize.min, |                                     mainAxisSize: MainAxisSize.min, | ||||||
|                                     children: [ |                                     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) |                                       if (mod.isBaseGame) | ||||||
|                                         Tooltip( |                                         Tooltip( | ||||||
|                                           message: 'Base Game', |                                           message: 'Base Game', | ||||||
| @@ -1062,6 +1201,22 @@ class _ModManagerPageState extends State<ModManagerPage> { | |||||||
|                                                 ).iconSizeRegular, |                                                 ).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: () { |                                   onTap: () { | ||||||
| @@ -1140,6 +1295,59 @@ class _ModManagerPageState extends State<ModManagerPage> { | |||||||
|     loadMods(); |     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) { |   void _toggleModActive(Mod mod) { | ||||||
|     // Cannot deactivate base game or expansions |     // Cannot deactivate base game or expansions | ||||||
|     if ((mod.isBaseGame || mod.isExpansion) && mod.enabled) { |     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', |         'Saving mod load order for ${_activeMods.length} active mods to $configPath', | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
|       // Save the mod list to the XML config file |       modManager.saveToConfig(LoadOrder(_activeMods)); | ||||||
|       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'); |  | ||||||
|  |  | ||||||
|       setState(() { |       setState(() { | ||||||
|         _isLoading = false; |         _isLoading = false; | ||||||
|   | |||||||
							
								
								
									
										227
									
								
								lib/mod.dart
									
									
									
									
									
								
							
							
						
						
									
										227
									
								
								lib/mod.dart
									
									
									
									
									
								
							| @@ -59,28 +59,28 @@ class Mod { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   static Mod fromDirectory(String path, {bool skipFileCount = false}) { |   static Mod fromDirectory(String path, {bool skipFileCount = false}) { | ||||||
|     final logger = Logger.instance; |     // final logger = Logger.instance; | ||||||
|     final stopwatch = Stopwatch()..start(); |     // 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'); |     final aboutFile = File('$path/About/About.xml'); | ||||||
|     if (!aboutFile.existsSync()) { |     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'); |       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 aboutXml = XmlDocument.parse(aboutFile.readAsStringSync()); | ||||||
|     final xmlTime = stopwatch.elapsedMilliseconds; |     // final xmlTime = stopwatch.elapsedMilliseconds; | ||||||
|  |  | ||||||
|     late final XmlElement metadata; |     late final XmlElement metadata; | ||||||
|     try { |     try { | ||||||
|       metadata = findCaseInsensitiveDoc(aboutXml, 'ModMetaData'); |       metadata = findCaseInsensitiveDoc(aboutXml, 'ModMetaData'); | ||||||
|       logger.info('Successfully found ModMetaData in About.xml'); |       // logger.info('Successfully found ModMetaData in About.xml'); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       logger.error( |       // logger.error( | ||||||
|         'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e', |       //   'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e', | ||||||
|       ); |       // ); | ||||||
|       throw Exception( |       throw Exception( | ||||||
|         'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e', |         'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e', | ||||||
|       ); |       ); | ||||||
| @@ -89,11 +89,11 @@ class Mod { | |||||||
|     late final String name; |     late final String name; | ||||||
|     try { |     try { | ||||||
|       name = metadata.findElements('name').first.innerText; |       name = metadata.findElements('name').first.innerText; | ||||||
|       logger.info('Mod name found: $name'); |       // logger.info('Mod name found: $name'); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       logger.error( |       // logger.error( | ||||||
|         'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e', |       //   'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e', | ||||||
|       ); |       // ); | ||||||
|       throw Exception( |       throw Exception( | ||||||
|         'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e', |         'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e', | ||||||
|       ); |       ); | ||||||
| @@ -102,11 +102,11 @@ class Mod { | |||||||
|     late final String id; |     late final String id; | ||||||
|     try { |     try { | ||||||
|       id = metadata.findElements('packageId').first.innerText.toLowerCase(); |       id = metadata.findElements('packageId').first.innerText.toLowerCase(); | ||||||
|       logger.info('Mod ID found: $id'); |       // logger.info('Mod ID found: $id'); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       logger.error( |       // logger.error( | ||||||
|         'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e', |       //   'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e', | ||||||
|       ); |       // ); | ||||||
|       throw Exception( |       throw Exception( | ||||||
|         'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e', |         'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e', | ||||||
|       ); |       ); | ||||||
| @@ -121,11 +121,11 @@ class Mod { | |||||||
|               .findElements('li') |               .findElements('li') | ||||||
|               .map((e) => e.innerText) |               .map((e) => e.innerText) | ||||||
|               .toList(); |               .toList(); | ||||||
|       logger.info('Supported versions found: ${versions.join(", ")}'); |       // logger.info('Supported versions found: ${versions.join(", ")}'); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       logger.error( |       // logger.error( | ||||||
|         'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e', |       //   'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e', | ||||||
|       ); |       // ); | ||||||
|       throw Exception( |       throw Exception( | ||||||
|         'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e', |         'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e', | ||||||
|       ); |       ); | ||||||
| @@ -134,11 +134,11 @@ class Mod { | |||||||
|     String description = ''; |     String description = ''; | ||||||
|     try { |     try { | ||||||
|       description = metadata.findElements('description').first.innerText; |       description = metadata.findElements('description').first.innerText; | ||||||
|       logger.info('Mod description found: $description'); |       // logger.info('Mod description found: $description'); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       logger.warning( |       // logger.warning( | ||||||
|         'Description element is missing in ModMetaData ($aboutFile).', |       //   'Description element is missing in ModMetaData ($aboutFile).', | ||||||
|       ); |       // ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     List<String> dependencies = []; |     List<String> dependencies = []; | ||||||
| @@ -156,11 +156,28 @@ class Mod { | |||||||
|                     e.findElements("packageId").first.innerText.toLowerCase(), |                     e.findElements("packageId").first.innerText.toLowerCase(), | ||||||
|               ) |               ) | ||||||
|               .toList(); |               .toList(); | ||||||
|       logger.info('Dependencies found: ${dependencies.join(", ")}'); |       // logger.info('Dependencies found: ${dependencies.join(", ")}'); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       logger.warning( |       // logger.warning( | ||||||
|         'Dependencies element is missing in ModMetaData ($aboutFile).', |       //   'Dependencies element is missing in ModMetaData ($aboutFile).', | ||||||
|  |       // ); | ||||||
|  |     } | ||||||
|  |     try { | ||||||
|  |       dependencies.addAll( | ||||||
|  |         metadata | ||||||
|  |             .findElements('modDependencies') | ||||||
|  |             .first | ||||||
|  |             .findElements('li') | ||||||
|  |             .map( | ||||||
|  |               (e) => e.findElements("packageId").first.innerText.toLowerCase(), | ||||||
|  |             ) | ||||||
|  |             .toList(), | ||||||
|       ); |       ); | ||||||
|  |       // logger.info('Additional dependencies found: ${dependencies.join(", ")}'); | ||||||
|  |     } catch (e) { | ||||||
|  |       // logger.warning( | ||||||
|  |       //   'modDependencies element is missing in ModMetaData ($aboutFile). Original error: $e', | ||||||
|  |       // ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     List<String> loadAfter = []; |     List<String> loadAfter = []; | ||||||
| @@ -172,12 +189,32 @@ class Mod { | |||||||
|               .findElements('li') |               .findElements('li') | ||||||
|               .map((e) => e.innerText.toLowerCase()) |               .map((e) => e.innerText.toLowerCase()) | ||||||
|               .toList(); |               .toList(); | ||||||
|       logger.info('Load after dependencies found: ${loadAfter.join(", ")}'); |       // logger.info( | ||||||
|  |       //   'Load after dependencies found: ${loadAfter.isNotEmpty ? loadAfter.join(", ") : "none"}', | ||||||
|  |       // ); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       logger.warning( |       // logger.warning( | ||||||
|         'Load after element is missing in ModMetaData ($aboutFile).', |       //   '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 = []; |     List<String> loadBefore = []; | ||||||
|     try { |     try { | ||||||
| @@ -188,11 +225,13 @@ class Mod { | |||||||
|               .findElements('li') |               .findElements('li') | ||||||
|               .map((e) => e.innerText.toLowerCase()) |               .map((e) => e.innerText.toLowerCase()) | ||||||
|               .toList(); |               .toList(); | ||||||
|       logger.info('Load before dependencies found: ${loadBefore.join(", ")}'); |       // logger.info( | ||||||
|  |       //   'Load before dependencies found: ${loadBefore.isNotEmpty ? loadBefore.join(", ") : "none"}', | ||||||
|  |       // ); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       logger.warning( |       // logger.warning( | ||||||
|         'Load before element is missing in ModMetaData ($aboutFile).', |       //   'Load before element is missing or empty in ModMetaData ($aboutFile). Original error: $e ', | ||||||
|       ); |       // ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     List<String> incompatibilities = []; |     List<String> incompatibilities = []; | ||||||
| @@ -204,52 +243,97 @@ class Mod { | |||||||
|               .findElements('li') |               .findElements('li') | ||||||
|               .map((e) => e.innerText.toLowerCase()) |               .map((e) => e.innerText.toLowerCase()) | ||||||
|               .toList(); |               .toList(); | ||||||
|       logger.info('Incompatibilities found: ${incompatibilities.join(", ")}'); |       // logger.info('Incompatibilities found: ${incompatibilities.join(", ")}'); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       logger.warning( |       // logger.warning( | ||||||
|         'Incompatibilities element is missing in ModMetaData ($aboutFile).', |       //   'Incompatibilities element is missing in ModMetaData ($aboutFile).', | ||||||
|       ); |       // ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     final metadataTime = stopwatch.elapsedMilliseconds - xmlTime; |     // final metadataTime = stopwatch.elapsedMilliseconds - xmlTime; | ||||||
|  |  | ||||||
|     int size = 0; |     int size = 0; | ||||||
|     if (!skipFileCount) { |     if (!skipFileCount) { | ||||||
|       size = |       // Find all directories matching version pattern (like "1.0", "1.4", etc.) | ||||||
|  |       final versionDirs = | ||||||
|           Directory(path) |           Directory(path) | ||||||
|               .listSync(recursive: true) |               .listSync(recursive: false) | ||||||
|  |               .whereType<Directory>() | ||||||
|               .where( |               .where( | ||||||
|                 (entity) => |                 (dir) => RegExp( | ||||||
|                     !entity.path |                   r'^\d+\.\d+$', | ||||||
|                         .split(Platform.pathSeparator) |                 ).hasMatch(dir.path.split(Platform.pathSeparator).last), | ||||||
|                         .last |  | ||||||
|                         .startsWith('.'), |  | ||||||
|               ) |               ) | ||||||
|               .length; |               .toList(); | ||||||
|       logger.info('File count in mod directory: $size'); |  | ||||||
|  |       // 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 |     // final fileCountTime = | ||||||
|     bool isBaseGame = id == 'ludeon.rimworld'; |     //     stopwatch.elapsedMilliseconds - metadataTime - xmlTime; | ||||||
|     bool isExpansion = !isBaseGame && id.startsWith('ludeon.rimworld.'); |     // final totalTime = stopwatch.elapsedMilliseconds; | ||||||
|  |  | ||||||
|     // 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; |  | ||||||
|  |  | ||||||
|     // Log detailed timing information |     // Log detailed timing information | ||||||
|     logger.info( |     // logger.info( | ||||||
|       'Mod $name timing: XML=${xmlTime}ms, Metadata=${metadataTime}ms, FileCount=${fileCountTime}ms, Total=${totalTime}ms', |     //   'Mod $name timing: XML=${xmlTime}ms, Metadata=${metadataTime}ms, FileCount=${fileCountTime}ms, Total=${totalTime}ms', | ||||||
|     ); |     // ); | ||||||
|  |  | ||||||
|  |     dependencies = dependencies.toSet().toList(); | ||||||
|  |     loadAfter = loadAfter.toSet().toList(); | ||||||
|  |     loadBefore = loadBefore.toSet().toList(); | ||||||
|  |     incompatibilities = incompatibilities.toSet().toList(); | ||||||
|     return Mod( |     return Mod( | ||||||
|       name: name, |       name: name, | ||||||
|       id: id, |       id: id, | ||||||
| @@ -261,8 +345,9 @@ class Mod { | |||||||
|       loadBefore: loadBefore, |       loadBefore: loadBefore, | ||||||
|       incompatibilities: incompatibilities, |       incompatibilities: incompatibilities, | ||||||
|       size: size, |       size: size, | ||||||
|       isBaseGame: isBaseGame, |       // No mods loaded from workshop are ever base or expansion games | ||||||
|       isExpansion: isExpansion, |       isBaseGame: false, | ||||||
|  |       isExpansion: false, | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -13,11 +13,99 @@ class LoadOrder { | |||||||
|     return order.map((mod) => mod.id).toList(); |     return order.map((mod) => mod.id).toList(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   LoadOrder(); |   LoadOrder([List<Mod>? order]) { | ||||||
|  |     this.order = order ?? []; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   bool get hasErrors => errors.isNotEmpty; |   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 { | class ModList { | ||||||
|   String configPath = ''; |   String configPath = ''; | ||||||
|   String modsPath = ''; |   String modsPath = ''; | ||||||
| @@ -42,44 +130,43 @@ class ModList { | |||||||
|     return newModlist; |     return newModlist; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Stream<Mod> loadAvailable() async* { |   Stream<Mod> loadAvailable({bool skipExistingSizes = false}) async* { | ||||||
|     final logger = Logger.instance; |     // final logger = Logger.instance; | ||||||
|     final stopwatch = Stopwatch()..start(); |     // final stopwatch = Stopwatch()..start(); | ||||||
|  |  | ||||||
|     final directory = Directory(modsPath); |     final directory = Directory(modsPath); | ||||||
|  |  | ||||||
|     if (!directory.existsSync()) { |     if (!directory.existsSync()) { | ||||||
|       logger.error('Error: Mods root directory does not exist: $modsPath'); |       // logger.error('Error: Mods root directory does not exist: $modsPath'); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     final List<FileSystemEntity> entities = directory.listSync(); |     final List<FileSystemEntity> entities = directory.listSync(); | ||||||
|     // TODO: Count only the latest version of each mod and not all versions |  | ||||||
|     final List<String> modDirectories = |     final List<String> modDirectories = | ||||||
|         entities.whereType<Directory>().map((dir) => dir.path).toList(); |         entities.whereType<Directory>().map((dir) => dir.path).toList(); | ||||||
|  |  | ||||||
|     logger.info( |     // logger.info( | ||||||
|       'Found ${modDirectories.length} mod directories (${stopwatch.elapsedMilliseconds}ms)', |     //   'Found ${modDirectories.length} mod directories (${stopwatch.elapsedMilliseconds}ms)', | ||||||
|     ); |     // ); | ||||||
|  |  | ||||||
|     for (final modDir in modDirectories) { |     for (final modDir in modDirectories) { | ||||||
|       try { |       try { | ||||||
|         final modStart = stopwatch.elapsedMilliseconds; |         // final modStart = stopwatch.elapsedMilliseconds; | ||||||
|  |  | ||||||
|         // Check if this directory contains a valid mod |         // Check if this directory contains a valid mod | ||||||
|         final aboutFile = File('$modDir/About/About.xml'); |         final aboutFile = File('$modDir/About/About.xml'); | ||||||
|         if (!aboutFile.existsSync()) { |         if (!aboutFile.existsSync()) { | ||||||
|           logger.warning('No About.xml found in directory: $modDir'); |           // logger.warning('No About.xml found in directory: $modDir'); | ||||||
|           continue; |           continue; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         final mod = Mod.fromDirectory(modDir); |         final mod = Mod.fromDirectory(modDir, skipFileCount: skipExistingSizes); | ||||||
|         logger.info('Loaded mod from directory: ${mod.name} (ID: ${mod.id})'); |         // logger.info('Loaded mod from directory: ${mod.name} (ID: ${mod.id})'); | ||||||
|  |  | ||||||
|         if (mods.containsKey(mod.id)) { |         if (mods.containsKey(mod.id)) { | ||||||
|           logger.warning( |           // logger.warning( | ||||||
|             'Mod $mod.id already exists in mods list, overwriting', |           //   'Mod ${mod.id} already exists in mods list, overwriting', | ||||||
|           ); |           // ); | ||||||
|           final existingMod = mods[mod.id]!; |           final existingMod = mods[mod.id]!; | ||||||
|           mods[mod.id] = Mod( |           mods[mod.id] = Mod( | ||||||
|             name: mod.name, |             name: mod.name, | ||||||
| @@ -96,20 +183,20 @@ class ModList { | |||||||
|             isBaseGame: existingMod.isBaseGame, |             isBaseGame: existingMod.isBaseGame, | ||||||
|             isExpansion: existingMod.isExpansion, |             isExpansion: existingMod.isExpansion, | ||||||
|           ); |           ); | ||||||
|           logger.info('Updated existing mod: ${mod.name} (ID: ${mod.id})'); |           // logger.info('Updated existing mod: ${mod.name} (ID: ${mod.id})'); | ||||||
|         } else { |         } else { | ||||||
|           mods[mod.id] = mod; |           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; |         // final modTime = stopwatch.elapsedMilliseconds - modStart; | ||||||
|         logger.info( |         // logger.info( | ||||||
|           'Loaded mod from directory: ${mod.name} (ID: ${mod.id}) in $modTime ms', |         //   'Loaded mod from directory: ${mod.name} (ID: ${mod.id}) in $modTime ms', | ||||||
|         ); |         // ); | ||||||
|         yield mod; |         yield mod; | ||||||
|       } catch (e) { |       } catch (e) { | ||||||
|         logger.error('Error loading mod from directory: $modDir'); |         // logger.error('Error loading mod from directory: $modDir'); | ||||||
|         logger.error('Error: $e'); |         // logger.error('Error: $e'); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -152,42 +239,30 @@ class ModList { | |||||||
|       for (final modElement in modElements) { |       for (final modElement in modElements) { | ||||||
|         final modId = modElement.innerText.toLowerCase(); |         final modId = modElement.innerText.toLowerCase(); | ||||||
|  |  | ||||||
|         // Check if this is a special Ludeon mod |         if (specialMods.containsKey(modId)) { | ||||||
|         final isBaseGame = modId == 'ludeon.rimworld'; |           logger.info('Loading special mod: $modId'); | ||||||
|         final isExpansion = |           mods[modId] = specialMods[modId]!.copyWith(); | ||||||
|             !isBaseGame && |           setEnabled(modId, true); | ||||||
|             modId.startsWith('ludeon.rimworld.') && |           logger.info('Enabled special mod: $modId'); | ||||||
|             knownExpansionIds.contains(modId); |           yield mods[modId]!; | ||||||
|  |           continue; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         final existingMod = mods[modId]; |         final existingMod = mods[modId]; | ||||||
|         final mod = Mod( |         final mod = Mod( | ||||||
|           name: |           name: existingMod?.name ?? modId, | ||||||
|               existingMod?.name ?? |  | ||||||
|               (isBaseGame |  | ||||||
|                   ? "RimWorld" |  | ||||||
|                   : isExpansion |  | ||||||
|                   ? "RimWorld ${_expansionNameFromId(modId)}" |  | ||||||
|                   : modId), |  | ||||||
|           id: existingMod?.id ?? modId, |           id: existingMod?.id ?? modId, | ||||||
|           path: existingMod?.path ?? '', |           path: existingMod?.path ?? '', | ||||||
|           versions: existingMod?.versions ?? [], |           versions: existingMod?.versions ?? [], | ||||||
|           description: |           description: existingMod?.description ?? '', | ||||||
|               existingMod?.description ?? |  | ||||||
|               (isBaseGame |  | ||||||
|                   ? "RimWorld base game" |  | ||||||
|                   : isExpansion |  | ||||||
|                   ? "RimWorld expansion" |  | ||||||
|                   : ""), |  | ||||||
|           dependencies: existingMod?.dependencies ?? [], |           dependencies: existingMod?.dependencies ?? [], | ||||||
|           loadAfter: |           loadAfter: existingMod?.loadAfter ?? [], | ||||||
|               existingMod?.loadAfter ?? |  | ||||||
|               (isExpansion ? ['ludeon.rimworld'] : []), |  | ||||||
|           loadBefore: existingMod?.loadBefore ?? [], |           loadBefore: existingMod?.loadBefore ?? [], | ||||||
|           incompatibilities: existingMod?.incompatibilities ?? [], |           incompatibilities: existingMod?.incompatibilities ?? [], | ||||||
|           enabled: existingMod?.enabled ?? false, |           enabled: existingMod?.enabled ?? false, | ||||||
|           size: existingMod?.size ?? 0, |           size: existingMod?.size ?? 0, | ||||||
|           isBaseGame: isBaseGame, |           isBaseGame: false, | ||||||
|           isExpansion: isExpansion, |           isExpansion: false, | ||||||
|         ); |         ); | ||||||
|         if (mods.containsKey(modId)) { |         if (mods.containsKey(modId)) { | ||||||
|           logger.warning('Mod $modId already exists in mods list, overwriting'); |           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) { |   void setEnabled(String modId, bool enabled) { | ||||||
|     if (mods.containsKey(modId)) { |     if (mods.containsKey(modId)) { | ||||||
|       final mod = mods[modId]!; |       final mod = mods[modId]!; | ||||||
| @@ -244,9 +386,15 @@ class ModList { | |||||||
|     loadOrder ??= LoadOrder(); |     loadOrder ??= LoadOrder(); | ||||||
|     final logger = Logger.instance; |     final logger = Logger.instance; | ||||||
|     logger.info('Generating load order...'); |     logger.info('Generating load order...'); | ||||||
|  |  | ||||||
|     for (final mod in activeMods.values) { |     for (final mod in activeMods.values) { | ||||||
|       logger.info('Checking mod: ${mod.id}'); |       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()}'); |       logger.info('Mod details: ${mod.toString()}'); | ||||||
|       for (final incomp in mod.incompatibilities) { |       for (final incomp in mod.incompatibilities) { | ||||||
|         if (activeMods.containsKey(incomp)) { |         if (activeMods.containsKey(incomp)) { | ||||||
| @@ -554,13 +702,76 @@ class ModList { | |||||||
|   LoadOrder loadRequired([LoadOrder? loadOrder]) { |   LoadOrder loadRequired([LoadOrder? loadOrder]) { | ||||||
|     loadOrder ??= LoadOrder(); |     loadOrder ??= LoadOrder(); | ||||||
|     final toEnable = <String>[]; |     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) { |     for (final modid in activeMods.keys) { | ||||||
|       loadDependencies(modid, loadOrder, toEnable); |       loadDependencies(modid, loadOrder, toEnable); | ||||||
|     } |     } | ||||||
|  |      | ||||||
|  |     // Enable all required dependencies | ||||||
|     for (final modid in toEnable) { |     for (final modid in toEnable) { | ||||||
|       setEnabled(modid, true); |       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]) { |   LoadOrder loadRequiredBaseGame([LoadOrder? loadOrder]) { | ||||||
| @@ -584,12 +795,3 @@ class ModList { | |||||||
|     return loadRequired(loadOrder); |     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 = {}; |   final Set<String> _problemMods = {}; | ||||||
|  |  | ||||||
|   // The currently selected mod IDs (for highlighting) |   // The currently selected mod IDs (for highlighting) | ||||||
|   List<String> _selectedMods = []; |   LoadOrder _loadOrder = LoadOrder(); | ||||||
|  |  | ||||||
|   // The next potential set of mods (from move calculation) |   // The next potential set of mods (from move calculation) | ||||||
|   Move? _nextForwardMove; |   Move? _nextForwardMove; | ||||||
| @@ -64,7 +64,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> { | |||||||
|     // Set initial active mods for highlighting |     // Set initial active mods for highlighting | ||||||
|     if (modManager.activeMods.isNotEmpty) { |     if (modManager.activeMods.isNotEmpty) { | ||||||
|       // Initially select all active mods |       // Initially select all active mods | ||||||
|       _selectedMods = List.from(modManager.activeMods.keys); |       _loadOrder = LoadOrder(modManager.activeMods.values.toList()); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Calculate initial moves |     // Calculate initial moves | ||||||
| @@ -100,7 +100,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> { | |||||||
|  |  | ||||||
|     // Use the mods from the load order result |     // Use the mods from the load order result | ||||||
|     setState(() { |     setState(() { | ||||||
|       _selectedMods = loadOrder.loadOrder; |       _loadOrder = loadOrder; | ||||||
|       _updateNextMoves(); |       _updateNextMoves(); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| @@ -118,7 +118,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> { | |||||||
|  |  | ||||||
|     // Use the mods from the load order result |     // Use the mods from the load order result | ||||||
|     setState(() { |     setState(() { | ||||||
|       _selectedMods = loadOrder.loadOrder; |       _loadOrder = loadOrder; | ||||||
|       _updateNextMoves(); |       _updateNextMoves(); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| @@ -155,7 +155,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> { | |||||||
|  |  | ||||||
|   void _saveTroubleshootingConfig() { |   void _saveTroubleshootingConfig() { | ||||||
|     // Only save if we have a valid selection |     // Only save if we have a valid selection | ||||||
|     if (_selectedMods.isEmpty) { |     if (_loadOrder.order.isEmpty) { | ||||||
|       ScaffoldMessenger.of(context).showSnackBar( |       ScaffoldMessenger.of(context).showSnackBar( | ||||||
|         const SnackBar( |         const SnackBar( | ||||||
|           content: Text('No mods selected to save'), |           content: Text('No mods selected to save'), | ||||||
| @@ -165,26 +165,22 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> { | |||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // First disable all mods |     modManager.saveToConfig(_loadOrder); | ||||||
|     modManager.disableAll(); |  | ||||||
|  |  | ||||||
|     // Then enable only the selected mods |  | ||||||
|     modManager.enableMods(_selectedMods); |  | ||||||
|  |  | ||||||
|     // Save the configuration (we don't have direct access to save method, so show a message) |     // Save the configuration (we don't have direct access to save method, so show a message) | ||||||
|     ScaffoldMessenger.of(context).showSnackBar( |     ScaffoldMessenger.of(context).showSnackBar( | ||||||
|       SnackBar( |       SnackBar( | ||||||
|         content: Text( |         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), |         duration: const Duration(seconds: 4), | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _markSelectedAsGood() { |   void _markSelectedAsGood() { | ||||||
|     if (_selectedMods.isEmpty) { |     if (_loadOrder.order.isEmpty) { | ||||||
|       ScaffoldMessenger.of(context).showSnackBar( |       ScaffoldMessenger.of(context).showSnackBar( | ||||||
|         const SnackBar( |         const SnackBar( | ||||||
|           content: Text('No mods selected to mark'), |           content: Text('No mods selected to mark'), | ||||||
| @@ -195,15 +191,15 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     setState(() { |     setState(() { | ||||||
|       for (final modId in _selectedMods) { |       for (final mod in _loadOrder.order) { | ||||||
|         _checkedMods.add(modId); |         _checkedMods.add(mod.id); | ||||||
|         _problemMods.remove(modId); |         _problemMods.remove(mod.id); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     ScaffoldMessenger.of(context).showSnackBar( |     ScaffoldMessenger.of(context).showSnackBar( | ||||||
|       SnackBar( |       SnackBar( | ||||||
|         content: Text('Marked ${_selectedMods.length} mods as good'), |         content: Text('Marked ${_loadOrder.order.length} mods as good'), | ||||||
|         backgroundColor: Colors.green, |         backgroundColor: Colors.green, | ||||||
|         duration: const Duration(seconds: 2), |         duration: const Duration(seconds: 2), | ||||||
|       ), |       ), | ||||||
| @@ -211,7 +207,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _markSelectedAsProblem() { |   void _markSelectedAsProblem() { | ||||||
|     if (_selectedMods.isEmpty) { |     if (_loadOrder.order.isEmpty) { | ||||||
|       ScaffoldMessenger.of(context).showSnackBar( |       ScaffoldMessenger.of(context).showSnackBar( | ||||||
|         const SnackBar( |         const SnackBar( | ||||||
|           content: Text('No mods selected to mark'), |           content: Text('No mods selected to mark'), | ||||||
| @@ -222,15 +218,15 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     setState(() { |     setState(() { | ||||||
|       for (final modId in _selectedMods) { |       for (final mod in _loadOrder.order) { | ||||||
|         _problemMods.add(modId); |         _problemMods.add(mod.id); | ||||||
|         _checkedMods.remove(modId); |         _checkedMods.remove(mod.id); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     ScaffoldMessenger.of(context).showSnackBar( |     ScaffoldMessenger.of(context).showSnackBar( | ||||||
|       SnackBar( |       SnackBar( | ||||||
|         content: Text('Marked ${_selectedMods.length} mods as problematic'), |         content: Text('Marked ${_loadOrder.order.length} mods as problematic'), | ||||||
|         backgroundColor: Colors.orange, |         backgroundColor: Colors.orange, | ||||||
|         duration: const Duration(seconds: 2), |         duration: const Duration(seconds: 2), | ||||||
|       ), |       ), | ||||||
| @@ -307,8 +303,8 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> { | |||||||
|                 // Compact instruction |                 // Compact instruction | ||||||
|                 Expanded( |                 Expanded( | ||||||
|                   child: Text( |                   child: Text( | ||||||
|                     _selectedMods.isNotEmpty |                     _loadOrder.order.isNotEmpty | ||||||
|                         ? 'Testing ${_selectedMods.length} mods. Tap highlighted mods to navigate. Mark results below:' |                         ? 'Testing ${_loadOrder.order.length} mods. Tap highlighted mods to navigate. Mark results below:' | ||||||
|                         : 'Click highlighted mods to begin testing. Blue→forward, purple←backward.', |                         : 'Click highlighted mods to begin testing. Blue→forward, purple←backward.', | ||||||
|                     style: TextStyle( |                     style: TextStyle( | ||||||
|                       fontSize: AppThemeExtension.of(context).textSizeRegular, |                       fontSize: AppThemeExtension.of(context).textSizeRegular, | ||||||
| @@ -379,7 +375,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> { | |||||||
|                 const Spacer(), |                 const Spacer(), | ||||||
|  |  | ||||||
|                 // Buttons to mark selected mods |                 // Buttons to mark selected mods | ||||||
|                 if (_selectedMods.isNotEmpty) ...[ |                 if (_loadOrder.order.isNotEmpty) ...[ | ||||||
|                   OutlinedButton.icon( |                   OutlinedButton.icon( | ||||||
|                     icon: Icon( |                     icon: Icon( | ||||||
|                       Icons.error, |                       Icons.error, | ||||||
| @@ -427,7 +423,7 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> { | |||||||
|                   onPressed: _resetTroubleshooter, |                   onPressed: _resetTroubleshooter, | ||||||
|                 ), |                 ), | ||||||
|  |  | ||||||
|                 if (_selectedMods.isNotEmpty) ...[ |                 if (_loadOrder.order.isNotEmpty) ...[ | ||||||
|                   const SizedBox(width: 4), |                   const SizedBox(width: 4), | ||||||
|                   // Save config button |                   // Save config button | ||||||
|                   OutlinedButton.icon( |                   OutlinedButton.icon( | ||||||
| @@ -492,7 +488,9 @@ class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> { | |||||||
|                 if (mod == null) return const SizedBox.shrink(); |                 if (mod == null) return const SizedBox.shrink(); | ||||||
|  |  | ||||||
|                 // Determine if this mod is in the selection range for highlighted navigation |                 // 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 |                 // Check if this mod would be included in the next Forward/Backward move | ||||||
|                 bool isInNextForward = false; |                 bool isInNextForward = false; | ||||||
|   | |||||||
| @@ -6,6 +6,10 @@ | |||||||
|  |  | ||||||
| #include "generated_plugin_registrant.h" | #include "generated_plugin_registrant.h" | ||||||
|  |  | ||||||
|  | #include <url_launcher_linux/url_launcher_plugin.h> | ||||||
|  |  | ||||||
| void fl_register_plugins(FlPluginRegistry* registry) { | 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 | list(APPEND FLUTTER_PLUGIN_LIST | ||||||
|  |   url_launcher_linux | ||||||
| ) | ) | ||||||
|  |  | ||||||
| list(APPEND FLUTTER_FFI_PLUGIN_LIST | list(APPEND FLUTTER_FFI_PLUGIN_LIST | ||||||
|   | |||||||
| @@ -5,6 +5,8 @@ | |||||||
| import FlutterMacOS | import FlutterMacOS | ||||||
| import Foundation | import Foundation | ||||||
|  |  | ||||||
|  | import url_launcher_macos | ||||||
|  |  | ||||||
| func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { | 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" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.0.6" |     version: "3.0.6" | ||||||
|  |   csslib: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: csslib | ||||||
|  |       sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" | ||||||
|  |       url: "https://pub.dev" | ||||||
|  |     source: hosted | ||||||
|  |     version: "1.0.2" | ||||||
|   cupertino_icons: |   cupertino_icons: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -118,6 +126,14 @@ packages: | |||||||
|     description: flutter |     description: flutter | ||||||
|     source: sdk |     source: sdk | ||||||
|     version: "0.0.0" |     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: |   flutter_lints: | ||||||
|     dependency: "direct dev" |     dependency: "direct dev" | ||||||
|     description: |     description: | ||||||
| @@ -126,11 +142,24 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "5.0.0" |     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: |   flutter_test: | ||||||
|     dependency: "direct dev" |     dependency: "direct dev" | ||||||
|     description: flutter |     description: flutter | ||||||
|     source: sdk |     source: sdk | ||||||
|     version: "0.0.0" |     version: "0.0.0" | ||||||
|  |   flutter_web_plugins: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: flutter | ||||||
|  |     source: sdk | ||||||
|  |     version: "0.0.0" | ||||||
|   frontend_server_client: |   frontend_server_client: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -147,6 +176,14 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.3" |     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: |   http_multi_server: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -219,6 +256,14 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "5.1.1" |     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: |   logging: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -227,6 +272,14 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.3.0" |     version: "1.3.0" | ||||||
|  |   markdown: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: markdown | ||||||
|  |       sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" | ||||||
|  |       url: "https://pub.dev" | ||||||
|  |     source: hosted | ||||||
|  |     version: "7.3.0" | ||||||
|   matcher: |   matcher: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -291,6 +344,14 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "6.1.0" |     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: |   pool: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -432,6 +493,70 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.4.0" |     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: |   vector_math: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -506,4 +631,4 @@ packages: | |||||||
|     version: "3.1.3" |     version: "3.1.3" | ||||||
| sdks: | sdks: | ||||||
|   dart: ">=3.7.2 <4.0.0" |   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 |   xml: ^6.5.0 | ||||||
|   intl: ^0.20.2 |   intl: ^0.20.2 | ||||||
|   path: ^1.9.1 |   path: ^1.9.1 | ||||||
|  |   flutter_markdown: ^0.6.20 | ||||||
|  |   flutter_html: ^3.0.0-beta.2 | ||||||
|  |   url_launcher: ^6.3.1 | ||||||
|  |  | ||||||
| dev_dependencies: | dev_dependencies: | ||||||
|   flutter_test: |   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 = [ |     final expected = [ | ||||||
|       'brrainz.harmony', |       'brrainz.harmony', | ||||||
|       'ludeon.rimworld', |       'ludeon.rimworld', | ||||||
|       'ludeon.rimworld.anomaly', |  | ||||||
|       'ludeon.rimworld.biotech', |  | ||||||
|       'ludeon.rimworld.ideology', |  | ||||||
|       'ludeon.rimworld.royalty', |       'ludeon.rimworld.royalty', | ||||||
|  |       'ludeon.rimworld.ideology', | ||||||
|  |       'ludeon.rimworld.biotech', | ||||||
|  |       'ludeon.rimworld.anomaly', | ||||||
|       'dubwise.rimatomics', |       'dubwise.rimatomics', | ||||||
|       'jecrell.doorsexpanded', |       'jecrell.doorsexpanded', | ||||||
|       'dubwise.rimefeller', |       'dubwise.rimefeller', | ||||||
| @@ -304,10 +304,10 @@ void main() { | |||||||
|       'brrainz.harmony', |       'brrainz.harmony', | ||||||
|       'ludeon.rimworld', |       'ludeon.rimworld', | ||||||
|       'bs.betterlog', |       'bs.betterlog', | ||||||
|       'ludeon.rimworld.anomaly', |  | ||||||
|       'ludeon.rimworld.royalty', |       'ludeon.rimworld.royalty', | ||||||
|       'ludeon.rimworld.ideology', |       'ludeon.rimworld.ideology', | ||||||
|       'ludeon.rimworld.biotech', |       'ludeon.rimworld.biotech', | ||||||
|  |       'ludeon.rimworld.anomaly', | ||||||
|     ]; |     ]; | ||||||
|     expect(order.loadOrder, equals(expected)); |     expect(order.loadOrder, equals(expected)); | ||||||
|   }); |   }); | ||||||
| @@ -712,10 +712,10 @@ void main() { | |||||||
|       'brrainz.harmony', |       'brrainz.harmony', | ||||||
|       'ludeon.rimworld', |       'ludeon.rimworld', | ||||||
|       'bs.betterlog', |       'bs.betterlog', | ||||||
|       'ludeon.rimworld.anomaly', |  | ||||||
|       'ludeon.rimworld.biotech', |  | ||||||
|       'ludeon.rimworld.ideology', |  | ||||||
|       'ludeon.rimworld.royalty', |       'ludeon.rimworld.royalty', | ||||||
|  |       'ludeon.rimworld.ideology', | ||||||
|  |       'ludeon.rimworld.biotech', | ||||||
|  |       'ludeon.rimworld.anomaly', | ||||||
|       'rah.rbse', |       'rah.rbse', | ||||||
|       'mlie.usethisinstead', |       'mlie.usethisinstead', | ||||||
|       'dubwise.rimatomics', |       'dubwise.rimatomics', | ||||||
|   | |||||||
| @@ -182,6 +182,38 @@ void main() { | |||||||
|       expect(result.errors, isEmpty); |       expect(result.errors, isEmpty); | ||||||
|       expect(result.loadOrder, equals(expected)); |       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', () { |   group('Test loadRequired', () { | ||||||
|   | |||||||
| @@ -6,6 +6,9 @@ | |||||||
|  |  | ||||||
| #include "generated_plugin_registrant.h" | #include "generated_plugin_registrant.h" | ||||||
|  |  | ||||||
|  | #include <url_launcher_windows/url_launcher_windows.h> | ||||||
|  |  | ||||||
| void RegisterPlugins(flutter::PluginRegistry* registry) { | void RegisterPlugins(flutter::PluginRegistry* registry) { | ||||||
|  |   UrlLauncherWindowsRegisterWithRegistrar( | ||||||
|  |       registry->GetRegistrarForPlugin("UrlLauncherWindows")); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ | |||||||
| # | # | ||||||
|  |  | ||||||
| list(APPEND FLUTTER_PLUGIN_LIST | list(APPEND FLUTTER_PLUGIN_LIST | ||||||
|  |   url_launcher_windows | ||||||
| ) | ) | ||||||
|  |  | ||||||
| list(APPEND FLUTTER_FFI_PLUGIN_LIST | 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)); |   project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); | ||||||
|  |  | ||||||
|   FlutterWindow window(project); |   FlutterWindow window(project); | ||||||
|   Win32Window::Point origin(10, 10); |   Win32Window::Size size(1920, 1080); | ||||||
|   Win32Window::Size size(1280, 720); |   Win32Window::Point origin((GetSystemMetrics(SM_CXSCREEN) - size.width) / 2, | ||||||
|  |                             (GetSystemMetrics(SM_CYSCREEN) - size.height) / 2); | ||||||
|   if (!window.Create(L"rimworld_modman", origin, size)) { |   if (!window.Create(L"rimworld_modman", origin, size)) { | ||||||
|     return EXIT_FAILURE; |     return EXIT_FAILURE; | ||||||
|   } |   } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user