payfrit-app/lib/screens/chat_screen.dart
John Mizerek c4792189dd App Store Version 2: Beacon scanning, preload caching, business selector
Features:
- Beacon scanner service for detecting nearby beacons
- Beacon cache for offline-first beacon resolution
- Preload cache for instant menu display
- Business selector screen for multi-location support
- Rescan button widget for quick beacon refresh
- Sign-in dialog for guest checkout flow
- Task type model for server tasks

Improvements:
- Enhanced menu browsing with category filtering
- Improved cart view with better modifier display
- Order history with detailed order tracking
- Chat screen improvements
- Better error handling in API service

Fixes:
- CashApp payment return crash fix
- Modifier nesting issues resolved
- Auto-expand modifier groups

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:51:54 -08:00

834 lines
24 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; // null if task needs to be created
final String userType; // 'customer' or 'worker'
final String? otherPartyName;
// Required for creating task when taskId is null
final int? businessId;
final int? servicePointId;
final int? orderId;
final int? userId;
const ChatScreen({
super.key,
this.taskId,
required this.userType,
this.otherPartyName,
this.businessId,
this.servicePointId,
this.orderId,
this.userId,
});
@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 = [];
final List<String> _pendingMessages = []; // Messages queued before task created
bool _isLoading = true;
bool _isConnecting = false;
bool _isSending = false;
bool _otherUserTyping = false;
String? _otherUserName;
String? _error;
bool _chatEnded = false;
bool _isCreatingTask = false; // True while creating task in background
int? _taskId; // Actual task ID (may be null initially)
StreamSubscription<ChatMessage>? _messageSubscription;
StreamSubscription<TypingEvent>? _typingSubscription;
StreamSubscription<ChatEvent>? _eventSubscription;
Timer? _typingDebounce;
Timer? _pollTimer;
@override
void initState() {
super.initState();
_otherUserName = widget.otherPartyName;
_taskId = widget.taskId;
_initializeChat();
}
Future<void> _initializeChat() async {
// Ensure auth is loaded first before any API calls
await _ensureAuth();
// If no taskId provided, we need to create the task
if (_taskId == null) {
setState(() {
_isCreatingTask = true;
_isLoading = false; // Allow user to see chat UI immediately
});
await _createTask();
} else {
// Then load messages and connect
await _loadMessages();
_connectToChat();
}
}
Future<void> _createTask() async {
try {
final taskId = await Api.createChatTask(
businessId: widget.businessId!,
servicePointId: widget.servicePointId!,
orderId: widget.orderId,
userId: widget.userId,
);
if (!mounted) return;
setState(() {
_taskId = taskId;
_isCreatingTask = false;
});
// Now load messages and connect
await _loadMessages();
_connectToChat();
// Send any pending messages that were queued
_sendPendingMessages();
} catch (e) {
if (mounted) {
setState(() {
_error = 'Failed to start chat: $e';
_isCreatingTask = false;
});
}
}
}
Future<void> _sendPendingMessages() async {
if (_pendingMessages.isEmpty || _taskId == null) return;
final messages = List<String>.from(_pendingMessages);
_pendingMessages.clear();
for (final text in messages) {
await _sendMessageText(text);
}
}
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 {
if (_taskId == null) return;
setState(() {
_isLoading = true;
_error = null;
});
try {
final result = await Api.getChatMessages(taskId: _taskId!);
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) {
if (mounted) {
setState(() {
_error = 'Failed to load messages';
_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) {
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: _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:
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:
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: _taskId!,
userToken: token,
userType: widget.userType,
);
if (mounted) {
setState(() => _isConnecting = false);
if (!connected) {
_startPolling();
}
}
}
void _startPolling() {
_pollTimer?.cancel();
_pollTimer = Timer.periodic(const Duration(seconds: 3), (_) {
if (!_chatEnded && mounted) {
_pollNewMessages();
}
});
}
Future<void> _pollNewMessages() async {
if (_taskId == null) return;
try {
final lastMessageId = _messages.isNotEmpty ? _messages.last.messageId : 0;
final result = await Api.getChatMessages(
taskId: _taskId!,
afterMessageId: lastMessageId,
);
if (mounted) {
// Check if chat has been closed (by worker or system auto-close)
if (result.chatClosed && !_chatEnded) {
setState(() {
_chatEnded = true;
});
_pollTimer?.cancel();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('This chat has ended', 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) {
// Polling error - will retry on next interval
}
}
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;
_messageController.clear();
_chatService.setTyping(false);
// If task not created yet, queue the message
if (_taskId == null) {
setState(() {
_pendingMessages.add(text);
// Add optimistic message to UI
_messages.add(ChatMessage(
messageId: -(_pendingMessages.length), // Negative ID for pending
taskId: 0,
senderUserId: widget.userId ?? 0,
senderType: widget.userType,
senderName: 'Me',
text: text,
createdOn: DateTime.now(),
isRead: false,
));
});
_scrollToBottom();
return;
}
await _sendMessageText(text);
}
Future<void> _sendMessageText(String text) async {
if (_taskId == null) return;
setState(() => _isSending = true);
try {
bool sentViaWebSocket = false;
if (_chatService.isConnected) {
// Try to send via WebSocket
sentViaWebSocket = _chatService.sendMessage(text);
}
if (!sentViaWebSocket) {
// Fallback to HTTP
final authData = await AuthStorage.loadAuth();
final userId = authData?.userId;
if (userId == null || userId == 0) {
throw StateError('Please sign in to send messages');
}
await Api.sendChatMessage(
taskId: _taskId!,
message: text,
userId: userId,
senderType: widget.userType,
);
// Refresh messages since we used HTTP
await _loadMessages();
}
} catch (e) {
if (mounted) {
final message = e.toString().replaceAll('StateError: ', '');
// Check if chat was closed - update state and show appropriate message
if (message.contains('chat has ended') || message.contains('chat_closed')) {
setState(() {
_chatEnded = true;
});
_pollTimer?.cancel();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('This chat has ended', style: TextStyle(color: Colors.black)),
backgroundColor: const Color(0xFF90EE90),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to send: $message'),
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 (_isCreatingTask)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: Colors.blue.shade50,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.blue.shade700,
),
),
const SizedBox(width: 12),
Text(
'Finding available staff...',
style: TextStyle(
color: Colors.blue.shade700,
fontWeight: FontWeight.w500,
),
),
],
),
),
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),
),
],
),
),
);
}
}