diff --git a/assets/images/payfrit_logo.png b/assets/images/payfrit_logo.png new file mode 100644 index 0000000..50032ed Binary files /dev/null and b/assets/images/payfrit_logo.png differ diff --git a/lib/app/app_router.dart b/lib/app/app_router.dart index 013d6c6..826cfe4 100644 --- a/lib/app/app_router.dart +++ b/lib/app/app_router.dart @@ -2,6 +2,7 @@ import "package:flutter/material.dart"; import "../screens/account_screen.dart"; import "../screens/about_screen.dart"; +import "../screens/chat_screen.dart"; import "../screens/address_edit_screen.dart"; import "../screens/address_list_screen.dart"; import "../screens/beacon_scan_screen.dart"; @@ -34,6 +35,7 @@ class AppRoutes { static const String addressEdit = "/address-edit"; static const String about = "/about"; static const String signup = "/signup"; + static const String chat = "/chat"; static Map 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/