import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'app_menu.dart'; import 'styles.dart'; import 'backend.dart'; import 'dart:async'; abstract class ChatEntry { String content; bool isFromUser; ChatEntry({required this.content, required this.isFromUser}); Alignment get alignment => isFromUser ? Alignment.centerRight : Alignment.centerLeft; EdgeInsets get padding => isFromUser ? EdgeInsets.zero : const EdgeInsets.only(left: 10); } class Message extends ChatEntry { int? id; String sender; int? threadId; Message({required this.sender, required content, this.id, this.threadId}) : super(content: content, isFromUser: sender == "user"); } class ThreadHeader extends ChatEntry { final int threadId; int messageCount; ThreadHeader({required this.threadId, required this.messageCount}) : super(content: "Thread ($messageCount messages)", isFromUser: false); } class NoteMessage extends Message { NoteMessage({sender, content, id, threadId}) : super(sender: sender, content: content, id: id, threadId: threadId); } class HiddenMessage extends Message { HiddenMessage({sender, content, id, threadId}) : super(sender: sender, content: content, id: id, threadId: threadId); } Message createMessageFromData (Map data) { switch (data['kind']) { case 'message': return Message(sender: data['sender'], content: data['content'], threadId: data['threadId']); break; case 'note': return NoteMessage(sender: data['sender'], content: data['content'], threadId: data['threadId']); break; default: return HiddenMessage(sender: data['sender'], content: data['content'], threadId: data['threadId']); } } class ChatScreen extends StatefulWidget { final int chatId; ChatScreen({required this.chatId}); static Future> getChatMessages(int chatId) async { final response = await Backend.sendAction("get-chat-messages", {'chatId': chatId}); List messageObjects = response['messages']; List messages = messageObjects.map((msg) => createMessageFromData(msg)).toList(); return messages; } @override _ChatScreenState createState() => _ChatScreenState(); } class _ChatScreenState extends State { // Chat State String? _title; List _messages = []; int? currentThreadId; // UI State Set expandedThreads = {}; int? editingMessageId = null; bool noteMode = false; // Internal Funcionality Offset? _longPressOffset; StreamSubscription? _chatSubscription; final TextEditingController _textController = TextEditingController(); final FocusNode _inputFocusNode = FocusNode(); @override void initState() { super.initState(); _loadInitialData(); _chatSubscription = Backend.chatMessages(widget.chatId).listen((data) { final message = createMessageFromData(data); if (!message.isFromUser) { addMessage(message); } }); BrowserContextMenu.disableContextMenu(); } @override void dispose() { _chatSubscription?.cancel(); // Cancel the subscription Backend.removeChatListener(widget.chatId); BrowserContextMenu.enableContextMenu(); _textController.dispose(); _inputFocusNode.dispose(); super.dispose(); } Future _loadInitialData() async { final chatInfo = (await Backend.getChat(widget.chatId))!; final initialMessages = await ChatScreen.getChatMessages(widget.chatId); setState(() { _messages.addAll(initialMessages); _title = chatInfo.name; currentThreadId = initialMessages.last.threadId; if (currentThreadId == null) { createThread(); } else { expandedThreads.add(currentThreadId!); } }); } List get displayEntries { List result = []; Map> threadGroups = {}; // First pass: Group messages by thread for (var message in _messages) { if (message.threadId != null) { threadGroups.putIfAbsent(message.threadId!, () => []).add(message); } } // Second pass: Build the display list with correct ordering for (var message in _messages) { if (message is NoteMessage || message.threadId == null) { // Non-threaded messages and notes go directly to the result result.add(message); } else { final threadId = message.threadId!; final threadMessages = threadGroups[threadId]!; // If expanded, add messages as they appear if (expandedThreads.contains(threadId)) { // Add the thread header before the first message if (message == threadMessages.first) { result.add(ThreadHeader(threadId: threadId, messageCount: threadMessages.length)); } result.add(message); } else { // Add collapsed thread header in place of the last message if (message == threadMessages.last) { result.add(ThreadHeader(threadId: threadId, messageCount: threadMessages.length)); } } } } // Add current thread mannually if it has no messages if (currentThreadId != null && !threadGroups.containsKey(currentThreadId!)) { result.add(ThreadHeader(threadId: currentThreadId!, messageCount: 0)); } return result; } void addMessage(Message message) { setState(() { _messages.add(message); }); } void sendMessage() async { final content = _textController.text; _textController.clear(); if (content.trim().isEmpty) return; // Don't send empty messages if (editingMessageId == null) { Backend.sendAction("send-chat-message", { 'chatId': widget.chatId, 'content': content, 'threadId': currentThreadId, 'kind': noteMode ? 'note' : null, }).then((response) { if (response['error'] != null) { print("Error sending chat message: ${response['error']}"); } }); final newMessage = noteMode ? NoteMessage(sender: "user", content: content, threadId: currentThreadId) : Message(sender: "user", content: content, threadId: currentThreadId); addMessage(newMessage); } else { Message message = _messages.firstWhere((element) => element.id == editingMessageId!); Backend.sendAction("update-chat-message", { 'messageId': editingMessageId, 'content': content, 'kind': noteMode ? 'note' : null, }).then((response) { if (response['error'] != null) { print("Error updating message: ${response['error']}"); } }); setState(() { message.content = content; }); } if (noteMode) { setState(() { noteMode = false; }); } } void createThread () async { final response = await Backend.sendAction("create-chat-thread", {'chatId': widget.chatId}); if (response['threadId'] != null) { setState(() { currentThreadId = response['threadId']; expandedThreads.add(currentThreadId!); }); } } Color getMessageColor(BuildContext context) { final backgroundColor = Theme.of(context).scaffoldBackgroundColor; final messageColor = Color.alphaBlend(Colors.white.withOpacity(0.1), backgroundColor); return messageColor; } void _showMessageContextMenu(BuildContext context, Message message, Offset position) { showMenu( context: context, position: RelativeRect.fromLTRB(position.dx, position.dy, position.dx + 1, position.dy + 1), items: [ PopupMenuItem( child: Text('Pin'), onTap: () { // TODO: Implement Pin functionality print('Pin clicked'); }, ), PopupMenuItem( child: Text('Edit'), onTap: () { _textController.text = message.content; setState(() { editingMessageId = message.id; }); }, ), PopupMenuItem( child: Text('Start Topic'), onTap: () { print('TODO: Start Topic from message'); }, ), PopupMenuItem( child: Text('Delete'), onTap: () { // TODO: Implement Delete functionality print('Delete clicked'); }, ), ], ); } Widget _buildThreadHeader(BuildContext context, ThreadHeader header) { return GestureDetector( onTap: () { setState(() { if (expandedThreads.contains(header.threadId)) { expandedThreads.remove(header.threadId); } else { expandedThreads.add(header.threadId); } }); }, child: Container( padding: EdgeInsets.all(8), margin: EdgeInsets.symmetric(vertical: 4), decoration: BoxDecoration( color: getMessageColor(context), borderRadius: BorderRadius.circular(4), ), child: Row( children: [ Icon(expandedThreads.contains(header.threadId) ? Icons.expand_less : Icons.expand_more), Text(header.content), ], ), ), ); } @override Widget build(BuildContext context) { final isWideScreen = MediaQuery.of(context).size.width > 600; final String? editInfo; if (editingMessageId != null) { if (noteMode) { editInfo = 'Editing Note ${editingMessageId}'; } else { editInfo = 'Editing ${editingMessageId}'; } } else if (noteMode) { editInfo = 'Editing Note'; } else { editInfo = null; } return Scaffold( appBar: AppBar( title: Text(_title ?? 'Chat Room ${widget.chatId}'), actions: [ IconButton(icon: Icon(Icons.settings), onPressed: () { Navigator.push(context, MaterialPageRoute(builder: (_) => AppMenu())); }), ], ), body: Column( children: [ Expanded( child: ListView.builder( itemCount: displayEntries.length, itemBuilder: (context, index) { final entry = displayEntries[index]; if (entry is ThreadHeader) { return _buildThreadHeader(context, entry); } else if (entry is Message) { final isNote = entry is NoteMessage; final message = entry as Message; return GestureDetector( onLongPressStart: (details) { _longPressOffset = details.globalPosition; }, onLongPress: () { _showMessageContextMenu(context, message, _longPressOffset!); _longPressOffset = null; }, onSecondaryTapUp: (details) { _showMessageContextMenu(context, message, details.globalPosition); }, child: Padding( padding: isWideScreen ? message.padding : EdgeInsets.zero, child: Align( alignment: isWideScreen ? Alignment.centerLeft : message.alignment, child: Container( margin: EdgeInsets.symmetric(vertical: 2, horizontal: 8), padding: EdgeInsets.all(10), decoration: BoxDecoration( color: getMessageColor(context), borderRadius: BorderRadius.circular(isNote ? 0 : 12), border: isNote ? Border.all(color: Colors.white) : null, ), child: SelectableText( message.content, toolbarOptions: ToolbarOptions( copy: true, selectAll: false, ), onTap: () {}, ), ), ), ), ); } else { // Unknown entry type return Text(entry.content); } }, ), ), Padding( padding: const EdgeInsets.all(8.0), child: Column( children: [ if (editInfo != null) ...[ Text(editInfo!), ], Expanded( child: KeyboardListener( focusNode: _inputFocusNode, onKeyEvent: (event) { // TODO: Probably doing this in the input focus node would be cleaner if (HardwareKeyboard.instance.isControlPressed && HardwareKeyboard.instance.isLogicalKeyPressed(LogicalKeyboardKey.enter)) { sendMessage(); //return KeyEventResult.handled; } //return KeyEventResult.ignored; }, child: TextField( controller: _textController, maxLines: null, // Allows multiline input keyboardType: TextInputType.multiline, // For better multiline support on mobile decoration: inputDecoration().copyWith(hintText: 'Type a message...'), // Added a hint ), ), ), Row( children: [ ElevatedButton( onPressed: () { sendMessage(); }, child: Text('Send'), ), ElevatedButton( onPressed: () { createThread(); print("TODO: Start new topic"); }, child: Text('Start New Topic'), ), ElevatedButton( onPressed: () { setState(() { noteMode = !noteMode; }); }, child: Text(noteMode ? 'Switch to Message' : 'Make Note'), ), if (editingMessageId!= null) ...[ ElevatedButton( onPressed: () { _textController.text = ""; setState(() { editingMessageId = null; }); }, child: Text('Cancel Edit'), ) ], ], ), ], ), ), ], ), ); } }