diff --git a/lib/providers/folder_provider.dart b/lib/providers/folder_provider.dart index 07264d0c..0af9bb81 100644 --- a/lib/providers/folder_provider.dart +++ b/lib/providers/folder_provider.dart @@ -182,7 +182,8 @@ class FolderProvider extends Notifier { List idsToDelete = []; for (final strategy in strategyList) { - if (strategy.folderID == folderID) { + if (strategy.folderID == folderID && + !StrategyProvider.isTemporaryStrategyId(strategy.id)) { idsToDelete.add(strategy.id); } } diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 20ddf3cf..d9c93a95 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -165,6 +165,8 @@ class StrategyState { required this.id, required this.storageDirectory, this.activePageId, + this.sessionKind = StrategySessionKind.saved, + this.sourceStrategyId, }); final bool isSaved; @@ -172,6 +174,12 @@ class StrategyState { final String id; final String? storageDirectory; final String? activePageId; + final StrategySessionKind sessionKind; + final String? sourceStrategyId; + + bool get isTemporarySession => sessionKind != StrategySessionKind.saved; + bool get isQuickBoard => sessionKind == StrategySessionKind.quickBoard; + bool get isTemporaryCopy => sessionKind == StrategySessionKind.temporaryCopy; StrategyState copyWith({ bool? isSaved, @@ -179,7 +187,10 @@ class StrategyState { String? id, String? storageDirectory, String? activePageId, + StrategySessionKind? sessionKind, + String? sourceStrategyId, bool clearActivePageId = false, + bool clearSourceStrategyId = false, }) { return StrategyState( isSaved: isSaved ?? this.isSaved, @@ -188,10 +199,20 @@ class StrategyState { storageDirectory: storageDirectory ?? this.storageDirectory, activePageId: clearActivePageId ? null : (activePageId ?? this.activePageId), + sessionKind: sessionKind ?? this.sessionKind, + sourceStrategyId: clearSourceStrategyId + ? null + : (sourceStrategyId ?? this.sourceStrategyId), ); } } +enum StrategySessionKind { + saved, + quickBoard, + temporaryCopy, +} + final strategyProvider = NotifierProvider(StrategyProvider.new); @@ -218,16 +239,22 @@ class NewerVersionImportException implements Exception { } class StrategyProvider extends Notifier { + static const String temporaryStrategyIdPrefix = '_temp_'; String? activePageID; @override StrategyState build() { + Future.microtask(() async { + await cleanUpOrphanedTemporaryStrategies(); + }); return StrategyState( isSaved: false, stratName: null, id: "testID", storageDirectory: null, activePageId: null, + sessionKind: StrategySessionKind.saved, + sourceStrategyId: null, ); } @@ -236,6 +263,25 @@ class StrategyProvider extends Notifier { bool _saveInProgress = false; bool _pendingSave = false; + static bool isTemporaryStrategyId(String id) { + return id.startsWith(temporaryStrategyIdPrefix); + } + + static String newTemporaryStrategyId() { + return '$temporaryStrategyIdPrefix${const Uuid().v4()}'; + } + + Future cleanUpOrphanedTemporaryStrategies() async { + final box = Hive.box(HiveBoxNames.strategiesBox); + final tempIds = box.values + .where((strategy) => isTemporaryStrategyId(strategy.id)) + .map((strategy) => strategy.id) + .toList(growable: false); + for (final id in tempIds) { + await deleteStrategy(id); + } + } + //Used For Images void setFromState(StrategyState newState) { state = newState; @@ -310,6 +356,8 @@ class StrategyProvider extends Notifier { id: "testID", storageDirectory: state.storageDirectory, activePageId: null, + sessionKind: StrategySessionKind.saved, + sourceStrategyId: null, ); } // --- MIGRATION: create a first page from legacy flat fields ---------------- @@ -979,7 +1027,12 @@ class StrategyProvider extends Notifier { await setActivePageAnimated(newPage.id); } - Future loadFromHive(String id) async { + Future loadFromHive( + String id, { + StrategySessionKind sessionKind = StrategySessionKind.saved, + String? sourceStrategyId, + bool isSaved = true, + }) async { final newStrat = Hive.box(HiveBoxNames.strategiesBox) .values .where((StrategyData strategy) { @@ -1042,22 +1095,26 @@ class StrategyProvider extends Notifier { if (kIsWeb) { state = StrategyState( - isSaved: true, + isSaved: isSaved, stratName: migratedStrategy.name, id: migratedStrategy.id, storageDirectory: null, activePageId: page.id, + sessionKind: sessionKind, + sourceStrategyId: sourceStrategyId, ); return; } final newDir = await setStorageDirectory(migratedStrategy.id); state = StrategyState( - isSaved: true, + isSaved: isSaved, stratName: migratedStrategy.name, id: migratedStrategy.id, storageDirectory: newDir.path, activePageId: page.id, + sessionKind: sessionKind, + sourceStrategyId: sourceStrategyId, ); } @@ -1288,6 +1345,13 @@ class StrategyProvider extends Notifier { } Future createNewStrategy(String name) async { + return createNewStrategyWithFolder(name: name, folderID: ref.read(folderProvider)); + } + + Future createNewStrategyWithFolder({ + required String name, + String? folderID, + }) async { final newID = const Uuid().v4(); final pageID = const Uuid().v4(); final defaultThemeProfileId = @@ -1317,7 +1381,7 @@ class StrategyProvider extends Notifier { // ignore: deprecated_member_use_from_same_package strategySettings: StrategySettings(), - folderID: ref.read(folderProvider), + folderID: folderID, themeProfileId: defaultThemeProfileId, ); @@ -1327,6 +1391,134 @@ class StrategyProvider extends Notifier { return newStrategy.id; } + Future createQuickBoard() async { + final tempId = newTemporaryStrategyId(); + final boardId = await createNewStrategyWithFolder( + name: 'Quick Board', + folderID: null, + ); + final box = Hive.box(HiveBoxNames.strategiesBox); + final created = box.get(boardId); + if (created == null) { + return boardId; + } + final temporary = created.copyWith(id: tempId, folderID: null); + await box.delete(boardId); + await box.put(tempId, temporary); + await loadFromHive( + tempId, + sessionKind: StrategySessionKind.quickBoard, + isSaved: false, + ); + return tempId; + } + + Future startTemporaryCopyFromCurrentStrategy() async { + if (state.stratName == null || state.isTemporarySession) return; + await _syncCurrentPageToHive(); + + final box = Hive.box(HiveBoxNames.strategiesBox); + final source = box.get(state.id); + if (source == null) return; + + final tempId = newTemporaryStrategyId(); + final temporary = source.copyWith( + id: tempId, + lastEdited: DateTime.now(), + ); + await box.put(tempId, temporary); + await loadFromHive( + tempId, + sessionKind: StrategySessionKind.temporaryCopy, + sourceStrategyId: source.id, + isSaved: false, + ); + } + + StrategyData? currentStrategyData() { + return Hive.box(HiveBoxNames.strategiesBox).get(state.id); + } + + Future discardTemporarySession() async { + if (!state.isTemporarySession) return; + final tempId = state.id; + final sourceId = state.sourceStrategyId; + await deleteStrategy(tempId); + if (sourceId != null) { + await loadFromHive(sourceId); + return; + } + await clearCurrentStrategy(); + } + + static StrategyData buildOverwriteFromTemporary({ + required StrategyData original, + required StrategyData temporary, + }) { + return temporary.copyWith( + id: original.id, + name: original.name, + folderID: original.folderID, + createdAt: original.createdAt, + lastEdited: DateTime.now(), + ); + } + + static StrategyData buildSavedCopyFromTemporary({ + required StrategyData temporary, + required String id, + required String name, + required String? folderID, + }) { + final now = DateTime.now(); + return temporary.copyWith( + id: id, + name: name, + folderID: folderID, + createdAt: now, + lastEdited: now, + ); + } + + Future overwriteOriginalFromTemporaryCopy() async { + if (!state.isTemporaryCopy || state.sourceStrategyId == null) return; + await _syncCurrentPageToHive(); + final box = Hive.box(HiveBoxNames.strategiesBox); + final temporary = box.get(state.id); + final original = box.get(state.sourceStrategyId!); + if (temporary == null || original == null) return; + + final merged = buildOverwriteFromTemporary( + original: original, + temporary: temporary, + ); + await box.put(original.id, merged); + await deleteStrategy(temporary.id); + await loadFromHive(original.id); + } + + Future saveTemporarySessionAsNewStrategy({ + required String name, + required String? folderID, + }) async { + if (!state.isTemporarySession) return null; + await _syncCurrentPageToHive(); + final box = Hive.box(HiveBoxNames.strategiesBox); + final temporary = box.get(state.id); + if (temporary == null) return null; + final newId = const Uuid().v4(); + final promoted = buildSavedCopyFromTemporary( + temporary: temporary, + id: newId, + name: name, + folderID: folderID, + ); + await box.put(newId, promoted); + await deleteStrategy(temporary.id); + await loadFromHive(newId); + return newId; + } + void setThemeProfileForCurrentStrategy(String profileId) { ref.read(strategyThemeProvider.notifier).setProfile(profileId); setUnsaved(); @@ -1387,7 +1579,9 @@ class StrategyProvider extends Notifier { if (currentFolder == null) return; final strategies = Hive.box(HiveBoxNames.strategiesBox) .values - .where((strategy) => strategy.folderID == folderID) + .where((strategy) => + strategy.folderID == folderID && + !isTemporaryStrategyId(strategy.id)) .toList(); final subFolders = diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index 1c95743c..26696ad7 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -19,6 +19,7 @@ import 'package:icarus/widgets/pages_bar.dart'; import 'package:icarus/widgets/save_and_load_button.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/widgets/dialogs/create_lineup_dialog.dart'; +import 'package:icarus/widgets/dialogs/strategy/temporary_session_flow.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -76,9 +77,20 @@ class _StrategyViewState extends ConsumerState ShadIconButton.ghost( foregroundColor: Colors.white, onPressed: () async { - await ref - .read(strategyProvider.notifier) - .forceSaveNow(ref.read(strategyProvider).id); + final canProceed = + await resolveTemporarySessionForNavigation( + context: context, + ref: ref, + ); + if (!canProceed) return; + final state = ref.read(strategyProvider); + if (state.stratName != null && + !state.isTemporarySession && + !state.isSaved) { + await ref + .read(strategyProvider.notifier) + .forceSaveNow(state.id); + } if (!context.mounted) return; ref @@ -169,14 +181,20 @@ class _StrategyViewState extends ConsumerState // bool isPreventClose = await windowManager.isPreventClose(); // if (!isPreventClose) return; - if (ref.read(strategyProvider).isSaved) { + final canProceed = await resolveTemporarySessionForNavigation( + context: context, + ref: ref, + ); + if (!canProceed) return; + final state = ref.read(strategyProvider); + if (state.isSaved) { await windowManager.close(); // Close the window/app return; } - await ref - .read(strategyProvider.notifier) - .forceSaveNow(ref.read(strategyProvider).id); + if (!state.isTemporarySession && state.stratName != null) { + await ref.read(strategyProvider.notifier).forceSaveNow(state.id); + } log("Window close"); await windowManager.close(); // Close the window/app } diff --git a/lib/widgets/dialogs/strategy/save_strategy_details_dialog.dart b/lib/widgets/dialogs/strategy/save_strategy_details_dialog.dart new file mode 100644 index 00000000..fd5e7aea --- /dev/null +++ b/lib/widgets/dialogs/strategy/save_strategy_details_dialog.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/widgets/custom_text_field.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class StrategySaveDetails { + const StrategySaveDetails({ + required this.name, + required this.folderId, + }); + + final String name; + final String? folderId; +} + +Future showStrategySaveDetailsDialog({ + required BuildContext context, + required String title, + required String confirmLabel, + required String initialName, + String? initialFolderId, +}) { + return showShadDialog( + context: context, + builder: (context) { + return _StrategySaveDetailsDialog( + title: title, + confirmLabel: confirmLabel, + initialName: initialName, + initialFolderId: initialFolderId, + ); + }, + ); +} + +class _StrategySaveDetailsDialog extends StatefulWidget { + const _StrategySaveDetailsDialog({ + required this.title, + required this.confirmLabel, + required this.initialName, + required this.initialFolderId, + }); + + final String title; + final String confirmLabel; + final String initialName; + final String? initialFolderId; + + @override + State<_StrategySaveDetailsDialog> createState() => + _StrategySaveDetailsDialogState(); +} + +class _StrategySaveDetailsDialogState extends State<_StrategySaveDetailsDialog> { + late final TextEditingController _nameController; + String? _selectedFolderId; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.initialName); + _selectedFolderId = widget.initialFolderId; + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + void _submit() { + final name = _nameController.text.trim(); + if (name.isEmpty) return; + Navigator.of(context).pop( + StrategySaveDetails(name: name, folderId: _selectedFolderId), + ); + } + + @override + Widget build(BuildContext context) { + final folders = Hive.box(HiveBoxNames.foldersBox) + .values + .toList(growable: false) + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + + return ShadDialog( + title: Text(widget.title), + actions: [ + ShadButton.secondary( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ShadButton( + onPressed: _submit, + child: Text(widget.confirmLabel), + ), + ], + child: SizedBox( + width: 340, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomTextField( + hintText: 'Strategy name', + controller: _nameController, + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: _selectedFolderId, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Folder', + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Root'), + ), + ...folders.map( + (folder) => DropdownMenuItem( + value: folder.id, + child: Text(folder.name), + ), + ), + ], + onChanged: (value) { + setState(() => _selectedFolderId = value); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/dialogs/strategy/temporary_session_flow.dart b/lib/widgets/dialogs/strategy/temporary_session_flow.dart new file mode 100644 index 00000000..a1120af1 --- /dev/null +++ b/lib/widgets/dialogs/strategy/temporary_session_flow.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/widgets/dialogs/strategy/save_strategy_details_dialog.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +enum TemporarySaveIntent { + cancel, + overwriteOriginal, + saveAsNew, + discard, +} + +Future _showTemporaryCopyDialog( + BuildContext context, { + required bool includeDiscard, +}) { + return showShadDialog( + context: context, + builder: (context) => ShadDialog( + title: const Text('Save Temporary Changes?'), + description: const Text( + 'Choose where to apply this temporary copy before leaving.', + ), + actions: [ + ShadButton.secondary( + onPressed: () => + Navigator.of(context).pop(TemporarySaveIntent.cancel), + child: const Text('Cancel'), + ), + if (includeDiscard) + ShadButton.secondary( + onPressed: () => + Navigator.of(context).pop(TemporarySaveIntent.discard), + child: const Text('Discard'), + ), + ShadButton.secondary( + onPressed: () => + Navigator.of(context).pop(TemporarySaveIntent.saveAsNew), + child: const Text('Save as New'), + ), + ShadButton( + onPressed: () => + Navigator.of(context).pop(TemporarySaveIntent.overwriteOriginal), + child: const Text('Overwrite Original'), + ), + ], + ), + ); +} + +Future _showQuickBoardDialog( + BuildContext context, { + required bool includeDiscard, +}) { + return showShadDialog( + context: context, + builder: (context) => ShadDialog( + title: const Text('Save Quick Board?'), + description: const Text( + 'Quick Boards are temporary. Save now or discard this session.', + ), + actions: [ + ShadButton.secondary( + onPressed: () => + Navigator.of(context).pop(TemporarySaveIntent.cancel), + child: const Text('Cancel'), + ), + if (includeDiscard) + ShadButton.secondary( + onPressed: () => + Navigator.of(context).pop(TemporarySaveIntent.discard), + child: const Text('Discard'), + ), + ShadButton( + onPressed: () => Navigator.of(context).pop(TemporarySaveIntent.saveAsNew), + child: const Text('Save'), + ), + ], + ), + ); +} + +Future resolveTemporarySessionForNavigation({ + required BuildContext context, + required WidgetRef ref, +}) async { + final strategyState = ref.read(strategyProvider); + if (!strategyState.isTemporarySession) return true; + final strategyNotifier = ref.read(strategyProvider.notifier); + + final intent = strategyState.isQuickBoard + ? await _showQuickBoardDialog(context, includeDiscard: true) + : await _showTemporaryCopyDialog(context, includeDiscard: true); + if (intent == null || intent == TemporarySaveIntent.cancel) return false; + + if (intent == TemporarySaveIntent.discard) { + await strategyNotifier.discardTemporarySession(); + return true; + } + if (intent == TemporarySaveIntent.overwriteOriginal) { + await strategyNotifier.overwriteOriginalFromTemporaryCopy(); + return true; + } + + final sourceName = strategyState.stratName ?? 'Strategy'; + final sourceStrategy = strategyNotifier.currentStrategyData(); + final details = await showStrategySaveDetailsDialog( + context: context, + title: strategyState.isQuickBoard ? 'Save Quick Board' : 'Save as New', + confirmLabel: 'Save', + initialName: strategyState.isQuickBoard ? sourceName : '$sourceName (Copy)', + initialFolderId: sourceStrategy?.folderID, + ); + if (details == null) return false; + await strategyNotifier.saveTemporarySessionAsNewStrategy( + name: details.name, + folderID: details.folderId, + ); + return true; +} + +Future resolveTemporarySessionForManualSave({ + required BuildContext context, + required WidgetRef ref, +}) async { + final strategyState = ref.read(strategyProvider); + if (!strategyState.isTemporarySession) { + await ref + .read(strategyProvider.notifier) + .forceSaveNow(ref.read(strategyProvider).id); + return true; + } + final strategyNotifier = ref.read(strategyProvider.notifier); + + if (strategyState.isTemporaryCopy) { + final intent = + await _showTemporaryCopyDialog(context, includeDiscard: false); + if (intent == null || intent == TemporarySaveIntent.cancel) return false; + if (intent == TemporarySaveIntent.overwriteOriginal) { + await strategyNotifier.overwriteOriginalFromTemporaryCopy(); + return true; + } + } + + final sourceName = strategyState.stratName ?? 'Strategy'; + final sourceStrategy = strategyNotifier.currentStrategyData(); + final details = await showStrategySaveDetailsDialog( + context: context, + title: strategyState.isQuickBoard ? 'Save Quick Board' : 'Save as New', + confirmLabel: 'Save', + initialName: strategyState.isQuickBoard ? sourceName : '$sourceName (Copy)', + initialFolderId: sourceStrategy?.folderID, + ); + if (details == null) return false; + await strategyNotifier.saveTemporarySessionAsNewStrategy( + name: details.name, + folderID: details.folderId, + ); + return true; +} diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 68694182..0450d18a 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -126,7 +126,12 @@ class FolderContent extends ConsumerWidget { builder: (context, folderBox, _) { final folders = folderBox.values.toList(); - final strategies = strategyBox.values.toList(); + final strategies = strategyBox.values + .where( + (strategy) => !StrategyProvider + .isTemporaryStrategyId(strategy.id), + ) + .toList(); final search = ref .watch(strategySearchQueryProvider) diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 564f0928..1720b299 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -203,6 +203,17 @@ class _FolderNavigatorState extends ConsumerState { leading: const Icon(Icons.add), child: const Text('Create Strategy'), ), + ShadButton.secondary( + onPressed: () async { + final strategyId = await ref + .read(strategyProvider.notifier) + .createQuickBoard(); + if (!context.mounted) return; + await navigateWithLoading(context, strategyId); + }, + leading: const Icon(Icons.bolt), + child: const Text('Quick Board'), + ), ], ) ], diff --git a/lib/widgets/global_shortcuts.dart b/lib/widgets/global_shortcuts.dart index 42a897bf..edd74863 100644 --- a/lib/widgets/global_shortcuts.dart +++ b/lib/widgets/global_shortcuts.dart @@ -17,6 +17,7 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/widgets/delete_helpers.dart'; import 'package:icarus/widgets/dialogs/in_app_debug_dialog.dart'; +import 'package:icarus/widgets/dialogs/strategy/temporary_session_flow.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:uuid/uuid.dart'; @@ -117,10 +118,10 @@ class _GlobalShortcutsState extends ConsumerState { SaveStrategyIntent: CallbackAction( onInvoke: (intent) async { _dismissDeleteMenu(); - final strategyId = ref.read(strategyProvider).id; - await ref.read(strategyProvider.notifier).forceSaveNow( - strategyId, - ); + await resolveTemporarySessionForManualSave( + context: context, + ref: ref, + ); return null; }, ), diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index a8d7308f..763d2d70 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -9,6 +9,7 @@ import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/widgets/dialogs/strategy/temporary_session_flow.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// Displays the current strategy name with a recent-strategies dropdown. @@ -71,11 +72,19 @@ class _StrategyQuickSwitcherState extends ConsumerState { setState(() => _isSwitching = true); try { + final canProceed = await resolveTemporarySessionForNavigation( + context: context, + ref: ref, + ); + if (!canProceed) return; + final latestState = ref.read(strategyProvider); // Keep current work persisted before switching strategies. - if (currentStrategy.stratName != null) { + if (latestState.stratName != null && + !latestState.isTemporarySession && + !latestState.isSaved) { await ref .read(strategyProvider.notifier) - .forceSaveNow(currentStrategy.id); + .forceSaveNow(latestState.id); } ref .read(interactionStateProvider.notifier) @@ -89,6 +98,13 @@ class _StrategyQuickSwitcherState extends ConsumerState { } } + Future _startTemporaryCopy() async { + if (_isSwitching || _isEditingName) return; + await ref + .read(strategyProvider.notifier) + .startTemporaryCopyFromCurrentStrategy(); + } + void _handleNameFocusChange() { if (_nameFocusNode.hasFocus || !_isEditingName) return; _commitEditingName(); @@ -97,7 +113,12 @@ class _StrategyQuickSwitcherState extends ConsumerState { void _startEditingName() { final currentStrategy = ref.read(strategyProvider); final currentName = currentStrategy.stratName; - if (_isSwitching || _isEditingName || currentName == null) return; + if (_isSwitching || + _isEditingName || + currentName == null || + currentStrategy.isTemporarySession) { + return; + } _closePortal(); _originalName = currentName; @@ -183,7 +204,11 @@ class _StrategyQuickSwitcherState extends ConsumerState { required String currentStrategyId, }) { final strategies = box.values - .where((strategy) => strategy.id != currentStrategyId) + .where( + (strategy) => + strategy.id != currentStrategyId && + !StrategyProvider.isTemporaryStrategyId(strategy.id), + ) .toList(growable: false); strategies.sort((a, b) => b.lastEdited.compareTo(a.lastEdited)); return strategies; @@ -241,6 +266,11 @@ class _StrategyQuickSwitcherState extends ConsumerState { Widget build(BuildContext context) { final currentStrategy = ref.watch(strategyProvider); final strategyName = currentStrategy.stratName ?? 'Untitled Strategy'; + final displayName = currentStrategy.isQuickBoard + ? 'Quick Board' + : currentStrategy.isTemporaryCopy + ? '$strategyName (Temporary Copy)' + : strategyName; final strategiesBox = Hive.box(HiveBoxNames.strategiesBox); return Padding( @@ -333,19 +363,21 @@ class _StrategyQuickSwitcherState extends ConsumerState { ], ); }, - child: Container( - width: _barWidth, - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), - ), - child: Row( - children: [ - Expanded( - child: _isEditingName + child: Row( + children: [ + Container( + width: _barWidth, + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Settings.tacticalVioletTheme.border, + ), + ), + child: Row( + children: [ + Expanded( + child: _isEditingName ? Padding( padding: const EdgeInsets.symmetric( horizontal: 12, @@ -412,14 +444,18 @@ class _StrategyQuickSwitcherState extends ConsumerState { : Tooltip( message: currentStrategy.stratName == null ? 'Load a strategy to rename it' - : 'Rename strategy', + : currentStrategy.isTemporarySession + ? 'Rename is disabled in temporary mode' + : 'Rename strategy', child: Material( color: Colors.transparent, child: InkWell( onTap: currentStrategy.stratName == null + || currentStrategy.isTemporarySession ? null : _startEditingName, mouseCursor: currentStrategy.stratName == null + || currentStrategy.isTemporarySession ? SystemMouseCursors.basic : SystemMouseCursors.click, borderRadius: BorderRadius.circular(8), @@ -429,7 +465,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { vertical: 8, ), child: Text( - strategyName, + displayName, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, @@ -442,36 +478,58 @@ class _StrategyQuickSwitcherState extends ConsumerState { ), ), ), + ), + Container( + width: 1, + height: 30, + color: Settings.tacticalVioletTheme.border, + ), + SizedBox( + width: 38, + child: ShadIconButton.ghost( + onPressed: _isSwitching || _isEditingName + ? null + : () => _isOpen ? _closePortal() : _openPortal(), + icon: _isSwitching + ? const SizedBox( + width: 16, + height: 16, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + _isOpen + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + color: Colors.white, + size: 18, + ), + ), + ), + ], ), - Container( - width: 1, - height: 30, - color: Settings.tacticalVioletTheme.border, - ), - SizedBox( - width: 38, - child: ShadIconButton.ghost( - onPressed: _isSwitching || _isEditingName - ? null - : () => _isOpen ? _closePortal() : _openPortal(), - icon: _isSwitching - ? const SizedBox( - width: 16, - height: 16, - child: - CircularProgressIndicator(strokeWidth: 2), - ) - : Icon( - _isOpen - ? Icons.keyboard_arrow_up - : Icons.keyboard_arrow_down, - color: Colors.white, - size: 18, - ), - ), + ), + const SizedBox(width: 8), + if (currentStrategy.isTemporarySession) + ShadButton.secondary( + onPressed: _isSwitching || _isEditingName + ? null + : () async { + await resolveTemporarySessionForNavigation( + context: context, + ref: ref, + ); + }, + child: const Text('Finish'), + ) + else + ShadButton.secondary( + onPressed: currentStrategy.stratName == null + ? null + : _startTemporaryCopy, + child: const Text('Temporary Copy'), ), - ], - ), + ], ), ); }, diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart index 5459cd5a..f758b9ba 100644 --- a/lib/widgets/strategy_save_icon_button.dart +++ b/lib/widgets/strategy_save_icon_button.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/auto_save_notifier.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/widgets/dialogs/strategy/temporary_session_flow.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:toastification/toastification.dart'; @@ -120,11 +121,11 @@ class _AutoSaveButtonState extends ConsumerState foregroundColor: Colors.white, icon: icon, onPressed: () async { - // manual save path shows a SnackBar - await ref - .read(strategyProvider.notifier) - .forceSaveNow(ref.read(strategyProvider).id); - if (!context.mounted) return; + final didSave = await resolveTemporarySessionForManualSave( + context: context, + ref: ref, + ); + if (!didSave || !context.mounted) return; toastification.showCustom( context: context, diff --git a/test/temporary_strategy_integrity_test.dart b/test/temporary_strategy_integrity_test.dart new file mode 100644 index 00000000..5813d526 --- /dev/null +++ b/test/temporary_strategy_integrity_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; + +StrategyData _buildStrategy({ + required String id, + required String name, + String? folderId, +}) { + return StrategyData( + id: id, + name: name, + mapData: MapValue.ascent, + versionNumber: 1, + lastEdited: DateTime.utc(2026, 1, 1), + createdAt: DateTime.utc(2026, 1, 1), + folderID: folderId, + pages: [ + StrategyPage( + id: 'page-1', + name: 'Page 1', + drawingData: const [], + agentData: const [], + abilityData: const [], + textData: const [], + imageData: const [], + utilityData: const [], + lineUps: const [], + sortIndex: 0, + isAttack: true, + settings: StrategySettings(), + ), + ], + ); +} + +void main() { + group('Temporary strategy integrity helpers', () { + test('temporary strategy IDs are detected reliably', () { + final tempId = StrategyProvider.newTemporaryStrategyId(); + expect(StrategyProvider.isTemporaryStrategyId(tempId), isTrue); + expect(StrategyProvider.isTemporaryStrategyId('saved-id-1'), isFalse); + }); + + test('overwrite from temporary preserves original identity', () { + final original = _buildStrategy( + id: 'saved-id', + name: 'Saved Name', + folderId: 'folder-A', + ); + final temporary = _buildStrategy( + id: '_temp_abc', + name: 'Saved Name (Temporary Copy)', + folderId: null, + ).copyWith( + mapData: MapValue.bind, + ); + + final merged = StrategyProvider.buildOverwriteFromTemporary( + original: original, + temporary: temporary, + ); + + expect(merged.id, original.id); + expect(merged.name, original.name); + expect(merged.folderID, original.folderID); + expect(merged.createdAt, original.createdAt); + expect(merged.mapData, MapValue.bind); + }); + + test('save as new from temporary produces distinct document', () { + final temporary = _buildStrategy( + id: '_temp_abc', + name: 'Quick Board', + ).copyWith(mapData: MapValue.pearl); + + final saved = StrategyProvider.buildSavedCopyFromTemporary( + temporary: temporary, + id: 'new-id', + name: 'Final Strategy', + folderID: 'folder-X', + ); + + expect(saved.id, 'new-id'); + expect(saved.name, 'Final Strategy'); + expect(saved.folderID, 'folder-X'); + expect(saved.mapData, MapValue.pearl); + expect(saved.createdAt, isNot(temporary.createdAt)); + }); + }); +}