payfrit-app/lib/screens/chat_screen.dart
John Mizerek 65b5b82546 Add customer-to-staff chat feature and group order invites
- Add real-time chat between customers and staff via WebSocket
- Add HTTP polling fallback when WebSocket unavailable
- Chat auto-closes when worker ends conversation with dialog notification
- Add user search API for group order invites (phone/email/name)
- Store group order invites in app state
- Add login check before starting chat with sign-in prompt
- Remove table change button (not allowed currently)
- Fix About screen to show dynamic version from pubspec
- Update snackbar styling to green with black text

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:08:09 -08:00

706 lines
21 KiB
Dart

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<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final ChatService _chatService = ChatService();
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final List<ChatMessage> _messages = [];
bool _isLoading = true;
bool _isConnecting = false;
bool _isSending = false;
bool _otherUserTyping = false;
String? _otherUserName;
String? _error;
bool _chatEnded = false;
StreamSubscription<ChatMessage>? _messageSubscription;
StreamSubscription<TypingEvent>? _typingSubscription;
StreamSubscription<ChatEvent>? _eventSubscription;
Timer? _typingDebounce;
Timer? _pollTimer;
@override
void initState() {
super.initState();
_otherUserName = widget.otherPartyName;
_initializeChat();
}
Future<void> _initializeChat() async {
// Ensure auth is loaded first before any API calls
await _ensureAuth();
// Then load messages and connect
await _loadMessages();
_connectToChat();
}
Future<void> _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<void> _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<void> _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<void> _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<void> _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),
),
],
),
),
);
}
}