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; final int messageCount; ThreadHeader({required this.threadId, required this.messageCount}) : super(content: "Thread ($messageCount messages)", isFromUser: false); } 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) => Message(sender: msg['sender'], content: msg['content'], id: msg['id'], threadId: msg['threadId']) ).toList(); return messages; } @override _ChatScreenState createState() => _ChatScreenState(); } class _ChatScreenState extends State { // Chat State String? _title; List _messages = []; int? currentThreadId; // UI State Set expandedThreads = {}; // 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 = Message(sender: data['sender'], content: data['content'], threadId: data['threadId']); 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.threadId == null) { // Non-threaded messages 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(String content) async { if (content.trim().isEmpty) return; // Don't send empty messages _textController.clear(); final newMessage = Message(sender: "user", content: content, threadId: currentThreadId); Backend.sendAction("send-chat-message", { 'chatId': widget.chatId, 'content': content, 'threadId': currentThreadId, }).then((response) { if (response['error'] != null) { print("Error sending chat message: ${response['error']}"); } }); addMessage(newMessage); } 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, 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: () { // TODO: Implement Edit functionality print('Edit clicked'); }, ), 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; 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 message = entry as Message; return GestureDetector( onLongPressStart: (details) { _longPressOffset = details.globalPosition; }, onLongPress: () { _showMessageContextMenu(context, _longPressOffset!); _longPressOffset = null; }, onSecondaryTapUp: (details) { _showMessageContextMenu(context, 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(12), ), child: Text(message.content), ), ), ), ); } else { // Unknown entry type return Text(entry.content); } }, ), ), Padding( padding: const EdgeInsets.all(8.0), child: Column( children: [ 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(_textController.text); //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(_textController.text); }, child: Text('Send'), ), ElevatedButton( onPressed: () { createThread(); print("TODO: Start new topic"); }, child: Text('Start New Topic'), ), ], ), ], ), ), ], ), ); } }