Skip to content

Desktop fixes and improvements #2601

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
244 changes: 204 additions & 40 deletions app/lib/desktop/pages/actions/widgets/desktop_action_group.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:omi/backend/http/api/conversations.dart';
import 'package:omi/backend/schema/conversation.dart';
import 'package:omi/backend/schema/structured.dart';
import 'package:omi/pages/conversation_detail/page.dart';
Expand All @@ -13,7 +15,7 @@ import 'package:omi/ui/atoms/omi_icon_badge.dart';
import 'package:omi/ui/atoms/omi_icon_button.dart';


class DesktopActionGroup extends StatelessWidget {
class DesktopActionGroup extends StatefulWidget {
final ServerConversation conversation;
final List<ActionItem> actionItems;

Expand All @@ -23,9 +25,145 @@ class DesktopActionGroup extends StatelessWidget {
required this.actionItems,
});

@override
State<DesktopActionGroup> createState() => _DesktopActionGroupState();
}

class _DesktopActionGroupState extends State<DesktopActionGroup> {
final Map<int, bool> _editingStates = {};
final Map<int, TextEditingController> _textControllers = {};
final Map<int, FocusNode> _focusNodes = {};

@override
void initState() {
super.initState();
_initializeControllers();
}

@override
void dispose() {
_disposeControllers();
super.dispose();
}

void _initializeControllers() {
for (int i = 0; i < widget.actionItems.length; i++) {
final itemIndex = widget.conversation.structured.actionItems.indexOf(widget.actionItems[i]);
_editingStates[itemIndex] = false;
_textControllers[itemIndex] = TextEditingController();
_focusNodes[itemIndex] = FocusNode();

// Listen for focus changes to save when user clicks outside
_focusNodes[itemIndex]!.addListener(() {
if (!_focusNodes[itemIndex]!.hasFocus && _editingStates[itemIndex] == true) {
_saveChanges(itemIndex);
}
});
}
}

void _disposeControllers() {
for (final controller in _textControllers.values) {
controller.dispose();
}
for (final focusNode in _focusNodes.values) {
focusNode.dispose();
}
}

void _startEditing(int itemIndex) {
final item = widget.actionItems.firstWhere((item) => widget.conversation.structured.actionItems.indexOf(item) == itemIndex);
setState(() {
_editingStates[itemIndex] = true;
_textControllers[itemIndex]!.text = item.description;
});

WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNodes[itemIndex]!.requestFocus();
_textControllers[itemIndex]!.selection = TextSelection(
baseOffset: 0,
extentOffset: _textControllers[itemIndex]!.text.length
);
});
}

void _cancelEditing(int itemIndex) {
setState(() {
_editingStates[itemIndex] = false;
});
}

void _saveChanges(int itemIndex) async {
if (_editingStates[itemIndex] != true) return;

final item = widget.actionItems.firstWhere((item) => widget.conversation.structured.actionItems.indexOf(item) == itemIndex);
final newText = _textControllers[itemIndex]!.text.trim();
final originalText = item.description;

if (newText.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Action item description cannot be empty'),
backgroundColor: Colors.red.shade400,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
duration: const Duration(seconds: 2),
),
);
return;
}

if (newText == originalText) {
_cancelEditing(itemIndex);
return;
}

updateActionItemDescription(widget.conversation.id, originalText, newText, itemIndex).catchError((e) => debugPrint('$e'));

final convoProvider = Provider.of<ConversationProvider>(context, listen: false);
convoProvider.updateActionItemDescriptionInConversation(widget.conversation.id, itemIndex, newText);

setState(() {
_editingStates[itemIndex] = false;
});
_showSavedMessage();
}

void _showSavedMessage() {
final overlay = Overlay.of(context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (_) => Positioned(
top: 50,
right: 20,
child: Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.green.shade600,
borderRadius: BorderRadius.circular(8),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 8, offset: const Offset(0, 4))],
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(FontAwesomeIcons.check, color: Colors.white, size: 14),
SizedBox(width: 8),
Text('Saved', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500)),
],
),
),
),
),
);
overlay.insert(entry);
Future.delayed(const Duration(seconds: 2), () => entry.remove());
}

@override
Widget build(BuildContext context) {
final sortedItems = [...actionItems]..sort((a, b) {
final sortedItems = [...widget.actionItems]..sort((a, b) {
if (a.completed == b.completed) return 0;
return a.completed ? 1 : -1;
});
Expand Down Expand Up @@ -81,7 +219,7 @@ class DesktopActionGroup extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
conversation.structured.title.isNotEmpty ? conversation.structured.title : 'Untitled Conversation',
widget.conversation.structured.title.isNotEmpty ? widget.conversation.structured.title : 'Untitled Conversation',
style: const TextStyle(
color: ResponsiveHelper.textPrimary,
fontSize: 16,
Expand Down Expand Up @@ -142,15 +280,16 @@ class DesktopActionGroup extends StatelessWidget {
}

Widget _buildGroupedActionItem(BuildContext context, ActionItem item) {
final itemIndex = conversation.structured.actionItems.indexOf(item);
final itemIndex = widget.conversation.structured.actionItems.indexOf(item);
final isEditing = _editingStates[itemIndex] == true;

return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: ResponsiveHelper.backgroundTertiary.withOpacity(0.4),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: ResponsiveHelper.backgroundTertiary.withOpacity(0.3),
color: isEditing ? ResponsiveHelper.purplePrimary.withOpacity(0.5) : ResponsiveHelper.backgroundTertiary.withOpacity(0.3),
width: 1,
),
),
Expand All @@ -160,36 +299,74 @@ class DesktopActionGroup extends StatelessWidget {
// Checkbox
OmiCheckbox(
value: item.completed,
onChanged: (v) => _toggleCompletion(context, item, itemIndex),
onChanged: (v) {
if (isEditing) return;
_toggleCompletion(context, item, itemIndex);
},
),

const SizedBox(width: 10),

// Content
Expanded(
child: Text(
item.description,
style: TextStyle(
color: item.completed ? ResponsiveHelper.textTertiary : ResponsiveHelper.textPrimary,
decoration: item.completed ? TextDecoration.lineThrough : null,
decorationColor: ResponsiveHelper.textTertiary,
fontSize: 14,
height: 1.3,
fontWeight: FontWeight.w500,
),
),
child: isEditing
? KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (KeyEvent event) {
if (event is KeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.enter) {
if (!HardwareKeyboard.instance.isShiftPressed) {
// Enter without Shift: save changes
_saveChanges(itemIndex);
}
// Enter with Shift: allow new line (default behavior)
}
}
},
child: TextField(
controller: _textControllers[itemIndex],
focusNode: _focusNodes[itemIndex],
style: const TextStyle(color: ResponsiveHelper.textPrimary, fontSize: 14, height: 1.3, fontWeight: FontWeight.w500),
decoration: const InputDecoration(border: InputBorder.none, contentPadding: EdgeInsets.zero, isDense: true),
maxLines: null,
onChanged: (_) => setState(() {}),
),
)
: GestureDetector(
onTap: () => _startEditing(itemIndex),
child: Text(
item.description,
style: TextStyle(
color: item.completed ? ResponsiveHelper.textTertiary : ResponsiveHelper.textPrimary,
decoration: item.completed ? TextDecoration.lineThrough : null,
decorationColor: ResponsiveHelper.textTertiary,
fontSize: 14,
height: 1.3,
fontWeight: FontWeight.w500,
),
),
),
),

const SizedBox(width: 8),

// Quick action button
OmiIconButton(
icon: FontAwesomeIcons.pen,
onPressed: () => _showEditActionSheet(context, item, itemIndex),
style: OmiIconButtonStyle.outline,
size: 24,
color: ResponsiveHelper.textSecondary,
),
if (isEditing)
OmiIconButton(
icon: (_textControllers[itemIndex]?.text.trim() != item.description) ? FontAwesomeIcons.check : FontAwesomeIcons.xmark,
onPressed: (_textControllers[itemIndex]?.text.trim() != item.description) ? () => _saveChanges(itemIndex) : () => _cancelEditing(itemIndex),
style: OmiIconButtonStyle.outline,
color: (_textControllers[itemIndex]?.text.trim() != item.description) ? Colors.green.shade600 : ResponsiveHelper.textSecondary,
size: 24,
)
else
OmiIconButton(
icon: FontAwesomeIcons.pen,
onPressed: () => _startEditing(itemIndex),
style: OmiIconButtonStyle.outline,
size: 24,
color: ResponsiveHelper.textSecondary,
),
],
),
);
Expand All @@ -198,33 +375,20 @@ class DesktopActionGroup extends StatelessWidget {
void _toggleCompletion(BuildContext context, ActionItem item, int itemIndex) {
final newValue = !item.completed;
context.read<ConversationProvider>().updateGlobalActionItemState(
conversation,
widget.conversation,
itemIndex,
newValue,
);
}

void _showEditActionSheet(BuildContext context, ActionItem item, int itemIndex) {
// For now, just show a simple snackbar - we'll implement the edit sheet later
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Edit functionality coming soon'),
backgroundColor: ResponsiveHelper.backgroundTertiary,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
duration: const Duration(seconds: 2),
),
);
}

void _navigateToConversationDetail(BuildContext context) async {
final convoProvider = Provider.of<ConversationProvider>(context, listen: false);

DateTime? date;
int? index;

for (final entry in convoProvider.groupedConversations.entries) {
final foundIndex = entry.value.indexWhere((c) => c.id == conversation.id);
final foundIndex = entry.value.indexWhere((c) => c.id == widget.conversation.id);
if (foundIndex != -1) {
date = entry.key;
index = foundIndex;
Expand All @@ -240,7 +404,7 @@ class DesktopActionGroup extends StatelessWidget {

await routeToPage(
context,
ConversationDetailPage(conversation: conversation),
ConversationDetailPage(conversation: widget.conversation),
);
}
}
Expand Down
Loading