diff --git a/lib/const/hive_boxes.dart b/lib/const/hive_boxes.dart index b1460824..88594cc7 100644 --- a/lib/const/hive_boxes.dart +++ b/lib/const/hive_boxes.dart @@ -4,4 +4,5 @@ class HiveBoxNames { static const mapThemeProfilesBox = "map_theme_profiles_box"; static const appPreferencesBox = "app_preferences_box"; static const favoriteAgentsBox = "favorite_agents_box"; + static const pinnedItemsBox = "pinned_items_box"; } diff --git a/lib/main.dart b/lib/main.dart index b0461de5..a78f606d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -79,6 +79,7 @@ Future main(List args) async { await Hive.openBox(HiveBoxNames.mapThemeProfilesBox); await Hive.openBox(HiveBoxNames.appPreferencesBox); await Hive.openBox(HiveBoxNames.favoriteAgentsBox); + await Hive.openBox(HiveBoxNames.pinnedItemsBox); await MapThemeProfilesProvider.bootstrap(); diff --git a/lib/providers/folder_provider.dart b/lib/providers/folder_provider.dart index 9a7377a8..865c33c4 100644 --- a/lib/providers/folder_provider.dart +++ b/lib/providers/folder_provider.dart @@ -4,6 +4,7 @@ import 'package:hive_ce_flutter/adapters.dart'; import 'package:icarus/const/custom_icons.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:uuid/uuid.dart'; @@ -174,6 +175,7 @@ class FolderProvider extends Notifier { } void deleteFolder(String folderID) async { + await ref.read(pinnedItemsProvider.notifier).removePin(folderID); // state = state.where((folder) => folder.id != folderID).toList(); final strategyList = diff --git a/lib/providers/pinned_items_provider.dart b/lib/providers/pinned_items_provider.dart new file mode 100644 index 00000000..ee2ccc7a --- /dev/null +++ b/lib/providers/pinned_items_provider.dart @@ -0,0 +1,122 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce_flutter/hive_flutter.dart'; +import 'package:icarus/const/hive_boxes.dart'; + +final pinnedItemsProvider = + NotifierProvider>( + PinnedItemsProvider.new); + +const _legacyTimestampThreshold = 1000000000000; + +List pinnedIdsInManualOrder(Map pinned) { + final entries = pinned.entries.toList()..sort(_comparePinnedEntries); + return entries.map((e) => e.key).toList(); +} + +int _comparePinnedEntries(MapEntry a, MapEntry b) { + final hasLegacyTimestamp = a.value > _legacyTimestampThreshold || + b.value > _legacyTimestampThreshold; + if (hasLegacyTimestamp) return b.value.compareTo(a.value); + return a.value.compareTo(b.value); +} + +/// Tracks which strategies/folders are pinned to the home screen. +/// +/// Stored as a Hive box keyed by the item's id, with a zero-based manual sort +/// index as the value. State is a `Map` of id -> order. +class PinnedItemsProvider extends Notifier> { + Box get _box => Hive.box(HiveBoxNames.pinnedItemsBox); + + @override + Map build() { + return _readFromBox(); + } + + bool isPinned(String id) => state.containsKey(id); + + List pinnedIdsByManualOrder() => pinnedIdsInManualOrder(state); + + @Deprecated('Use pinnedIdsByManualOrder') + List pinnedIdsByRecency() => pinnedIdsByManualOrder(); + + Future togglePin(String id) async { + if (isPinned(id)) { + await removePin(id); + return; + } + await _saveOrder([id, ...pinnedIdsByManualOrder()]); + } + + Future removePin(String id) async { + if (!isPinned(id)) return; + final orderedIds = pinnedIdsByManualOrder()..remove(id); + await _saveOrder(orderedIds); + } + + Future movePinUp(String id) async { + final orderedIds = pinnedIdsByManualOrder(); + final index = orderedIds.indexOf(id); + if (index <= 0) return; + orderedIds + ..removeAt(index) + ..insert(index - 1, id); + await _saveOrder(orderedIds); + } + + Future movePinDown(String id) async { + final orderedIds = pinnedIdsByManualOrder(); + final index = orderedIds.indexOf(id); + if (index == -1 || index == orderedIds.length - 1) return; + orderedIds + ..removeAt(index) + ..insert(index + 1, id); + await _saveOrder(orderedIds); + } + + Future movePinToTop(String id) async { + final orderedIds = pinnedIdsByManualOrder(); + if (!orderedIds.remove(id)) return; + await _saveOrder([id, ...orderedIds]); + } + + Future movePin({ + required String id, + required String targetId, + required bool insertAfterTarget, + }) async { + if (id == targetId || !isPinned(id) || !isPinned(targetId)) return; + + final orderedIds = pinnedIdsByManualOrder()..remove(id); + final targetIndex = orderedIds.indexOf(targetId); + if (targetIndex == -1) return; + + orderedIds.insert( + targetIndex + (insertAfterTarget ? 1 : 0), + id, + ); + await _saveOrder(orderedIds); + } + + Future _saveOrder(List orderedIds) async { + final nextState = { + for (final entry in orderedIds.asMap().entries) entry.value: entry.key, + }; + await _box.putAll(nextState); + final staleKeys = _box.keys + .where((key) => key is! String || !nextState.containsKey(key)) + .toList(); + await _box.deleteAll(staleKeys); + state = nextState; + } + + Map _readFromBox() { + final result = {}; + for (final key in _box.keys) { + if (key is! String) continue; // resilient to stale/invalid keys + final value = _box.get(key); + if (value == null) continue; + result[key] = value; + } + return result; + } +} diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index d88881a4..54c2f59b 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -27,6 +27,7 @@ import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/auto_save_notifier.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/favorite_agents_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; @@ -3561,6 +3562,7 @@ class StrategyProvider extends Notifier { } Future deleteStrategy(String strategyID) async { + await ref.read(pinnedItemsProvider.notifier).removePin(strategyID); await Hive.box(HiveBoxNames.strategiesBox).delete(strategyID); final directory = await getApplicationSupportDirectory(); diff --git a/lib/providers/user_preferences_provider.dart b/lib/providers/user_preferences_provider.dart index ede9972d..ba17f7f5 100644 --- a/lib/providers/user_preferences_provider.dart +++ b/lib/providers/user_preferences_provider.dart @@ -665,13 +665,18 @@ class AppPreferencesNotifier extends Notifier { } AppPreferences _readFromHive() { - return Hive.box(HiveBoxNames.appPreferencesBox).get( - MapThemeProfilesProvider.appPreferencesSingletonKey, - ) ?? - AppPreferences( - defaultThemeProfileIdForNewStrategies: - MapThemeProfilesProvider.immutableDefaultProfileId, - ); + final fallback = AppPreferences( + defaultThemeProfileIdForNewStrategies: + MapThemeProfilesProvider.immutableDefaultProfileId, + ); + try { + return Hive.box(HiveBoxNames.appPreferencesBox).get( + MapThemeProfilesProvider.appPreferencesSingletonKey, + ) ?? + fallback; + } on HiveError { + return fallback; + } } } diff --git a/lib/widgets/draggable_widgets/agents/agent_widget.dart b/lib/widgets/draggable_widgets/agents/agent_widget.dart index 40c1baf6..89e574db 100644 --- a/lib/widgets/draggable_widgets/agents/agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/agent_widget.dart @@ -199,8 +199,10 @@ class AgentWidget extends ConsumerWidget { ) : null; + final canShowAgentContextMenu = + !isScreenshot && (lineUpId != null || (id != null && id!.isNotEmpty)); final contextMenuItems = [ - if (!isScreenshot) + if (canShowAgentContextMenu) ShadContextMenuItem.raw( variant: ShadContextMenuItemVariant.primary, height: 36, diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index d4b23db1..8d314c77 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -12,8 +12,8 @@ import 'package:icarus/widgets/custom_search_field.dart'; import 'package:icarus/widgets/ica_drop_target.dart'; import 'package:icarus/widgets/dot_painter.dart'; import 'package:icarus/widgets/folder_pill.dart'; +import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -// ... your existing imports class FolderContent extends ConsumerWidget { FolderContent({super.key, this.folder}); @@ -32,8 +32,6 @@ class FolderContent extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Move all your existing grid logic here from FolderView - // Filter by folder?.id instead of folder.id final strategiesBoxListenable = ref.watch(strategiesListenable); return Stack( @@ -171,6 +169,36 @@ class FolderContent extends ConsumerWidget { folders.sort( (a, b) => a.dateCreated.compareTo(b.dateCreated)); + final pinned = ref.watch(pinnedItemsProvider); + if (pinned.isNotEmpty && search.isEmpty) { + final orderedPinnedIds = + pinnedIdsInManualOrder(pinned); + final pinnedIdOrder = { + for (final entry + in orderedPinnedIds.asMap().entries) + entry.value: entry.key, + }; + + final pinnedStrategies = strategies + .where( + (strategy) => pinned.containsKey(strategy.id)) + .toList() + ..sort((a, b) => pinnedIdOrder[a.id]! + .compareTo(pinnedIdOrder[b.id]!)); + final pinnedFolders = folders + .where((listFolder) => + pinned.containsKey(listFolder.id)) + .toList() + ..sort((a, b) => pinnedIdOrder[a.id]! + .compareTo(pinnedIdOrder[b.id]!)); + + strategies + .removeWhere((s) => pinned.containsKey(s.id)); + folders.removeWhere((f) => pinned.containsKey(f.id)); + strategies.insertAll(0, pinnedStrategies); + folders.insertAll(0, pinnedFolders); + } + // Check if both folders and strategies are empty if (folders.isEmpty && strategies.isEmpty) { return const IcaDropTarget( diff --git a/lib/widgets/folder_pill.dart b/lib/widgets/folder_pill.dart index 5afab198..7d3826a2 100644 --- a/lib/widgets/folder_pill.dart +++ b/lib/widgets/folder_pill.dart @@ -5,6 +5,7 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; import 'package:icarus/widgets/folder_edit_dialog.dart'; import 'package:icarus/widgets/folder_navigator.dart'; +import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class FolderPill extends ConsumerStatefulWidget { @@ -60,6 +61,10 @@ class _FolderPillState extends ConsumerState @override Widget build(BuildContext context) { + final pinned = ref.watch(pinnedItemsProvider); + final id = widget.folder.id; + final isPinned = pinned.containsKey(id); + return Draggable( feedback: _buildDragFeedback(), dragAnchorStrategy: pointerDragAnchorStrategy, @@ -68,15 +73,38 @@ class _FolderPillState extends ConsumerState onWillAcceptWithDetails: (details) { final item = details.data; if (widget.isDemo) return false; + if (item is FolderItem && + item.folder.id != id && + isPinned && + pinned.containsKey(item.folder.id)) { + return true; + } if (item is FolderItem) { - return item.folder.id != widget.folder.id && - !_isParentFolder(item.folder.id); + return item.folder.id != id && !_isParentFolder(item.folder.id); } return true; }, - onAcceptWithDetails: (details) { + onAcceptWithDetails: (details) async { if (widget.isDemo) return; final item = details.data; + final draggedPinnedId = item is FolderItem && + item.folder.id != id && + pinned.containsKey(item.folder.id) + ? item.folder.id + : null; + if (draggedPinnedId != null && isPinned) { + final renderObject = context.findRenderObject(); + if (renderObject is! RenderBox) return; + final localOffset = renderObject.globalToLocal(details.offset); + await ref.read(pinnedItemsProvider.notifier).movePin( + id: draggedPinnedId, + targetId: id, + insertAfterTarget: + localOffset.dx > renderObject.size.width / 2, + ); + return; + } + if (item is StrategyItem) { ref.read(strategyProvider.notifier).moveToFolder( strategyID: item.strategy.id, parentID: widget.folder.id); @@ -191,7 +219,18 @@ class _FolderPillState extends ConsumerState } List _buildMenuItems() { + final pinned = ref.watch(pinnedItemsProvider); + final id = widget.folder.id; + final isPinned = pinned.containsKey(id); return [ + ShadContextMenuItem( + leading: Icon(isPinned ? Icons.push_pin : Icons.push_pin_outlined), + child: Text(isPinned ? 'Unpin' : 'Pin'), + onPressed: () { + if (widget.isDemo) return; + ref.read(pinnedItemsProvider.notifier).togglePin(id); + }, + ), ShadContextMenuItem( leading: const Icon(Icons.text_fields), child: const Text('Edit'), diff --git a/lib/widgets/page_transition_overlay.dart b/lib/widgets/page_transition_overlay.dart index 6319ab72..102d8362 100644 --- a/lib/widgets/page_transition_overlay.dart +++ b/lib/widgets/page_transition_overlay.dart @@ -443,7 +443,7 @@ class PlacedWidgetPreview { if (w is PlacedAgent) { return AgentWidget( isAlly: w.isAlly, - id: w.id, + id: '', agent: AgentData.agents[w.type]!, state: w.state, deadStateProgress: deadStateProgress, diff --git a/lib/widgets/sidebar_widgets/agent_dragable.dart b/lib/widgets/sidebar_widgets/agent_dragable.dart index d261302f..c643e560 100644 --- a/lib/widgets/sidebar_widgets/agent_dragable.dart +++ b/lib/widgets/sidebar_widgets/agent_dragable.dart @@ -48,6 +48,8 @@ class _AgentDragableState extends ConsumerState late final Animation _clickScale; Timer? _favoriteHoverDelayTimer; DateTime _starOffEnabledAt = DateTime.fromMillisecondsSinceEpoch(0); + Offset? _pointerDownPosition; + bool _pointerMovedForDrag = false; @override void initState() { @@ -102,6 +104,34 @@ class _AgentDragableState extends ConsumerState await _clickController.forward(from: 0); } + void _handlePointerDown(PointerDownEvent event) { + _pointerDownPosition = event.position; + _pointerMovedForDrag = false; + } + + void _handlePointerMove(PointerMoveEvent event) { + final pointerDownPosition = _pointerDownPosition; + if (pointerDownPosition == null) return; + + if ((event.position - pointerDownPosition).distance > 18) { + _pointerMovedForDrag = true; + } + } + + void _handlePointerUp(AgentData agent) { + if (!_pointerMovedForDrag) { + ref.read(dragNotifier.notifier).updateDragState(false); + ref.read(abilityBarProvider.notifier).updateData(agent); + } + _pointerDownPosition = null; + _pointerMovedForDrag = false; + } + + void _handlePointerCancel(PointerCancelEvent event) { + _pointerDownPosition = null; + _pointerMovedForDrag = false; + } + @override Widget build(BuildContext context) { final agent = widget.agent; @@ -149,138 +179,100 @@ class _AgentDragableState extends ConsumerState ? (canShowStarOff ? const Color(0xFFE53935) : const Color(0xFFFF9800)) : (_isStarHovered ? const Color(0xFFFF9800) : const Color(0xFF9AA0A6)); - return IgnorePointer( - ignoring: - ref.watch(dragNotifier) == true || shouldDisableForLockedAddItem, - child: Draggable( - data: agent, - onDragStarted: () { - if (ref.read(interactionStateProvider) == InteractionState.drawing || - ref.read(interactionStateProvider) == InteractionState.erasing) { - ref - .read(interactionStateProvider.notifier) - .update(InteractionState.navigation); - } - ref.read(dragNotifier.notifier).updateDragState(true); - }, - onDraggableCanceled: (velocity, offset) { - ref.read(dragNotifier.notifier).updateDragState(false); - }, - onDragCompleted: () { - ref.read(dragNotifier.notifier).updateDragState(false); - }, - feedback: Opacity( - opacity: Settings.feedbackOpacity, - child: ZoomTransform(child: AgentFeedback(agent: agent)), - ), - dragAnchorStrategy: (draggable, context, position) { - final agentSize = CoordinateSystem.instance - .scale(ref.watch(strategySettingsProvider).agentSize); - return Offset( - (agentSize / 2), - (agentSize / 2), - ).scale(ref.read(screenZoomProvider), ref.read(screenZoomProvider)); - }, - child: MouseRegion( - cursor: shouldDisableForLockedAddItem - ? SystemMouseCursors.forbidden - : SystemMouseCursors.click, - onEnter: (_) => setState(() => _isTileHovered = true), - onExit: (_) { - setState(() { - _isTileHovered = false; - _isStarHovered = false; - }); - }, - child: AnimatedOpacity( - key: ValueKey('agent-dim-opacity-${agent.type.name}'), - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - opacity: tileOpacity, - child: Stack( - clipBehavior: Clip.none, - children: [ - InkWell( - mouseCursor: shouldDisableForLockedAddItem - ? SystemMouseCursors.forbidden - : SystemMouseCursors.click, - onTap: shouldDisableForLockedAddItem - ? null - : () { - ref - .read(abilityBarProvider.notifier) - .updateData(agent); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: ColoredBox( - color: displayColor, - child: Image.asset( - agent.iconPath, - fit: BoxFit.cover, - ), - ), + Widget tile = MouseRegion( + cursor: shouldDisableForLockedAddItem + ? SystemMouseCursors.forbidden + : SystemMouseCursors.click, + onEnter: (_) => setState(() => _isTileHovered = true), + onExit: (_) { + setState(() { + _isTileHovered = false; + _isStarHovered = false; + }); + }, + child: AnimatedOpacity( + key: ValueKey('agent-dim-opacity-${agent.type.name}'), + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + opacity: tileOpacity, + child: Stack( + clipBehavior: Clip.none, + children: [ + InkWell( + mouseCursor: shouldDisableForLockedAddItem + ? SystemMouseCursors.forbidden + : SystemMouseCursors.click, + onTap: shouldDisableForLockedAddItem + ? null + : () { + ref.read(abilityBarProvider.notifier).updateData(agent); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: ColoredBox( + color: displayColor, + child: Image.asset( + agent.iconPath, + fit: BoxFit.cover, ), ), - Positioned( - top: 2, - right: 2, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 130), - curve: Curves.easeOutCubic, - opacity: showStar ? 1 : 0, - child: MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _isStarHovered = true), - onExit: (_) => setState(() { - _isStarHovered = false; - _isStarPressed = false; - }), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (_) => setState(() => _isStarPressed = true), - onTapUp: (_) => setState(() => _isStarPressed = false), - onTapCancel: () => - setState(() => _isStarPressed = false), - onTap: () => _toggleFavoriteWithFeedback( - type: agent.type, - wasFavorite: isFavorite, + ), + ), + Positioned( + top: 2, + right: 2, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 130), + curve: Curves.easeOutCubic, + opacity: showStar ? 1 : 0, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _isStarHovered = true), + onExit: (_) => setState(() { + _isStarHovered = false; + _isStarPressed = false; + }), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => setState(() => _isStarPressed = true), + onTapUp: (_) => setState(() => _isStarPressed = false), + onTapCancel: () => setState(() => _isStarPressed = false), + onTap: () => _toggleFavoriteWithFeedback( + type: agent.type, + wasFavorite: isFavorite, + ), + child: AnimatedBuilder( + animation: _clickController, + builder: (context, child) { + final pressScale = _isStarPressed ? 0.88 : 1.0; + return Transform.scale( + scale: pressScale * _clickScale.value, + child: child, + ); + }, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 120), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: child, ), - child: AnimatedBuilder( - animation: _clickController, - builder: (context, child) { - final pressScale = _isStarPressed ? 0.88 : 1.0; - return Transform.scale( - scale: pressScale * _clickScale.value, - child: child, - ); - }, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 120), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - transitionBuilder: (child, animation) => - FadeTransition( - opacity: animation, - child: child, - ), - child: SizedBox.square( - key: ValueKey("${isFavorite}_$canShowStarOff"), - dimension: 18, - child: Center( - child: Icon( - iconData, - size: iconSize, - color: iconColor, - shadows: [ - BoxShadow( - color: Colors.black.withAlpha(100), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], + child: SizedBox.square( + key: ValueKey("${isFavorite}_$canShowStarOff"), + dimension: 18, + child: Center( + child: Icon( + iconData, + size: iconSize, + color: iconColor, + shadows: [ + BoxShadow( + color: Colors.black.withAlpha(100), + blurRadius: 4, + offset: const Offset(0, 2), ), - ), + ], ), ), ), @@ -288,11 +280,56 @@ class _AgentDragableState extends ConsumerState ), ), ), - ], + ), ), - ), + ], ), ), ); + + if (shouldDisableForLockedAddItem) return tile; + + tile = Draggable( + data: agent, + onDragStarted: () { + if (ref.read(interactionStateProvider) == InteractionState.drawing || + ref.read(interactionStateProvider) == InteractionState.erasing) { + ref + .read(interactionStateProvider.notifier) + .update(InteractionState.navigation); + } + ref.read(dragNotifier.notifier).updateDragState(true); + }, + onDraggableCanceled: (velocity, offset) { + ref.read(dragNotifier.notifier).updateDragState(false); + }, + onDragCompleted: () { + ref.read(dragNotifier.notifier).updateDragState(false); + }, + feedback: Opacity( + opacity: Settings.feedbackOpacity, + child: ZoomTransform(child: AgentFeedback(agent: agent)), + ), + dragAnchorStrategy: (draggable, context, position) { + final agentSize = CoordinateSystem.instance + .scale(ref.watch(strategySettingsProvider).agentSize); + return Offset( + (agentSize / 2), + (agentSize / 2), + ).scale(ref.read(screenZoomProvider), ref.read(screenZoomProvider)); + }, + child: tile, + ); + + return Listener( + onPointerDown: _handlePointerDown, + onPointerMove: _handlePointerMove, + onPointerUp: (_) => _handlePointerUp(agent), + onPointerCancel: _handlePointerCancel, + child: IgnorePointer( + ignoring: ref.watch(dragNotifier) == true, + child: tile, + ), + ); } } diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 47a64a28..079ba476 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -10,6 +10,7 @@ import 'package:icarus/widgets/dialogs/strategy/delete_strategy_alert_dialog.dar import 'package:icarus/widgets/dialogs/strategy/rename_strategy_dialog.dart'; import 'package:icarus/widgets/folder_navigator.dart'; import 'package:icarus/widgets/strategy_tile/strategy_tile_sections.dart'; +import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class StrategyTile extends ConsumerStatefulWidget { @@ -40,83 +41,130 @@ class _StrategyTileState extends ConsumerState { @override Widget build(BuildContext context) { final viewData = StrategyTileViewData(widget.strategyData); + final pinned = ref.watch(pinnedItemsProvider); + final id = widget.strategyData.id; + final isPinned = pinned.containsKey(id); - return Draggable( - data: StrategyItem(widget.strategyData), - dragAnchorStrategy: pointerDragAnchorStrategy, - feedback: Opacity( - opacity: 0.95, - child: Material( - color: Colors.transparent, - child: StrategyTileDragPreview(data: viewData), - ), - ), - child: MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => - setState(() => _highlightColor = Settings.tacticalVioletTheme.ring), - onExit: (_) => setState( - () => _highlightColor = Settings.tacticalVioletTheme.border), - child: AbsorbPointer( - absorbing: _isLoading, - child: ShadContextMenuRegion( - controller: _rightClickMenuController, - items: _buildMenuItems(), - child: GestureDetector( - onTap: () => _openStrategy(context), - child: Stack( - children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 100), - decoration: BoxDecoration( - color: ShadTheme.of(context).colorScheme.card, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: _highlightColor, width: 2), - ), - padding: const EdgeInsets.all(8), - child: Column( - children: [ - Expanded( - child: StrategyTileThumbnail( - assetPath: viewData.thumbnailAsset, + return DragTarget( + onWillAcceptWithDetails: (details) { + final item = details.data; + return item is StrategyItem && + item.strategy.id != id && + isPinned && + pinned.containsKey(item.strategy.id); + }, + onAcceptWithDetails: (details) async { + final item = details.data; + if (item is! StrategyItem) return; + final renderObject = context.findRenderObject(); + if (renderObject is! RenderBox) return; + + final localOffset = renderObject.globalToLocal(details.offset); + await ref.read(pinnedItemsProvider.notifier).movePin( + id: item.strategy.id, + targetId: id, + insertAfterTarget: localOffset.dx > renderObject.size.width / 2, + ); + }, + builder: (context, candidateData, rejectedData) { + final isPinDropTarget = candidateData.any((item) => + item is StrategyItem && + item.strategy.id != id && + isPinned && + pinned.containsKey(item.strategy.id)); + + return Draggable( + data: StrategyItem(widget.strategyData), + dragAnchorStrategy: pointerDragAnchorStrategy, + feedback: Opacity( + opacity: 0.95, + child: Material( + color: Colors.transparent, + child: StrategyTileDragPreview(data: viewData), + ), + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState( + () => _highlightColor = Settings.tacticalVioletTheme.ring), + onExit: (_) => setState( + () => _highlightColor = Settings.tacticalVioletTheme.border), + child: AbsorbPointer( + absorbing: _isLoading, + child: ShadContextMenuRegion( + controller: _rightClickMenuController, + items: _buildMenuItems(), + child: GestureDetector( + onTap: () => _openStrategy(context), + child: Stack( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 100), + decoration: BoxDecoration( + color: ShadTheme.of(context).colorScheme.card, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isPinDropTarget + ? Settings.tacticalVioletTheme.ring + : _highlightColor, + width: isPinDropTarget ? 3 : 2, ), ), - const SizedBox(height: 10), - Expanded(child: StrategyTileDetails(data: viewData)), - ], - ), - ), - Align( - alignment: Alignment.topRight, - child: Padding( - padding: const EdgeInsets.all(16), - child: ShadContextMenuRegion( - controller: _menuButtonController, - items: _buildMenuItems(), - child: ShadIconButton.secondary( - width: 28, - height: 28, - onPressed: () { - _menuButtonController.toggle(); - }, - icon: const Icon( - Icons.more_vert_outlined, + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Expanded( + child: StrategyTileThumbnail( + assetPath: viewData.thumbnailAsset, + ), + ), + const SizedBox(height: 10), + Expanded( + child: StrategyTileDetails(data: viewData)), + ], + ), + ), + Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.all(16), + child: ShadContextMenuRegion( + controller: _menuButtonController, + items: _buildMenuItems(), + child: ShadIconButton.secondary( + width: 28, + height: 28, + onPressed: () { + _menuButtonController.toggle(); + }, + icon: const Icon( + Icons.more_vert_outlined, + ), + ), ), ), ), - ), + ], ), - ], + ), ), ), ), - ), - ), + ); + }, ); } List _buildMenuItems() { + final pinned = ref.watch(pinnedItemsProvider); + final id = widget.strategyData.id; + final isPinned = pinned.containsKey(id); return [ + ShadContextMenuItem( + leading: Icon(isPinned ? Icons.push_pin : Icons.push_pin_outlined), + child: Text(isPinned ? 'Unpin' : 'Pin'), + onPressed: () => ref.read(pinnedItemsProvider.notifier).togglePin(id), + ), ShadContextMenuItem( leading: const Icon(LucideIcons.pencil), child: const Text('Rename'), diff --git a/test/pinned_items_provider_test.dart b/test/pinned_items_provider_test.dart new file mode 100644 index 00000000..88badede --- /dev/null +++ b/test/pinned_items_provider_test.dart @@ -0,0 +1,131 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/providers/pinned_items_provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late ProviderContainer container; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('icarus-pins-'); + Hive.init(tempDir.path); + await Hive.openBox(HiveBoxNames.pinnedItemsBox); + container = ProviderContainer(); + }); + + tearDown(() async { + container.dispose(); + await Hive.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('pinning an id makes it pinned, toggling again unpins', () async { + final notifier = container.read(pinnedItemsProvider.notifier); + + expect(notifier.isPinned('a'), false); + + await notifier.togglePin('a'); + expect(notifier.isPinned('a'), true); + + await notifier.togglePin('a'); + expect(notifier.isPinned('a'), false); + }); + + test('pinned ids stay in manual order with newest pin at the top', () async { + final notifier = container.read(pinnedItemsProvider.notifier); + + await notifier.togglePin('first'); + await notifier.togglePin('second'); + + expect(notifier.pinnedIdsByManualOrder(), ['second', 'first']); + }); + + test('removePin is a no-op when the id is not pinned', () async { + final notifier = container.read(pinnedItemsProvider.notifier); + + await notifier.removePin('missing'); // should not throw + expect(notifier.isPinned('missing'), false); + }); + + test('movePinUp and movePinDown update manual order', () async { + final notifier = container.read(pinnedItemsProvider.notifier); + + await notifier.togglePin('third'); + await notifier.togglePin('second'); + await notifier.togglePin('first'); + + await notifier.movePinDown('first'); + expect(notifier.pinnedIdsByManualOrder(), ['second', 'first', 'third']); + + await notifier.movePinUp('third'); + expect(notifier.pinnedIdsByManualOrder(), ['second', 'third', 'first']); + }); + + test('movePinToTop moves pinned id to the start', () async { + final notifier = container.read(pinnedItemsProvider.notifier); + + await notifier.togglePin('third'); + await notifier.togglePin('second'); + await notifier.togglePin('first'); + + await notifier.movePinToTop('third'); + + expect(notifier.pinnedIdsByManualOrder(), ['third', 'first', 'second']); + }); + + test('movePin reorders an id around a target id', () async { + final notifier = container.read(pinnedItemsProvider.notifier); + + await notifier.togglePin('third'); + await notifier.togglePin('second'); + await notifier.togglePin('first'); + + await notifier.movePin( + id: 'third', + targetId: 'first', + insertAfterTarget: false, + ); + expect(notifier.pinnedIdsByManualOrder(), ['third', 'first', 'second']); + + await notifier.movePin( + id: 'third', + targetId: 'second', + insertAfterTarget: true, + ); + expect(notifier.pinnedIdsByManualOrder(), ['first', 'second', 'third']); + }); + + test('saved order removes stale box keys', () async { + final notifier = container.read(pinnedItemsProvider.notifier); + final box = Hive.box(HiveBoxNames.pinnedItemsBox); + + await notifier.togglePin('first'); + await notifier.togglePin('second'); + await notifier.removePin('first'); + + expect(box.containsKey('first'), false); + expect(box.toMap(), {'second': 0}); + }); + + test('legacy timestamp pins keep recency order until saved', () async { + final box = Hive.box(HiveBoxNames.pinnedItemsBox); + await box.put('first', 1710000000000); + await box.put('second', 1720000000000); + final legacyContainer = ProviderContainer(); + addTearDown(legacyContainer.dispose); + final notifier = legacyContainer.read(pinnedItemsProvider.notifier); + + expect(notifier.pinnedIdsByManualOrder(), ['second', 'first']); + + await notifier.movePinDown('second'); + expect(notifier.pinnedIdsByManualOrder(), ['first', 'second']); + }); +}