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..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/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/widgets/folder_content.dart b/lib/widgets/folder_content.dart index d4b23db1..89c28b96 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -12,8 +12,9 @@ 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'; import 'package:shadcn_ui/shadcn_ui.dart'; -// ... your existing imports class FolderContent extends ConsumerWidget { FolderContent({super.key, this.folder}); @@ -171,6 +172,43 @@ 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) { + final pinnedIds = ref + .read(pinnedItemsProvider.notifier) + .pinnedIdsByRecency(); + final strategyById = { + for (final s in strategyBox.values) s.id: s + }; + final folderById = { + 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) { + pinnedStrategies.add(s); + continue; + } + final f = folderById[id]; + 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) { return const IcaDropTarget( 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'), 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'), 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); + }); +}