From 65b5b82546366cd78bf3808d8e10408d287b42f3 Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Sun, 11 Jan 2026 12:08:09 -0800 Subject: [PATCH] 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 --- assets/images/payfrit_logo.png | Bin 0 -> 18474 bytes lib/app/app_router.dart | 2 + lib/app/app_state.dart | 17 + lib/models/chat_message.dart | 52 ++ lib/screens/about_screen.dart | 27 +- lib/screens/chat_screen.dart | 706 ++++++++++++++++++ lib/screens/group_order_invite_screen.dart | 105 +-- lib/screens/login_screen.dart | 452 +++++++---- lib/screens/menu_browse_screen.dart | 289 ++++++- lib/screens/restaurant_select_screen.dart | 7 + lib/screens/signup_screen.dart | 29 +- lib/services/api.dart | 277 ++++++- lib/services/beacon_permissions.dart | 19 +- lib/services/chat_service.dart | 281 +++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 56 ++ pubspec.yaml | 4 + 17 files changed, 2119 insertions(+), 206 deletions(-) create mode 100644 assets/images/payfrit_logo.png create mode 100644 lib/models/chat_message.dart create mode 100644 lib/screens/chat_screen.dart create mode 100644 lib/services/chat_service.dart diff --git a/assets/images/payfrit_logo.png b/assets/images/payfrit_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..50032ed3492dd86daf2bf8cd42bc618a96b090d1 GIT binary patch literal 18474 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 uB%nGB0f get routes => { splash: (_) => const SplashScreen(), diff --git a/lib/app/app_state.dart b/lib/app/app_state.dart index 359fd9d..f3278ca 100644 --- a/lib/app/app_state.dart +++ b/lib/app/app_state.dart @@ -23,6 +23,8 @@ class AppState extends ChangeNotifier { int? _activeOrderId; int? _activeOrderStatusId; + List _groupOrderInvites = []; + int? get selectedBusinessId => _selectedBusinessId; String? get selectedBusinessName => _selectedBusinessName; int? get selectedServicePointId => _selectedServicePointId; @@ -44,6 +46,9 @@ class AppState extends ChangeNotifier { int? get activeOrderStatusId => _activeOrderStatusId; bool get hasActiveOrder => _activeOrderId != null; + List get groupOrderInvites => _groupOrderInvites; + bool get isGroupOrder => _groupOrderInvites.isNotEmpty; + bool get hasLocationSelection => _selectedBusinessId != null && _selectedServicePointId != null; @@ -137,6 +142,16 @@ class AppState extends ChangeNotifier { notifyListeners(); } + void setGroupOrderInvites(List userIds) { + _groupOrderInvites = userIds; + notifyListeners(); + } + + void clearGroupOrderInvites() { + _groupOrderInvites = []; + notifyListeners(); + } + void clearAll() { _selectedBusinessId = null; _selectedServicePointId = null; @@ -148,5 +163,7 @@ class AppState extends ChangeNotifier { _activeOrderId = null; _activeOrderStatusId = null; + + _groupOrderInvites = []; } } diff --git a/lib/models/chat_message.dart b/lib/models/chat_message.dart new file mode 100644 index 0000000..1724c39 --- /dev/null +++ b/lib/models/chat_message.dart @@ -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 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 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; +} diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index f75b3ed..3df53a1 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -1,8 +1,31 @@ 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}); + @override + State createState() => _AboutScreenState(); +} + +class _AboutScreenState extends State { + String _version = ''; + + @override + void initState() { + super.initState(); + _loadVersion(); + } + + Future _loadVersion() async { + final info = await PackageInfo.fromPlatform(); + if (mounted) { + setState(() { + _version = 'Version ${info.version}'; + }); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -44,7 +67,7 @@ class AboutScreen extends StatelessWidget { // Version Center( child: Text( - 'Version 0.1.0', + _version, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart new file mode 100644 index 0000000..8ffa6b7 --- /dev/null +++ b/lib/screens/chat_screen.dart @@ -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 createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final ChatService _chatService = ChatService(); + final TextEditingController _messageController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + final List _messages = []; + + bool _isLoading = true; + bool _isConnecting = false; + bool _isSending = false; + bool _otherUserTyping = false; + String? _otherUserName; + String? _error; + bool _chatEnded = false; + + StreamSubscription? _messageSubscription; + StreamSubscription? _typingSubscription; + StreamSubscription? _eventSubscription; + Timer? _typingDebounce; + Timer? _pollTimer; + + @override + void initState() { + super.initState(); + _otherUserName = widget.otherPartyName; + _initializeChat(); + } + + Future _initializeChat() async { + // Ensure auth is loaded first before any API calls + await _ensureAuth(); + // Then load messages and connect + await _loadMessages(); + _connectToChat(); + } + + Future _ensureAuth() async { + if (Api.authToken == null || Api.authToken!.isEmpty) { + final authData = await AuthStorage.loadAuth(); + if (authData != null && authData.token.isNotEmpty) { + Api.setAuthToken(authData.token); + } + } + } + + @override + void dispose() { + _messageSubscription?.cancel(); + _typingSubscription?.cancel(); + _eventSubscription?.cancel(); + _typingDebounce?.cancel(); + _pollTimer?.cancel(); + _chatService.disconnect(); + _messageController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + Future _loadMessages() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + debugPrint('[Chat] Loading messages for task ${widget.taskId}...'); + final result = await Api.getChatMessages(taskId: widget.taskId); + debugPrint('[Chat] Loaded ${result.messages.length} messages, chatClosed: ${result.chatClosed}'); + if (mounted) { + final wasClosed = result.chatClosed && !_chatEnded; + setState(() { + _messages.clear(); + _messages.addAll(result.messages); + _isLoading = false; + if (result.chatClosed) { + _chatEnded = true; + } + }); + _scrollToBottom(); + + // Show dialog if chat was just closed + if (wasClosed) { + _showChatEndedDialog(); + } + } + } catch (e) { + debugPrint('[Chat] Error loading messages: $e'); + if (mounted) { + setState(() { + _error = 'Failed to load messages: $e'; + _isLoading = false; + }); + } + } + } + + Future _connectToChat() async { + setState(() => _isConnecting = true); + + // Auth should already be loaded by _initializeChat + final token = Api.authToken; + if (token == null || token.isEmpty) { + debugPrint('[Chat] No auth token, skipping WebSocket (will use HTTP fallback with polling)'); + setState(() => _isConnecting = false); + _startPolling(); + return; + } + + // Set up stream listeners + _messageSubscription = _chatService.messages.listen((message) { + // Avoid duplicates + if (!_messages.any((m) => m.messageId == message.messageId)) { + setState(() { + _messages.add(message); + }); + _scrollToBottom(); + + // Mark as read if from the other party + if (message.senderType != widget.userType) { + Api.markChatMessagesRead( + taskId: widget.taskId, + readerType: widget.userType, + ); + } + } + }); + + _typingSubscription = _chatService.typingEvents.listen((event) { + if (event.userType != widget.userType) { + setState(() { + _otherUserTyping = event.isTyping; + if (event.userName.isNotEmpty) { + _otherUserName = event.userName; + } + }); + } + }); + + _eventSubscription = _chatService.events.listen((event) { + switch (event.type) { + case ChatEventType.joined: + debugPrint('Joined chat room'); + break; + case ChatEventType.userJoined: + final name = event.data?['userName'] ?? 'Someone'; + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$name joined the chat', style: const TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), + duration: const Duration(seconds: 2), + ), + ); + setState(() { + _otherUserName = name; + }); + } + break; + case ChatEventType.userLeft: + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Other user left the chat', style: TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), + duration: const Duration(seconds: 2), + ), + ); + } + break; + case ChatEventType.chatEnded: + setState(() { + _chatEnded = true; + }); + if (mounted) { + _showChatEndedDialog(); + } + break; + case ChatEventType.disconnected: + debugPrint('Disconnected from chat'); + break; + case ChatEventType.error: + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(event.message ?? 'Chat error'), + backgroundColor: Colors.red, + ), + ); + } + break; + } + }); + + final connected = await _chatService.connect( + taskId: widget.taskId, + userToken: token, + userType: widget.userType, + ); + + if (mounted) { + setState(() => _isConnecting = false); + if (!connected) { + debugPrint('Failed to connect to WebSocket, using HTTP fallback with polling'); + _startPolling(); + } + } + } + + void _startPolling() { + _pollTimer?.cancel(); + _pollTimer = Timer.periodic(const Duration(seconds: 3), (_) { + if (!_chatEnded && mounted) { + _pollNewMessages(); + } + }); + } + + Future _pollNewMessages() async { + try { + final lastMessageId = _messages.isNotEmpty ? _messages.last.messageId : 0; + final result = await Api.getChatMessages( + taskId: widget.taskId, + afterMessageId: lastMessageId, + ); + + if (mounted) { + // Check if chat has been closed by worker + if (result.chatClosed && !_chatEnded) { + setState(() { + _chatEnded = true; + }); + _pollTimer?.cancel(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('This chat has been closed by staff', style: TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), + ), + ); + } + + // Add any new messages + if (result.messages.isNotEmpty) { + setState(() { + for (final msg in result.messages) { + if (!_messages.any((m) => m.messageId == msg.messageId)) { + _messages.add(msg); + } + } + }); + _scrollToBottom(); + } + } + } catch (e) { + debugPrint('[Chat] Poll error: $e'); + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + void _onTextChanged(String text) { + // Debounce typing indicator + _typingDebounce?.cancel(); + if (text.isNotEmpty) { + _chatService.setTyping(true); + _typingDebounce = Timer(const Duration(seconds: 2), () { + _chatService.setTyping(false); + }); + } else { + _chatService.setTyping(false); + } + } + + Future _sendMessage() async { + final text = _messageController.text.trim(); + if (text.isEmpty || _isSending || _chatEnded) return; + + setState(() => _isSending = true); + _chatService.setTyping(false); + + try { + bool sentViaWebSocket = false; + + if (_chatService.isConnected) { + // Try to send via WebSocket + sentViaWebSocket = _chatService.sendMessage(text); + if (sentViaWebSocket) { + _messageController.clear(); + } + } + + if (!sentViaWebSocket) { + // Fallback to HTTP + debugPrint('[Chat] WebSocket not available, using HTTP fallback'); + final authData = await AuthStorage.loadAuth(); + final userId = authData?.userId; + + if (userId == null || userId == 0) { + throw StateError('Not logged in. Please sign in again.'); + } + + debugPrint('[Chat] Sending HTTP message: taskId=${widget.taskId}, userId=$userId'); + + await Api.sendChatMessage( + taskId: widget.taskId, + message: text, + userId: userId, + senderType: widget.userType, + ); + _messageController.clear(); + + // Refresh messages since we used HTTP + await _loadMessages(); + } + } catch (e) { + debugPrint('Error sending message: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to send: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isSending = false); + } + } + } + + /// Show dialog when chat is closed by worker, then navigate back + void _showChatEndedDialog() { + // Stop polling since chat is ended + _pollTimer?.cancel(); + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Text('Chat Ended'), + content: const Text('The staff member has closed this chat. You can start a new chat if you need further assistance.'), + actions: [ + FilledButton( + onPressed: () { + Navigator.pop(context); // Close dialog + Navigator.pop(this.context); // Go back to previous screen + }, + child: const Text('OK'), + ), + ], + ), + ); + } + + void _closeChat() { + if (widget.userType != 'worker') return; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Close Chat'), + content: const Text('Are you sure you want to close this chat?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _chatService.closeChat(); + Navigator.pop(this.context); + }, + child: const Text('Close', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final title = widget.userType == 'customer' + ? 'Chat with Staff' + : 'Chat with Customer'; + + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title), + if (_otherUserName != null) + Text( + _otherUserName!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white70, + ), + ), + ], + ), + actions: [ + if (_isConnecting) + const Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + ) + else if (_chatService.isConnected) + const Padding( + padding: EdgeInsets.all(16), + child: Icon(Icons.wifi, color: Colors.green, size: 20), + ) + else + const Padding( + padding: EdgeInsets.all(16), + child: Icon(Icons.wifi_off, color: Colors.orange, size: 20), + ), + if (widget.userType == 'worker' && !_chatEnded) + IconButton( + icon: const Icon(Icons.close), + onPressed: _closeChat, + tooltip: 'Close Chat', + ), + ], + ), + body: Column( + children: [ + if (_chatEnded) + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: Colors.orange.shade100, + child: const Text( + 'This chat has ended', + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + Expanded( + child: _buildMessageList(), + ), + if (_otherUserTyping) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Row( + children: [ + Text( + '${_otherUserName ?? "Other user"} is typing...', + style: TextStyle( + color: Colors.grey.shade600, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + if (!_chatEnded) _buildInputArea(), + ], + ), + ); + } + + Widget _buildMessageList() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Error: $_error'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadMessages, + child: const Text('Retry'), + ), + ], + ), + ); + } + + if (_messages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + 'No messages yet', + style: TextStyle(color: Colors.grey.shade600), + ), + const SizedBox(height: 8), + Text( + 'Start the conversation!', + style: TextStyle(color: Colors.grey.shade500, fontSize: 12), + ), + ], + ), + ); + } + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: _messages.length, + itemBuilder: (context, index) { + final message = _messages[index]; + final isMe = message.senderType == widget.userType; + return _buildMessageBubble(message, isMe); + }, + ); + } + + Widget _buildMessageBubble(ChatMessage message, bool isMe) { + final timeFormat = DateFormat.jm(); + final time = timeFormat.format(message.createdOn); + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isMe) ...[ + CircleAvatar( + radius: 16, + backgroundColor: Colors.grey.shade300, + child: Text( + message.senderName.isNotEmpty + ? message.senderName[0].toUpperCase() + : (message.senderType == 'worker' ? 'S' : 'C'), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 8), + ], + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isMe ? Theme.of(context).primaryColor : Colors.grey.shade200, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(isMe ? 16 : 4), + bottomRight: Radius.circular(isMe ? 4 : 16), + ), + ), + child: Column( + crossAxisAlignment: + isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (!isMe && message.senderName.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + message.senderName, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + ), + ), + Text( + message.text, + style: TextStyle( + color: isMe ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 4), + Text( + time, + style: TextStyle( + fontSize: 10, + color: isMe ? Colors.white70 : Colors.grey.shade600, + ), + ), + ], + ), + ), + ), + if (isMe) ...[ + const SizedBox(width: 8), + CircleAvatar( + radius: 16, + backgroundColor: Theme.of(context).primaryColor.withOpacity(0.7), + child: const Text( + 'Me', + style: TextStyle(fontSize: 10, color: Colors.white), + ), + ), + ], + ], + ), + ); + } + + Widget _buildInputArea() { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + child: Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Type a message...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + ), + textInputAction: TextInputAction.send, + onChanged: _onTextChanged, + onSubmitted: (_) => _sendMessage(), + ), + ), + const SizedBox(width: 8), + FloatingActionButton( + mini: true, + onPressed: _isSending ? null : _sendMessage, + child: _isSending + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.send), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/group_order_invite_screen.dart b/lib/screens/group_order_invite_screen.dart index 4e87763..36ae7c1 100644 --- a/lib/screens/group_order_invite_screen.dart +++ b/lib/screens/group_order_invite_screen.dart @@ -3,6 +3,8 @@ import 'package:provider/provider.dart'; import '../app/app_router.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 /// Shown after selecting Delivery or Takeaway @@ -14,12 +16,27 @@ class GroupOrderInviteScreen extends StatefulWidget { } class _GroupOrderInviteScreenState extends State { - final List _invitedUsers = []; + final List _invitedUsers = []; final TextEditingController _searchController = TextEditingController(); bool _isSearching = false; + int? _currentUserId; - // Mock search results - in production this would come from API - final List<_UserResult> _searchResults = []; + List _searchResults = []; + + @override + void initState() { + super.initState(); + _loadCurrentUserId(); + } + + Future _loadCurrentUserId() async { + final auth = await AuthStorage.loadAuth(); + if (auth != null && mounted) { + setState(() { + _currentUserId = auth.userId; + }); + } + } @override void dispose() { @@ -27,10 +44,18 @@ class _GroupOrderInviteScreenState extends State { super.dispose(); } - void _searchUsers(String query) { + Future _searchUsers(String query) async { if (query.isEmpty) { setState(() { - _searchResults.clear(); + _searchResults = []; + _isSearching = false; + }); + return; + } + + if (query.length < 3) { + setState(() { + _searchResults = []; _isSearching = false; }); return; @@ -38,33 +63,38 @@ class _GroupOrderInviteScreenState extends State { setState(() => _isSearching = true); - // TODO: Replace with actual API call to search users by phone/email/username - // For now, show placeholder - Future.delayed(const Duration(milliseconds: 500), () { + try { + final results = await Api.searchUsers( + query: query, + currentUserId: _currentUserId, + ); + if (mounted && _searchController.text == query) { + // Filter out already invited users + final filteredResults = results.where((user) => + !_invitedUsers.any((invited) => invited.userId == user.userId)).toList(); + setState(() { - _searchResults.clear(); - // Mock results - would come from API - if (query.length >= 3) { - _searchResults.addAll([ - _UserResult( - userId: 1, - name: 'John D.', - phone: '***-***-${query.substring(0, 4)}', - ), - ]); - } + _searchResults = filteredResults; _isSearching = false; }); } - }); + } catch (e) { + debugPrint('[GroupOrderInvite] Search error: $e'); + if (mounted) { + setState(() { + _searchResults = []; + _isSearching = false; + }); + } + } } - void _inviteUser(_UserResult user) { - if (!_invitedUsers.contains(user.name)) { + void _inviteUser(UserSearchResult user) { + if (!_invitedUsers.any((u) => u.userId == user.userId)) { setState(() { - _invitedUsers.add(user.name); - _searchResults.clear(); + _invitedUsers.add(user); + _searchResults = []; _searchController.clear(); }); @@ -79,16 +109,17 @@ class _GroupOrderInviteScreenState extends State { } } - void _removeInvite(String userName) { + void _removeInvite(UserSearchResult user) { setState(() { - _invitedUsers.remove(userName); + _invitedUsers.removeWhere((u) => u.userId == user.userId); }); } void _continueToRestaurants() { - // Store invited users in app state if needed + // Store invited users in app state final appState = context.read(); - // TODO: appState.setGroupOrderInvites(_invitedUsers); + final invitedUserIds = _invitedUsers.map((u) => u.userId).toList(); + appState.setGroupOrderInvites(invitedUserIds); Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); } @@ -227,7 +258,7 @@ class _GroupOrderInviteScreenState extends State { Wrap( spacing: 8, runSpacing: 8, - children: _invitedUsers.map((name) { + children: _invitedUsers.map((user) { return Chip( avatar: CircleAvatar( backgroundColor: Colors.green.withAlpha(50), @@ -237,9 +268,9 @@ class _GroupOrderInviteScreenState extends State { color: Colors.green, ), ), - label: Text(name), + label: Text(user.name), deleteIcon: const Icon(Icons.close, size: 18), - onDeleted: () => _removeInvite(name), + onDeleted: () => _removeInvite(user), backgroundColor: Colors.grey.shade800, labelStyle: const TextStyle(color: Colors.white), ); @@ -291,15 +322,3 @@ class _GroupOrderInviteScreenState extends State { ); } } - -class _UserResult { - final int userId; - final String name; - final String phone; - - const _UserResult({ - required this.userId, - required this.name, - required this.phone, - }); -} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index ce5ed02..6dec46a 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -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 "../app/app_router.dart"; @@ -6,6 +7,8 @@ import "../app/app_state.dart"; import "../services/api.dart"; import "../services/auth_storage.dart"; +enum LoginStep { phone, otp } + class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -14,22 +17,39 @@ class LoginScreen extends StatefulWidget { } class _LoginScreenState extends State { - final _formKey = GlobalKey(); - final _usernameController = TextEditingController(); - final _passwordController = TextEditingController(); + LoginStep _currentStep = LoginStep.phone; + + final _phoneController = TextEditingController(); + final _otpController = TextEditingController(); + + String _uuid = ""; + String _phone = ""; bool _isLoading = false; String? _errorMessage; @override void dispose() { - _usernameController.dispose(); - _passwordController.dispose(); + _phoneController.dispose(); + _otpController.dispose(); super.dispose(); } - Future _handleLogin() async { - if (!_formKey.currentState!.validate()) { + String _formatPhoneNumber(String input) { + final digits = input.replaceAll(RegExp(r'[^\d]'), ''); + if (digits.length == 11 && digits.startsWith('1')) { + return digits.substring(1); + } + return digits; + } + + Future _handleSendOtp() async { + final phone = _formatPhoneNumber(_phoneController.text); + + if (phone.length != 10) { + setState(() { + _errorMessage = "Please enter a valid 10-digit phone number"; + }); return; } @@ -39,37 +59,125 @@ class _LoginScreenState extends State { }); try { - final result = await Api.login( - username: _usernameController.text.trim(), - password: _passwordController.text, - ); + final response = await Api.sendLoginOtp(phone: phone); + + 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 _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; // Save credentials for persistent login await AuthStorage.saveAuth( - userId: result.userId, - token: result.token, + userId: response.userId, + token: response.token, ); - // Set the auth token on the API class - Api.setAuthToken(result.token); - final appState = context.read(); - 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()) { Navigator.of(context).pop(); } else { - // No previous route - go to splash which will auto-navigate based on beacon detection Navigator.of(context).pushReplacementNamed(AppRoutes.splash); } } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = e.toString().replaceFirst("StateError: ", ""); + _isLoading = false; + }); + } + } + + Future _handleResendOtp() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final response = await Api.sendLoginOtp(phone: _phone); + if (!mounted) return; 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; }); } @@ -79,127 +187,211 @@ class _LoginScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text("Login"), + title: Text(_currentStep == LoginStep.phone ? "Login" : "Verify Phone"), ), body: Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - 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( - "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; - }, - ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_currentStep == LoginStep.phone) _buildPhoneStep(), + if (_currentStep == LoginStep.otp) _buildOtpStep(), + + // Error message + if (_errorMessage != null) ...[ const SizedBox(height: 16), - TextFormField( - 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(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"), - ), + _buildErrorMessage(), ], - ), + ], ), ), ), ), ); } + + 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(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(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), + ), + ), + ], + ), + ); + } } diff --git a/lib/screens/menu_browse_screen.dart b/lib/screens/menu_browse_screen.dart index ffeaf3b..89f2604 100644 --- a/lib/screens/menu_browse_screen.dart +++ b/lib/screens/menu_browse_screen.dart @@ -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 "../app/app_router.dart"; @@ -6,6 +7,8 @@ import "../app/app_state.dart"; import "../models/cart.dart"; import "../models/menu_item.dart"; import "../services/api.dart"; +import "../services/auth_storage.dart"; +import "chat_screen.dart"; class MenuBrowseScreen extends StatefulWidget { const MenuBrowseScreen({super.key}); @@ -78,6 +81,260 @@ class _MenuBrowseScreenState extends State { return items; } + bool _isCallingServer = false; + + /// Show bottom sheet with choice: Server Visit or Chat + Future _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 _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( + 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 _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 _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 _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() { _itemsByCategory.clear(); _itemsByParent.clear(); @@ -220,32 +477,14 @@ class _MenuBrowseScreenState extends State { ], ), actions: [ - // Only show table change button for dine-in orders - if (appState.isDineIn) + // Call Server button - only for dine-in orders at a table + if (appState.isDineIn && _servicePointId != null) IconButton( - icon: const Icon(Icons.table_restaurant), - tooltip: "Change Table", - onPressed: () { - // 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); - }, + icon: const Icon(Icons.room_service), + tooltip: "Call Server", + onPressed: () => _handleCallServer(appState), ), + // Table change button removed - not allowed currently IconButton( icon: Badge( label: Text("${appState.cartItemCount}"), diff --git a/lib/screens/restaurant_select_screen.dart b/lib/screens/restaurant_select_screen.dart index 12c52df..1f16714 100644 --- a/lib/screens/restaurant_select_screen.dart +++ b/lib/screens/restaurant_select_screen.dart @@ -35,6 +35,13 @@ class _RestaurantSelectScreenState extends State { void initState() { super.initState(); _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.setOrderType(null); + }); } Future> _loadRestaurants() async { diff --git a/lib/screens/signup_screen.dart b/lib/screens/signup_screen.dart index 6283d88..958e16f 100644 --- a/lib/screens/signup_screen.dart +++ b/lib/screens/signup_screen.dart @@ -77,6 +77,14 @@ class _SignupScreenState extends State { if (!mounted) return; + if (response.uuid.isEmpty) { + setState(() { + _errorMessage = "Server returned empty UUID - please try again"; + _isLoading = false; + }); + return; + } + setState(() { _uuid = response.uuid; _phone = phone; @@ -86,13 +94,21 @@ class _SignupScreenState extends State { } catch (e) { if (!mounted) return; setState(() { - _errorMessage = e.toString().replaceFirst("StateError: ", ""); + _errorMessage = "Error: ${e.toString().replaceFirst("StateError: ", "")}"; _isLoading = false; }); } } Future _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(); if (otp.length != 6) { @@ -108,7 +124,9 @@ class _SignupScreenState extends State { }); try { + print('[Signup] Calling verifyOtp...'); final response = await Api.verifyOtp(uuid: _uuid, otp: otp); + print('[Signup] verifyOtp success: userId=${response.userId}, needsProfile=${response.needsProfile}'); if (!mounted) return; @@ -120,18 +138,22 @@ class _SignupScreenState extends State { userId: response.userId, token: response.token, ); + print('[Signup] Auth saved, token set'); if (response.needsProfile) { + print('[Signup] Profile needed, going to profile step'); // Go to profile step setState(() { _currentStep = SignupStep.profile; _isLoading = false; }); } else { + print('[Signup] Profile complete, finishing signup'); // Profile already complete - go to app _completeSignup(); } } catch (e) { + print('[Signup] verifyOtp error: $e'); if (!mounted) return; setState(() { _errorMessage = e.toString().replaceFirst("StateError: ", ""); @@ -164,16 +186,19 @@ class _SignupScreenState extends State { }); try { + print('[Signup] Calling completeProfile: firstName=$firstName, lastName=$lastName, email=$email'); await Api.completeProfile( firstName: firstName, lastName: lastName, email: email, ); + print('[Signup] completeProfile success'); if (!mounted) return; _completeSignup(); } catch (e) { + print('[Signup] completeProfile error: $e'); if (!mounted) return; setState(() { _errorMessage = e.toString().replaceFirst("StateError: ", ""); @@ -426,7 +451,7 @@ class _SignupScreenState extends State { fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 32), + const SizedBox(height: 24), TextFormField( controller: _otpController, decoration: const InputDecoration( diff --git a/lib/services/api.dart b/lib/services/api.dart index 9633426..8f75cfc 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -2,6 +2,7 @@ import "dart:convert"; import "package:http/http.dart" as http; import "../models/cart.dart"; +import "../models/chat_message.dart"; import "../models/menu_item.dart"; import "../models/order_detail.dart"; import "../models/order_history.dart"; @@ -52,13 +53,32 @@ class SendOtpResponse { }); factory SendOtpResponse.fromJson(Map 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( - uuid: (json["UUID"] as String?) ?? "", - message: (json["MESSAGE"] as String?) ?? "", + uuid: uuid, + 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 { final int userId; final String token; @@ -305,11 +325,13 @@ class Api { required String lastName, required String email, }) async { + print('[API] completeProfile: token=${_userToken?.substring(0, 8) ?? "NULL"}...'); final raw = await _postRaw("/auth/completeProfile.cfm", { "firstName": firstName, "lastName": lastName, "email": email, }); + print('[API] completeProfile response: ${raw.statusCode} - ${raw.rawBody}'); final j = _requireJson(raw, "CompleteProfile"); if (!_ok(j)) { @@ -318,6 +340,8 @@ class Api { throw StateError("This email is already associated with another account"); } else if (err == "invalid_email") { throw StateError("Please enter a valid email address"); + } else if (err == "unauthorized") { + throw StateError("Authentication failed - please try signing up again"); } else { throw StateError("Failed to save profile: ${j["MESSAGE"] ?? err}"); } @@ -329,6 +353,59 @@ class Api { return sendOtp(phone: phone); } + // ------------------------- + // Login via OTP (for existing verified accounts) + // ------------------------- + + /// Send OTP to phone number for LOGIN (existing accounts only) + static Future 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 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) // ------------------------- @@ -579,6 +656,36 @@ class Api { return j; } + // ------------------------- + // Tasks / Service Requests + // ------------------------- + + /// Call server to the table - creates a service request task + static Future callServer({ + required int businessId, + required int servicePointId, + int? orderId, + int? userId, + String? message, + }) async { + final body = { + "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 // ------------------------- @@ -854,6 +961,36 @@ class Api { ); } + /// Search for users by phone, email, or name (for group order invites) + static Future> 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? ?? []; + return usersJson.map((e) { + final user = e as Map; + 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 static Future getProfile() async { final raw = await _getRaw("/auth/profile.cfm"); @@ -896,6 +1033,142 @@ class Api { final orderData = j["ORDER"] as Map? ?? {}; 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 getActiveChat({ + required int businessId, + required int servicePointId, + }) async { + final body = { + "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 createChatTask({ + required int businessId, + required int servicePointId, + int? orderId, + int? userId, + String? initialMessage, + }) async { + final body = { + "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 messages, bool chatClosed})> getChatMessages({ + required int taskId, + int? afterMessageId, + }) async { + final body = { + "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 + ? [] + : arr.map((e) { + final item = e is Map ? e : (e as Map).cast(); + 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 sendChatMessage({ + required int taskId, + required String message, + int? userId, + String? senderType, + }) async { + final body = { + "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 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 { diff --git a/lib/services/beacon_permissions.dart b/lib/services/beacon_permissions.dart index 4317b6f..c6a6234 100644 --- a/lib/services/beacon_permissions.dart +++ b/lib/services/beacon_permissions.dart @@ -94,8 +94,8 @@ class BeaconPermissions { debugPrint('[BeaconPermissions] ⚠️ Bluetooth is OFF, requesting enable...'); await requestEnableBluetooth(); - // Poll for Bluetooth state change (wait up to 10 seconds) - for (int i = 0; i < 20; i++) { + // Poll for Bluetooth state change - short wait first + for (int i = 0; i < 6; i++) { await Future.delayed(const Duration(milliseconds: 500)); final newState = await flutterBeacon.bluetoothState; 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'); return false; } catch (e) { diff --git a/lib/services/chat_service.dart b/lib/services/chat_service.dart new file mode 100644 index 0000000..339159c --- /dev/null +++ b/lib/services/chat_service.dart @@ -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 _messageController = + StreamController.broadcast(); + final StreamController _typingController = + StreamController.broadcast(); + final StreamController _eventController = + StreamController.broadcast(); + + bool _isConnected = false; + + /// Stream of incoming messages + Stream get messages => _messageController.stream; + + /// Stream of typing events + Stream get typingEvents => _typingController.stream; + + /// Stream of chat events (user joined/left, chat ended) + Stream 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 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(); + + 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); + _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 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 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, + }); +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ab1fdba..825c3be 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,11 @@ import FlutterMacOS import Foundation import file_selector_macos +import package_info_plus import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 2962830..77346a9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -304,6 +304,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" json_annotation: dependency: transitive description: @@ -344,6 +352,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -384,6 +400,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -565,6 +597,22 @@ packages: description: flutter source: sdk 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: dependency: transitive description: @@ -669,6 +717,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e41943a..37a13cf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,8 @@ dependencies: flutter_stripe: ^11.4.0 image_picker: ^1.0.7 intl: ^0.19.0 + socket_io_client: ^2.0.3+1 + package_info_plus: ^8.0.0 dev_dependencies: flutter_test: @@ -35,3 +37,5 @@ flutter_launcher_icons: flutter: uses-material-design: true + assets: + - assets/images/