Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/const/hive_boxes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
1 change: 1 addition & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Future<void> main(List<String> args) async {
await Hive.openBox<MapThemeProfile>(HiveBoxNames.mapThemeProfilesBox);
await Hive.openBox<AppPreferences>(HiveBoxNames.appPreferencesBox);
await Hive.openBox<bool>(HiveBoxNames.favoriteAgentsBox);
await Hive.openBox<int>(HiveBoxNames.pinnedItemsBox);

await MapThemeProfilesProvider.bootstrap();

Expand Down
2 changes: 2 additions & 0 deletions lib/providers/folder_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -174,6 +175,7 @@ class FolderProvider extends Notifier<String?> {
}

void deleteFolder(String folderID) async {
await ref.read(pinnedItemsProvider.notifier).removePin(folderID);
// state = state.where((folder) => folder.id != folderID).toList();

final strategyList =
Expand Down
57 changes: 57 additions & 0 deletions lib/providers/pinned_items_provider.dart
Original file line number Diff line number Diff line change
@@ -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, Map<String, int>>(
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<String, int>` of id -> pinnedAt.
class PinnedItemsProvider extends Notifier<Map<String, int>> {
Box<int> get _box => Hive.box<int>(HiveBoxNames.pinnedItemsBox);

@override
Map<String, int> build() {
return _readFromBox();
}

bool isPinned(String id) => state.containsKey(id);

/// Pinned ids, most recently pinned first.
List<String> pinnedIdsByRecency() {
final entries = state.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return entries.map((e) => e.key).toList();
}

Future<void> 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<void> removePin(String id) async {
if (!isPinned(id)) return;
await _box.delete(id);
state = {...state}..remove(id);
}

Map<String, int> _readFromBox() {
final result = <String, int>{};
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;
}
}
2 changes: 2 additions & 0 deletions lib/providers/strategy_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -3561,6 +3562,7 @@ class StrategyProvider extends Notifier<StrategyState> {
}

Future<void> deleteStrategy(String strategyID) async {
await ref.read(pinnedItemsProvider.notifier).removePin(strategyID);
await Hive.box<StrategyData>(HiveBoxNames.strategiesBox).delete(strategyID);

final directory = await getApplicationSupportDirectory();
Expand Down
40 changes: 39 additions & 1 deletion lib/widgets/folder_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down Expand Up @@ -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();
Comment on lines +182 to +184
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 ref.read inside build() for state access

pinnedIdsByRecency() is called via ref.read(pinnedItemsProvider.notifier) on line 183 inside build(). Riverpod's ref.read takes a snapshot at call time and does not subscribe, so in the narrow window between the ref.watch on line 180 and this ref.read, the notifier state could theoretically change (e.g. a concurrent async pin toggle completing). In practice this means pinned (line 180) and the snapshot used by pinnedIdsByRecency() could differ. Since pinned is already the full Map<String, int> state, you can derive the sorted order directly from it — no ref.read needed.

final strategyById = {
for (final s in strategyBox.values) s.id: s
};
final folderById = {
for (final f in folderBox.values) f.id: f
};

final pinnedStrategies = <StrategyData>[];
final pinnedFolders = <Folder>[];
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(
Expand Down
11 changes: 11 additions & 0 deletions lib/widgets/folder_pill.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -191,7 +192,17 @@ class _FolderPillState extends ConsumerState<FolderPill>
}

List<ShadContextMenuItem> _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'),
Expand Down
10 changes: 10 additions & 0 deletions lib/widgets/strategy_tile/strategy_tile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -116,7 +117,16 @@ class _StrategyTileState extends ConsumerState<StrategyTile> {
}

List<ShadContextMenuItem> _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'),
Expand Down
58 changes: 58 additions & 0 deletions test/pinned_items_provider_test.dart
Original file line number Diff line number Diff line change
@@ -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<int>(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<void>.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);
});
}