payfrit-app/lib/services/chat_service.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

281 lines
6.9 KiB
Dart

import 'dart:async';
import 'package:socket_io_client/socket_io_client.dart' as io;
import '../models/chat_message.dart';
import 'api.dart';
/// Service for managing real-time chat via WebSocket
class ChatService {
static const String _wsBaseUrl = 'https://app.payfrit.com:3001';
io.Socket? _socket;
int? _currentTaskId;
String? _userType;
final StreamController<ChatMessage> _messageController =
StreamController<ChatMessage>.broadcast();
final StreamController<TypingEvent> _typingController =
StreamController<TypingEvent>.broadcast();
final StreamController<ChatEvent> _eventController =
StreamController<ChatEvent>.broadcast();
bool _isConnected = false;
/// Stream of incoming messages
Stream<ChatMessage> get messages => _messageController.stream;
/// Stream of typing events
Stream<TypingEvent> get typingEvents => _typingController.stream;
/// Stream of chat events (user joined/left, chat ended)
Stream<ChatEvent> get events => _eventController.stream;
/// Whether the socket is currently connected
bool get isConnected => _isConnected;
/// Current task ID
int? get currentTaskId => _currentTaskId;
/// Connect to chat for a specific task
Future<bool> connect({
required int taskId,
required String userToken,
required String userType,
}) async {
// Disconnect if already connected to a different task
if (_socket != null) {
await disconnect();
}
_currentTaskId = taskId;
_userType = userType;
final completer = Completer<bool>();
try {
_socket = io.io(
_wsBaseUrl,
io.OptionBuilder()
.setTransports(['websocket'])
.disableAutoConnect()
.build(),
);
_socket!.onConnect((_) {
print('[ChatService] Connected to WebSocket');
_isConnected = true;
// Join the chat room
_socket!.emit('join-chat', {
'taskId': taskId,
'userToken': userToken,
'userType': userType,
});
});
_socket!.on('joined', (data) {
print('[ChatService] Joined chat room: $data');
_eventController.add(ChatEvent(
type: ChatEventType.joined,
data: data,
));
if (!completer.isCompleted) {
completer.complete(true);
}
});
_socket!.on('error', (data) {
print('[ChatService] Error: $data');
_eventController.add(ChatEvent(
type: ChatEventType.error,
message: data['message'] ?? 'Unknown error',
));
if (!completer.isCompleted) {
completer.complete(false);
}
});
_socket!.on('new-message', (data) {
print('[ChatService] New message: $data');
final message = ChatMessage.fromJson(data as Map<String, dynamic>);
_messageController.add(message);
});
_socket!.on('user-typing', (data) {
_typingController.add(TypingEvent(
userType: data['userType'] ?? '',
userName: data['userName'] ?? '',
isTyping: data['isTyping'] ?? false,
));
});
_socket!.on('user-joined', (data) {
print('[ChatService] User joined: $data');
_eventController.add(ChatEvent(
type: ChatEventType.userJoined,
data: data,
));
});
_socket!.on('user-left', (data) {
print('[ChatService] User left: $data');
_eventController.add(ChatEvent(
type: ChatEventType.userLeft,
data: data,
));
});
_socket!.on('chat-ended', (data) {
print('[ChatService] Chat ended: $data');
_eventController.add(ChatEvent(
type: ChatEventType.chatEnded,
message: data['message'] ?? 'Chat has ended',
));
});
_socket!.onDisconnect((_) {
print('[ChatService] Disconnected from WebSocket');
_isConnected = false;
_eventController.add(ChatEvent(
type: ChatEventType.disconnected,
));
});
_socket!.onConnectError((error) {
print('[ChatService] Connection error: $error');
_isConnected = false;
if (!completer.isCompleted) {
completer.complete(false);
}
});
_socket!.connect();
// Timeout after 10 seconds
return completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () {
print('[ChatService] Connection timeout');
return false;
},
);
} catch (e) {
print('[ChatService] Connection exception: $e');
return false;
}
}
/// Send a message via WebSocket
/// Returns true if message was sent, false if not connected
bool sendMessage(String text) {
if (_socket == null || !_isConnected || _currentTaskId == null) {
print('[ChatService] Cannot send - not connected');
return false;
}
_socket!.emit('send-message', {
'taskId': _currentTaskId,
'message': text,
});
return true;
}
/// Send a message via HTTP (fallback)
Future<int> sendMessageHttp({
required int taskId,
required String message,
int? userId,
String? senderType,
}) async {
return Api.sendChatMessage(
taskId: taskId,
message: message,
userId: userId,
senderType: senderType,
);
}
/// Notify that user is typing
void setTyping(bool isTyping) {
if (_socket == null || !_isConnected || _currentTaskId == null) return;
_socket!.emit('typing', {
'taskId': _currentTaskId,
'isTyping': isTyping,
});
}
/// Close the chat (only workers can do this)
void closeChat() {
if (_socket == null || !_isConnected || _currentTaskId == null) return;
_socket!.emit('chat-closed', {
'taskId': _currentTaskId,
});
}
/// Leave the chat room
void leaveChat() {
if (_socket == null || _currentTaskId == null) return;
_socket!.emit('leave-chat', {
'taskId': _currentTaskId,
});
}
/// Disconnect from WebSocket
Future<void> disconnect() async {
if (_socket != null) {
leaveChat();
_socket!.disconnect();
_socket!.dispose();
_socket = null;
}
_isConnected = false;
_currentTaskId = null;
_userType = null;
}
/// Dispose the service and clean up streams
void dispose() {
disconnect();
_messageController.close();
_typingController.close();
_eventController.close();
}
}
/// Event for typing indicators
class TypingEvent {
final String userType;
final String userName;
final bool isTyping;
const TypingEvent({
required this.userType,
required this.userName,
required this.isTyping,
});
}
/// Event type for chat state changes
enum ChatEventType {
joined,
userJoined,
userLeft,
chatEnded,
disconnected,
error,
}
/// Event for chat state changes
class ChatEvent {
final ChatEventType type;
final String? message;
final dynamic data;
const ChatEvent({
required this.type,
this.message,
this.data,
});
}