Add customer-to-staff chat feature and group order invites
- Add real-time chat between customers and staff via WebSocket - Add HTTP polling fallback when WebSocket unavailable - Chat auto-closes when worker ends conversation with dialog notification - Add user search API for group order invites (phone/email/name) - Store group order invites in app state - Add login check before starting chat with sign-in prompt - Remove table change button (not allowed currently) - Fix About screen to show dynamic version from pubspec - Update snackbar styling to green with black text Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2522970078
commit
65b5b82546
17 changed files with 2119 additions and 206 deletions
BIN
assets/images/payfrit_logo.png
Normal file
BIN
assets/images/payfrit_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -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<String, WidgetBuilder> get routes => {
|
||||
splash: (_) => const SplashScreen(),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ class AppState extends ChangeNotifier {
|
|||
int? _activeOrderId;
|
||||
int? _activeOrderStatusId;
|
||||
|
||||
List<int> _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<int> 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<int> 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 = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
52
lib/models/chat_message.dart
Normal file
52
lib/models/chat_message.dart
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
class ChatMessage {
|
||||
final int messageId;
|
||||
final int taskId;
|
||||
final int senderUserId;
|
||||
final String senderType; // 'customer' or 'worker'
|
||||
final String senderName;
|
||||
final String text;
|
||||
final DateTime createdOn;
|
||||
final bool isRead;
|
||||
|
||||
const ChatMessage({
|
||||
required this.messageId,
|
||||
required this.taskId,
|
||||
required this.senderUserId,
|
||||
required this.senderType,
|
||||
required this.senderName,
|
||||
required this.text,
|
||||
required this.createdOn,
|
||||
this.isRead = false,
|
||||
});
|
||||
|
||||
factory ChatMessage.fromJson(Map<String, dynamic> json) {
|
||||
return ChatMessage(
|
||||
messageId: (json["MessageID"] as num?)?.toInt() ?? (json["messageId"] as num?)?.toInt() ?? 0,
|
||||
taskId: (json["TaskID"] as num?)?.toInt() ?? (json["taskId"] as num?)?.toInt() ?? 0,
|
||||
senderUserId: (json["SenderUserID"] as num?)?.toInt() ?? (json["senderUserId"] as num?)?.toInt() ?? 0,
|
||||
senderType: json["SenderType"] as String? ?? json["senderType"] as String? ?? "customer",
|
||||
senderName: json["SenderName"] as String? ?? json["senderName"] as String? ?? "",
|
||||
text: json["Text"] as String? ?? json["MessageText"] as String? ?? json["text"] as String? ?? "",
|
||||
createdOn: DateTime.tryParse(
|
||||
json["CreatedOn"] as String? ?? json["timestamp"] as String? ?? ""
|
||||
) ?? DateTime.now(),
|
||||
isRead: json["IsRead"] == 1 || json["IsRead"] == true || json["isRead"] == true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"messageId": messageId,
|
||||
"taskId": taskId,
|
||||
"senderUserId": senderUserId,
|
||||
"senderType": senderType,
|
||||
"senderName": senderName,
|
||||
"text": text,
|
||||
"timestamp": createdOn.toIso8601String(),
|
||||
"isRead": isRead,
|
||||
};
|
||||
}
|
||||
|
||||
/// Check if this message was sent by the current user
|
||||
bool isMine(String userType) => senderType == userType;
|
||||
}
|
||||
|
|
@ -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<AboutScreen> createState() => _AboutScreenState();
|
||||
}
|
||||
|
||||
class _AboutScreenState extends State<AboutScreen> {
|
||||
String _version = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadVersion();
|
||||
}
|
||||
|
||||
Future<void> _loadVersion() async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_version = 'Version ${info.version}';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
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,
|
||||
),
|
||||
|
|
|
|||
706
lib/screens/chat_screen.dart
Normal file
706
lib/screens/chat_screen.dart
Normal file
|
|
@ -0,0 +1,706 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../models/chat_message.dart';
|
||||
import '../services/api.dart';
|
||||
import '../services/chat_service.dart';
|
||||
import '../services/auth_storage.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
final int taskId;
|
||||
final String userType; // 'customer' or 'worker'
|
||||
final String? otherPartyName;
|
||||
|
||||
const ChatScreen({
|
||||
super.key,
|
||||
required this.taskId,
|
||||
required this.userType,
|
||||
this.otherPartyName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
final ChatService _chatService = ChatService();
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final List<ChatMessage> _messages = [];
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isConnecting = false;
|
||||
bool _isSending = false;
|
||||
bool _otherUserTyping = false;
|
||||
String? _otherUserName;
|
||||
String? _error;
|
||||
bool _chatEnded = false;
|
||||
|
||||
StreamSubscription<ChatMessage>? _messageSubscription;
|
||||
StreamSubscription<TypingEvent>? _typingSubscription;
|
||||
StreamSubscription<ChatEvent>? _eventSubscription;
|
||||
Timer? _typingDebounce;
|
||||
Timer? _pollTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_otherUserName = widget.otherPartyName;
|
||||
_initializeChat();
|
||||
}
|
||||
|
||||
Future<void> _initializeChat() async {
|
||||
// Ensure auth is loaded first before any API calls
|
||||
await _ensureAuth();
|
||||
// Then load messages and connect
|
||||
await _loadMessages();
|
||||
_connectToChat();
|
||||
}
|
||||
|
||||
Future<void> _ensureAuth() async {
|
||||
if (Api.authToken == null || Api.authToken!.isEmpty) {
|
||||
final authData = await AuthStorage.loadAuth();
|
||||
if (authData != null && authData.token.isNotEmpty) {
|
||||
Api.setAuthToken(authData.token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageSubscription?.cancel();
|
||||
_typingSubscription?.cancel();
|
||||
_eventSubscription?.cancel();
|
||||
_typingDebounce?.cancel();
|
||||
_pollTimer?.cancel();
|
||||
_chatService.disconnect();
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadMessages() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
debugPrint('[Chat] Loading messages for task ${widget.taskId}...');
|
||||
final result = await Api.getChatMessages(taskId: widget.taskId);
|
||||
debugPrint('[Chat] Loaded ${result.messages.length} messages, chatClosed: ${result.chatClosed}');
|
||||
if (mounted) {
|
||||
final wasClosed = result.chatClosed && !_chatEnded;
|
||||
setState(() {
|
||||
_messages.clear();
|
||||
_messages.addAll(result.messages);
|
||||
_isLoading = false;
|
||||
if (result.chatClosed) {
|
||||
_chatEnded = true;
|
||||
}
|
||||
});
|
||||
_scrollToBottom();
|
||||
|
||||
// Show dialog if chat was just closed
|
||||
if (wasClosed) {
|
||||
_showChatEndedDialog();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[Chat] Error loading messages: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Failed to load messages: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectToChat() async {
|
||||
setState(() => _isConnecting = true);
|
||||
|
||||
// Auth should already be loaded by _initializeChat
|
||||
final token = Api.authToken;
|
||||
if (token == null || token.isEmpty) {
|
||||
debugPrint('[Chat] No auth token, skipping WebSocket (will use HTTP fallback with polling)');
|
||||
setState(() => _isConnecting = false);
|
||||
_startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up stream listeners
|
||||
_messageSubscription = _chatService.messages.listen((message) {
|
||||
// Avoid duplicates
|
||||
if (!_messages.any((m) => m.messageId == message.messageId)) {
|
||||
setState(() {
|
||||
_messages.add(message);
|
||||
});
|
||||
_scrollToBottom();
|
||||
|
||||
// Mark as read if from the other party
|
||||
if (message.senderType != widget.userType) {
|
||||
Api.markChatMessagesRead(
|
||||
taskId: widget.taskId,
|
||||
readerType: widget.userType,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_typingSubscription = _chatService.typingEvents.listen((event) {
|
||||
if (event.userType != widget.userType) {
|
||||
setState(() {
|
||||
_otherUserTyping = event.isTyping;
|
||||
if (event.userName.isNotEmpty) {
|
||||
_otherUserName = event.userName;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_eventSubscription = _chatService.events.listen((event) {
|
||||
switch (event.type) {
|
||||
case ChatEventType.joined:
|
||||
debugPrint('Joined chat room');
|
||||
break;
|
||||
case ChatEventType.userJoined:
|
||||
final name = event.data?['userName'] ?? 'Someone';
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$name joined the chat', style: const TextStyle(color: Colors.black)),
|
||||
backgroundColor: const Color(0xFF90EE90),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_otherUserName = name;
|
||||
});
|
||||
}
|
||||
break;
|
||||
case ChatEventType.userLeft:
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Other user left the chat', style: TextStyle(color: Colors.black)),
|
||||
backgroundColor: const Color(0xFF90EE90),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case ChatEventType.chatEnded:
|
||||
setState(() {
|
||||
_chatEnded = true;
|
||||
});
|
||||
if (mounted) {
|
||||
_showChatEndedDialog();
|
||||
}
|
||||
break;
|
||||
case ChatEventType.disconnected:
|
||||
debugPrint('Disconnected from chat');
|
||||
break;
|
||||
case ChatEventType.error:
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(event.message ?? 'Chat error'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
final connected = await _chatService.connect(
|
||||
taskId: widget.taskId,
|
||||
userToken: token,
|
||||
userType: widget.userType,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isConnecting = false);
|
||||
if (!connected) {
|
||||
debugPrint('Failed to connect to WebSocket, using HTTP fallback with polling');
|
||||
_startPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 3), (_) {
|
||||
if (!_chatEnded && mounted) {
|
||||
_pollNewMessages();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pollNewMessages() async {
|
||||
try {
|
||||
final lastMessageId = _messages.isNotEmpty ? _messages.last.messageId : 0;
|
||||
final result = await Api.getChatMessages(
|
||||
taskId: widget.taskId,
|
||||
afterMessageId: lastMessageId,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
// Check if chat has been closed by worker
|
||||
if (result.chatClosed && !_chatEnded) {
|
||||
setState(() {
|
||||
_chatEnded = true;
|
||||
});
|
||||
_pollTimer?.cancel();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('This chat has been closed by staff', style: TextStyle(color: Colors.black)),
|
||||
backgroundColor: const Color(0xFF90EE90),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Add any new messages
|
||||
if (result.messages.isNotEmpty) {
|
||||
setState(() {
|
||||
for (final msg in result.messages) {
|
||||
if (!_messages.any((m) => m.messageId == msg.messageId)) {
|
||||
_messages.add(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
_scrollToBottom();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[Chat] Poll error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onTextChanged(String text) {
|
||||
// Debounce typing indicator
|
||||
_typingDebounce?.cancel();
|
||||
if (text.isNotEmpty) {
|
||||
_chatService.setTyping(true);
|
||||
_typingDebounce = Timer(const Duration(seconds: 2), () {
|
||||
_chatService.setTyping(false);
|
||||
});
|
||||
} else {
|
||||
_chatService.setTyping(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
final text = _messageController.text.trim();
|
||||
if (text.isEmpty || _isSending || _chatEnded) return;
|
||||
|
||||
setState(() => _isSending = true);
|
||||
_chatService.setTyping(false);
|
||||
|
||||
try {
|
||||
bool sentViaWebSocket = false;
|
||||
|
||||
if (_chatService.isConnected) {
|
||||
// Try to send via WebSocket
|
||||
sentViaWebSocket = _chatService.sendMessage(text);
|
||||
if (sentViaWebSocket) {
|
||||
_messageController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (!sentViaWebSocket) {
|
||||
// Fallback to HTTP
|
||||
debugPrint('[Chat] WebSocket not available, using HTTP fallback');
|
||||
final authData = await AuthStorage.loadAuth();
|
||||
final userId = authData?.userId;
|
||||
|
||||
if (userId == null || userId == 0) {
|
||||
throw StateError('Not logged in. Please sign in again.');
|
||||
}
|
||||
|
||||
debugPrint('[Chat] Sending HTTP message: taskId=${widget.taskId}, userId=$userId');
|
||||
|
||||
await Api.sendChatMessage(
|
||||
taskId: widget.taskId,
|
||||
message: text,
|
||||
userId: userId,
|
||||
senderType: widget.userType,
|
||||
);
|
||||
_messageController.clear();
|
||||
|
||||
// Refresh messages since we used HTTP
|
||||
await _loadMessages();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error sending message: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to send: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSending = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show dialog when chat is closed by worker, then navigate back
|
||||
void _showChatEndedDialog() {
|
||||
// Stop polling since chat is ended
|
||||
_pollTimer?.cancel();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Chat Ended'),
|
||||
content: const Text('The staff member has closed this chat. You can start a new chat if you need further assistance.'),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context); // Close dialog
|
||||
Navigator.pop(this.context); // Go back to previous screen
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _closeChat() {
|
||||
if (widget.userType != 'worker') return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Close Chat'),
|
||||
content: const Text('Are you sure you want to close this chat?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_chatService.closeChat();
|
||||
Navigator.pop(this.context);
|
||||
},
|
||||
child: const Text('Close', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.userType == 'customer'
|
||||
? 'Chat with Staff'
|
||||
: 'Chat with Customer';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title),
|
||||
if (_otherUserName != null)
|
||||
Text(
|
||||
_otherUserName!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (_isConnecting)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_chatService.isConnected)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Icon(Icons.wifi, color: Colors.green, size: 20),
|
||||
)
|
||||
else
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Icon(Icons.wifi_off, color: Colors.orange, size: 20),
|
||||
),
|
||||
if (widget.userType == 'worker' && !_chatEnded)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _closeChat,
|
||||
tooltip: 'Close Chat',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (_chatEnded)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: Colors.orange.shade100,
|
||||
child: const Text(
|
||||
'This chat has ended',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildMessageList(),
|
||||
),
|
||||
if (_otherUserTyping)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${_otherUserName ?? "Other user"} is typing...',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!_chatEnded) _buildInputArea(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageList() {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Error: $_error'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadMessages,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_messages.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No messages yet',
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Start the conversation!',
|
||||
style: TextStyle(color: Colors.grey.shade500, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = _messages[index];
|
||||
final isMe = message.senderType == widget.userType;
|
||||
return _buildMessageBubble(message, isMe);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(ChatMessage message, bool isMe) {
|
||||
final timeFormat = DateFormat.jm();
|
||||
final time = timeFormat.format(message.createdOn);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!isMe) ...[
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.grey.shade300,
|
||||
child: Text(
|
||||
message.senderName.isNotEmpty
|
||||
? message.senderName[0].toUpperCase()
|
||||
: (message.senderType == 'worker' ? 'S' : 'C'),
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isMe ? Theme.of(context).primaryColor : Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isMe ? 16 : 4),
|
||||
bottomRight: Radius.circular(isMe ? 4 : 16),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMe && message.senderName.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
message.senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
message.text,
|
||||
style: TextStyle(
|
||||
color: isMe ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
time,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: isMe ? Colors.white70 : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isMe) ...[
|
||||
const SizedBox(width: 8),
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.7),
|
||||
child: const Text(
|
||||
'Me',
|
||||
style: TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputArea() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type a message...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onChanged: _onTextChanged,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
onPressed: _isSending ? null : _sendMessage,
|
||||
child: _isSending
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GroupOrderInviteScreen> {
|
||||
final List<String> _invitedUsers = [];
|
||||
final List<UserSearchResult> _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<UserSearchResult> _searchResults = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCurrentUserId();
|
||||
}
|
||||
|
||||
Future<void> _loadCurrentUserId() async {
|
||||
final auth = await AuthStorage.loadAuth();
|
||||
if (auth != null && mounted) {
|
||||
setState(() {
|
||||
_currentUserId = auth.userId;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
|
@ -27,10 +44,18 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
void _searchUsers(String query) {
|
||||
Future<void> _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<GroupOrderInviteScreen> {
|
|||
|
||||
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<GroupOrderInviteScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
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<AppState>();
|
||||
// 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<GroupOrderInviteScreen> {
|
|||
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<GroupOrderInviteScreen> {
|
|||
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<GroupOrderInviteScreen> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UserResult {
|
||||
final int userId;
|
||||
final String name;
|
||||
final String phone;
|
||||
|
||||
const _UserResult({
|
||||
required this.userId,
|
||||
required this.name,
|
||||
required this.phone,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<void> _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<void> _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<LoginScreen> {
|
|||
});
|
||||
|
||||
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<void> _handleVerifyOtp() async {
|
||||
if (_uuid.isEmpty) {
|
||||
setState(() {
|
||||
_errorMessage = "Session expired. Please go back and try again.";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final otp = _otpController.text.trim();
|
||||
|
||||
if (otp.length != 6) {
|
||||
setState(() {
|
||||
_errorMessage = "Please enter the 6-digit code";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await Api.verifyLoginOtp(uuid: _uuid, otp: otp);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 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>();
|
||||
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<void> _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,17 +187,35 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
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: [
|
||||
if (_currentStep == LoginStep.phone) _buildPhoneStep(),
|
||||
if (_currentStep == LoginStep.otp) _buildOtpStep(),
|
||||
|
||||
// Error message
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildErrorMessage(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhoneStep() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
|
|
@ -103,55 +229,152 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
"Sign in to order",
|
||||
"Enter your phone number to login",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
const SizedBox(height: 32),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Email or Phone Number",
|
||||
labelText: "Phone Number",
|
||||
hintText: "(555) 123-4567",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person),
|
||||
prefixIcon: Icon(Icons.phone),
|
||||
prefixText: "+1 ",
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
],
|
||||
enabled: !_isLoading,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return "Please enter your email or phone number";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onFieldSubmitted: (_) => _handleSendOtp(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handleSendOtp,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text("Send Login Code"),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () {
|
||||
Navigator.of(context).pushReplacementNamed(AppRoutes.signup);
|
||||
},
|
||||
child: const Text("Don't have an account? Sign Up"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOtpStep() {
|
||||
final formattedPhone = _phone.length == 10
|
||||
? "(${_phone.substring(0, 3)}) ${_phone.substring(3, 6)}-${_phone.substring(6)}"
|
||||
: _phone;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sms,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"We sent a code to",
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
formattedPhone,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
controller: _otpController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Password",
|
||||
labelText: "Login Code",
|
||||
hintText: "123456",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.lock),
|
||||
),
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
],
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
letterSpacing: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
enabled: !_isLoading,
|
||||
onFieldSubmitted: (_) => _handleLogin(),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return "Please enter your password";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onFieldSubmitted: (_) => _handleVerifyOtp(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_errorMessage != null)
|
||||
Container(
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handleVerifyOtp,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text("Login"),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : _handleResendOtp,
|
||||
child: const Text("Resend Code"),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
TextButton(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_currentStep = LoginStep.phone;
|
||||
_otpController.clear();
|
||||
_errorMessage = null;
|
||||
});
|
||||
},
|
||||
child: const Text("Change Number"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorMessage() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
|
@ -169,37 +392,6 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handleLogin,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text("Login"),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () {
|
||||
Navigator.of(context)
|
||||
.pushReplacementNamed(AppRoutes.signup);
|
||||
},
|
||||
child: const Text("Don't have an account? Sign Up"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MenuBrowseScreen> {
|
|||
return items;
|
||||
}
|
||||
|
||||
bool _isCallingServer = false;
|
||||
|
||||
/// Show bottom sheet with choice: Server Visit or Chat
|
||||
Future<void> _handleCallServer(AppState appState) async {
|
||||
if (_businessId == null || _servicePointId == null) return;
|
||||
|
||||
// Check for active chat first
|
||||
int? activeTaskId;
|
||||
try {
|
||||
activeTaskId = await Api.getActiveChat(
|
||||
businessId: _businessId!,
|
||||
servicePointId: _servicePointId!,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[Menu] Error checking active chat: $e');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'How can we help?',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: Colors.orange,
|
||||
child: Icon(Icons.room_service, color: Colors.white),
|
||||
),
|
||||
title: const Text('Request Server Visit'),
|
||||
subtitle: const Text('Staff will come to your table'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_sendServerRequest(appState);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
// Show either "Rejoin Chat" OR "Chat with Staff" - never both
|
||||
if (activeTaskId != null)
|
||||
ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: Colors.green,
|
||||
child: Icon(Icons.chat_bubble, color: Colors.white),
|
||||
),
|
||||
title: const Text('Rejoin Chat'),
|
||||
subtitle: const Text('Continue your conversation'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_rejoinChat(activeTaskId!);
|
||||
},
|
||||
)
|
||||
else
|
||||
ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.chat, color: Colors.white),
|
||||
),
|
||||
title: const Text('Chat with Staff'),
|
||||
subtitle: const Text('Send a message to our team'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_startChat(appState);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if user is logged in, prompt login if not
|
||||
/// Returns true if logged in, false if user needs to log in
|
||||
Future<bool> _ensureLoggedIn() async {
|
||||
final auth = await AuthStorage.loadAuth();
|
||||
if (auth != null && auth.userId > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!mounted) return false;
|
||||
|
||||
// Show login prompt
|
||||
final shouldLogin = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Sign In Required'),
|
||||
content: const Text('Please sign in to use the chat feature.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Sign In'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldLogin == true && mounted) {
|
||||
Navigator.pushNamed(context, AppRoutes.login);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Rejoin an existing active chat
|
||||
Future<void> _rejoinChat(int taskId) async {
|
||||
if (!await _ensureLoggedIn()) return;
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatScreen(
|
||||
taskId: taskId,
|
||||
userType: 'customer',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Send a server visit request (ping)
|
||||
Future<void> _sendServerRequest(AppState appState) async {
|
||||
if (_isCallingServer) return;
|
||||
setState(() => _isCallingServer = true);
|
||||
|
||||
try {
|
||||
await Api.callServer(
|
||||
businessId: _businessId!,
|
||||
servicePointId: _servicePointId!,
|
||||
orderId: appState.cartOrderId,
|
||||
userId: appState.userId,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.black),
|
||||
SizedBox(width: 8),
|
||||
Expanded(child: Text("Server has been notified", style: TextStyle(color: Colors.black))),
|
||||
],
|
||||
),
|
||||
backgroundColor: const Color(0xFF90EE90),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.black),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text("Failed to call server: $e", style: const TextStyle(color: Colors.black))),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.red.shade100,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isCallingServer = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new chat with staff
|
||||
Future<void> _startChat(AppState appState) async {
|
||||
if (_isCallingServer) return;
|
||||
|
||||
// Check login first
|
||||
if (!await _ensureLoggedIn()) return;
|
||||
|
||||
setState(() => _isCallingServer = true);
|
||||
|
||||
try {
|
||||
// Reload auth to get userId
|
||||
final auth = await AuthStorage.loadAuth();
|
||||
final userId = auth?.userId;
|
||||
|
||||
// Create new chat
|
||||
final taskId = await Api.createChatTask(
|
||||
businessId: _businessId!,
|
||||
servicePointId: _servicePointId!,
|
||||
orderId: appState.cartOrderId,
|
||||
userId: userId,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Navigate to chat screen
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatScreen(
|
||||
taskId: taskId,
|
||||
userType: 'customer',
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.black),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text("Failed to start chat: $e", style: const TextStyle(color: Colors.black))),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.red.shade100,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isCallingServer = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _organizeItems() {
|
||||
_itemsByCategory.clear();
|
||||
_itemsByParent.clear();
|
||||
|
|
@ -220,32 +477,14 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
],
|
||||
),
|
||||
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}"),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
|
|||
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>();
|
||||
appState.setOrderType(null);
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Restaurant>> _loadRestaurants() async {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,14 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||
|
||||
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<SignupScreen> {
|
|||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_errorMessage = e.toString().replaceFirst("StateError: ", "");
|
||||
_errorMessage = "Error: ${e.toString().replaceFirst("StateError: ", "")}";
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleVerifyOtp() async {
|
||||
// Validate UUID first
|
||||
if (_uuid.isEmpty) {
|
||||
setState(() {
|
||||
_errorMessage = "Session expired - UUID is empty. Please go back and resend code.";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final otp = _otpController.text.trim();
|
||||
|
||||
if (otp.length != 6) {
|
||||
|
|
@ -108,7 +124,9 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||
});
|
||||
|
||||
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<SignupScreen> {
|
|||
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<SignupScreen> {
|
|||
});
|
||||
|
||||
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<SignupScreen> {
|
|||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const SizedBox(height: 24),
|
||||
TextFormField(
|
||||
controller: _otpController,
|
||||
decoration: const InputDecoration(
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> json) {
|
||||
// Try both uppercase and lowercase keys for compatibility
|
||||
final uuid = (json["UUID"] as String?) ?? (json["uuid"] as String?) ?? "";
|
||||
final message = (json["MESSAGE"] as String?) ?? (json["message"] as String?) ?? "";
|
||||
return SendOtpResponse(
|
||||
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<SendOtpResponse> sendLoginOtp({required String phone}) async {
|
||||
final raw = await _postRaw("/auth/loginOTP.cfm", {"phone": phone});
|
||||
final j = _requireJson(raw, "LoginOTP");
|
||||
|
||||
if (!_ok(j)) {
|
||||
final err = _err(j);
|
||||
if (err == "no_account") {
|
||||
throw StateError("No account found with this phone number. Please sign up first.");
|
||||
} else if (err == "invalid_phone") {
|
||||
throw StateError("Please enter a valid 10-digit phone number");
|
||||
} else {
|
||||
throw StateError("Failed to send code: ${j["MESSAGE"] ?? err}");
|
||||
}
|
||||
}
|
||||
|
||||
return SendOtpResponse.fromJson(j);
|
||||
}
|
||||
|
||||
/// Verify OTP for LOGIN and get auth token
|
||||
static Future<LoginResponse> verifyLoginOtp({
|
||||
required String uuid,
|
||||
required String otp,
|
||||
}) async {
|
||||
final raw = await _postRaw("/auth/verifyLoginOTP.cfm", {
|
||||
"uuid": uuid,
|
||||
"otp": otp,
|
||||
});
|
||||
final j = _requireJson(raw, "VerifyLoginOTP");
|
||||
|
||||
if (!_ok(j)) {
|
||||
final err = _err(j);
|
||||
if (err == "invalid_otp") {
|
||||
throw StateError("Invalid code. Please try again.");
|
||||
} else if (err == "expired") {
|
||||
throw StateError("Session expired. Please request a new code.");
|
||||
} else {
|
||||
throw StateError("Login failed: ${j["MESSAGE"] ?? err}");
|
||||
}
|
||||
}
|
||||
|
||||
final response = LoginResponse.fromJson(j);
|
||||
|
||||
// Store token for future requests
|
||||
setAuthToken(response.token);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Businesses (legacy model name: Restaurant)
|
||||
// -------------------------
|
||||
|
|
@ -579,6 +656,36 @@ class Api {
|
|||
return j;
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Tasks / Service Requests
|
||||
// -------------------------
|
||||
|
||||
/// Call server to the table - creates a service request task
|
||||
static Future<void> callServer({
|
||||
required int businessId,
|
||||
required int servicePointId,
|
||||
int? orderId,
|
||||
int? userId,
|
||||
String? message,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
"BusinessID": businessId,
|
||||
"ServicePointID": servicePointId,
|
||||
};
|
||||
if (orderId != null && orderId > 0) body["OrderID"] = orderId;
|
||||
if (userId != null && userId > 0) body["UserID"] = userId;
|
||||
if (message != null && message.isNotEmpty) body["Message"] = message;
|
||||
|
||||
final raw = await _postRaw("/tasks/callServer.cfm", body);
|
||||
final j = _requireJson(raw, "CallServer");
|
||||
|
||||
if (!_ok(j)) {
|
||||
throw StateError(
|
||||
"CallServer failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Beacons
|
||||
// -------------------------
|
||||
|
|
@ -854,6 +961,36 @@ class Api {
|
|||
);
|
||||
}
|
||||
|
||||
/// Search for users by phone, email, or name (for group order invites)
|
||||
static Future<List<UserSearchResult>> searchUsers({
|
||||
required String query,
|
||||
int? currentUserId,
|
||||
}) async {
|
||||
if (query.length < 3) return [];
|
||||
|
||||
final raw = await _postRaw("/users/search.cfm", {
|
||||
"Query": query,
|
||||
"CurrentUserID": currentUserId ?? 0,
|
||||
});
|
||||
final j = _requireJson(raw, "SearchUsers");
|
||||
|
||||
if (!_ok(j)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final usersJson = j["USERS"] as List<dynamic>? ?? [];
|
||||
return usersJson.map((e) {
|
||||
final user = e as Map<String, dynamic>;
|
||||
return UserSearchResult(
|
||||
userId: (user["UserID"] as num).toInt(),
|
||||
name: user["Name"] as String? ?? "",
|
||||
phone: user["Phone"] as String? ?? "",
|
||||
email: user["Email"] as String? ?? "",
|
||||
avatarUrl: user["AvatarUrl"] as String? ?? "",
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Get user profile
|
||||
static Future<UserProfile> getProfile() async {
|
||||
final raw = await _getRaw("/auth/profile.cfm");
|
||||
|
|
@ -896,6 +1033,142 @@ class Api {
|
|||
final orderData = j["ORDER"] as Map<String, dynamic>? ?? {};
|
||||
return OrderDetail.fromJson(orderData);
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Chat
|
||||
// -------------------------
|
||||
|
||||
/// Check if there's an active chat for the service point
|
||||
/// Returns the task ID if found, null otherwise
|
||||
static Future<int?> getActiveChat({
|
||||
required int businessId,
|
||||
required int servicePointId,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
"BusinessID": businessId,
|
||||
"ServicePointID": servicePointId,
|
||||
};
|
||||
|
||||
final raw = await _postRaw("/chat/getActiveChat.cfm", body);
|
||||
final j = _requireJson(raw, "GetActiveChat");
|
||||
|
||||
if (!_ok(j)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final hasActiveChat = j["HAS_ACTIVE_CHAT"] == true;
|
||||
if (!hasActiveChat) return null;
|
||||
|
||||
final taskId = (j["TASK_ID"] as num?)?.toInt();
|
||||
return (taskId != null && taskId > 0) ? taskId : null;
|
||||
}
|
||||
|
||||
/// Create a chat task and return the task ID
|
||||
static Future<int> createChatTask({
|
||||
required int businessId,
|
||||
required int servicePointId,
|
||||
int? orderId,
|
||||
int? userId,
|
||||
String? initialMessage,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
"BusinessID": businessId,
|
||||
"ServicePointID": servicePointId,
|
||||
};
|
||||
if (orderId != null && orderId > 0) body["OrderID"] = orderId;
|
||||
if (userId != null && userId > 0) body["UserID"] = userId;
|
||||
if (initialMessage != null && initialMessage.isNotEmpty) {
|
||||
body["Message"] = initialMessage;
|
||||
}
|
||||
|
||||
final raw = await _postRaw("/tasks/createChat.cfm", body);
|
||||
final j = _requireJson(raw, "CreateChatTask");
|
||||
|
||||
if (!_ok(j)) {
|
||||
throw StateError(
|
||||
"CreateChatTask failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}",
|
||||
);
|
||||
}
|
||||
|
||||
return (j["TaskID"] ?? j["TASK_ID"] as num).toInt();
|
||||
}
|
||||
|
||||
/// Get chat messages for a task
|
||||
/// Returns messages and whether the chat has been closed by the worker
|
||||
static Future<({List<ChatMessage> messages, bool chatClosed})> getChatMessages({
|
||||
required int taskId,
|
||||
int? afterMessageId,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
"TaskID": taskId,
|
||||
};
|
||||
if (afterMessageId != null && afterMessageId > 0) {
|
||||
body["AfterMessageID"] = afterMessageId;
|
||||
}
|
||||
|
||||
final raw = await _postRaw("/chat/getMessages.cfm", body);
|
||||
final j = _requireJson(raw, "GetChatMessages");
|
||||
|
||||
if (!_ok(j)) {
|
||||
throw StateError("GetChatMessages failed: ${_err(j)}");
|
||||
}
|
||||
|
||||
final arr = _pickArray(j, const ["MESSAGES", "messages"]);
|
||||
final messages = arr == null
|
||||
? <ChatMessage>[]
|
||||
: arr.map((e) {
|
||||
final item = e is Map<String, dynamic> ? e : (e as Map).cast<String, dynamic>();
|
||||
return ChatMessage.fromJson(item);
|
||||
}).toList();
|
||||
|
||||
// Check if chat has been closed (task completed)
|
||||
final chatClosed = j["CHAT_CLOSED"] == true || j["chat_closed"] == true;
|
||||
|
||||
return (messages: messages, chatClosed: chatClosed);
|
||||
}
|
||||
|
||||
/// Send a chat message (HTTP fallback when WebSocket unavailable)
|
||||
static Future<int> sendChatMessage({
|
||||
required int taskId,
|
||||
required String message,
|
||||
int? userId,
|
||||
String? senderType,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
"TaskID": taskId,
|
||||
"Message": message,
|
||||
};
|
||||
if (userId != null) body["UserID"] = userId;
|
||||
if (senderType != null) body["SenderType"] = senderType;
|
||||
|
||||
final raw = await _postRaw("/chat/sendMessage.cfm", body);
|
||||
final j = _requireJson(raw, "SendChatMessage");
|
||||
|
||||
if (!_ok(j)) {
|
||||
throw StateError("SendChatMessage failed: ${_err(j)}");
|
||||
}
|
||||
|
||||
return ((j["MessageID"] ?? j["MESSAGE_ID"]) as num).toInt();
|
||||
}
|
||||
|
||||
/// Mark chat messages as read
|
||||
static Future<void> markChatMessagesRead({
|
||||
required int taskId,
|
||||
required String readerType,
|
||||
}) async {
|
||||
final raw = await _postRaw("/chat/markRead.cfm", {
|
||||
"TaskID": taskId,
|
||||
"ReaderType": readerType,
|
||||
});
|
||||
final j = _requireJson(raw, "MarkChatMessagesRead");
|
||||
|
||||
if (!_ok(j)) {
|
||||
throw StateError("MarkChatMessagesRead failed: ${_err(j)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Get auth token for WebSocket authentication
|
||||
static String? get authToken => _userToken;
|
||||
}
|
||||
|
||||
class OrderHistoryResponse {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
281
lib/services/chat_service.dart
Normal file
281
lib/services/chat_service.dart
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import 'dart:async';
|
||||
import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||
import '../models/chat_message.dart';
|
||||
import 'api.dart';
|
||||
|
||||
/// Service for managing real-time chat via WebSocket
|
||||
class ChatService {
|
||||
static const String _wsBaseUrl = 'https://app.payfrit.com:3001';
|
||||
|
||||
io.Socket? _socket;
|
||||
int? _currentTaskId;
|
||||
String? _userType;
|
||||
|
||||
final StreamController<ChatMessage> _messageController =
|
||||
StreamController<ChatMessage>.broadcast();
|
||||
final StreamController<TypingEvent> _typingController =
|
||||
StreamController<TypingEvent>.broadcast();
|
||||
final StreamController<ChatEvent> _eventController =
|
||||
StreamController<ChatEvent>.broadcast();
|
||||
|
||||
bool _isConnected = false;
|
||||
|
||||
/// Stream of incoming messages
|
||||
Stream<ChatMessage> get messages => _messageController.stream;
|
||||
|
||||
/// Stream of typing events
|
||||
Stream<TypingEvent> get typingEvents => _typingController.stream;
|
||||
|
||||
/// Stream of chat events (user joined/left, chat ended)
|
||||
Stream<ChatEvent> get events => _eventController.stream;
|
||||
|
||||
/// Whether the socket is currently connected
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
/// Current task ID
|
||||
int? get currentTaskId => _currentTaskId;
|
||||
|
||||
/// Connect to chat for a specific task
|
||||
Future<bool> connect({
|
||||
required int taskId,
|
||||
required String userToken,
|
||||
required String userType,
|
||||
}) async {
|
||||
// Disconnect if already connected to a different task
|
||||
if (_socket != null) {
|
||||
await disconnect();
|
||||
}
|
||||
|
||||
_currentTaskId = taskId;
|
||||
_userType = userType;
|
||||
|
||||
final completer = Completer<bool>();
|
||||
|
||||
try {
|
||||
_socket = io.io(
|
||||
_wsBaseUrl,
|
||||
io.OptionBuilder()
|
||||
.setTransports(['websocket'])
|
||||
.disableAutoConnect()
|
||||
.build(),
|
||||
);
|
||||
|
||||
_socket!.onConnect((_) {
|
||||
print('[ChatService] Connected to WebSocket');
|
||||
_isConnected = true;
|
||||
|
||||
// Join the chat room
|
||||
_socket!.emit('join-chat', {
|
||||
'taskId': taskId,
|
||||
'userToken': userToken,
|
||||
'userType': userType,
|
||||
});
|
||||
});
|
||||
|
||||
_socket!.on('joined', (data) {
|
||||
print('[ChatService] Joined chat room: $data');
|
||||
_eventController.add(ChatEvent(
|
||||
type: ChatEventType.joined,
|
||||
data: data,
|
||||
));
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(true);
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('error', (data) {
|
||||
print('[ChatService] Error: $data');
|
||||
_eventController.add(ChatEvent(
|
||||
type: ChatEventType.error,
|
||||
message: data['message'] ?? 'Unknown error',
|
||||
));
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(false);
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('new-message', (data) {
|
||||
print('[ChatService] New message: $data');
|
||||
final message = ChatMessage.fromJson(data as Map<String, dynamic>);
|
||||
_messageController.add(message);
|
||||
});
|
||||
|
||||
_socket!.on('user-typing', (data) {
|
||||
_typingController.add(TypingEvent(
|
||||
userType: data['userType'] ?? '',
|
||||
userName: data['userName'] ?? '',
|
||||
isTyping: data['isTyping'] ?? false,
|
||||
));
|
||||
});
|
||||
|
||||
_socket!.on('user-joined', (data) {
|
||||
print('[ChatService] User joined: $data');
|
||||
_eventController.add(ChatEvent(
|
||||
type: ChatEventType.userJoined,
|
||||
data: data,
|
||||
));
|
||||
});
|
||||
|
||||
_socket!.on('user-left', (data) {
|
||||
print('[ChatService] User left: $data');
|
||||
_eventController.add(ChatEvent(
|
||||
type: ChatEventType.userLeft,
|
||||
data: data,
|
||||
));
|
||||
});
|
||||
|
||||
_socket!.on('chat-ended', (data) {
|
||||
print('[ChatService] Chat ended: $data');
|
||||
_eventController.add(ChatEvent(
|
||||
type: ChatEventType.chatEnded,
|
||||
message: data['message'] ?? 'Chat has ended',
|
||||
));
|
||||
});
|
||||
|
||||
_socket!.onDisconnect((_) {
|
||||
print('[ChatService] Disconnected from WebSocket');
|
||||
_isConnected = false;
|
||||
_eventController.add(ChatEvent(
|
||||
type: ChatEventType.disconnected,
|
||||
));
|
||||
});
|
||||
|
||||
_socket!.onConnectError((error) {
|
||||
print('[ChatService] Connection error: $error');
|
||||
_isConnected = false;
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(false);
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.connect();
|
||||
|
||||
// Timeout after 10 seconds
|
||||
return completer.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
print('[ChatService] Connection timeout');
|
||||
return false;
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
print('[ChatService] Connection exception: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a message via WebSocket
|
||||
/// Returns true if message was sent, false if not connected
|
||||
bool sendMessage(String text) {
|
||||
if (_socket == null || !_isConnected || _currentTaskId == null) {
|
||||
print('[ChatService] Cannot send - not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
_socket!.emit('send-message', {
|
||||
'taskId': _currentTaskId,
|
||||
'message': text,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Send a message via HTTP (fallback)
|
||||
Future<int> sendMessageHttp({
|
||||
required int taskId,
|
||||
required String message,
|
||||
int? userId,
|
||||
String? senderType,
|
||||
}) async {
|
||||
return Api.sendChatMessage(
|
||||
taskId: taskId,
|
||||
message: message,
|
||||
userId: userId,
|
||||
senderType: senderType,
|
||||
);
|
||||
}
|
||||
|
||||
/// Notify that user is typing
|
||||
void setTyping(bool isTyping) {
|
||||
if (_socket == null || !_isConnected || _currentTaskId == null) return;
|
||||
|
||||
_socket!.emit('typing', {
|
||||
'taskId': _currentTaskId,
|
||||
'isTyping': isTyping,
|
||||
});
|
||||
}
|
||||
|
||||
/// Close the chat (only workers can do this)
|
||||
void closeChat() {
|
||||
if (_socket == null || !_isConnected || _currentTaskId == null) return;
|
||||
|
||||
_socket!.emit('chat-closed', {
|
||||
'taskId': _currentTaskId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Leave the chat room
|
||||
void leaveChat() {
|
||||
if (_socket == null || _currentTaskId == null) return;
|
||||
|
||||
_socket!.emit('leave-chat', {
|
||||
'taskId': _currentTaskId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Disconnect from WebSocket
|
||||
Future<void> disconnect() async {
|
||||
if (_socket != null) {
|
||||
leaveChat();
|
||||
_socket!.disconnect();
|
||||
_socket!.dispose();
|
||||
_socket = null;
|
||||
}
|
||||
_isConnected = false;
|
||||
_currentTaskId = null;
|
||||
_userType = null;
|
||||
}
|
||||
|
||||
/// Dispose the service and clean up streams
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_messageController.close();
|
||||
_typingController.close();
|
||||
_eventController.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Event for typing indicators
|
||||
class TypingEvent {
|
||||
final String userType;
|
||||
final String userName;
|
||||
final bool isTyping;
|
||||
|
||||
const TypingEvent({
|
||||
required this.userType,
|
||||
required this.userName,
|
||||
required this.isTyping,
|
||||
});
|
||||
}
|
||||
|
||||
/// Event type for chat state changes
|
||||
enum ChatEventType {
|
||||
joined,
|
||||
userJoined,
|
||||
userLeft,
|
||||
chatEnded,
|
||||
disconnected,
|
||||
error,
|
||||
}
|
||||
|
||||
/// Event for chat state changes
|
||||
class ChatEvent {
|
||||
final ChatEventType type;
|
||||
final String? message;
|
||||
final dynamic data;
|
||||
|
||||
const ChatEvent({
|
||||
required this.type,
|
||||
this.message,
|
||||
this.data,
|
||||
});
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
56
pubspec.lock
56
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:
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue