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
122 changes: 122 additions & 0 deletions lib/providers/pinned_items_provider.dart
Original file line number Diff line number Diff line change
@@ -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, Map<String, int>>(
PinnedItemsProvider.new);

const _legacyTimestampThreshold = 1000000000000;

List<String> pinnedIdsInManualOrder(Map<String, int> pinned) {
final entries = pinned.entries.toList()..sort(_comparePinnedEntries);
return entries.map((e) => e.key).toList();
}

int _comparePinnedEntries(MapEntry<String, int> a, MapEntry<String, int> 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<String, int>` of id -> order.
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);

List<String> pinnedIdsByManualOrder() => pinnedIdsInManualOrder(state);

@Deprecated('Use pinnedIdsByManualOrder')
List<String> pinnedIdsByRecency() => pinnedIdsByManualOrder();

Future<void> togglePin(String id) async {
if (isPinned(id)) {
await removePin(id);
return;
}
await _saveOrder([id, ...pinnedIdsByManualOrder()]);
}

Future<void> removePin(String id) async {
if (!isPinned(id)) return;
final orderedIds = pinnedIdsByManualOrder()..remove(id);
await _saveOrder(orderedIds);
}

Future<void> 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<void> 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<void> movePinToTop(String id) async {
final orderedIds = pinnedIdsByManualOrder();
if (!orderedIds.remove(id)) return;
await _saveOrder([id, ...orderedIds]);
}

Future<void> 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<void> _saveOrder(List<String> orderedIds) async {
final nextState = <String, int>{
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;
}
Comment on lines +100 to +110
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 Non-atomic pin storage write

_box.clear() and _box.putAll() are two separate async operations. If the app is terminated (crash, force-close, OS kill) between the two calls, the box is left empty and all pin data is permanently lost. A safer pattern is to write the new state first and then remove stale keys, so there is never a window where valid data has been erased but the replacement has not yet landed.

Suggested change
Future<void> _saveOrder(List<String> orderedIds) async {
final nextState = <String, int>{
for (final entry in orderedIds.asMap().entries) entry.value: entry.key,
};
await _box.clear();
await _box.putAll(nextState);
state = nextState;
}
Future<void> _saveOrder(List<String> orderedIds) async {
final nextState = <String, int>{
for (final entry in orderedIds.asMap().entries) entry.value: entry.key,
};
// Write new entries first, then remove stale keys so there is no window
// where the box is empty if the app is terminated mid-write.
await _box.putAll(nextState);
final staleKeys =
_box.keys.whereType<String>().where((k) => !nextState.containsKey(k));
await _box.deleteAll(staleKeys.toList());
state = nextState;
}


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
19 changes: 12 additions & 7 deletions lib/providers/user_preferences_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -665,13 +665,18 @@ class AppPreferencesNotifier extends Notifier<AppPreferences> {
}

AppPreferences _readFromHive() {
return Hive.box<AppPreferences>(HiveBoxNames.appPreferencesBox).get(
MapThemeProfilesProvider.appPreferencesSingletonKey,
) ??
AppPreferences(
defaultThemeProfileIdForNewStrategies:
MapThemeProfilesProvider.immutableDefaultProfileId,
);
final fallback = AppPreferences(
defaultThemeProfileIdForNewStrategies:
MapThemeProfilesProvider.immutableDefaultProfileId,
);
try {
return Hive.box<AppPreferences>(HiveBoxNames.appPreferencesBox).get(
MapThemeProfilesProvider.appPreferencesSingletonKey,
) ??
fallback;
} on HiveError {
return fallback;
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion lib/widgets/draggable_widgets/agents/agent_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,10 @@ class AgentWidget extends ConsumerWidget {
)
: null;

final canShowAgentContextMenu =
!isScreenshot && (lineUpId != null || (id != null && id!.isNotEmpty));
final contextMenuItems = <ShadContextMenuItem>[
if (!isScreenshot)
if (canShowAgentContextMenu)
ShadContextMenuItem.raw(
variant: ShadContextMenuItemVariant.primary,
height: 36,
Expand Down
34 changes: 31 additions & 3 deletions lib/widgets/folder_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
45 changes: 42 additions & 3 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 @@ -60,6 +61,10 @@ class _FolderPillState extends ConsumerState<FolderPill>

@override
Widget build(BuildContext context) {
final pinned = ref.watch(pinnedItemsProvider);
final id = widget.folder.id;
final isPinned = pinned.containsKey(id);

return Draggable<GridItem>(
feedback: _buildDragFeedback(),
dragAnchorStrategy: pointerDragAnchorStrategy,
Expand All @@ -68,15 +73,38 @@ class _FolderPillState extends ConsumerState<FolderPill>
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);
Expand Down Expand Up @@ -191,7 +219,18 @@ class _FolderPillState extends ConsumerState<FolderPill>
}

List<ShadContextMenuItem> _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'),
Expand Down
2 changes: 1 addition & 1 deletion lib/widgets/page_transition_overlay.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading