import 'dart:async'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../models/chat_message.dart'; import '../services/api.dart'; import '../services/chat_service.dart'; import '../services/auth_storage.dart'; class ChatScreen extends StatefulWidget { final int taskId; final String userType; // 'customer' or 'worker' final String? otherPartyName; const ChatScreen({ super.key, required this.taskId, required this.userType, this.otherPartyName, }); @override State createState() => _ChatScreenState(); } class _ChatScreenState extends State { final ChatService _chatService = ChatService(); final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); final List _messages = []; bool _isLoading = true; bool _isConnecting = false; bool _isSending = false; bool _otherUserTyping = false; String? _otherUserName; String? _error; bool _chatEnded = false; StreamSubscription? _messageSubscription; StreamSubscription? _typingSubscription; StreamSubscription? _eventSubscription; Timer? _typingDebounce; Timer? _pollTimer; @override void initState() { super.initState(); _otherUserName = widget.otherPartyName; _initializeChat(); } Future _initializeChat() async { // Ensure auth is loaded first before any API calls await _ensureAuth(); // Then load messages and connect await _loadMessages(); _connectToChat(); } Future _ensureAuth() async { if (Api.authToken == null || Api.authToken!.isEmpty) { final authData = await AuthStorage.loadAuth(); if (authData != null && authData.token.isNotEmpty) { Api.setAuthToken(authData.token); } } } @override void dispose() { _messageSubscription?.cancel(); _typingSubscription?.cancel(); _eventSubscription?.cancel(); _typingDebounce?.cancel(); _pollTimer?.cancel(); _chatService.disconnect(); _messageController.dispose(); _scrollController.dispose(); super.dispose(); } Future _loadMessages() async { setState(() { _isLoading = true; _error = null; }); try { debugPrint('[Chat] Loading messages for task ${widget.taskId}...'); final result = await Api.getChatMessages(taskId: widget.taskId); debugPrint('[Chat] Loaded ${result.messages.length} messages, chatClosed: ${result.chatClosed}'); if (mounted) { final wasClosed = result.chatClosed && !_chatEnded; setState(() { _messages.clear(); _messages.addAll(result.messages); _isLoading = false; if (result.chatClosed) { _chatEnded = true; } }); _scrollToBottom(); // Show dialog if chat was just closed if (wasClosed) { _showChatEndedDialog(); } } } catch (e) { debugPrint('[Chat] Error loading messages: $e'); if (mounted) { setState(() { _error = 'Failed to load messages: $e'; _isLoading = false; }); } } } Future _connectToChat() async { setState(() => _isConnecting = true); // Auth should already be loaded by _initializeChat final token = Api.authToken; if (token == null || token.isEmpty) { debugPrint('[Chat] No auth token, skipping WebSocket (will use HTTP fallback with polling)'); setState(() => _isConnecting = false); _startPolling(); return; } // Set up stream listeners _messageSubscription = _chatService.messages.listen((message) { // Avoid duplicates if (!_messages.any((m) => m.messageId == message.messageId)) { setState(() { _messages.add(message); }); _scrollToBottom(); // Mark as read if from the other party if (message.senderType != widget.userType) { Api.markChatMessagesRead( taskId: widget.taskId, readerType: widget.userType, ); } } }); _typingSubscription = _chatService.typingEvents.listen((event) { if (event.userType != widget.userType) { setState(() { _otherUserTyping = event.isTyping; if (event.userName.isNotEmpty) { _otherUserName = event.userName; } }); } }); _eventSubscription = _chatService.events.listen((event) { switch (event.type) { case ChatEventType.joined: debugPrint('Joined chat room'); break; case ChatEventType.userJoined: final name = event.data?['userName'] ?? 'Someone'; if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('$name joined the chat', style: const TextStyle(color: Colors.black)), backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), duration: const Duration(seconds: 2), ), ); setState(() { _otherUserName = name; }); } break; case ChatEventType.userLeft: if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('Other user left the chat', style: TextStyle(color: Colors.black)), backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), duration: const Duration(seconds: 2), ), ); } break; case ChatEventType.chatEnded: setState(() { _chatEnded = true; }); if (mounted) { _showChatEndedDialog(); } break; case ChatEventType.disconnected: debugPrint('Disconnected from chat'); break; case ChatEventType.error: if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(event.message ?? 'Chat error'), backgroundColor: Colors.red, ), ); } break; } }); final connected = await _chatService.connect( taskId: widget.taskId, userToken: token, userType: widget.userType, ); if (mounted) { setState(() => _isConnecting = false); if (!connected) { debugPrint('Failed to connect to WebSocket, using HTTP fallback with polling'); _startPolling(); } } } void _startPolling() { _pollTimer?.cancel(); _pollTimer = Timer.periodic(const Duration(seconds: 3), (_) { if (!_chatEnded && mounted) { _pollNewMessages(); } }); } Future _pollNewMessages() async { try { final lastMessageId = _messages.isNotEmpty ? _messages.last.messageId : 0; final result = await Api.getChatMessages( taskId: widget.taskId, afterMessageId: lastMessageId, ); if (mounted) { // Check if chat has been closed by worker if (result.chatClosed && !_chatEnded) { setState(() { _chatEnded = true; }); _pollTimer?.cancel(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('This chat has been closed by staff', style: TextStyle(color: Colors.black)), backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } // Add any new messages if (result.messages.isNotEmpty) { setState(() { for (final msg in result.messages) { if (!_messages.any((m) => m.messageId == msg.messageId)) { _messages.add(msg); } } }); _scrollToBottom(); } } } catch (e) { debugPrint('[Chat] Poll error: $e'); } } void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }); } void _onTextChanged(String text) { // Debounce typing indicator _typingDebounce?.cancel(); if (text.isNotEmpty) { _chatService.setTyping(true); _typingDebounce = Timer(const Duration(seconds: 2), () { _chatService.setTyping(false); }); } else { _chatService.setTyping(false); } } Future _sendMessage() async { final text = _messageController.text.trim(); if (text.isEmpty || _isSending || _chatEnded) return; setState(() => _isSending = true); _chatService.setTyping(false); try { bool sentViaWebSocket = false; if (_chatService.isConnected) { // Try to send via WebSocket sentViaWebSocket = _chatService.sendMessage(text); if (sentViaWebSocket) { _messageController.clear(); } } if (!sentViaWebSocket) { // Fallback to HTTP debugPrint('[Chat] WebSocket not available, using HTTP fallback'); final authData = await AuthStorage.loadAuth(); final userId = authData?.userId; if (userId == null || userId == 0) { throw StateError('Not logged in. Please sign in again.'); } debugPrint('[Chat] Sending HTTP message: taskId=${widget.taskId}, userId=$userId'); await Api.sendChatMessage( taskId: widget.taskId, message: text, userId: userId, senderType: widget.userType, ); _messageController.clear(); // Refresh messages since we used HTTP await _loadMessages(); } } catch (e) { debugPrint('Error sending message: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to send: $e'), backgroundColor: Colors.red, ), ); } } finally { if (mounted) { setState(() => _isSending = false); } } } /// Show dialog when chat is closed by worker, then navigate back void _showChatEndedDialog() { // Stop polling since chat is ended _pollTimer?.cancel(); showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( title: const Text('Chat Ended'), content: const Text('The staff member has closed this chat. You can start a new chat if you need further assistance.'), actions: [ FilledButton( onPressed: () { Navigator.pop(context); // Close dialog Navigator.pop(this.context); // Go back to previous screen }, child: const Text('OK'), ), ], ), ); } void _closeChat() { if (widget.userType != 'worker') return; showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Close Chat'), content: const Text('Are you sure you want to close this chat?'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), TextButton( onPressed: () { Navigator.pop(context); _chatService.closeChat(); Navigator.pop(this.context); }, child: const Text('Close', style: TextStyle(color: Colors.red)), ), ], ), ); } @override Widget build(BuildContext context) { final title = widget.userType == 'customer' ? 'Chat with Staff' : 'Chat with Customer'; return Scaffold( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title), if (_otherUserName != null) Text( _otherUserName!, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.white70, ), ), ], ), actions: [ if (_isConnecting) const Padding( padding: EdgeInsets.all(16), child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ), ) else if (_chatService.isConnected) const Padding( padding: EdgeInsets.all(16), child: Icon(Icons.wifi, color: Colors.green, size: 20), ) else const Padding( padding: EdgeInsets.all(16), child: Icon(Icons.wifi_off, color: Colors.orange, size: 20), ), if (widget.userType == 'worker' && !_chatEnded) IconButton( icon: const Icon(Icons.close), onPressed: _closeChat, tooltip: 'Close Chat', ), ], ), body: Column( children: [ if (_chatEnded) Container( width: double.infinity, padding: const EdgeInsets.all(12), color: Colors.orange.shade100, child: const Text( 'This chat has ended', textAlign: TextAlign.center, style: TextStyle(fontWeight: FontWeight.bold), ), ), Expanded( child: _buildMessageList(), ), if (_otherUserTyping) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Row( children: [ Text( '${_otherUserName ?? "Other user"} is typing...', style: TextStyle( color: Colors.grey.shade600, fontStyle: FontStyle.italic, ), ), ], ), ), if (!_chatEnded) _buildInputArea(), ], ), ); } Widget _buildMessageList() { if (_isLoading) { return const Center(child: CircularProgressIndicator()); } if (_error != null) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Error: $_error'), const SizedBox(height: 16), ElevatedButton( onPressed: _loadMessages, child: const Text('Retry'), ), ], ), ); } if (_messages.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey.shade400), const SizedBox(height: 16), Text( 'No messages yet', style: TextStyle(color: Colors.grey.shade600), ), const SizedBox(height: 8), Text( 'Start the conversation!', style: TextStyle(color: Colors.grey.shade500, fontSize: 12), ), ], ), ); } return ListView.builder( controller: _scrollController, padding: const EdgeInsets.all(16), itemCount: _messages.length, itemBuilder: (context, index) { final message = _messages[index]; final isMe = message.senderType == widget.userType; return _buildMessageBubble(message, isMe); }, ); } Widget _buildMessageBubble(ChatMessage message, bool isMe) { final timeFormat = DateFormat.jm(); final time = timeFormat.format(message.createdOn); return Padding( padding: const EdgeInsets.only(bottom: 12), child: Row( mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!isMe) ...[ CircleAvatar( radius: 16, backgroundColor: Colors.grey.shade300, child: Text( message.senderName.isNotEmpty ? message.senderName[0].toUpperCase() : (message.senderType == 'worker' ? 'S' : 'C'), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), ), ), const SizedBox(width: 8), ], Flexible( child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( color: isMe ? Theme.of(context).primaryColor : Colors.grey.shade200, borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), bottomLeft: Radius.circular(isMe ? 16 : 4), bottomRight: Radius.circular(isMe ? 4 : 16), ), ), child: Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (!isMe && message.senderName.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( message.senderName, style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: Colors.grey.shade700, ), ), ), Text( message.text, style: TextStyle( color: isMe ? Colors.white : Colors.black87, ), ), const SizedBox(height: 4), Text( time, style: TextStyle( fontSize: 10, color: isMe ? Colors.white70 : Colors.grey.shade600, ), ), ], ), ), ), if (isMe) ...[ const SizedBox(width: 8), CircleAvatar( radius: 16, backgroundColor: Theme.of(context).primaryColor.withOpacity(0.7), child: const Text( 'Me', style: TextStyle(fontSize: 10, color: Colors.white), ), ), ], ], ), ); } Widget _buildInputArea() { return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 4, offset: const Offset(0, -2), ), ], ), child: SafeArea( child: Row( children: [ Expanded( child: TextField( controller: _messageController, decoration: InputDecoration( hintText: 'Type a message...', border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none, ), filled: true, fillColor: Colors.grey.shade100, contentPadding: const EdgeInsets.symmetric( horizontal: 20, vertical: 10, ), ), textInputAction: TextInputAction.send, onChanged: _onTextChanged, onSubmitted: (_) => _sendMessage(), ), ), const SizedBox(width: 8), FloatingActionButton( mini: true, onPressed: _isSending ? null : _sendMessage, child: _isSending ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Icon(Icons.send), ), ], ), ), ); } }