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>
This commit is contained in:
John Mizerek 2026-01-11 12:08:09 -08:00
parent 2522970078
commit 65b5b82546
17 changed files with 2119 additions and 206 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import "../screens/account_screen.dart"; import "../screens/account_screen.dart";
import "../screens/about_screen.dart"; import "../screens/about_screen.dart";
import "../screens/chat_screen.dart";
import "../screens/address_edit_screen.dart"; import "../screens/address_edit_screen.dart";
import "../screens/address_list_screen.dart"; import "../screens/address_list_screen.dart";
import "../screens/beacon_scan_screen.dart"; import "../screens/beacon_scan_screen.dart";
@ -34,6 +35,7 @@ class AppRoutes {
static const String addressEdit = "/address-edit"; static const String addressEdit = "/address-edit";
static const String about = "/about"; static const String about = "/about";
static const String signup = "/signup"; static const String signup = "/signup";
static const String chat = "/chat";
static Map<String, WidgetBuilder> get routes => { static Map<String, WidgetBuilder> get routes => {
splash: (_) => const SplashScreen(), splash: (_) => const SplashScreen(),

View file

@ -23,6 +23,8 @@ class AppState extends ChangeNotifier {
int? _activeOrderId; int? _activeOrderId;
int? _activeOrderStatusId; int? _activeOrderStatusId;
List<int> _groupOrderInvites = [];
int? get selectedBusinessId => _selectedBusinessId; int? get selectedBusinessId => _selectedBusinessId;
String? get selectedBusinessName => _selectedBusinessName; String? get selectedBusinessName => _selectedBusinessName;
int? get selectedServicePointId => _selectedServicePointId; int? get selectedServicePointId => _selectedServicePointId;
@ -44,6 +46,9 @@ class AppState extends ChangeNotifier {
int? get activeOrderStatusId => _activeOrderStatusId; int? get activeOrderStatusId => _activeOrderStatusId;
bool get hasActiveOrder => _activeOrderId != null; bool get hasActiveOrder => _activeOrderId != null;
List<int> get groupOrderInvites => _groupOrderInvites;
bool get isGroupOrder => _groupOrderInvites.isNotEmpty;
bool get hasLocationSelection => bool get hasLocationSelection =>
_selectedBusinessId != null && _selectedServicePointId != null; _selectedBusinessId != null && _selectedServicePointId != null;
@ -137,6 +142,16 @@ class AppState extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setGroupOrderInvites(List<int> userIds) {
_groupOrderInvites = userIds;
notifyListeners();
}
void clearGroupOrderInvites() {
_groupOrderInvites = [];
notifyListeners();
}
void clearAll() { void clearAll() {
_selectedBusinessId = null; _selectedBusinessId = null;
_selectedServicePointId = null; _selectedServicePointId = null;
@ -148,5 +163,7 @@ class AppState extends ChangeNotifier {
_activeOrderId = null; _activeOrderId = null;
_activeOrderStatusId = null; _activeOrderStatusId = null;
_groupOrderInvites = [];
} }
} }

View file

@ -0,0 +1,52 @@
class ChatMessage {
final int messageId;
final int taskId;
final int senderUserId;
final String senderType; // 'customer' or 'worker'
final String senderName;
final String text;
final DateTime createdOn;
final bool isRead;
const ChatMessage({
required this.messageId,
required this.taskId,
required this.senderUserId,
required this.senderType,
required this.senderName,
required this.text,
required this.createdOn,
this.isRead = false,
});
factory ChatMessage.fromJson(Map<String, dynamic> json) {
return ChatMessage(
messageId: (json["MessageID"] as num?)?.toInt() ?? (json["messageId"] as num?)?.toInt() ?? 0,
taskId: (json["TaskID"] as num?)?.toInt() ?? (json["taskId"] as num?)?.toInt() ?? 0,
senderUserId: (json["SenderUserID"] as num?)?.toInt() ?? (json["senderUserId"] as num?)?.toInt() ?? 0,
senderType: json["SenderType"] as String? ?? json["senderType"] as String? ?? "customer",
senderName: json["SenderName"] as String? ?? json["senderName"] as String? ?? "",
text: json["Text"] as String? ?? json["MessageText"] as String? ?? json["text"] as String? ?? "",
createdOn: DateTime.tryParse(
json["CreatedOn"] as String? ?? json["timestamp"] as String? ?? ""
) ?? DateTime.now(),
isRead: json["IsRead"] == 1 || json["IsRead"] == true || json["isRead"] == true,
);
}
Map<String, dynamic> toJson() {
return {
"messageId": messageId,
"taskId": taskId,
"senderUserId": senderUserId,
"senderType": senderType,
"senderName": senderName,
"text": text,
"timestamp": createdOn.toIso8601String(),
"isRead": isRead,
};
}
/// Check if this message was sent by the current user
bool isMine(String userType) => senderType == userType;
}

View file

@ -1,8 +1,31 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
class AboutScreen extends StatelessWidget { class AboutScreen extends StatefulWidget {
const AboutScreen({super.key}); const AboutScreen({super.key});
@override
State<AboutScreen> createState() => _AboutScreenState();
}
class _AboutScreenState extends State<AboutScreen> {
String _version = '';
@override
void initState() {
super.initState();
_loadVersion();
}
Future<void> _loadVersion() async {
final info = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_version = 'Version ${info.version}';
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -44,7 +67,7 @@ class AboutScreen extends StatelessWidget {
// Version // Version
Center( Center(
child: Text( child: Text(
'Version 0.1.0', _version,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),

View file

@ -0,0 +1,706 @@
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),
),
],
),
),
);
}
}

View file

@ -3,6 +3,8 @@ import 'package:provider/provider.dart';
import '../app/app_router.dart'; import '../app/app_router.dart';
import '../app/app_state.dart'; import '../app/app_state.dart';
import '../services/api.dart';
import '../services/auth_storage.dart';
/// Screen to invite additional Payfrit users to join a group order /// Screen to invite additional Payfrit users to join a group order
/// Shown after selecting Delivery or Takeaway /// Shown after selecting Delivery or Takeaway
@ -14,12 +16,27 @@ class GroupOrderInviteScreen extends StatefulWidget {
} }
class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> { class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
final List<String> _invitedUsers = []; final List<UserSearchResult> _invitedUsers = [];
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
bool _isSearching = false; bool _isSearching = false;
int? _currentUserId;
// Mock search results - in production this would come from API List<UserSearchResult> _searchResults = [];
final List<_UserResult> _searchResults = [];
@override
void initState() {
super.initState();
_loadCurrentUserId();
}
Future<void> _loadCurrentUserId() async {
final auth = await AuthStorage.loadAuth();
if (auth != null && mounted) {
setState(() {
_currentUserId = auth.userId;
});
}
}
@override @override
void dispose() { void dispose() {
@ -27,10 +44,18 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
super.dispose(); super.dispose();
} }
void _searchUsers(String query) { Future<void> _searchUsers(String query) async {
if (query.isEmpty) { if (query.isEmpty) {
setState(() { setState(() {
_searchResults.clear(); _searchResults = [];
_isSearching = false;
});
return;
}
if (query.length < 3) {
setState(() {
_searchResults = [];
_isSearching = false; _isSearching = false;
}); });
return; return;
@ -38,33 +63,38 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
setState(() => _isSearching = true); setState(() => _isSearching = true);
// TODO: Replace with actual API call to search users by phone/email/username try {
// For now, show placeholder final results = await Api.searchUsers(
Future.delayed(const Duration(milliseconds: 500), () { query: query,
currentUserId: _currentUserId,
);
if (mounted && _searchController.text == query) { if (mounted && _searchController.text == query) {
// Filter out already invited users
final filteredResults = results.where((user) =>
!_invitedUsers.any((invited) => invited.userId == user.userId)).toList();
setState(() { setState(() {
_searchResults.clear(); _searchResults = filteredResults;
// Mock results - would come from API
if (query.length >= 3) {
_searchResults.addAll([
_UserResult(
userId: 1,
name: 'John D.',
phone: '***-***-${query.substring(0, 4)}',
),
]);
}
_isSearching = false; _isSearching = false;
}); });
} }
}); } catch (e) {
debugPrint('[GroupOrderInvite] Search error: $e');
if (mounted) {
setState(() {
_searchResults = [];
_isSearching = false;
});
}
}
} }
void _inviteUser(_UserResult user) { void _inviteUser(UserSearchResult user) {
if (!_invitedUsers.contains(user.name)) { if (!_invitedUsers.any((u) => u.userId == user.userId)) {
setState(() { setState(() {
_invitedUsers.add(user.name); _invitedUsers.add(user);
_searchResults.clear(); _searchResults = [];
_searchController.clear(); _searchController.clear();
}); });
@ -79,16 +109,17 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
} }
} }
void _removeInvite(String userName) { void _removeInvite(UserSearchResult user) {
setState(() { setState(() {
_invitedUsers.remove(userName); _invitedUsers.removeWhere((u) => u.userId == user.userId);
}); });
} }
void _continueToRestaurants() { void _continueToRestaurants() {
// Store invited users in app state if needed // Store invited users in app state
final appState = context.read<AppState>(); final appState = context.read<AppState>();
// TODO: appState.setGroupOrderInvites(_invitedUsers); final invitedUserIds = _invitedUsers.map((u) => u.userId).toList();
appState.setGroupOrderInvites(invitedUserIds);
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
} }
@ -227,7 +258,7 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: _invitedUsers.map((name) { children: _invitedUsers.map((user) {
return Chip( return Chip(
avatar: CircleAvatar( avatar: CircleAvatar(
backgroundColor: Colors.green.withAlpha(50), backgroundColor: Colors.green.withAlpha(50),
@ -237,9 +268,9 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
color: Colors.green, color: Colors.green,
), ),
), ),
label: Text(name), label: Text(user.name),
deleteIcon: const Icon(Icons.close, size: 18), deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: () => _removeInvite(name), onDeleted: () => _removeInvite(user),
backgroundColor: Colors.grey.shade800, backgroundColor: Colors.grey.shade800,
labelStyle: const TextStyle(color: Colors.white), labelStyle: const TextStyle(color: Colors.white),
); );
@ -291,15 +322,3 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
); );
} }
} }
class _UserResult {
final int userId;
final String name;
final String phone;
const _UserResult({
required this.userId,
required this.name,
required this.phone,
});
}

View file

@ -1,4 +1,5 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:provider/provider.dart"; import "package:provider/provider.dart";
import "../app/app_router.dart"; import "../app/app_router.dart";
@ -6,6 +7,8 @@ import "../app/app_state.dart";
import "../services/api.dart"; import "../services/api.dart";
import "../services/auth_storage.dart"; import "../services/auth_storage.dart";
enum LoginStep { phone, otp }
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@ -14,22 +17,39 @@ class LoginScreen extends StatefulWidget {
} }
class _LoginScreenState extends State<LoginScreen> { class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>(); LoginStep _currentStep = LoginStep.phone;
final _usernameController = TextEditingController();
final _passwordController = TextEditingController(); final _phoneController = TextEditingController();
final _otpController = TextEditingController();
String _uuid = "";
String _phone = "";
bool _isLoading = false; bool _isLoading = false;
String? _errorMessage; String? _errorMessage;
@override @override
void dispose() { void dispose() {
_usernameController.dispose(); _phoneController.dispose();
_passwordController.dispose(); _otpController.dispose();
super.dispose(); super.dispose();
} }
Future<void> _handleLogin() async { String _formatPhoneNumber(String input) {
if (!_formKey.currentState!.validate()) { final digits = input.replaceAll(RegExp(r'[^\d]'), '');
if (digits.length == 11 && digits.startsWith('1')) {
return digits.substring(1);
}
return digits;
}
Future<void> _handleSendOtp() async {
final phone = _formatPhoneNumber(_phoneController.text);
if (phone.length != 10) {
setState(() {
_errorMessage = "Please enter a valid 10-digit phone number";
});
return; return;
} }
@ -39,37 +59,125 @@ class _LoginScreenState extends State<LoginScreen> {
}); });
try { try {
final result = await Api.login( final response = await Api.sendLoginOtp(phone: phone);
username: _usernameController.text.trim(),
password: _passwordController.text, if (!mounted) return;
);
if (response.uuid.isEmpty) {
setState(() {
_errorMessage = "Server error - please try again";
_isLoading = false;
});
return;
}
setState(() {
_uuid = response.uuid;
_phone = phone;
_currentStep = LoginStep.otp;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = e.toString().replaceFirst("StateError: ", "");
_isLoading = false;
});
}
}
Future<void> _handleVerifyOtp() async {
if (_uuid.isEmpty) {
setState(() {
_errorMessage = "Session expired. Please go back and try again.";
});
return;
}
final otp = _otpController.text.trim();
if (otp.length != 6) {
setState(() {
_errorMessage = "Please enter the 6-digit code";
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await Api.verifyLoginOtp(uuid: _uuid, otp: otp);
if (!mounted) return; if (!mounted) return;
// Save credentials for persistent login // Save credentials for persistent login
await AuthStorage.saveAuth( await AuthStorage.saveAuth(
userId: result.userId, userId: response.userId,
token: result.token, token: response.token,
); );
// Set the auth token on the API class
Api.setAuthToken(result.token);
final appState = context.read<AppState>(); final appState = context.read<AppState>();
appState.setUserId(result.userId); appState.setUserId(response.userId);
// Go back to previous screen (menu) or splash if no previous route // Show success and navigate
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Welcome back${response.userFirstName.isNotEmpty ? ', ${response.userFirstName}' : ''}!",
style: const TextStyle(color: Colors.black),
),
backgroundColor: const Color(0xFF90EE90),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
// Navigate to main app
if (Navigator.of(context).canPop()) { if (Navigator.of(context).canPop()) {
Navigator.of(context).pop(); Navigator.of(context).pop();
} else { } else {
// No previous route - go to splash which will auto-navigate based on beacon detection
Navigator.of(context).pushReplacementNamed(AppRoutes.splash); Navigator.of(context).pushReplacementNamed(AppRoutes.splash);
} }
} catch (e) { } catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = e.toString().replaceFirst("StateError: ", "");
_isLoading = false;
});
}
}
Future<void> _handleResendOtp() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await Api.sendLoginOtp(phone: _phone);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_errorMessage = e.toString(); _uuid = response.uuid;
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text("New code sent!", style: TextStyle(color: Colors.black)),
backgroundColor: const Color(0xFF90EE90),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = e.toString().replaceFirst("StateError: ", "");
_isLoading = false; _isLoading = false;
}); });
} }
@ -79,127 +187,211 @@ class _LoginScreenState extends State<LoginScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text("Login"), title: Text(_currentStep == LoginStep.phone ? "Login" : "Verify Phone"),
), ),
body: Center( body: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400), constraints: const BoxConstraints(maxWidth: 400),
child: Form( child: Column(
key: _formKey, mainAxisAlignment: MainAxisAlignment.center,
child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center, children: [
crossAxisAlignment: CrossAxisAlignment.stretch, if (_currentStep == LoginStep.phone) _buildPhoneStep(),
children: [ if (_currentStep == LoginStep.otp) _buildOtpStep(),
const Text(
"PAYFRIT", // Error message
textAlign: TextAlign.center, if (_errorMessage != null) ...[
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
const SizedBox(height: 8),
const Text(
"Sign in to order",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
const SizedBox(height: 48),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: "Email or Phone Number",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
enabled: !_isLoading,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Please enter your email or phone number";
}
return null;
},
),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( _buildErrorMessage(),
controller: _passwordController,
decoration: const InputDecoration(
labelText: "Password",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock),
),
obscureText: true,
textInputAction: TextInputAction.done,
enabled: !_isLoading,
onFieldSubmitted: (_) => _handleLogin(),
validator: (value) {
if (value == null || value.isEmpty) {
return "Please enter your password";
}
return null;
},
),
const SizedBox(height: 24),
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade300),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red.shade900),
),
),
],
),
),
FilledButton(
onPressed: _isLoading ? null : _handleLogin,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text("Login"),
),
const SizedBox(height: 16),
TextButton(
onPressed: _isLoading
? null
: () {
Navigator.of(context)
.pushReplacementNamed(AppRoutes.signup);
},
child: const Text("Don't have an account? Sign Up"),
),
], ],
), ],
), ),
), ),
), ),
), ),
); );
} }
Widget _buildPhoneStep() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
"PAYFRIT",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
const SizedBox(height: 8),
const Text(
"Enter your phone number to login",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
const SizedBox(height: 32),
TextFormField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: "Phone Number",
hintText: "(555) 123-4567",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
prefixText: "+1 ",
),
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
enabled: !_isLoading,
onFieldSubmitted: (_) => _handleSendOtp(),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isLoading ? null : _handleSendOtp,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text("Send Login Code"),
),
const SizedBox(height: 16),
TextButton(
onPressed: _isLoading
? null
: () {
Navigator.of(context).pushReplacementNamed(AppRoutes.signup);
},
child: const Text("Don't have an account? Sign Up"),
),
],
);
}
Widget _buildOtpStep() {
final formattedPhone = _phone.length == 10
? "(${_phone.substring(0, 3)}) ${_phone.substring(3, 6)}-${_phone.substring(6)}"
: _phone;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.sms,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
"We sent a code to",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
formattedPhone,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
TextFormField(
controller: _otpController,
decoration: const InputDecoration(
labelText: "Login Code",
hintText: "123456",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock),
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 24,
letterSpacing: 8,
fontWeight: FontWeight.bold,
),
enabled: !_isLoading,
onFieldSubmitted: (_) => _handleVerifyOtp(),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isLoading ? null : _handleVerifyOtp,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text("Login"),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: _isLoading ? null : _handleResendOtp,
child: const Text("Resend Code"),
),
const SizedBox(width: 16),
TextButton(
onPressed: _isLoading
? null
: () {
setState(() {
_currentStep = LoginStep.phone;
_otpController.clear();
_errorMessage = null;
});
},
child: const Text("Change Number"),
),
],
),
],
);
}
Widget _buildErrorMessage() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade300),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red.shade900),
),
),
],
),
);
}
} }

View file

@ -1,4 +1,5 @@
import "package:flutter/material.dart"; import "package:flutter/foundation.dart";
import "package:flutter/material.dart";
import "package:provider/provider.dart"; import "package:provider/provider.dart";
import "../app/app_router.dart"; import "../app/app_router.dart";
@ -6,6 +7,8 @@ import "../app/app_state.dart";
import "../models/cart.dart"; import "../models/cart.dart";
import "../models/menu_item.dart"; import "../models/menu_item.dart";
import "../services/api.dart"; import "../services/api.dart";
import "../services/auth_storage.dart";
import "chat_screen.dart";
class MenuBrowseScreen extends StatefulWidget { class MenuBrowseScreen extends StatefulWidget {
const MenuBrowseScreen({super.key}); const MenuBrowseScreen({super.key});
@ -78,6 +81,260 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
return items; return items;
} }
bool _isCallingServer = false;
/// Show bottom sheet with choice: Server Visit or Chat
Future<void> _handleCallServer(AppState appState) async {
if (_businessId == null || _servicePointId == null) return;
// Check for active chat first
int? activeTaskId;
try {
activeTaskId = await Api.getActiveChat(
businessId: _businessId!,
servicePointId: _servicePointId!,
);
} catch (e) {
debugPrint('[Menu] Error checking active chat: $e');
}
if (!mounted) return;
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
const Text(
'How can we help?',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.orange,
child: Icon(Icons.room_service, color: Colors.white),
),
title: const Text('Request Server Visit'),
subtitle: const Text('Staff will come to your table'),
onTap: () {
Navigator.pop(context);
_sendServerRequest(appState);
},
),
const Divider(),
// Show either "Rejoin Chat" OR "Chat with Staff" - never both
if (activeTaskId != null)
ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.green,
child: Icon(Icons.chat_bubble, color: Colors.white),
),
title: const Text('Rejoin Chat'),
subtitle: const Text('Continue your conversation'),
onTap: () {
Navigator.pop(context);
_rejoinChat(activeTaskId!);
},
)
else
ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.blue,
child: Icon(Icons.chat, color: Colors.white),
),
title: const Text('Chat with Staff'),
subtitle: const Text('Send a message to our team'),
onTap: () {
Navigator.pop(context);
_startChat(appState);
},
),
],
),
),
),
);
}
/// Check if user is logged in, prompt login if not
/// Returns true if logged in, false if user needs to log in
Future<bool> _ensureLoggedIn() async {
final auth = await AuthStorage.loadAuth();
if (auth != null && auth.userId > 0) {
return true;
}
if (!mounted) return false;
// Show login prompt
final shouldLogin = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sign In Required'),
content: const Text('Please sign in to use the chat feature.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Sign In'),
),
],
),
);
if (shouldLogin == true && mounted) {
Navigator.pushNamed(context, AppRoutes.login);
}
return false;
}
/// Rejoin an existing active chat
Future<void> _rejoinChat(int taskId) async {
if (!await _ensureLoggedIn()) return;
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(
taskId: taskId,
userType: 'customer',
),
),
);
}
/// Send a server visit request (ping)
Future<void> _sendServerRequest(AppState appState) async {
if (_isCallingServer) return;
setState(() => _isCallingServer = true);
try {
await Api.callServer(
businessId: _businessId!,
servicePointId: _servicePointId!,
orderId: appState.cartOrderId,
userId: appState.userId,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Row(
children: [
Icon(Icons.check_circle, color: Colors.black),
SizedBox(width: 8),
Expanded(child: Text("Server has been notified", style: TextStyle(color: Colors.black))),
],
),
backgroundColor: const Color(0xFF90EE90),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error, color: Colors.black),
const SizedBox(width: 8),
Expanded(child: Text("Failed to call server: $e", style: const TextStyle(color: Colors.black))),
],
),
backgroundColor: Colors.red.shade100,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
} finally {
if (mounted) {
setState(() => _isCallingServer = false);
}
}
}
/// Start a new chat with staff
Future<void> _startChat(AppState appState) async {
if (_isCallingServer) return;
// Check login first
if (!await _ensureLoggedIn()) return;
setState(() => _isCallingServer = true);
try {
// Reload auth to get userId
final auth = await AuthStorage.loadAuth();
final userId = auth?.userId;
// Create new chat
final taskId = await Api.createChatTask(
businessId: _businessId!,
servicePointId: _servicePointId!,
orderId: appState.cartOrderId,
userId: userId,
);
if (!mounted) return;
// Navigate to chat screen
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(
taskId: taskId,
userType: 'customer',
),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error, color: Colors.black),
const SizedBox(width: 8),
Expanded(child: Text("Failed to start chat: $e", style: const TextStyle(color: Colors.black))),
],
),
backgroundColor: Colors.red.shade100,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
} finally {
if (mounted) {
setState(() => _isCallingServer = false);
}
}
}
void _organizeItems() { void _organizeItems() {
_itemsByCategory.clear(); _itemsByCategory.clear();
_itemsByParent.clear(); _itemsByParent.clear();
@ -220,32 +477,14 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
], ],
), ),
actions: [ actions: [
// Only show table change button for dine-in orders // Call Server button - only for dine-in orders at a table
if (appState.isDineIn) if (appState.isDineIn && _servicePointId != null)
IconButton( IconButton(
icon: const Icon(Icons.table_restaurant), icon: const Icon(Icons.room_service),
tooltip: "Change Table", tooltip: "Call Server",
onPressed: () { onPressed: () => _handleCallServer(appState),
// Prevent changing tables if there's an active order (dine and dash prevention)
if (appState.activeOrderId != null) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("Cannot Change Table"),
content: const Text("Please complete or cancel your current order before changing tables."),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("OK"),
),
],
),
);
return;
}
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
},
), ),
// Table change button removed - not allowed currently
IconButton( IconButton(
icon: Badge( icon: Badge(
label: Text("${appState.cartItemCount}"), label: Text("${appState.cartItemCount}"),

View file

@ -35,6 +35,13 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_restaurantsFuture = _loadRestaurants(); _restaurantsFuture = _loadRestaurants();
// Clear order type when arriving at restaurant select (no beacon = not dine-in)
// This ensures the table change icon doesn't appear for delivery/takeaway orders
WidgetsBinding.instance.addPostFrameCallback((_) {
final appState = context.read<AppState>();
appState.setOrderType(null);
});
} }
Future<List<Restaurant>> _loadRestaurants() async { Future<List<Restaurant>> _loadRestaurants() async {

View file

@ -77,6 +77,14 @@ class _SignupScreenState extends State<SignupScreen> {
if (!mounted) return; if (!mounted) return;
if (response.uuid.isEmpty) {
setState(() {
_errorMessage = "Server returned empty UUID - please try again";
_isLoading = false;
});
return;
}
setState(() { setState(() {
_uuid = response.uuid; _uuid = response.uuid;
_phone = phone; _phone = phone;
@ -86,13 +94,21 @@ class _SignupScreenState extends State<SignupScreen> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_errorMessage = e.toString().replaceFirst("StateError: ", ""); _errorMessage = "Error: ${e.toString().replaceFirst("StateError: ", "")}";
_isLoading = false; _isLoading = false;
}); });
} }
} }
Future<void> _handleVerifyOtp() async { Future<void> _handleVerifyOtp() async {
// Validate UUID first
if (_uuid.isEmpty) {
setState(() {
_errorMessage = "Session expired - UUID is empty. Please go back and resend code.";
});
return;
}
final otp = _otpController.text.trim(); final otp = _otpController.text.trim();
if (otp.length != 6) { if (otp.length != 6) {
@ -108,7 +124,9 @@ class _SignupScreenState extends State<SignupScreen> {
}); });
try { try {
print('[Signup] Calling verifyOtp...');
final response = await Api.verifyOtp(uuid: _uuid, otp: otp); final response = await Api.verifyOtp(uuid: _uuid, otp: otp);
print('[Signup] verifyOtp success: userId=${response.userId}, needsProfile=${response.needsProfile}');
if (!mounted) return; if (!mounted) return;
@ -120,18 +138,22 @@ class _SignupScreenState extends State<SignupScreen> {
userId: response.userId, userId: response.userId,
token: response.token, token: response.token,
); );
print('[Signup] Auth saved, token set');
if (response.needsProfile) { if (response.needsProfile) {
print('[Signup] Profile needed, going to profile step');
// Go to profile step // Go to profile step
setState(() { setState(() {
_currentStep = SignupStep.profile; _currentStep = SignupStep.profile;
_isLoading = false; _isLoading = false;
}); });
} else { } else {
print('[Signup] Profile complete, finishing signup');
// Profile already complete - go to app // Profile already complete - go to app
_completeSignup(); _completeSignup();
} }
} catch (e) { } catch (e) {
print('[Signup] verifyOtp error: $e');
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_errorMessage = e.toString().replaceFirst("StateError: ", ""); _errorMessage = e.toString().replaceFirst("StateError: ", "");
@ -164,16 +186,19 @@ class _SignupScreenState extends State<SignupScreen> {
}); });
try { try {
print('[Signup] Calling completeProfile: firstName=$firstName, lastName=$lastName, email=$email');
await Api.completeProfile( await Api.completeProfile(
firstName: firstName, firstName: firstName,
lastName: lastName, lastName: lastName,
email: email, email: email,
); );
print('[Signup] completeProfile success');
if (!mounted) return; if (!mounted) return;
_completeSignup(); _completeSignup();
} catch (e) { } catch (e) {
print('[Signup] completeProfile error: $e');
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_errorMessage = e.toString().replaceFirst("StateError: ", ""); _errorMessage = e.toString().replaceFirst("StateError: ", "");
@ -426,7 +451,7 @@ class _SignupScreenState extends State<SignupScreen> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 24),
TextFormField( TextFormField(
controller: _otpController, controller: _otpController,
decoration: const InputDecoration( decoration: const InputDecoration(

View file

@ -2,6 +2,7 @@ import "dart:convert";
import "package:http/http.dart" as http; import "package:http/http.dart" as http;
import "../models/cart.dart"; import "../models/cart.dart";
import "../models/chat_message.dart";
import "../models/menu_item.dart"; import "../models/menu_item.dart";
import "../models/order_detail.dart"; import "../models/order_detail.dart";
import "../models/order_history.dart"; import "../models/order_history.dart";
@ -52,13 +53,32 @@ class SendOtpResponse {
}); });
factory SendOtpResponse.fromJson(Map<String, dynamic> json) { factory SendOtpResponse.fromJson(Map<String, dynamic> json) {
// Try both uppercase and lowercase keys for compatibility
final uuid = (json["UUID"] as String?) ?? (json["uuid"] as String?) ?? "";
final message = (json["MESSAGE"] as String?) ?? (json["message"] as String?) ?? "";
return SendOtpResponse( return SendOtpResponse(
uuid: (json["UUID"] as String?) ?? "", uuid: uuid,
message: (json["MESSAGE"] as String?) ?? "", message: message,
); );
} }
} }
class UserSearchResult {
final int userId;
final String name;
final String phone;
final String email;
final String avatarUrl;
const UserSearchResult({
required this.userId,
required this.name,
required this.phone,
required this.email,
required this.avatarUrl,
});
}
class VerifyOtpResponse { class VerifyOtpResponse {
final int userId; final int userId;
final String token; final String token;
@ -305,11 +325,13 @@ class Api {
required String lastName, required String lastName,
required String email, required String email,
}) async { }) async {
print('[API] completeProfile: token=${_userToken?.substring(0, 8) ?? "NULL"}...');
final raw = await _postRaw("/auth/completeProfile.cfm", { final raw = await _postRaw("/auth/completeProfile.cfm", {
"firstName": firstName, "firstName": firstName,
"lastName": lastName, "lastName": lastName,
"email": email, "email": email,
}); });
print('[API] completeProfile response: ${raw.statusCode} - ${raw.rawBody}');
final j = _requireJson(raw, "CompleteProfile"); final j = _requireJson(raw, "CompleteProfile");
if (!_ok(j)) { if (!_ok(j)) {
@ -318,6 +340,8 @@ class Api {
throw StateError("This email is already associated with another account"); throw StateError("This email is already associated with another account");
} else if (err == "invalid_email") { } else if (err == "invalid_email") {
throw StateError("Please enter a valid email address"); throw StateError("Please enter a valid email address");
} else if (err == "unauthorized") {
throw StateError("Authentication failed - please try signing up again");
} else { } else {
throw StateError("Failed to save profile: ${j["MESSAGE"] ?? err}"); throw StateError("Failed to save profile: ${j["MESSAGE"] ?? err}");
} }
@ -329,6 +353,59 @@ class Api {
return sendOtp(phone: phone); return sendOtp(phone: phone);
} }
// -------------------------
// Login via OTP (for existing verified accounts)
// -------------------------
/// Send OTP to phone number for LOGIN (existing accounts only)
static Future<SendOtpResponse> sendLoginOtp({required String phone}) async {
final raw = await _postRaw("/auth/loginOTP.cfm", {"phone": phone});
final j = _requireJson(raw, "LoginOTP");
if (!_ok(j)) {
final err = _err(j);
if (err == "no_account") {
throw StateError("No account found with this phone number. Please sign up first.");
} else if (err == "invalid_phone") {
throw StateError("Please enter a valid 10-digit phone number");
} else {
throw StateError("Failed to send code: ${j["MESSAGE"] ?? err}");
}
}
return SendOtpResponse.fromJson(j);
}
/// Verify OTP for LOGIN and get auth token
static Future<LoginResponse> verifyLoginOtp({
required String uuid,
required String otp,
}) async {
final raw = await _postRaw("/auth/verifyLoginOTP.cfm", {
"uuid": uuid,
"otp": otp,
});
final j = _requireJson(raw, "VerifyLoginOTP");
if (!_ok(j)) {
final err = _err(j);
if (err == "invalid_otp") {
throw StateError("Invalid code. Please try again.");
} else if (err == "expired") {
throw StateError("Session expired. Please request a new code.");
} else {
throw StateError("Login failed: ${j["MESSAGE"] ?? err}");
}
}
final response = LoginResponse.fromJson(j);
// Store token for future requests
setAuthToken(response.token);
return response;
}
// ------------------------- // -------------------------
// Businesses (legacy model name: Restaurant) // Businesses (legacy model name: Restaurant)
// ------------------------- // -------------------------
@ -579,6 +656,36 @@ class Api {
return j; return j;
} }
// -------------------------
// Tasks / Service Requests
// -------------------------
/// Call server to the table - creates a service request task
static Future<void> callServer({
required int businessId,
required int servicePointId,
int? orderId,
int? userId,
String? message,
}) async {
final body = <String, dynamic>{
"BusinessID": businessId,
"ServicePointID": servicePointId,
};
if (orderId != null && orderId > 0) body["OrderID"] = orderId;
if (userId != null && userId > 0) body["UserID"] = userId;
if (message != null && message.isNotEmpty) body["Message"] = message;
final raw = await _postRaw("/tasks/callServer.cfm", body);
final j = _requireJson(raw, "CallServer");
if (!_ok(j)) {
throw StateError(
"CallServer failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}",
);
}
}
// ------------------------- // -------------------------
// Beacons // Beacons
// ------------------------- // -------------------------
@ -854,6 +961,36 @@ class Api {
); );
} }
/// Search for users by phone, email, or name (for group order invites)
static Future<List<UserSearchResult>> searchUsers({
required String query,
int? currentUserId,
}) async {
if (query.length < 3) return [];
final raw = await _postRaw("/users/search.cfm", {
"Query": query,
"CurrentUserID": currentUserId ?? 0,
});
final j = _requireJson(raw, "SearchUsers");
if (!_ok(j)) {
return [];
}
final usersJson = j["USERS"] as List<dynamic>? ?? [];
return usersJson.map((e) {
final user = e as Map<String, dynamic>;
return UserSearchResult(
userId: (user["UserID"] as num).toInt(),
name: user["Name"] as String? ?? "",
phone: user["Phone"] as String? ?? "",
email: user["Email"] as String? ?? "",
avatarUrl: user["AvatarUrl"] as String? ?? "",
);
}).toList();
}
/// Get user profile /// Get user profile
static Future<UserProfile> getProfile() async { static Future<UserProfile> getProfile() async {
final raw = await _getRaw("/auth/profile.cfm"); final raw = await _getRaw("/auth/profile.cfm");
@ -896,6 +1033,142 @@ class Api {
final orderData = j["ORDER"] as Map<String, dynamic>? ?? {}; final orderData = j["ORDER"] as Map<String, dynamic>? ?? {};
return OrderDetail.fromJson(orderData); return OrderDetail.fromJson(orderData);
} }
// -------------------------
// Chat
// -------------------------
/// Check if there's an active chat for the service point
/// Returns the task ID if found, null otherwise
static Future<int?> getActiveChat({
required int businessId,
required int servicePointId,
}) async {
final body = <String, dynamic>{
"BusinessID": businessId,
"ServicePointID": servicePointId,
};
final raw = await _postRaw("/chat/getActiveChat.cfm", body);
final j = _requireJson(raw, "GetActiveChat");
if (!_ok(j)) {
return null;
}
final hasActiveChat = j["HAS_ACTIVE_CHAT"] == true;
if (!hasActiveChat) return null;
final taskId = (j["TASK_ID"] as num?)?.toInt();
return (taskId != null && taskId > 0) ? taskId : null;
}
/// Create a chat task and return the task ID
static Future<int> createChatTask({
required int businessId,
required int servicePointId,
int? orderId,
int? userId,
String? initialMessage,
}) async {
final body = <String, dynamic>{
"BusinessID": businessId,
"ServicePointID": servicePointId,
};
if (orderId != null && orderId > 0) body["OrderID"] = orderId;
if (userId != null && userId > 0) body["UserID"] = userId;
if (initialMessage != null && initialMessage.isNotEmpty) {
body["Message"] = initialMessage;
}
final raw = await _postRaw("/tasks/createChat.cfm", body);
final j = _requireJson(raw, "CreateChatTask");
if (!_ok(j)) {
throw StateError(
"CreateChatTask failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}",
);
}
return (j["TaskID"] ?? j["TASK_ID"] as num).toInt();
}
/// Get chat messages for a task
/// Returns messages and whether the chat has been closed by the worker
static Future<({List<ChatMessage> messages, bool chatClosed})> getChatMessages({
required int taskId,
int? afterMessageId,
}) async {
final body = <String, dynamic>{
"TaskID": taskId,
};
if (afterMessageId != null && afterMessageId > 0) {
body["AfterMessageID"] = afterMessageId;
}
final raw = await _postRaw("/chat/getMessages.cfm", body);
final j = _requireJson(raw, "GetChatMessages");
if (!_ok(j)) {
throw StateError("GetChatMessages failed: ${_err(j)}");
}
final arr = _pickArray(j, const ["MESSAGES", "messages"]);
final messages = arr == null
? <ChatMessage>[]
: arr.map((e) {
final item = e is Map<String, dynamic> ? e : (e as Map).cast<String, dynamic>();
return ChatMessage.fromJson(item);
}).toList();
// Check if chat has been closed (task completed)
final chatClosed = j["CHAT_CLOSED"] == true || j["chat_closed"] == true;
return (messages: messages, chatClosed: chatClosed);
}
/// Send a chat message (HTTP fallback when WebSocket unavailable)
static Future<int> sendChatMessage({
required int taskId,
required String message,
int? userId,
String? senderType,
}) async {
final body = <String, dynamic>{
"TaskID": taskId,
"Message": message,
};
if (userId != null) body["UserID"] = userId;
if (senderType != null) body["SenderType"] = senderType;
final raw = await _postRaw("/chat/sendMessage.cfm", body);
final j = _requireJson(raw, "SendChatMessage");
if (!_ok(j)) {
throw StateError("SendChatMessage failed: ${_err(j)}");
}
return ((j["MessageID"] ?? j["MESSAGE_ID"]) as num).toInt();
}
/// Mark chat messages as read
static Future<void> markChatMessagesRead({
required int taskId,
required String readerType,
}) async {
final raw = await _postRaw("/chat/markRead.cfm", {
"TaskID": taskId,
"ReaderType": readerType,
});
final j = _requireJson(raw, "MarkChatMessagesRead");
if (!_ok(j)) {
throw StateError("MarkChatMessagesRead failed: ${_err(j)}");
}
}
/// Get auth token for WebSocket authentication
static String? get authToken => _userToken;
} }
class OrderHistoryResponse { class OrderHistoryResponse {

View file

@ -94,8 +94,8 @@ class BeaconPermissions {
debugPrint('[BeaconPermissions] ⚠️ Bluetooth is OFF, requesting enable...'); debugPrint('[BeaconPermissions] ⚠️ Bluetooth is OFF, requesting enable...');
await requestEnableBluetooth(); await requestEnableBluetooth();
// Poll for Bluetooth state change (wait up to 10 seconds) // Poll for Bluetooth state change - short wait first
for (int i = 0; i < 20; i++) { for (int i = 0; i < 6; i++) {
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
final newState = await flutterBeacon.bluetoothState; final newState = await flutterBeacon.bluetoothState;
debugPrint('[BeaconPermissions] 📶 Polling Bluetooth state ($i): $newState'); debugPrint('[BeaconPermissions] 📶 Polling Bluetooth state ($i): $newState');
@ -105,6 +105,21 @@ class BeaconPermissions {
} }
} }
// If still off after 3 seconds, try opening Bluetooth settings directly
debugPrint('[BeaconPermissions] ⚠️ Bluetooth still OFF, opening settings...');
await openBluetoothSettings();
// Poll again for up to 15 seconds (user needs time to toggle in settings)
for (int i = 0; i < 30; i++) {
await Future.delayed(const Duration(milliseconds: 500));
final newState = await flutterBeacon.bluetoothState;
debugPrint('[BeaconPermissions] 📶 Polling Bluetooth state after settings ($i): $newState');
if (newState == BluetoothState.stateOn) {
debugPrint('[BeaconPermissions] ✅ Bluetooth is now ON');
return true;
}
}
debugPrint('[BeaconPermissions] ❌ Bluetooth still OFF after waiting'); debugPrint('[BeaconPermissions] ❌ Bluetooth still OFF after waiting');
return false; return false;
} catch (e) { } catch (e) {

View file

@ -0,0 +1,281 @@
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,
});
}

View file

@ -6,9 +6,11 @@ import FlutterMacOS
import Foundation import Foundation
import file_selector_macos import file_selector_macos
import package_info_plus
import shared_preferences_foundation import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
} }

View file

@ -304,6 +304,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.19.0" version: "0.19.0"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
@ -344,6 +352,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -384,6 +400,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
url: "https://pub.dev"
source: hosted
version: "8.3.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -565,6 +597,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
socket_io_client:
dependency: "direct main"
description:
name: socket_io_client
sha256: ede469f3e4c55e8528b4e023bdedbc20832e8811ab9b61679d1ba3ed5f01f23b
url: "https://pub.dev"
source: hosted
version: "2.0.3+1"
socket_io_common:
dependency: transitive
description:
name: socket_io_common
sha256: "2ab92f8ff3ebbd4b353bf4a98bee45cc157e3255464b2f90f66e09c4472047eb"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@ -669,6 +717,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View file

@ -18,6 +18,8 @@ dependencies:
flutter_stripe: ^11.4.0 flutter_stripe: ^11.4.0
image_picker: ^1.0.7 image_picker: ^1.0.7
intl: ^0.19.0 intl: ^0.19.0
socket_io_client: ^2.0.3+1
package_info_plus: ^8.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -35,3 +37,5 @@ flutter_launcher_icons:
flutter: flutter:
uses-material-design: true uses-material-design: true
assets:
- assets/images/