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, + ); + }); + }); }