From 7a386c9070d3c993a186ba7fbc4de36930a99846 Mon Sep 17 00:00:00 2001 From: Mohammed Njie Date: Thu, 21 May 2026 18:50:55 -0500 Subject: [PATCH 01/14] Add PinnedItemsProvider and pins Hive box --- lib/const/hive_boxes.dart | 1 + lib/main.dart | 1 + lib/providers/pinned_items_provider.dart | 57 +++++++++++++++++++++++ test/pinned_items_provider_test.dart | 58 ++++++++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 lib/providers/pinned_items_provider.dart create mode 100644 test/pinned_items_provider_test.dart 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/pinned_items_provider.dart b/lib/providers/pinned_items_provider.dart new file mode 100644 index 00000000..f67dee83 --- /dev/null +++ b/lib/providers/pinned_items_provider.dart @@ -0,0 +1,57 @@ +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); + +/// Tracks which strategies/folders are pinned to the home screen. +/// +/// Stored as a Hive box keyed by the item's id, with the pin timestamp +/// (milliseconds since epoch) as the value. The timestamp gives us ordering. +/// State is a `Map` of id -> pinnedAt. +class PinnedItemsProvider extends Notifier> { + Box get _box => Hive.box(HiveBoxNames.pinnedItemsBox); + + @override + Map build() { + return _readFromBox(); + } + + bool isPinned(String id) => state.containsKey(id); + + /// Pinned ids, most recently pinned first. + List pinnedIdsByRecency() { + final entries = state.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + return entries.map((e) => e.key).toList(); + } + + Future togglePin(String id) async { + if (isPinned(id)) { + await removePin(id); + return; + } + final now = DateTime.now().millisecondsSinceEpoch; + await _box.put(id, now); + state = {...state, id: now}; + } + + Future removePin(String id) async { + if (!isPinned(id)) return; + await _box.delete(id); + state = {...state}..remove(id); + } + + 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/test/pinned_items_provider_test.dart b/test/pinned_items_provider_test.dart new file mode 100644 index 00000000..70df9dcb --- /dev/null +++ b/test/pinned_items_provider_test.dart @@ -0,0 +1,58 @@ +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('pinnedIdsByRecency returns most-recently-pinned first', () async { + final notifier = container.read(pinnedItemsProvider.notifier); + + await notifier.togglePin('first'); + await Future.delayed(const Duration(milliseconds: 5)); + await notifier.togglePin('second'); + + expect(notifier.pinnedIdsByRecency(), ['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); + }); +} From efc15082ff03033c1b3e9d2d23650eb71909fe28 Mon Sep 17 00:00:00 2001 From: Mohammed Njie Date: Thu, 21 May 2026 18:52:07 -0500 Subject: [PATCH 02/14] Add Pin/Unpin action to strategy tile menu --- lib/widgets/strategy_tile/strategy_tile.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 47a64a28..864e3094 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 { @@ -116,7 +117,16 @@ class _StrategyTileState extends ConsumerState { } List _buildMenuItems() { + final isPinned = + ref.watch(pinnedItemsProvider).containsKey(widget.strategyData.id); return [ + ShadContextMenuItem( + leading: Icon(isPinned ? Icons.push_pin : Icons.push_pin_outlined), + child: Text(isPinned ? 'Unpin' : 'Pin'), + onPressed: () => ref + .read(pinnedItemsProvider.notifier) + .togglePin(widget.strategyData.id), + ), ShadContextMenuItem( leading: const Icon(LucideIcons.pencil), child: const Text('Rename'), From 9747dc11f2bd88b0844944b11f9ada9e1a42e74f Mon Sep 17 00:00:00 2001 From: Mohammed Njie Date: Thu, 21 May 2026 18:52:57 -0500 Subject: [PATCH 03/14] Add Pin/Unpin action to folder pill menu --- lib/widgets/folder_pill.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/widgets/folder_pill.dart b/lib/widgets/folder_pill.dart index 5afab198..bd4dc843 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 { @@ -191,7 +192,17 @@ class _FolderPillState extends ConsumerState } List _buildMenuItems() { + final isPinned = + ref.watch(pinnedItemsProvider).containsKey(widget.folder.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(widget.folder.id); + }, + ), ShadContextMenuItem( leading: const Icon(Icons.text_fields), child: const Text('Edit'), From 2ff17afbce4924322ba17a3b0eed09bde1a4890d Mon Sep 17 00:00:00 2001 From: Mohammed Njie Date: Thu, 21 May 2026 18:53:57 -0500 Subject: [PATCH 04/14] Remove pins when strategies or folders are deleted --- lib/providers/folder_provider.dart | 2 ++ lib/providers/strategy_provider.dart | 2 ++ 2 files changed, 4 insertions(+) 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/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(); From 515e37183791b0873ffa5fa46cb6ca25251ff2fb Mon Sep 17 00:00:00 2001 From: Mohammed Njie Date: Thu, 21 May 2026 18:55:28 -0500 Subject: [PATCH 05/14] Render pinned strategies and folders at home screen --- lib/widgets/folder_content.dart | 92 ++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index d4b23db1..6c982640 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -12,6 +12,7 @@ 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 @@ -171,8 +172,44 @@ class FolderContent extends ConsumerWidget { folders.sort( (a, b) => a.dateCreated.compareTo(b.dateCreated)); + // Pinned section is only shown at the home/root screen. + final isRoot = folder == null; + final pinned = ref.watch(pinnedItemsProvider); + final pinnedIds = ref + .read(pinnedItemsProvider.notifier) + .pinnedIdsByRecency(); + + List pinnedStrategies = []; + List pinnedFolders = []; + if (isRoot && pinned.isNotEmpty) { + final allStrategies = strategyBox.values.toList(); + final allFolders = folderBox.values.toList(); + final strategyById = { + for (final s in allStrategies) s.id: s + }; + final folderById = { + for (final f in allFolders) f.id: f + }; + + for (final id in pinnedIds) { + final s = strategyById[id]; + if (s != null) { + pinnedStrategies.add(s); + continue; + } + final f = folderById[id]; + if (f != null) pinnedFolders.add(f); + } + + strategies.removeWhere((s) => pinned.containsKey(s.id)); + folders.removeWhere((f) => pinned.containsKey(f.id)); + } + // Check if both folders and strategies are empty - if (folders.isEmpty && strategies.isEmpty) { + if (folders.isEmpty && + strategies.isEmpty && + pinnedFolders.isEmpty && + pinnedStrategies.isEmpty) { return const IcaDropTarget( child: Center( child: Column( @@ -205,6 +242,59 @@ class FolderContent extends ConsumerWidget { return CustomScrollView( slivers: [ + if (pinnedFolders.isNotEmpty || + pinnedStrategies.isNotEmpty) ...[ + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + 'Pinned', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + if (pinnedFolders.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + 16, 4, 16, 8), + child: Wrap( + spacing: 10, + runSpacing: 10, + children: pinnedFolders + .map((f) => FolderPill(folder: f)) + .toList(), + ), + ), + ), + if (pinnedStrategies.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.fromLTRB( + 16, 4, 16, 8), + sliver: SliverGrid( + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisExtent: 250, + crossAxisSpacing: 20, + mainAxisSpacing: 20, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => StrategyTile( + strategyData: + pinnedStrategies[index], + ), + childCount: pinnedStrategies.length, + ), + ), + ), + const SliverToBoxAdapter( + child: Divider(indent: 16, endIndent: 16), + ), + ], // Folder pills section (wrap row) if (folders.isNotEmpty) SliverToBoxAdapter( From 1e515d359be24ecd2fd9f689a95e1dbcdd2bf182 Mon Sep 17 00:00:00 2001 From: Mohammed Njie Date: Thu, 21 May 2026 19:01:29 -0500 Subject: [PATCH 06/14] Make Pinned header visible and label the All section --- lib/widgets/folder_content.dart | 49 +++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 6c982640..0ea823d1 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -244,15 +244,29 @@ class FolderContent extends ConsumerWidget { slivers: [ if (pinnedFolders.isNotEmpty || pinnedStrategies.isNotEmpty) ...[ - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 4), - child: Text( - 'Pinned', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), + padding: const EdgeInsets.fromLTRB( + 16, 16, 16, 4), + child: Row( + children: [ + Icon( + Icons.push_pin, + size: 18, + color: Settings + .tacticalVioletTheme.primary, + ), + const SizedBox(width: 6), + Text( + 'Pinned', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Settings + .tacticalVioletTheme.primary, + ), + ), + ], ), ), ), @@ -294,6 +308,25 @@ class FolderContent extends ConsumerWidget { const SliverToBoxAdapter( child: Divider(indent: 16, endIndent: 16), ), + // Header for the normal (un-pinned) section, + // shown only alongside the Pinned section so + // the two read as distinct groups. + if (folders.isNotEmpty || strategies.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + 16, 8, 16, 4), + child: Text( + 'All', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Settings + .tacticalVioletTheme.foreground, + ), + ), + ), + ), ], // Folder pills section (wrap row) if (folders.isNotEmpty) From 869c658e62f2d25faa701393b57c68a0b76073d7 Mon Sep 17 00:00:00 2001 From: Mohammed Njie Date: Thu, 21 May 2026 22:01:21 -0500 Subject: [PATCH 07/14] Float pinned items to top of grid instead of a separate section --- lib/widgets/folder_content.dart | 119 +++++--------------------------- 1 file changed, 17 insertions(+), 102 deletions(-) diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 0ea823d1..2af71431 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -172,25 +172,25 @@ class FolderContent extends ConsumerWidget { folders.sort( (a, b) => a.dateCreated.compareTo(b.dateCreated)); - // Pinned section is only shown at the home/root screen. + // At the home/root screen (and only when not searching), + // pinned items — which may live in any folder — float to + // the top of the normal lists. They render exactly like + // every other tile/pill, just ordered first. final isRoot = folder == null; final pinned = ref.watch(pinnedItemsProvider); - final pinnedIds = ref - .read(pinnedItemsProvider.notifier) - .pinnedIdsByRecency(); - - List pinnedStrategies = []; - List pinnedFolders = []; - if (isRoot && pinned.isNotEmpty) { - final allStrategies = strategyBox.values.toList(); - final allFolders = folderBox.values.toList(); + if (isRoot && pinned.isNotEmpty && search.isEmpty) { + final pinnedIds = ref + .read(pinnedItemsProvider.notifier) + .pinnedIdsByRecency(); final strategyById = { - for (final s in allStrategies) s.id: s + for (final s in strategyBox.values) s.id: s }; final folderById = { - for (final f in allFolders) f.id: f + for (final f in folderBox.values) f.id: f }; + final pinnedStrategies = []; + final pinnedFolders = []; for (final id in pinnedIds) { final s = strategyById[id]; if (s != null) { @@ -201,15 +201,16 @@ class FolderContent extends ConsumerWidget { if (f != null) pinnedFolders.add(f); } + // Remove pinned items from their normal position, then + // re-insert at the front so they sort to the top. 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 && - pinnedFolders.isEmpty && - pinnedStrategies.isEmpty) { + if (folders.isEmpty && strategies.isEmpty) { return const IcaDropTarget( child: Center( child: Column( @@ -242,92 +243,6 @@ class FolderContent extends ConsumerWidget { return CustomScrollView( slivers: [ - if (pinnedFolders.isNotEmpty || - pinnedStrategies.isNotEmpty) ...[ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB( - 16, 16, 16, 4), - child: Row( - children: [ - Icon( - Icons.push_pin, - size: 18, - color: Settings - .tacticalVioletTheme.primary, - ), - const SizedBox(width: 6), - Text( - 'Pinned', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: Settings - .tacticalVioletTheme.primary, - ), - ), - ], - ), - ), - ), - if (pinnedFolders.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB( - 16, 4, 16, 8), - child: Wrap( - spacing: 10, - runSpacing: 10, - children: pinnedFolders - .map((f) => FolderPill(folder: f)) - .toList(), - ), - ), - ), - if (pinnedStrategies.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.fromLTRB( - 16, 4, 16, 8), - sliver: SliverGrid( - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisExtent: 250, - crossAxisSpacing: 20, - mainAxisSpacing: 20, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => StrategyTile( - strategyData: - pinnedStrategies[index], - ), - childCount: pinnedStrategies.length, - ), - ), - ), - const SliverToBoxAdapter( - child: Divider(indent: 16, endIndent: 16), - ), - // Header for the normal (un-pinned) section, - // shown only alongside the Pinned section so - // the two read as distinct groups. - if (folders.isNotEmpty || strategies.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB( - 16, 8, 16, 4), - child: Text( - 'All', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: Settings - .tacticalVioletTheme.foreground, - ), - ), - ), - ), - ], // Folder pills section (wrap row) if (folders.isNotEmpty) SliverToBoxAdapter( From 6a635f7bcc71c2712a9a0c9fcb3bdd3482c1372b Mon Sep 17 00:00:00 2001 From: Mohammed Njie Date: Sat, 23 May 2026 08:49:41 -0500 Subject: [PATCH 08/14] Update lib/widgets/folder_content.dart Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- lib/widgets/folder_content.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 2af71431..89c28b96 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -14,7 +14,7 @@ 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 +import 'package:shadcn_ui/shadcn_ui.dart'; class FolderContent extends ConsumerWidget { FolderContent({super.key, this.folder}); From f86eab53e5bced1b46e5f1fe7c782445168d09ae Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 01:43:40 +0000 Subject: [PATCH 09/14] Add manual pinned item ordering --- lib/providers/pinned_items_provider.dart | 90 +++++++++++++++++--- lib/widgets/folder_content.dart | 13 ++- lib/widgets/folder_pill.dart | 33 ++++++- lib/widgets/strategy_tile/strategy_tile.dart | 28 ++++-- test/pinned_items_provider_test.dart | 45 +++++++++- 5 files changed, 176 insertions(+), 33 deletions(-) diff --git a/lib/providers/pinned_items_provider.dart b/lib/providers/pinned_items_provider.dart index f67dee83..9f37689d 100644 --- a/lib/providers/pinned_items_provider.dart +++ b/lib/providers/pinned_items_provider.dart @@ -6,11 +6,24 @@ 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 the pin timestamp -/// (milliseconds since epoch) as the value. The timestamp gives us ordering. -/// State is a `Map` of id -> pinnedAt. +/// 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); @@ -21,27 +34,76 @@ class PinnedItemsProvider extends Notifier> { bool isPinned(String id) => state.containsKey(id); - /// Pinned ids, most recently pinned first. - List pinnedIdsByRecency() { - final entries = state.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value)); - return entries.map((e) => e.key).toList(); - } + List pinnedIdsByManualOrder() => pinnedIdsInManualOrder(state); + + @Deprecated('Use pinnedIdsByManualOrder') + List pinnedIdsByRecency() => pinnedIdsByManualOrder(); Future togglePin(String id) async { if (isPinned(id)) { await removePin(id); return; } - final now = DateTime.now().millisecondsSinceEpoch; - await _box.put(id, now); - state = {...state, id: now}; + await _saveOrder([id, ...pinnedIdsByManualOrder()]); } Future removePin(String id) async { if (!isPinned(id)) return; - await _box.delete(id); - state = {...state}..remove(id); + 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.clear(); + await _box.putAll(nextState); + state = nextState; } Map _readFromBox() { diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 89c28b96..56c7b0dd 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -14,7 +14,6 @@ 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'; -import 'package:shadcn_ui/shadcn_ui.dart'; class FolderContent extends ConsumerWidget { FolderContent({super.key, this.folder}); @@ -33,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( @@ -179,9 +176,8 @@ class FolderContent extends ConsumerWidget { final isRoot = folder == null; final pinned = ref.watch(pinnedItemsProvider); if (isRoot && pinned.isNotEmpty && search.isEmpty) { - final pinnedIds = ref - .read(pinnedItemsProvider.notifier) - .pinnedIdsByRecency(); + final orderedPinnedIds = + pinnedIdsInManualOrder(pinned); final strategyById = { for (final s in strategyBox.values) s.id: s }; @@ -191,7 +187,7 @@ class FolderContent extends ConsumerWidget { final pinnedStrategies = []; final pinnedFolders = []; - for (final id in pinnedIds) { + for (final id in orderedPinnedIds) { final s = strategyById[id]; if (s != null) { pinnedStrategies.add(s); @@ -203,7 +199,8 @@ class FolderContent extends ConsumerWidget { // Remove pinned items from their normal position, then // re-insert at the front so they sort to the top. - strategies.removeWhere((s) => pinned.containsKey(s.id)); + strategies + .removeWhere((s) => pinned.containsKey(s.id)); folders.removeWhere((f) => pinned.containsKey(f.id)); strategies.insertAll(0, pinnedStrategies); folders.insertAll(0, pinnedFolders); diff --git a/lib/widgets/folder_pill.dart b/lib/widgets/folder_pill.dart index bd4dc843..f4208f9f 100644 --- a/lib/widgets/folder_pill.dart +++ b/lib/widgets/folder_pill.dart @@ -192,17 +192,44 @@ class _FolderPillState extends ConsumerState } List _buildMenuItems() { - final isPinned = - ref.watch(pinnedItemsProvider).containsKey(widget.folder.id); + 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(widget.folder.id); + ref.read(pinnedItemsProvider.notifier).togglePin(id); }, ), + if (isPinned) ...[ + ShadContextMenuItem( + leading: const Icon(Icons.vertical_align_top), + child: const Text('Move Pin to Top'), + onPressed: () { + if (widget.isDemo) return; + ref.read(pinnedItemsProvider.notifier).movePinToTop(id); + }, + ), + ShadContextMenuItem( + leading: const Icon(Icons.keyboard_arrow_up), + child: const Text('Move Pin Up'), + onPressed: () { + if (widget.isDemo) return; + ref.read(pinnedItemsProvider.notifier).movePinUp(id); + }, + ), + ShadContextMenuItem( + leading: const Icon(Icons.keyboard_arrow_down), + child: const Text('Move Pin Down'), + onPressed: () { + if (widget.isDemo) return; + ref.read(pinnedItemsProvider.notifier).movePinDown(id); + }, + ), + ], ShadContextMenuItem( leading: const Icon(Icons.text_fields), child: const Text('Edit'), diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 864e3094..2ba2a1bc 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -117,16 +117,34 @@ class _StrategyTileState extends ConsumerState { } List _buildMenuItems() { - final isPinned = - ref.watch(pinnedItemsProvider).containsKey(widget.strategyData.id); + 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(widget.strategyData.id), + onPressed: () => ref.read(pinnedItemsProvider.notifier).togglePin(id), ), + if (isPinned) ...[ + ShadContextMenuItem( + leading: const Icon(Icons.vertical_align_top), + child: const Text('Move Pin to Top'), + onPressed: () => + ref.read(pinnedItemsProvider.notifier).movePinToTop(id), + ), + ShadContextMenuItem( + leading: const Icon(Icons.keyboard_arrow_up), + child: const Text('Move Pin Up'), + onPressed: () => ref.read(pinnedItemsProvider.notifier).movePinUp(id), + ), + ShadContextMenuItem( + leading: const Icon(Icons.keyboard_arrow_down), + child: const Text('Move Pin Down'), + onPressed: () => + ref.read(pinnedItemsProvider.notifier).movePinDown(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 index 70df9dcb..9c78ba1d 100644 --- a/test/pinned_items_provider_test.dart +++ b/test/pinned_items_provider_test.dart @@ -39,14 +39,13 @@ void main() { expect(notifier.isPinned('a'), false); }); - test('pinnedIdsByRecency returns most-recently-pinned first', () async { + 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 Future.delayed(const Duration(milliseconds: 5)); await notifier.togglePin('second'); - expect(notifier.pinnedIdsByRecency(), ['second', 'first']); + expect(notifier.pinnedIdsByManualOrder(), ['second', 'first']); }); test('removePin is a no-op when the id is not pinned', () async { @@ -55,4 +54,44 @@ void main() { 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('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']); + }); } From e98001f5cb668fcbec48c185d84b0c30daffc4d0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 02:04:08 +0000 Subject: [PATCH 10/14] Address manual pin ordering review feedback --- lib/providers/pinned_items_provider.dart | 5 ++- lib/widgets/folder_content.dart | 41 ++++++++++-------------- test/pinned_items_provider_test.dart | 34 ++++++++++++++++++++ 3 files changed, 55 insertions(+), 25 deletions(-) diff --git a/lib/providers/pinned_items_provider.dart b/lib/providers/pinned_items_provider.dart index 9f37689d..ee2ccc7a 100644 --- a/lib/providers/pinned_items_provider.dart +++ b/lib/providers/pinned_items_provider.dart @@ -101,8 +101,11 @@ class PinnedItemsProvider extends Notifier> { final nextState = { for (final entry in orderedIds.asMap().entries) entry.value: entry.key, }; - await _box.clear(); await _box.putAll(nextState); + final staleKeys = _box.keys + .where((key) => key is! String || !nextState.containsKey(key)) + .toList(); + await _box.deleteAll(staleKeys); state = nextState; } diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 56c7b0dd..8d314c77 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -169,36 +169,29 @@ class FolderContent extends ConsumerWidget { folders.sort( (a, b) => a.dateCreated.compareTo(b.dateCreated)); - // At the home/root screen (and only when not searching), - // pinned items — which may live in any folder — float to - // the top of the normal lists. They render exactly like - // every other tile/pill, just ordered first. - final isRoot = folder == null; final pinned = ref.watch(pinnedItemsProvider); - if (isRoot && pinned.isNotEmpty && search.isEmpty) { + if (pinned.isNotEmpty && search.isEmpty) { final orderedPinnedIds = pinnedIdsInManualOrder(pinned); - final strategyById = { - for (final s in strategyBox.values) s.id: s - }; - final folderById = { - for (final f in folderBox.values) f.id: f + final pinnedIdOrder = { + for (final entry + in orderedPinnedIds.asMap().entries) + entry.value: entry.key, }; - final pinnedStrategies = []; - final pinnedFolders = []; - for (final id in orderedPinnedIds) { - final s = strategyById[id]; - if (s != null) { - pinnedStrategies.add(s); - continue; - } - final f = folderById[id]; - if (f != null) pinnedFolders.add(f); - } + 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]!)); - // Remove pinned items from their normal position, then - // re-insert at the front so they sort to the top. strategies .removeWhere((s) => pinned.containsKey(s.id)); folders.removeWhere((f) => pinned.containsKey(f.id)); diff --git a/test/pinned_items_provider_test.dart b/test/pinned_items_provider_test.dart index 9c78ba1d..88badede 100644 --- a/test/pinned_items_provider_test.dart +++ b/test/pinned_items_provider_test.dart @@ -81,6 +81,40 @@ void main() { 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); From 6fbd42a5eecb574651718d79c4184daf5e1ece2e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 02:13:38 +0000 Subject: [PATCH 11/14] Fix preview tests without Hive-backed preferences --- lib/providers/user_preferences_provider.dart | 19 ++++++++++++------- .../agents/agent_widget.dart | 4 +++- lib/widgets/page_transition_overlay.dart | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) 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/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, From cfb8f601269a43f419dcf4ef0b2d9959d151f5d2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 02:19:59 +0000 Subject: [PATCH 12/14] Reset drag state before agent tap selection --- lib/widgets/sidebar_widgets/agent_dragable.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/widgets/sidebar_widgets/agent_dragable.dart b/lib/widgets/sidebar_widgets/agent_dragable.dart index d261302f..e1d277fd 100644 --- a/lib/widgets/sidebar_widgets/agent_dragable.dart +++ b/lib/widgets/sidebar_widgets/agent_dragable.dart @@ -207,6 +207,9 @@ class _AgentDragableState extends ConsumerState onTap: shouldDisableForLockedAddItem ? null : () { + ref + .read(dragNotifier.notifier) + .updateDragState(false); ref .read(abilityBarProvider.notifier) .updateData(agent); From c845c3a863fa7ad6661fb8f864fddf735d0974f2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 02:47:20 +0000 Subject: [PATCH 13/14] Handle agent taps after stale drag state --- .../sidebar_widgets/agent_dragable.dart | 300 ++++++++++-------- 1 file changed, 167 insertions(+), 133 deletions(-) diff --git a/lib/widgets/sidebar_widgets/agent_dragable.dart b/lib/widgets/sidebar_widgets/agent_dragable.dart index e1d277fd..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,141 +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(dragNotifier.notifier) - .updateDragState(false); - 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), ), - ), + ], ), ), ), @@ -291,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, + ), + ); } } From 7b295205ad917a1af351c95eed1e7ee5141929ca Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 14:22:37 +0000 Subject: [PATCH 14/14] Use drag and drop for pinned reorder --- lib/widgets/folder_pill.dart | 59 +++--- lib/widgets/strategy_tile/strategy_tile.dart | 182 ++++++++++--------- 2 files changed, 131 insertions(+), 110 deletions(-) diff --git a/lib/widgets/folder_pill.dart b/lib/widgets/folder_pill.dart index f4208f9f..7d3826a2 100644 --- a/lib/widgets/folder_pill.dart +++ b/lib/widgets/folder_pill.dart @@ -61,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, @@ -69,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); @@ -204,32 +231,6 @@ class _FolderPillState extends ConsumerState ref.read(pinnedItemsProvider.notifier).togglePin(id); }, ), - if (isPinned) ...[ - ShadContextMenuItem( - leading: const Icon(Icons.vertical_align_top), - child: const Text('Move Pin to Top'), - onPressed: () { - if (widget.isDemo) return; - ref.read(pinnedItemsProvider.notifier).movePinToTop(id); - }, - ), - ShadContextMenuItem( - leading: const Icon(Icons.keyboard_arrow_up), - child: const Text('Move Pin Up'), - onPressed: () { - if (widget.isDemo) return; - ref.read(pinnedItemsProvider.notifier).movePinUp(id); - }, - ), - ShadContextMenuItem( - leading: const Icon(Icons.keyboard_arrow_down), - child: const Text('Move Pin Down'), - onPressed: () { - if (widget.isDemo) return; - ref.read(pinnedItemsProvider.notifier).movePinDown(id); - }, - ), - ], ShadContextMenuItem( leading: const Icon(Icons.text_fields), child: const Text('Edit'), diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 2ba2a1bc..079ba476 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -41,78 +41,117 @@ 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, + ), + ), ), ), ), - ), + ], ), - ], + ), ), ), ), - ), - ), + ); + }, ); } @@ -126,25 +165,6 @@ class _StrategyTileState extends ConsumerState { child: Text(isPinned ? 'Unpin' : 'Pin'), onPressed: () => ref.read(pinnedItemsProvider.notifier).togglePin(id), ), - if (isPinned) ...[ - ShadContextMenuItem( - leading: const Icon(Icons.vertical_align_top), - child: const Text('Move Pin to Top'), - onPressed: () => - ref.read(pinnedItemsProvider.notifier).movePinToTop(id), - ), - ShadContextMenuItem( - leading: const Icon(Icons.keyboard_arrow_up), - child: const Text('Move Pin Up'), - onPressed: () => ref.read(pinnedItemsProvider.notifier).movePinUp(id), - ), - ShadContextMenuItem( - leading: const Icon(Icons.keyboard_arrow_down), - child: const Text('Move Pin Down'), - onPressed: () => - ref.read(pinnedItemsProvider.notifier).movePinDown(id), - ), - ], ShadContextMenuItem( leading: const Icon(LucideIcons.pencil), child: const Text('Rename'),