From 2640774553a286c2266a34c11b0e2e1d266297c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:44:53 +0000 Subject: [PATCH] Merge a profile into your enabled mods Profiles could only be switched to, which disables everything the profile doesn't list. Merging instead takes a profile's mod list and turns on whatever isn't already on, leaving the rest of the setup alone. A mod that already has a version enabled is skipped entirely, so having several versions installed can't change which one is active. Nothing is ever disabled and no version is swapped. Mods that do get newly enabled use their latest installed version rather than the version the profile happens to have saved. Merging doesn't change which profile is tracked. It edits your enabled mods the same way toggling them by hand does, so a tracked profile reads as modified afterwards. The confirmation dialog lists what will be enabled, says how many mods were already on, calls out the ones staying on a different version than the profile saved, and reuses the existing missing-mods section for mods that aren't installed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DTwygsuLYtbnuZjFw87iHb --- lib/mod_profiles/mod_profile_card.dart | 19 + lib/mod_profiles/mod_profiles_manager.dart | 431 +++++++++++++++++++-- lib/widgets/overflow_menu_button.dart | 28 +- test/mod_profiles_test.dart | 177 +++++++++ 4 files changed, 625 insertions(+), 30 deletions(-) diff --git a/lib/mod_profiles/mod_profile_card.dart b/lib/mod_profiles/mod_profile_card.dart index 8b605ced..a79765a6 100644 --- a/lib/mod_profiles/mod_profile_card.dart +++ b/lib/mod_profiles/mod_profile_card.dart @@ -430,6 +430,25 @@ class _ModProfileCardState extends ConsumerState { ); }, ).toEntry(0), + if (!isSaveGame) + OverflowMenuItem( + title: 'Merge into your enabled mods', + subtitle: + "Turns on this profile's mods that you" + " don't already have on. Nothing else" + " changes.", + icon: Icons.merge_type, + enabled: + !isGameRunning && !trackedStatus.isLoading, + tooltip: isGameRunning + ? "Game is running" + : null, + onTap: () { + ref + .read(modProfilesProvider.notifier) + .showMergeDialog(profile!, context); + }, + ).toEntry(1), ], ), if (!isSaveGame) diff --git a/lib/mod_profiles/mod_profiles_manager.dart b/lib/mod_profiles/mod_profiles_manager.dart index fecf8608..090f3639 100644 --- a/lib/mod_profiles/mod_profiles_manager.dart +++ b/lib/mod_profiles/mod_profiles_manager.dart @@ -448,6 +448,88 @@ class ModProfileManagerNotifier return toVariantAlternate; } + /// Computes what merging [profile] into the currently enabled mods would do. + /// + /// A merge only turns mods on. Any mod that already has a version enabled is + /// left exactly as it is, so having several versions of a mod installed can + /// never change which one is active. Nothing is disabled and no version is + /// swapped. Mods that aren't on yet are turned on at their latest installed + /// version, not at the version [profile] happens to have saved. + /// + /// Returns one change per mod in the profile: [ModChangeType.enable] for a + /// mod that will be turned on, [ModChangeType.skip] for one already on, and + /// [ModChangeType.missingMod] for one that isn't installed. + static List computeModProfileMergeChanges( + ModProfile profile, + List allMods, + List currentlyEnabledModVariants, + ) { + final modsById = {for (final mod in allMods) mod.id: mod}; + final enabledVariantsByModId = { + for (final variant in currentlyEnabledModVariants) + variant.modInfo.id: variant, + }; + + final changes = []; + final handledModIds = {}; + + for (final profileVariant in profile.enabledModVariants) { + final modId = profileVariant.modId; + // A profile shouldn't list the same mod twice, but if it does, the first + // entry wins rather than producing two rows for one mod. + if (!handledModIds.add(modId)) continue; + + final mod = modsById[modId]; + final alreadyEnabledVariant = enabledVariantsByModId[modId]; + + if (alreadyEnabledVariant != null) { + // Already on. Leave it alone, whichever version that happens to be. + changes.add( + ModChange( + modId: modId, + mod: mod, + fromVariant: alreadyEnabledVariant, + toVariant: null, + variantAsShallowMod: profileVariant, + toVariantAlternate: null, + changeType: ModChangeType.skip, + ), + ); + continue; + } + + final toVariant = mod?.findHighestVersion; + if (mod == null || toVariant == null) { + changes.add( + ModChange( + modId: modId, + mod: mod, + fromVariant: null, + toVariant: null, + variantAsShallowMod: profileVariant, + toVariantAlternate: null, + changeType: ModChangeType.missingMod, + ), + ); + continue; + } + + changes.add( + ModChange( + modId: modId, + mod: mod, + fromVariant: null, + toVariant: toVariant, + variantAsShallowMod: profileVariant, + toVariantAlternate: null, + changeType: ModChangeType.enable, + ), + ); + } + + return changes; + } + /// Enables exactly the mods saved in [modProfileId] and tracks that profile. /// /// Pass [allowReapply] to run even when the profile is already tracked. That @@ -539,6 +621,94 @@ class ModProfileManagerNotifier } } + /// Turns on every mod in [modProfileId] that isn't already enabled, leaving + /// the rest of the current setup alone. + /// + /// Mods that already have a version enabled are skipped entirely, so a merge + /// never changes which version of an already-on mod is active. Newly enabled + /// mods get their latest installed version. Nothing is ever disabled. + /// + /// Which profile is tracked doesn't change. A merge edits your enabled mods + /// the same way toggling mods by hand does, so a tracked profile shows up as + /// modified afterwards. + Future mergeModProfile(String modProfileId) async { + Fimber.i("Merging mod profile $modProfileId into the enabled mods."); + final modVariantsNotifier = ref.read(AppState.modVariants.notifier); + final modManagerNotifier = ref.read(modManager.notifier); + var didChangeAnything = false; + + try { + final profile = state.value?.modProfiles.firstWhereOrNull( + (profile) => profile.id == modProfileId, + ); + if (profile == null) { + Fimber.w("Profile $modProfileId not found."); + return; + } + + final allMods = ref.read(AppState.mods); + final currentlyEnabledModVariants = ref.read(AppState.enabledModVariants); + final modsToEnable = + computeModProfileMergeChanges( + profile, + allMods, + currentlyEnabledModVariants, + ) + .where((change) => change.changeType == ModChangeType.enable) + .toList(); + + if (modsToEnable.isEmpty) { + Fimber.i( + "Nothing to merge from '${profile.name}'; every mod in it is" + " already enabled or isn't installed.", + ); + return; + } + + modVariantsNotifier.shouldAutomaticallyReloadOnFilesChanged = false; + isChangingModProfileProvider = true; + didChangeAnything = true; + + for (final change in modsToEnable) { + final mod = change.mod; + if (mod == null) { + Fimber.w("Mod not found for change ${change.toVariant?.smolId}."); + continue; + } + + Fimber.d( + "Merge: enabling ${mod.id} at ${change.toVariant?.smolId}.", + ); + // Should check for game version, but we don't have a WidgetRef here. + await modManagerNotifier.changeActiveModVariant( + mod, + change.toVariant, + notifyWatchers: false, + validateDependencies: false, + ); + } + + await modManagerNotifier.validateModDependencies(); + Fimber.i( + "Merged ${modsToEnable.length} mods from '${profile.name}' into the" + " enabled mods.", + ); + } catch (e, stack) { + Fimber.e( + "Failed to merge mod profile $modProfileId.", + ex: e, + stacktrace: stack, + ); + } finally { + if (didChangeAnything) { + modVariantsNotifier.shouldAutomaticallyReloadOnFilesChanged = true; + isChangingModProfileProvider = false; + // Reload all just in case. + await modVariantsNotifier.reloadModVariants(); + } + } + } + /// Replaces [profileId]'s saved mods with whatever is enabled right now. /// /// Returns false if the profile is gone or the write failed, so callers that @@ -659,6 +829,229 @@ class ModProfileManagerNotifier ); } + /// Asks whether to merge [profile] into the enabled mods, then merges. + /// + /// Unlike activating, this leaves the tracked profile and everything already + /// enabled alone. See [mergeModProfile]. + void showMergeDialog(ModProfile profile, BuildContext context) { + if (!context.mounted) return; + if (readTrackedProfileStatus().isLoading) return; + + final allMods = ref.read(AppState.mods); + final currentlyEnabledModVariants = ref.read(AppState.enabledModVariants); + final changes = computeModProfileMergeChanges( + profile, + allMods, + currentlyEnabledModVariants, + ); + + final modsToEnable = changes + .where((change) => change.changeType == ModChangeType.enable) + .toList(); + final modsLeftAlone = changes + .where((change) => change.changeType == ModChangeType.skip) + .toList(); + final missingMods = changes + .where((change) => change.changeType == ModChangeType.missingMod) + .toList(); + + // Mods already on at some version other than the one the profile saved. + // The merge keeps what's on, which is the part worth spelling out. Profiles + // saved without a version can't be compared, so they aren't called out. + final keptAtOtherVersion = modsLeftAlone + .where( + (change) => + change.variantAsShallowMod?.version != null && + change.fromVariant?.modInfo.version != + change.variantAsShallowMod?.version, + ) + .toList(); + + final modIconsById = Map.fromEntries( + allMods + .map((mod) => mod.findHighestVersion) + .nonNulls + .map((variant) => MapEntry(variant.modInfo.id, variant.iconFilePath)), + ); + + showDialog( + context: context, + builder: (context) { + final theme = Theme.of(context); + final iconColor = theme.iconTheme.color?.withOpacity(0.8); + final hasNothingToEnable = modsToEnable.isEmpty; + + return AlertDialog( + title: Text("Merge '${profile.name}' into your mods?"), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Merging only turns mods on. Nothing is disabled, and mods" + " you already have on keep the version they're on now.", + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + if (modsToEnable.isNotEmpty) ...[ + Text( + "Mods Being Enabled", + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + _buildChangeSection( + null, + "Enabling mod at its latest installed version", + modsToEnable, + Icons.check, + iconColor, + modIconsById, + context, + ), + const SizedBox(height: 16), + ], + if (modsLeftAlone.isNotEmpty) ...[ + Text( + "Already Enabled", + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + modsLeftAlone.length == 1 + ? "1 mod in this profile is already enabled and stays" + " exactly as it is." + : "${modsLeftAlone.length} mods in this profile are" + " already enabled and stay exactly as they are.", + style: theme.textTheme.bodyMedium, + ), + if (keptAtOtherVersion.isNotEmpty) ...[ + const SizedBox(height: 8), + _buildKeptVersionsSection( + keptAtOtherVersion, + iconColor, + modIconsById, + context, + ), + ], + const SizedBox(height: 16), + ], + if (missingMods.isNotEmpty) + _buildMissingModsSection( + missingMods, + const [], + iconColor, + modIconsById, + context, + ), + if (changes.isEmpty) + Text( + "'${profile.name}' has no mods saved in it, so there's" + " nothing to merge.", + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(hasNothingToEnable ? 'Close' : 'Cancel'), + ), + if (!hasNothingToEnable) + TextButton.icon( + onPressed: () { + Navigator.of(context).pop(); + mergeModProfile(profile.id); + }, + icon: missingMods.isNotEmpty ? const Icon(Icons.warning) : null, + label: Text( + missingMods.isNotEmpty + ? 'Merge (ignore missing mods)' + : 'Merge', + ), + ), + ], + ); + }, + ); + } + + /// Rows for mods a merge leaves alone because they're already enabled at a + /// version other than the one the profile saved. + Widget _buildKeptVersionsSection( + List keptAtOtherVersion, + Color? iconColor, + Map modIconsById, + BuildContext context, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: keptAtOtherVersion.map((change) { + final modName = + change.fromVariant?.modInfo.nameOrId ?? + change.variantAsShallowMod?.nameOrId ?? + change.modId; + final keptVersion = + change.fromVariant?.modInfo.version?.toString() ?? 'Unknown'; + final profileVersion = + change.variantAsShallowMod?.version?.toString() ?? 'Unknown'; + + return _buildChangeRow( + change: change, + text: + '$modName stays on $keptVersion' + ' (this profile has $profileVersion).', + icon: Icons.remove, + tooltip: "Already enabled, so it's left alone", + iconColor: iconColor, + modIconsById: modIconsById, + context: context, + ); + }).toList(), + ); + } + + /// One row of a change list: a change-type icon, the mod's icon, and text. + Widget _buildChangeRow({ + required ModChange change, + required String text, + required IconData icon, + required String? tooltip, + required Color? iconColor, + required Map modIconsById, + required BuildContext context, + }) { + final theme = Theme.of(context); + return Padding( + padding: const .symmetric(vertical: 2), + child: TextWithIcon( + leading: MovingTooltipWidget.text( + message: tooltip, + child: Icon(icon, color: iconColor, size: 20), + ), + widget: TextWithIcon( + leading: modIconsById[change.modId] != null + ? Image.file(modIconsById[change.modId]!.toFile(), width: 20) + : null, + text: text, + style: GoogleFonts.roboto( + textStyle: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + ), + ), + ); + } + void _showProfileChangeDialog( ModProfile profile, BuildContext context, { @@ -948,29 +1341,15 @@ class ModProfileManagerNotifier String text, IconData icon, String tooltip, - ) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: TextWithIcon( - leading: MovingTooltipWidget.text( - message: tooltip, - child: Icon(icon, color: iconColor, size: 20), - ), - widget: TextWithIcon( - leading: modIconsById[change.modId] != null - ? Image.file(modIconsById[change.modId]!.toFile(), width: 20) - : null, - text: text, - style: GoogleFonts.roboto( - textStyle: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 14, - ), - ), - ), - ), - ); - } + ) => _buildChangeRow( + change: change, + text: text, + icon: icon, + tooltip: tooltip, + iconColor: iconColor, + modIconsById: modIconsById, + context: context, + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1074,7 +1453,11 @@ class ModProfileManagerNotifier } } -enum ModChangeType { enable, disable, swap, missingMod, missingVariant } +/// What applying a profile would do to one mod. +/// +/// [skip] only comes out of [ModProfileManagerNotifier.computeModProfileMergeChanges]: +/// the mod is already enabled, so a merge leaves it and its active version alone. +enum ModChangeType { enable, disable, swap, missingMod, missingVariant, skip } class ModChange { final String modId; diff --git a/lib/widgets/overflow_menu_button.dart b/lib/widgets/overflow_menu_button.dart index b600b95b..204ebfda 100644 --- a/lib/widgets/overflow_menu_button.dart +++ b/lib/widgets/overflow_menu_button.dart @@ -44,23 +44,39 @@ class OverflowMenuItem { final VoidCallback onTap; final String? subtitle; + /// When false, the row is greyed out and can't be tapped. + final bool enabled; + + /// Shown on hover, e.g. to say why the row is greyed out. + final String? tooltip; + const OverflowMenuItem({ required this.title, required this.icon, required this.onTap, this.subtitle, + this.enabled = true, + this.tooltip, }); - PopupMenuEntry toEntry(int? key) => PopupMenuItem( - value: key, - onTap: onTap, - child: ListTile( + PopupMenuEntry toEntry(int? key) { + final content = ListTile( dense: true, + enabled: enabled, leading: Icon(icon), title: Text(title), subtitle: subtitle != null ? Text(subtitle!) : null, - ), - ); + ); + + return PopupMenuItem( + value: key, + enabled: enabled, + onTap: enabled ? onTap : null, + child: tooltip != null + ? MovingTooltipWidget.text(message: tooltip!, child: content) + : content, + ); + } } /// Menu item that renders as a [CheckedPopupMenuItem] with a checkmark. diff --git a/test/mod_profiles_test.dart b/test/mod_profiles_test.dart index 6971f94c..387ec46d 100644 --- a/test/mod_profiles_test.dart +++ b/test/mod_profiles_test.dart @@ -544,4 +544,181 @@ void main() { expect(missingVariant.modId, 'modE'); }); }); + + group('computeModProfileMergeChanges', () { + final v100 = Version.parse("1.0.0", sanitizeInput: true); + final v110 = Version.parse("1.1.0", sanitizeInput: true); + final v200 = Version.parse("2.0.0", sanitizeInput: true); + + ModVariant variantOf(String modId, Version version) => ModVariant( + modInfo: ModInfo(id: modId, version: version), + versionCheckerInfo: null, + modFolder: Directory(''), + hasNonBrickedModInfo: true, + gameCoreFolder: Directory(''), + ); + + Mod modOf(String modId, List installedVersions) => Mod( + id: modId, + isEnabledInGame: false, + modVariants: installedVersions + .map((version) => variantOf(modId, version)) + .toList(), + ); + + ModProfile profileOf(List enabledModVariants) => + ModProfile( + id: 'profile1', + name: 'Profile 1', + description: '', + sortOrder: 1, + enabledModVariants: enabledModVariants, + ); + + ShallowModVariant memberOf(String modId, Version version) => + ShallowModVariant( + modId: modId, + smolVariantId: createSmolId(modId, version), + version: version, + ); + + test('enables a profile mod that is not currently enabled', () { + final changes = ModProfileManagerNotifier.computeModProfileMergeChanges( + profileOf([memberOf('modA', v100)]), + [modOf('modA', [v100])], + [], + ); + + expect(changes, hasLength(1)); + expect(changes.first.changeType, ModChangeType.enable); + expect(changes.first.modId, 'modA'); + expect(changes.first.toVariant?.modInfo.version, v100); + }); + + test('newly enabled mods use the latest installed version, not the ' + 'version the profile saved', () { + final changes = ModProfileManagerNotifier.computeModProfileMergeChanges( + // Profile saved 1.0.0, but 2.0.0 is also installed. + profileOf([memberOf('modA', v100)]), + [modOf('modA', [v100, v200])], + [], + ); + + expect(changes, hasLength(1)); + expect(changes.first.changeType, ModChangeType.enable); + expect(changes.first.toVariant?.modInfo.version, v200); + }); + + test('leaves an already-enabled mod alone even when the profile wants a ' + 'different installed version', () { + final changes = ModProfileManagerNotifier.computeModProfileMergeChanges( + // Profile wants 2.0.0; 1.0.0 is the one that's on. + profileOf([memberOf('modA', v200)]), + [modOf('modA', [v100, v200])], + [variantOf('modA', v100)], + ); + + expect(changes, hasLength(1)); + expect(changes.first.changeType, ModChangeType.skip); + expect(changes.first.modId, 'modA'); + // The version that's on stays on, and nothing is queued to change it. + expect(changes.first.fromVariant?.modInfo.version, v100); + expect(changes.first.toVariant, isNull); + expect( + changes.where((c) => c.changeType == ModChangeType.swap), + isEmpty, + ); + }); + + test('never disables a mod that is on but missing from the profile', () { + final changes = ModProfileManagerNotifier.computeModProfileMergeChanges( + profileOf([memberOf('modA', v100)]), + [modOf('modA', [v100]), modOf('modB', [v100])], + [variantOf('modB', v100)], + ); + + // modB isn't in the profile, so the merge says nothing about it at all. + expect(changes, hasLength(1)); + expect(changes.first.modId, 'modA'); + expect( + changes.where((c) => c.changeType == ModChangeType.disable), + isEmpty, + ); + }); + + test('reports a profile mod that is not installed as missing', () { + final changes = ModProfileManagerNotifier.computeModProfileMergeChanges( + profileOf([memberOf('modA', v100)]), + [], + [], + ); + + expect(changes, hasLength(1)); + expect(changes.first.changeType, ModChangeType.missingMod); + expect(changes.first.modId, 'modA'); + expect(changes.first.toVariant, isNull); + }); + + test('produces no changes when the profile is empty', () { + final changes = ModProfileManagerNotifier.computeModProfileMergeChanges( + profileOf([]), + [modOf('modA', [v100])], + [variantOf('modA', v100)], + ); + + expect(changes, isEmpty); + }); + + test('handles a mix of enable, skip, and missing in one profile', () { + // modA: off, two versions installed -> enable at the newest, 2.0.0. + // modB: on at 1.0.0, profile wants 1.1.0 -> skip, stays on 1.0.0. + // modC: on at the same version the profile wants -> skip. + // modD: not installed -> missing. + final changes = ModProfileManagerNotifier.computeModProfileMergeChanges( + profileOf([ + memberOf('modA', v100), + memberOf('modB', v110), + memberOf('modC', v100), + memberOf('modD', v100), + ]), + [ + modOf('modA', [v100, v200]), + modOf('modB', [v100, v110]), + modOf('modC', [v100]), + ], + [variantOf('modB', v100), variantOf('modC', v100)], + ); + + expect(changes, hasLength(4)); + + final enabled = changes + .where((c) => c.changeType == ModChangeType.enable) + .toList(); + expect(enabled, hasLength(1)); + expect(enabled.first.modId, 'modA'); + expect(enabled.first.toVariant?.modInfo.version, v200); + + final skipped = changes + .where((c) => c.changeType == ModChangeType.skip) + .map((c) => c.modId) + .toList(); + expect(skipped, containsAll(['modB', 'modC'])); + + final missing = changes + .where((c) => c.changeType == ModChangeType.missingMod) + .toList(); + expect(missing, hasLength(1)); + expect(missing.first.modId, 'modD'); + + // A merge only ever turns mods on. + expect( + changes.where( + (c) => + c.changeType == ModChangeType.disable || + c.changeType == ModChangeType.swap, + ), + isEmpty, + ); + }); + }); }