From 040f24058ec09a0925f6391a3dfe83a690f44425 Mon Sep 17 00:00:00 2001 From: Nik Shevchenko Date: Fri, 16 Jan 2026 01:58:57 -0800 Subject: [PATCH 1/2] feat: task sorting persistence and drag-drop reordering - Rename 'Actions' to 'Tasks' in macOS menu - Add persistent task sorting that survives app restarts - Tasks keep their position when moving between categories (e.g., tomorrow to today) - Add drag-and-drop reordering for macOS tasks page (matching mobile behavior) - Store sort order per category in SharedPreferences --- app/lib/backend/preferences.dart | 40 +++ .../pages/actions/desktop_actions_page.dart | 263 +++++++++++++++++- app/lib/desktop/pages/desktop_home_page.dart | 4 +- .../pages/action_items/action_items_page.dart | 113 ++++---- 4 files changed, 360 insertions(+), 60 deletions(-) diff --git a/app/lib/backend/preferences.dart b/app/lib/backend/preferences.dart index 52e5efb589..9b69029423 100644 --- a/app/lib/backend/preferences.dart +++ b/app/lib/backend/preferences.dart @@ -512,6 +512,46 @@ class SharedPreferencesUtil { List get enabledCalendarIds => getStringList('enabledCalendarIds') ?? []; + //---------------------------- Task Sorting ---------------------------------// + + /// Get task sort order map (taskId -> sortOrder) + Map get taskSortOrder { + final String json = getString('taskSortOrder'); + if (json.isEmpty) return {}; + try { + final Map decoded = jsonDecode(json); + return decoded.map((key, value) => MapEntry(key, value as int)); + } catch (e) { + return {}; + } + } + + /// Set task sort order map + set taskSortOrder(Map value) { + saveString('taskSortOrder', jsonEncode(value)); + } + + /// Update sort order for a single task + void updateTaskSortOrder(String taskId, int sortOrder) { + final current = taskSortOrder; + current[taskId] = sortOrder; + taskSortOrder = current; + } + + /// Update sort orders for multiple tasks + void updateTaskSortOrders(Map updates) { + final current = taskSortOrder; + current.addAll(updates); + taskSortOrder = current; + } + + /// Remove sort order for a task (when task is deleted) + void removeTaskSortOrder(String taskId) { + final current = taskSortOrder; + current.remove(taskId); + taskSortOrder = current; + } + //--------------------------------- Auth ------------------------------------// String get authToken => getString('authToken'); diff --git a/app/lib/desktop/pages/actions/desktop_actions_page.dart b/app/lib/desktop/pages/actions/desktop_actions_page.dart index 8a74850e11..debb414595 100644 --- a/app/lib/desktop/pages/actions/desktop_actions_page.dart +++ b/app/lib/desktop/pages/actions/desktop_actions_page.dart @@ -5,6 +5,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:provider/provider.dart'; import 'package:visibility_detector/visibility_detector.dart'; +import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/schema.dart'; import 'package:omi/desktop/pages/actions/widgets/desktop_action_item_form_dialog.dart'; import 'package:omi/providers/action_items_provider.dart'; @@ -43,6 +44,10 @@ class DesktopActionsPageState extends State // Show completed tasks bool _showCompleted = false; + // Track the item being hovered over during drag + String? _hoveredItemId; + bool _hoverAbove = false; // true = insert above, false = insert below + void _requestFocusIfPossible() { if (mounted && _focusNode.canRequestFocus) { _focusNode.requestFocus(); @@ -221,6 +226,111 @@ class DesktopActionsPageState extends State HapticFeedback.lightImpact(); } + // Get ordered items for a category, respecting persistent sort order + List _getOrderedItems(List items) { + if (items.isEmpty) return items; + + // Get persistent sort order + final sortOrderMap = SharedPreferencesUtil().taskSortOrder; + + // Sort items by their sort order (lower first), items without order go by createdAt + final sortedItems = List.from(items); + sortedItems.sort((a, b) { + final orderA = sortOrderMap[a.id]; + final orderB = sortOrderMap[b.id]; + + // If both have sort order, sort by it + if (orderA != null && orderB != null) { + return orderA.compareTo(orderB); + } + // If only one has sort order, that one comes first + if (orderA != null) return -1; + if (orderB != null) return 1; + // If neither has sort order, sort by createdAt (newest first) + if (a.createdAt != null && b.createdAt != null) { + return b.createdAt!.compareTo(a.createdAt!); + } + return 0; + }); + + return sortedItems; + } + + // Reorder item within category and persist the order + void _reorderItemInCategory( + ActionItemWithMetadata draggedItem, + String targetItemId, + bool insertAbove, + TaskCategory category, + List categoryItems, + ) { + // Create ordered list for this category + final orderedIds = categoryItems.map((i) => i.id).toList(); + + // Remove dragged item from its current position + orderedIds.remove(draggedItem.id); + + // Find target position + final targetIndex = orderedIds.indexOf(targetItemId); + if (targetIndex != -1) { + // Insert above or below target + final insertIndex = insertAbove ? targetIndex : targetIndex + 1; + orderedIds.insert(insertIndex, draggedItem.id); + } else { + // Target not found, add at end + orderedIds.add(draggedItem.id); + } + + // Persist the new sort order + final updates = {}; + for (var i = 0; i < orderedIds.length; i++) { + updates[orderedIds[i]] = i * 10; + } + SharedPreferencesUtil().updateTaskSortOrders(updates); + + setState(() { + _hoveredItemId = null; + }); + } + + void _reorderItemToFirst( + ActionItemWithMetadata draggedItem, + TaskCategory category, + List categoryItems, + ) { + final orderedIds = categoryItems.map((i) => i.id).toList(); + orderedIds.remove(draggedItem.id); + orderedIds.insert(0, draggedItem.id); + + final updates = {}; + for (var i = 0; i < orderedIds.length; i++) { + updates[orderedIds[i]] = i * 10; + } + SharedPreferencesUtil().updateTaskSortOrders(updates); + + setState(() { + _hoveredItemId = null; + }); + } + + TaskCategory _getCategoryForItem(ActionItemWithMetadata item) { + final now = DateTime.now(); + final startOfTomorrow = DateTime(now.year, now.month, now.day + 1); + final startOfDayAfterTomorrow = DateTime(now.year, now.month, now.day + 2); + + if (item.dueAt == null) { + return TaskCategory.noDeadline; + } + final dueDate = item.dueAt!; + if (dueDate.isBefore(startOfTomorrow)) { + return TaskCategory.today; + } else if (dueDate.isBefore(startOfDayAfterTomorrow)) { + return TaskCategory.tomorrow; + } else { + return TaskCategory.later; + } + } + @override Widget build(BuildContext context) { super.build(context); @@ -515,16 +625,20 @@ class DesktopActionsPageState extends State required ActionItemsProvider provider, }) { final title = _getCategoryTitle(category); + final orderedItems = _getOrderedItems(items); return Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), child: DragTarget( onWillAcceptWithDetails: (details) => true, onAcceptWithDetails: (details) { - _updateTaskCategory(details.data, category); + // Only change category if dropped on empty area (not on a specific item) + if (_hoveredItemId == null) { + _updateTaskCategory(details.data, category); + } }, builder: (context, candidateData, rejectedData) { - final isHovering = candidateData.isNotEmpty; + final isHovering = candidateData.isNotEmpty && _hoveredItemId == null; return AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( @@ -548,9 +662,9 @@ class DesktopActionsPageState extends State ), ), const Spacer(), - if (items.isNotEmpty) + if (orderedItems.isNotEmpty) Text( - '${items.length}', + '${orderedItems.length}', style: const TextStyle( color: ResponsiveHelper.textTertiary, fontSize: 14, @@ -560,8 +674,17 @@ class DesktopActionsPageState extends State ), ), - // Task items - ...items.map((item) => _buildTaskItem(item, provider)), + // Drop zone for first position + if (orderedItems.isNotEmpty) + _buildFirstPositionDropZone(category, orderedItems, candidateData.isNotEmpty), + + // Task items with drag/drop support + ...orderedItems.map((item) => _buildTaskItemWithDrop( + item, + provider, + category: category, + categoryItems: orderedItems, + )), // Spacing after section const SizedBox(height: 8), @@ -573,12 +696,140 @@ class DesktopActionsPageState extends State ); } + Widget _buildFirstPositionDropZone( + TaskCategory category, + List categoryItems, + bool isDragging, + ) { + final isHoveredFirst = _hoveredItemId == '_first_${category.name}'; + + return DragTarget( + onWillAcceptWithDetails: (details) { + if (categoryItems.isNotEmpty && details.data.id == categoryItems.first.id) { + return false; + } + return true; + }, + onAcceptWithDetails: (details) { + final draggedItem = details.data; + _reorderItemToFirst(draggedItem, category, categoryItems); + final draggedCategory = _getCategoryForItem(draggedItem); + if (draggedCategory != category) { + _updateTaskCategory(draggedItem, category); + } + }, + onMove: (details) { + if (_hoveredItemId != '_first_${category.name}') { + setState(() { + _hoveredItemId = '_first_${category.name}'; + }); + } + }, + onLeave: (data) { + if (_hoveredItemId == '_first_${category.name}') { + setState(() { + _hoveredItemId = null; + }); + } + }, + builder: (context, candidateData, rejectedData) { + final showIndicator = isHoveredFirst && candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: showIndicator ? 4 : (isDragging ? 16 : 2), + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: showIndicator ? ResponsiveHelper.purplePrimary : Colors.transparent, + borderRadius: BorderRadius.circular(2), + ), + ); + }, + ); + } + + Widget _buildTaskItemWithDrop( + ActionItemWithMetadata item, + ActionItemsProvider provider, { + required TaskCategory category, + required List categoryItems, + }) { + final isHovered = _hoveredItemId == item.id; + + return DragTarget( + onWillAcceptWithDetails: (details) { + return details.data.id != item.id; + }, + onAcceptWithDetails: (details) { + final draggedItem = details.data; + _reorderItemInCategory(draggedItem, item.id, _hoverAbove, category, categoryItems); + final draggedCategory = _getCategoryForItem(draggedItem); + if (draggedCategory != category) { + _updateTaskCategory(draggedItem, category); + } + }, + onMove: (details) { + final RenderBox? box = context.findRenderObject() as RenderBox?; + if (box != null) { + final localPosition = box.globalToLocal(details.offset); + final isAbove = localPosition.dy < 20; + if (_hoveredItemId != item.id || _hoverAbove != isAbove) { + setState(() { + _hoveredItemId = item.id; + _hoverAbove = isAbove; + }); + } + } + }, + onLeave: (data) { + if (_hoveredItemId == item.id) { + setState(() { + _hoveredItemId = null; + }); + } + }, + builder: (context, candidateData, rejectedData) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drop indicator above + if (isHovered && _hoverAbove && candidateData.isNotEmpty) + Container( + height: 2, + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: ResponsiveHelper.purplePrimary, + borderRadius: BorderRadius.circular(1), + ), + ), + _buildTaskItem(item, provider), + // Drop indicator below + if (isHovered && !_hoverAbove && candidateData.isNotEmpty) + Container( + height: 2, + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: ResponsiveHelper.purplePrimary, + borderRadius: BorderRadius.circular(1), + ), + ), + ], + ); + }, + ); + } + Widget _buildTaskItem(ActionItemWithMetadata item, ActionItemsProvider provider) { final indentLevel = _getIndentLevel(item.id); final indentWidth = indentLevel * 28.0; return LongPressDraggable( data: item, + delay: const Duration(milliseconds: 150), + onDragEnd: (details) { + setState(() { + _hoveredItemId = null; + }); + }, feedback: Material( color: Colors.transparent, child: Container( diff --git a/app/lib/desktop/pages/desktop_home_page.dart b/app/lib/desktop/pages/desktop_home_page.dart index 64f682cde5..c7146e4442 100644 --- a/app/lib/desktop/pages/desktop_home_page.dart +++ b/app/lib/desktop/pages/desktop_home_page.dart @@ -498,7 +498,7 @@ class _DesktopHomePageState extends State with WidgetsBindingOb ), _buildNavItem( icon: FontAwesomeIcons.squareCheck, - label: 'Actions', + label: 'Tasks', index: 3, isSelected: homeProvider.selectedIndex == 3, onTap: () => _navigateToIndex(3, homeProvider), @@ -791,7 +791,7 @@ class _DesktopHomePageState extends State with WidgetsBindingOb child: InkWell( onTap: () { MixpanelManager() - .bottomNavigationTabClicked(['Conversations', 'Chat', 'Memories', 'Actions', 'Apps'][index]); + .bottomNavigationTabClicked(['Conversations', 'Chat', 'Memories', 'Tasks', 'Apps'][index]); onTap(); }, borderRadius: BorderRadius.circular(10), diff --git a/app/lib/pages/action_items/action_items_page.dart b/app/lib/pages/action_items/action_items_page.dart index 37f6a7887f..cfd0e67f6b 100644 --- a/app/lib/pages/action_items/action_items_page.dart +++ b/app/lib/pages/action_items/action_items_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/schema.dart'; import 'package:omi/providers/action_items_provider.dart'; import 'package:omi/services/app_review_service.dart'; @@ -25,9 +26,6 @@ class _ActionItemsPageState extends State with AutomaticKeepAli // Track indent levels for each task (task id -> indent level 0-3) final Map _indentLevels = {}; - // Track custom order for each category (category -> list of item ids) - final Map> _categoryOrder = {}; - // Track the item being hovered over during drag String? _hoveredItemId; bool _hoverAbove = false; // true = insert above, false = insert below @@ -204,35 +202,40 @@ class _ActionItemsPageState extends State with AutomaticKeepAli HapticFeedback.lightImpact(); } - // Get ordered items for a category, respecting custom order + // Get ordered items for a category, respecting persistent sort order List _getOrderedItems( TaskCategory category, List items, ) { - final order = _categoryOrder[category]; - if (order == null || order.isEmpty) { - return items; - } + if (items.isEmpty) return items; - // Sort items based on custom order, new items go at the end - final orderedItems = []; - final itemMap = {for (var item in items) item.id: item}; + // Get persistent sort order + final sortOrderMap = SharedPreferencesUtil().taskSortOrder; - // Add items in custom order - for (final id in order) { - if (itemMap.containsKey(id)) { - orderedItems.add(itemMap[id]!); - itemMap.remove(id); - } - } + // Sort items by their sort order (lower first), items without order go by createdAt + final sortedItems = List.from(items); + sortedItems.sort((a, b) { + final orderA = sortOrderMap[a.id]; + final orderB = sortOrderMap[b.id]; - // Add any remaining items (new ones not in custom order) - orderedItems.addAll(itemMap.values); + // If both have sort order, sort by it + if (orderA != null && orderB != null) { + return orderA.compareTo(orderB); + } + // If only one has sort order, that one comes first + if (orderA != null) return -1; + if (orderB != null) return 1; + // If neither has sort order, sort by createdAt (newest first) + if (a.createdAt != null && b.createdAt != null) { + return b.createdAt!.compareTo(a.createdAt!); + } + return 0; + }); - return orderedItems; + return sortedItems; } - // Reorder item within category + // Reorder item within category and persist the order void _reorderItemInCategory( ActionItemWithMetadata draggedItem, String targetItemId, @@ -240,28 +243,31 @@ class _ActionItemsPageState extends State with AutomaticKeepAli TaskCategory category, List categoryItems, ) { - setState(() { - // Initialize category order if needed - if (!_categoryOrder.containsKey(category)) { - _categoryOrder[category] = categoryItems.map((i) => i.id).toList(); - } - - final order = _categoryOrder[category]!; - - // Remove dragged item from its current position - order.remove(draggedItem.id); + // Create ordered list for this category + final orderedIds = categoryItems.map((i) => i.id).toList(); + + // Remove dragged item from its current position + orderedIds.remove(draggedItem.id); + + // Find target position + final targetIndex = orderedIds.indexOf(targetItemId); + if (targetIndex != -1) { + // Insert above or below target + final insertIndex = insertAbove ? targetIndex : targetIndex + 1; + orderedIds.insert(insertIndex, draggedItem.id); + } else { + // Target not found, add at end + orderedIds.add(draggedItem.id); + } - // Find target position - final targetIndex = order.indexOf(targetItemId); - if (targetIndex != -1) { - // Insert above or below target - final insertIndex = insertAbove ? targetIndex : targetIndex + 1; - order.insert(insertIndex, draggedItem.id); - } else { - // Target not found, add at end - order.add(draggedItem.id); - } + // Persist the new sort order (use index * 10 to allow for future insertions) + final updates = {}; + for (var i = 0; i < orderedIds.length; i++) { + updates[orderedIds[i]] = i * 10; + } + SharedPreferencesUtil().updateTaskSortOrders(updates); + setState(() { // Clear hover state _hoveredItemId = null; }); @@ -529,20 +535,23 @@ class _ActionItemsPageState extends State with AutomaticKeepAli TaskCategory category, List categoryItems, ) { - setState(() { - // Initialize category order if needed - if (!_categoryOrder.containsKey(category)) { - _categoryOrder[category] = categoryItems.map((i) => i.id).toList(); - } + // Create ordered list for this category + final orderedIds = categoryItems.map((i) => i.id).toList(); - final order = _categoryOrder[category]!; + // Remove dragged item from its current position + orderedIds.remove(draggedItem.id); - // Remove dragged item from its current position - order.remove(draggedItem.id); + // Insert at first position + orderedIds.insert(0, draggedItem.id); - // Insert at first position - order.insert(0, draggedItem.id); + // Persist the new sort order (use index * 10 to allow for future insertions) + final updates = {}; + for (var i = 0; i < orderedIds.length; i++) { + updates[orderedIds[i]] = i * 10; + } + SharedPreferencesUtil().updateTaskSortOrders(updates); + setState(() { // Clear hover state _hoveredItemId = null; }); From 2ee21e77a3a1ee0c18e7e18e080e6006f10acc29 Mon Sep 17 00:00:00 2001 From: Nik Shevchenko Date: Fri, 16 Jan 2026 03:14:10 -0800 Subject: [PATCH 2/2] fix: revert task sorting changes that broke task display Keep only the 'Actions' -> 'Tasks' rename, remove sorting persistence that was causing tasks not to render. --- app/lib/backend/preferences.dart | 40 --- .../pages/actions/desktop_actions_page.dart | 263 +----------------- .../pages/action_items/action_items_page.dart | 113 ++++---- 3 files changed, 58 insertions(+), 358 deletions(-) diff --git a/app/lib/backend/preferences.dart b/app/lib/backend/preferences.dart index 9b69029423..52e5efb589 100644 --- a/app/lib/backend/preferences.dart +++ b/app/lib/backend/preferences.dart @@ -512,46 +512,6 @@ class SharedPreferencesUtil { List get enabledCalendarIds => getStringList('enabledCalendarIds') ?? []; - //---------------------------- Task Sorting ---------------------------------// - - /// Get task sort order map (taskId -> sortOrder) - Map get taskSortOrder { - final String json = getString('taskSortOrder'); - if (json.isEmpty) return {}; - try { - final Map decoded = jsonDecode(json); - return decoded.map((key, value) => MapEntry(key, value as int)); - } catch (e) { - return {}; - } - } - - /// Set task sort order map - set taskSortOrder(Map value) { - saveString('taskSortOrder', jsonEncode(value)); - } - - /// Update sort order for a single task - void updateTaskSortOrder(String taskId, int sortOrder) { - final current = taskSortOrder; - current[taskId] = sortOrder; - taskSortOrder = current; - } - - /// Update sort orders for multiple tasks - void updateTaskSortOrders(Map updates) { - final current = taskSortOrder; - current.addAll(updates); - taskSortOrder = current; - } - - /// Remove sort order for a task (when task is deleted) - void removeTaskSortOrder(String taskId) { - final current = taskSortOrder; - current.remove(taskId); - taskSortOrder = current; - } - //--------------------------------- Auth ------------------------------------// String get authToken => getString('authToken'); diff --git a/app/lib/desktop/pages/actions/desktop_actions_page.dart b/app/lib/desktop/pages/actions/desktop_actions_page.dart index debb414595..8a74850e11 100644 --- a/app/lib/desktop/pages/actions/desktop_actions_page.dart +++ b/app/lib/desktop/pages/actions/desktop_actions_page.dart @@ -5,7 +5,6 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:provider/provider.dart'; import 'package:visibility_detector/visibility_detector.dart'; -import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/schema.dart'; import 'package:omi/desktop/pages/actions/widgets/desktop_action_item_form_dialog.dart'; import 'package:omi/providers/action_items_provider.dart'; @@ -44,10 +43,6 @@ class DesktopActionsPageState extends State // Show completed tasks bool _showCompleted = false; - // Track the item being hovered over during drag - String? _hoveredItemId; - bool _hoverAbove = false; // true = insert above, false = insert below - void _requestFocusIfPossible() { if (mounted && _focusNode.canRequestFocus) { _focusNode.requestFocus(); @@ -226,111 +221,6 @@ class DesktopActionsPageState extends State HapticFeedback.lightImpact(); } - // Get ordered items for a category, respecting persistent sort order - List _getOrderedItems(List items) { - if (items.isEmpty) return items; - - // Get persistent sort order - final sortOrderMap = SharedPreferencesUtil().taskSortOrder; - - // Sort items by their sort order (lower first), items without order go by createdAt - final sortedItems = List.from(items); - sortedItems.sort((a, b) { - final orderA = sortOrderMap[a.id]; - final orderB = sortOrderMap[b.id]; - - // If both have sort order, sort by it - if (orderA != null && orderB != null) { - return orderA.compareTo(orderB); - } - // If only one has sort order, that one comes first - if (orderA != null) return -1; - if (orderB != null) return 1; - // If neither has sort order, sort by createdAt (newest first) - if (a.createdAt != null && b.createdAt != null) { - return b.createdAt!.compareTo(a.createdAt!); - } - return 0; - }); - - return sortedItems; - } - - // Reorder item within category and persist the order - void _reorderItemInCategory( - ActionItemWithMetadata draggedItem, - String targetItemId, - bool insertAbove, - TaskCategory category, - List categoryItems, - ) { - // Create ordered list for this category - final orderedIds = categoryItems.map((i) => i.id).toList(); - - // Remove dragged item from its current position - orderedIds.remove(draggedItem.id); - - // Find target position - final targetIndex = orderedIds.indexOf(targetItemId); - if (targetIndex != -1) { - // Insert above or below target - final insertIndex = insertAbove ? targetIndex : targetIndex + 1; - orderedIds.insert(insertIndex, draggedItem.id); - } else { - // Target not found, add at end - orderedIds.add(draggedItem.id); - } - - // Persist the new sort order - final updates = {}; - for (var i = 0; i < orderedIds.length; i++) { - updates[orderedIds[i]] = i * 10; - } - SharedPreferencesUtil().updateTaskSortOrders(updates); - - setState(() { - _hoveredItemId = null; - }); - } - - void _reorderItemToFirst( - ActionItemWithMetadata draggedItem, - TaskCategory category, - List categoryItems, - ) { - final orderedIds = categoryItems.map((i) => i.id).toList(); - orderedIds.remove(draggedItem.id); - orderedIds.insert(0, draggedItem.id); - - final updates = {}; - for (var i = 0; i < orderedIds.length; i++) { - updates[orderedIds[i]] = i * 10; - } - SharedPreferencesUtil().updateTaskSortOrders(updates); - - setState(() { - _hoveredItemId = null; - }); - } - - TaskCategory _getCategoryForItem(ActionItemWithMetadata item) { - final now = DateTime.now(); - final startOfTomorrow = DateTime(now.year, now.month, now.day + 1); - final startOfDayAfterTomorrow = DateTime(now.year, now.month, now.day + 2); - - if (item.dueAt == null) { - return TaskCategory.noDeadline; - } - final dueDate = item.dueAt!; - if (dueDate.isBefore(startOfTomorrow)) { - return TaskCategory.today; - } else if (dueDate.isBefore(startOfDayAfterTomorrow)) { - return TaskCategory.tomorrow; - } else { - return TaskCategory.later; - } - } - @override Widget build(BuildContext context) { super.build(context); @@ -625,20 +515,16 @@ class DesktopActionsPageState extends State required ActionItemsProvider provider, }) { final title = _getCategoryTitle(category); - final orderedItems = _getOrderedItems(items); return Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), child: DragTarget( onWillAcceptWithDetails: (details) => true, onAcceptWithDetails: (details) { - // Only change category if dropped on empty area (not on a specific item) - if (_hoveredItemId == null) { - _updateTaskCategory(details.data, category); - } + _updateTaskCategory(details.data, category); }, builder: (context, candidateData, rejectedData) { - final isHovering = candidateData.isNotEmpty && _hoveredItemId == null; + final isHovering = candidateData.isNotEmpty; return AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( @@ -662,9 +548,9 @@ class DesktopActionsPageState extends State ), ), const Spacer(), - if (orderedItems.isNotEmpty) + if (items.isNotEmpty) Text( - '${orderedItems.length}', + '${items.length}', style: const TextStyle( color: ResponsiveHelper.textTertiary, fontSize: 14, @@ -674,17 +560,8 @@ class DesktopActionsPageState extends State ), ), - // Drop zone for first position - if (orderedItems.isNotEmpty) - _buildFirstPositionDropZone(category, orderedItems, candidateData.isNotEmpty), - - // Task items with drag/drop support - ...orderedItems.map((item) => _buildTaskItemWithDrop( - item, - provider, - category: category, - categoryItems: orderedItems, - )), + // Task items + ...items.map((item) => _buildTaskItem(item, provider)), // Spacing after section const SizedBox(height: 8), @@ -696,140 +573,12 @@ class DesktopActionsPageState extends State ); } - Widget _buildFirstPositionDropZone( - TaskCategory category, - List categoryItems, - bool isDragging, - ) { - final isHoveredFirst = _hoveredItemId == '_first_${category.name}'; - - return DragTarget( - onWillAcceptWithDetails: (details) { - if (categoryItems.isNotEmpty && details.data.id == categoryItems.first.id) { - return false; - } - return true; - }, - onAcceptWithDetails: (details) { - final draggedItem = details.data; - _reorderItemToFirst(draggedItem, category, categoryItems); - final draggedCategory = _getCategoryForItem(draggedItem); - if (draggedCategory != category) { - _updateTaskCategory(draggedItem, category); - } - }, - onMove: (details) { - if (_hoveredItemId != '_first_${category.name}') { - setState(() { - _hoveredItemId = '_first_${category.name}'; - }); - } - }, - onLeave: (data) { - if (_hoveredItemId == '_first_${category.name}') { - setState(() { - _hoveredItemId = null; - }); - } - }, - builder: (context, candidateData, rejectedData) { - final showIndicator = isHoveredFirst && candidateData.isNotEmpty; - return AnimatedContainer( - duration: const Duration(milliseconds: 150), - height: showIndicator ? 4 : (isDragging ? 16 : 2), - margin: const EdgeInsets.symmetric(horizontal: 4), - decoration: BoxDecoration( - color: showIndicator ? ResponsiveHelper.purplePrimary : Colors.transparent, - borderRadius: BorderRadius.circular(2), - ), - ); - }, - ); - } - - Widget _buildTaskItemWithDrop( - ActionItemWithMetadata item, - ActionItemsProvider provider, { - required TaskCategory category, - required List categoryItems, - }) { - final isHovered = _hoveredItemId == item.id; - - return DragTarget( - onWillAcceptWithDetails: (details) { - return details.data.id != item.id; - }, - onAcceptWithDetails: (details) { - final draggedItem = details.data; - _reorderItemInCategory(draggedItem, item.id, _hoverAbove, category, categoryItems); - final draggedCategory = _getCategoryForItem(draggedItem); - if (draggedCategory != category) { - _updateTaskCategory(draggedItem, category); - } - }, - onMove: (details) { - final RenderBox? box = context.findRenderObject() as RenderBox?; - if (box != null) { - final localPosition = box.globalToLocal(details.offset); - final isAbove = localPosition.dy < 20; - if (_hoveredItemId != item.id || _hoverAbove != isAbove) { - setState(() { - _hoveredItemId = item.id; - _hoverAbove = isAbove; - }); - } - } - }, - onLeave: (data) { - if (_hoveredItemId == item.id) { - setState(() { - _hoveredItemId = null; - }); - } - }, - builder: (context, candidateData, rejectedData) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Drop indicator above - if (isHovered && _hoverAbove && candidateData.isNotEmpty) - Container( - height: 2, - margin: const EdgeInsets.symmetric(horizontal: 4), - decoration: BoxDecoration( - color: ResponsiveHelper.purplePrimary, - borderRadius: BorderRadius.circular(1), - ), - ), - _buildTaskItem(item, provider), - // Drop indicator below - if (isHovered && !_hoverAbove && candidateData.isNotEmpty) - Container( - height: 2, - margin: const EdgeInsets.symmetric(horizontal: 4), - decoration: BoxDecoration( - color: ResponsiveHelper.purplePrimary, - borderRadius: BorderRadius.circular(1), - ), - ), - ], - ); - }, - ); - } - Widget _buildTaskItem(ActionItemWithMetadata item, ActionItemsProvider provider) { final indentLevel = _getIndentLevel(item.id); final indentWidth = indentLevel * 28.0; return LongPressDraggable( data: item, - delay: const Duration(milliseconds: 150), - onDragEnd: (details) { - setState(() { - _hoveredItemId = null; - }); - }, feedback: Material( color: Colors.transparent, child: Container( diff --git a/app/lib/pages/action_items/action_items_page.dart b/app/lib/pages/action_items/action_items_page.dart index cfd0e67f6b..37f6a7887f 100644 --- a/app/lib/pages/action_items/action_items_page.dart +++ b/app/lib/pages/action_items/action_items_page.dart @@ -3,7 +3,6 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/schema.dart'; import 'package:omi/providers/action_items_provider.dart'; import 'package:omi/services/app_review_service.dart'; @@ -26,6 +25,9 @@ class _ActionItemsPageState extends State with AutomaticKeepAli // Track indent levels for each task (task id -> indent level 0-3) final Map _indentLevels = {}; + // Track custom order for each category (category -> list of item ids) + final Map> _categoryOrder = {}; + // Track the item being hovered over during drag String? _hoveredItemId; bool _hoverAbove = false; // true = insert above, false = insert below @@ -202,40 +204,35 @@ class _ActionItemsPageState extends State with AutomaticKeepAli HapticFeedback.lightImpact(); } - // Get ordered items for a category, respecting persistent sort order + // Get ordered items for a category, respecting custom order List _getOrderedItems( TaskCategory category, List items, ) { - if (items.isEmpty) return items; - - // Get persistent sort order - final sortOrderMap = SharedPreferencesUtil().taskSortOrder; + final order = _categoryOrder[category]; + if (order == null || order.isEmpty) { + return items; + } - // Sort items by their sort order (lower first), items without order go by createdAt - final sortedItems = List.from(items); - sortedItems.sort((a, b) { - final orderA = sortOrderMap[a.id]; - final orderB = sortOrderMap[b.id]; + // Sort items based on custom order, new items go at the end + final orderedItems = []; + final itemMap = {for (var item in items) item.id: item}; - // If both have sort order, sort by it - if (orderA != null && orderB != null) { - return orderA.compareTo(orderB); + // Add items in custom order + for (final id in order) { + if (itemMap.containsKey(id)) { + orderedItems.add(itemMap[id]!); + itemMap.remove(id); } - // If only one has sort order, that one comes first - if (orderA != null) return -1; - if (orderB != null) return 1; - // If neither has sort order, sort by createdAt (newest first) - if (a.createdAt != null && b.createdAt != null) { - return b.createdAt!.compareTo(a.createdAt!); - } - return 0; - }); + } - return sortedItems; + // Add any remaining items (new ones not in custom order) + orderedItems.addAll(itemMap.values); + + return orderedItems; } - // Reorder item within category and persist the order + // Reorder item within category void _reorderItemInCategory( ActionItemWithMetadata draggedItem, String targetItemId, @@ -243,31 +240,28 @@ class _ActionItemsPageState extends State with AutomaticKeepAli TaskCategory category, List categoryItems, ) { - // Create ordered list for this category - final orderedIds = categoryItems.map((i) => i.id).toList(); - - // Remove dragged item from its current position - orderedIds.remove(draggedItem.id); - - // Find target position - final targetIndex = orderedIds.indexOf(targetItemId); - if (targetIndex != -1) { - // Insert above or below target - final insertIndex = insertAbove ? targetIndex : targetIndex + 1; - orderedIds.insert(insertIndex, draggedItem.id); - } else { - // Target not found, add at end - orderedIds.add(draggedItem.id); - } + setState(() { + // Initialize category order if needed + if (!_categoryOrder.containsKey(category)) { + _categoryOrder[category] = categoryItems.map((i) => i.id).toList(); + } - // Persist the new sort order (use index * 10 to allow for future insertions) - final updates = {}; - for (var i = 0; i < orderedIds.length; i++) { - updates[orderedIds[i]] = i * 10; - } - SharedPreferencesUtil().updateTaskSortOrders(updates); + final order = _categoryOrder[category]!; + + // Remove dragged item from its current position + order.remove(draggedItem.id); + + // Find target position + final targetIndex = order.indexOf(targetItemId); + if (targetIndex != -1) { + // Insert above or below target + final insertIndex = insertAbove ? targetIndex : targetIndex + 1; + order.insert(insertIndex, draggedItem.id); + } else { + // Target not found, add at end + order.add(draggedItem.id); + } - setState(() { // Clear hover state _hoveredItemId = null; }); @@ -535,23 +529,20 @@ class _ActionItemsPageState extends State with AutomaticKeepAli TaskCategory category, List categoryItems, ) { - // Create ordered list for this category - final orderedIds = categoryItems.map((i) => i.id).toList(); + setState(() { + // Initialize category order if needed + if (!_categoryOrder.containsKey(category)) { + _categoryOrder[category] = categoryItems.map((i) => i.id).toList(); + } - // Remove dragged item from its current position - orderedIds.remove(draggedItem.id); + final order = _categoryOrder[category]!; - // Insert at first position - orderedIds.insert(0, draggedItem.id); + // Remove dragged item from its current position + order.remove(draggedItem.id); - // Persist the new sort order (use index * 10 to allow for future insertions) - final updates = {}; - for (var i = 0; i < orderedIds.length; i++) { - updates[orderedIds[i]] = i * 10; - } - SharedPreferencesUtil().updateTaskSortOrders(updates); + // Insert at first position + order.insert(0, draggedItem.id); - setState(() { // Clear hover state _hoveredItemId = null; });