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/account_screen.dart";
|
||||||
import "../screens/about_screen.dart";
|
import "../screens/about_screen.dart";
|
||||||
|
import "../screens/chat_screen.dart";
|
||||||
import "../screens/address_edit_screen.dart";
|
import "../screens/address_edit_screen.dart";
|
||||||
import "../screens/address_list_screen.dart";
|
import "../screens/address_list_screen.dart";
|
||||||
import "../screens/beacon_scan_screen.dart";
|
import "../screens/beacon_scan_screen.dart";
|
||||||
|
|
@ -34,6 +35,7 @@ class AppRoutes {
|
||||||
static const String addressEdit = "/address-edit";
|
static const String addressEdit = "/address-edit";
|
||||||
static const String about = "/about";
|
static const String about = "/about";
|
||||||
static const String signup = "/signup";
|
static const String signup = "/signup";
|
||||||
|
static const String chat = "/chat";
|
||||||
|
|
||||||
static Map<String, WidgetBuilder> get routes => {
|
static Map<String, WidgetBuilder> get routes => {
|
||||||
splash: (_) => const SplashScreen(),
|
splash: (_) => const SplashScreen(),
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ class AppState extends ChangeNotifier {
|
||||||
int? _activeOrderId;
|
int? _activeOrderId;
|
||||||
int? _activeOrderStatusId;
|
int? _activeOrderStatusId;
|
||||||
|
|
||||||
|
List<int> _groupOrderInvites = [];
|
||||||
|
|
||||||
int? get selectedBusinessId => _selectedBusinessId;
|
int? get selectedBusinessId => _selectedBusinessId;
|
||||||
String? get selectedBusinessName => _selectedBusinessName;
|
String? get selectedBusinessName => _selectedBusinessName;
|
||||||
int? get selectedServicePointId => _selectedServicePointId;
|
int? get selectedServicePointId => _selectedServicePointId;
|
||||||
|
|
@ -44,6 +46,9 @@ class AppState extends ChangeNotifier {
|
||||||
int? get activeOrderStatusId => _activeOrderStatusId;
|
int? get activeOrderStatusId => _activeOrderStatusId;
|
||||||
bool get hasActiveOrder => _activeOrderId != null;
|
bool get hasActiveOrder => _activeOrderId != null;
|
||||||
|
|
||||||
|
List<int> get groupOrderInvites => _groupOrderInvites;
|
||||||
|
bool get isGroupOrder => _groupOrderInvites.isNotEmpty;
|
||||||
|
|
||||||
bool get hasLocationSelection =>
|
bool get hasLocationSelection =>
|
||||||
_selectedBusinessId != null && _selectedServicePointId != null;
|
_selectedBusinessId != null && _selectedServicePointId != null;
|
||||||
|
|
||||||
|
|
@ -137,6 +142,16 @@ class AppState extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setGroupOrderInvites(List<int> userIds) {
|
||||||
|
_groupOrderInvites = userIds;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearGroupOrderInvites() {
|
||||||
|
_groupOrderInvites = [];
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void clearAll() {
|
void clearAll() {
|
||||||
_selectedBusinessId = null;
|
_selectedBusinessId = null;
|
||||||
_selectedServicePointId = null;
|
_selectedServicePointId = null;
|
||||||
|
|
@ -148,5 +163,7 @@ class AppState extends ChangeNotifier {
|
||||||
|
|
||||||
_activeOrderId = null;
|
_activeOrderId = null;
|
||||||
_activeOrderStatusId = null;
|
_activeOrderStatusId = null;
|
||||||
|
|
||||||
|
_groupOrderInvites = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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:flutter/material.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
class AboutScreen extends StatelessWidget {
|
class AboutScreen extends StatefulWidget {
|
||||||
const AboutScreen({super.key});
|
const AboutScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AboutScreen> createState() => _AboutScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AboutScreenState extends State<AboutScreen> {
|
||||||
|
String _version = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVersion() async {
|
||||||
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_version = 'Version ${info.version}';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -44,7 +67,7 @@ class AboutScreen extends StatelessWidget {
|
||||||
// Version
|
// Version
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Version 0.1.0',
|
_version,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
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_router.dart';
|
||||||
import '../app/app_state.dart';
|
import '../app/app_state.dart';
|
||||||
|
import '../services/api.dart';
|
||||||
|
import '../services/auth_storage.dart';
|
||||||
|
|
||||||
/// Screen to invite additional Payfrit users to join a group order
|
/// Screen to invite additional Payfrit users to join a group order
|
||||||
/// Shown after selecting Delivery or Takeaway
|
/// Shown after selecting Delivery or Takeaway
|
||||||
|
|
@ -14,12 +16,27 @@ class GroupOrderInviteScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
|
class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
|
||||||
final List<String> _invitedUsers = [];
|
final List<UserSearchResult> _invitedUsers = [];
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
bool _isSearching = false;
|
bool _isSearching = false;
|
||||||
|
int? _currentUserId;
|
||||||
|
|
||||||
// Mock search results - in production this would come from API
|
List<UserSearchResult> _searchResults = [];
|
||||||
final List<_UserResult> _searchResults = [];
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadCurrentUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCurrentUserId() async {
|
||||||
|
final auth = await AuthStorage.loadAuth();
|
||||||
|
if (auth != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_currentUserId = auth.userId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|
@ -27,10 +44,18 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _searchUsers(String query) {
|
Future<void> _searchUsers(String query) async {
|
||||||
if (query.isEmpty) {
|
if (query.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_searchResults.clear();
|
_searchResults = [];
|
||||||
|
_isSearching = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.length < 3) {
|
||||||
|
setState(() {
|
||||||
|
_searchResults = [];
|
||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -38,33 +63,38 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
|
||||||
|
|
||||||
setState(() => _isSearching = true);
|
setState(() => _isSearching = true);
|
||||||
|
|
||||||
// TODO: Replace with actual API call to search users by phone/email/username
|
try {
|
||||||
// For now, show placeholder
|
final results = await Api.searchUsers(
|
||||||
Future.delayed(const Duration(milliseconds: 500), () {
|
query: query,
|
||||||
|
currentUserId: _currentUserId,
|
||||||
|
);
|
||||||
|
|
||||||
if (mounted && _searchController.text == query) {
|
if (mounted && _searchController.text == query) {
|
||||||
|
// Filter out already invited users
|
||||||
|
final filteredResults = results.where((user) =>
|
||||||
|
!_invitedUsers.any((invited) => invited.userId == user.userId)).toList();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_searchResults.clear();
|
_searchResults = filteredResults;
|
||||||
// Mock results - would come from API
|
|
||||||
if (query.length >= 3) {
|
|
||||||
_searchResults.addAll([
|
|
||||||
_UserResult(
|
|
||||||
userId: 1,
|
|
||||||
name: 'John D.',
|
|
||||||
phone: '***-***-${query.substring(0, 4)}',
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[GroupOrderInvite] Search error: $e');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_searchResults = [];
|
||||||
|
_isSearching = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _inviteUser(_UserResult user) {
|
void _inviteUser(UserSearchResult user) {
|
||||||
if (!_invitedUsers.contains(user.name)) {
|
if (!_invitedUsers.any((u) => u.userId == user.userId)) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_invitedUsers.add(user.name);
|
_invitedUsers.add(user);
|
||||||
_searchResults.clear();
|
_searchResults = [];
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -79,16 +109,17 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _removeInvite(String userName) {
|
void _removeInvite(UserSearchResult user) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_invitedUsers.remove(userName);
|
_invitedUsers.removeWhere((u) => u.userId == user.userId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _continueToRestaurants() {
|
void _continueToRestaurants() {
|
||||||
// Store invited users in app state if needed
|
// Store invited users in app state
|
||||||
final appState = context.read<AppState>();
|
final appState = context.read<AppState>();
|
||||||
// TODO: appState.setGroupOrderInvites(_invitedUsers);
|
final invitedUserIds = _invitedUsers.map((u) => u.userId).toList();
|
||||||
|
appState.setGroupOrderInvites(invitedUserIds);
|
||||||
|
|
||||||
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
|
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
|
||||||
}
|
}
|
||||||
|
|
@ -227,7 +258,7 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: _invitedUsers.map((name) {
|
children: _invitedUsers.map((user) {
|
||||||
return Chip(
|
return Chip(
|
||||||
avatar: CircleAvatar(
|
avatar: CircleAvatar(
|
||||||
backgroundColor: Colors.green.withAlpha(50),
|
backgroundColor: Colors.green.withAlpha(50),
|
||||||
|
|
@ -237,9 +268,9 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
|
||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
label: Text(name),
|
label: Text(user.name),
|
||||||
deleteIcon: const Icon(Icons.close, size: 18),
|
deleteIcon: const Icon(Icons.close, size: 18),
|
||||||
onDeleted: () => _removeInvite(name),
|
onDeleted: () => _removeInvite(user),
|
||||||
backgroundColor: Colors.grey.shade800,
|
backgroundColor: Colors.grey.shade800,
|
||||||
labelStyle: const TextStyle(color: Colors.white),
|
labelStyle: const TextStyle(color: Colors.white),
|
||||||
);
|
);
|
||||||
|
|
@ -291,15 +322,3 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UserResult {
|
|
||||||
final int userId;
|
|
||||||
final String name;
|
|
||||||
final String phone;
|
|
||||||
|
|
||||||
const _UserResult({
|
|
||||||
required this.userId,
|
|
||||||
required this.name,
|
|
||||||
required this.phone,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter/services.dart";
|
||||||
import "package:provider/provider.dart";
|
import "package:provider/provider.dart";
|
||||||
|
|
||||||
import "../app/app_router.dart";
|
import "../app/app_router.dart";
|
||||||
|
|
@ -6,6 +7,8 @@ import "../app/app_state.dart";
|
||||||
import "../services/api.dart";
|
import "../services/api.dart";
|
||||||
import "../services/auth_storage.dart";
|
import "../services/auth_storage.dart";
|
||||||
|
|
||||||
|
enum LoginStep { phone, otp }
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -14,22 +17,39 @@ class LoginScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LoginScreenState extends State<LoginScreen> {
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
LoginStep _currentStep = LoginStep.phone;
|
||||||
final _usernameController = TextEditingController();
|
|
||||||
final _passwordController = TextEditingController();
|
final _phoneController = TextEditingController();
|
||||||
|
final _otpController = TextEditingController();
|
||||||
|
|
||||||
|
String _uuid = "";
|
||||||
|
String _phone = "";
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_usernameController.dispose();
|
_phoneController.dispose();
|
||||||
_passwordController.dispose();
|
_otpController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLogin() async {
|
String _formatPhoneNumber(String input) {
|
||||||
if (!_formKey.currentState!.validate()) {
|
final digits = input.replaceAll(RegExp(r'[^\d]'), '');
|
||||||
|
if (digits.length == 11 && digits.startsWith('1')) {
|
||||||
|
return digits.substring(1);
|
||||||
|
}
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSendOtp() async {
|
||||||
|
final phone = _formatPhoneNumber(_phoneController.text);
|
||||||
|
|
||||||
|
if (phone.length != 10) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = "Please enter a valid 10-digit phone number";
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,37 +59,125 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await Api.login(
|
final response = await Api.sendLoginOtp(phone: phone);
|
||||||
username: _usernameController.text.trim(),
|
|
||||||
password: _passwordController.text,
|
if (!mounted) return;
|
||||||
);
|
|
||||||
|
if (response.uuid.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = "Server error - please try again";
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_uuid = response.uuid;
|
||||||
|
_phone = phone;
|
||||||
|
_currentStep = LoginStep.otp;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = e.toString().replaceFirst("StateError: ", "");
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleVerifyOtp() async {
|
||||||
|
if (_uuid.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = "Session expired. Please go back and try again.";
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final otp = _otpController.text.trim();
|
||||||
|
|
||||||
|
if (otp.length != 6) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = "Please enter the 6-digit code";
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await Api.verifyLoginOtp(uuid: _uuid, otp: otp);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Save credentials for persistent login
|
// Save credentials for persistent login
|
||||||
await AuthStorage.saveAuth(
|
await AuthStorage.saveAuth(
|
||||||
userId: result.userId,
|
userId: response.userId,
|
||||||
token: result.token,
|
token: response.token,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set the auth token on the API class
|
|
||||||
Api.setAuthToken(result.token);
|
|
||||||
|
|
||||||
final appState = context.read<AppState>();
|
final appState = context.read<AppState>();
|
||||||
appState.setUserId(result.userId);
|
appState.setUserId(response.userId);
|
||||||
|
|
||||||
// Go back to previous screen (menu) or splash if no previous route
|
// Show success and navigate
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
"Welcome back${response.userFirstName.isNotEmpty ? ', ${response.userFirstName}' : ''}!",
|
||||||
|
style: const TextStyle(color: Colors.black),
|
||||||
|
),
|
||||||
|
backgroundColor: const Color(0xFF90EE90),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to main app
|
||||||
if (Navigator.of(context).canPop()) {
|
if (Navigator.of(context).canPop()) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
} else {
|
} else {
|
||||||
// No previous route - go to splash which will auto-navigate based on beacon detection
|
|
||||||
Navigator.of(context).pushReplacementNamed(AppRoutes.splash);
|
Navigator.of(context).pushReplacementNamed(AppRoutes.splash);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = e.toString().replaceFirst("StateError: ", "");
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleResendOtp() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await Api.sendLoginOtp(phone: _phone);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = e.toString();
|
_uuid = response.uuid;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text("New code sent!", style: TextStyle(color: Colors.black)),
|
||||||
|
backgroundColor: const Color(0xFF90EE90),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = e.toString().replaceFirst("StateError: ", "");
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -79,17 +187,35 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text("Login"),
|
title: Text(_currentStep == LoginStep.phone ? "Login" : "Verify Phone"),
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 400),
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
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,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
|
|
@ -103,55 +229,152 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
const Text(
|
||||||
"Sign in to order",
|
"Enter your phone number to login",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 32),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _usernameController,
|
controller: _phoneController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: "Email or Phone Number",
|
labelText: "Phone Number",
|
||||||
|
hintText: "(555) 123-4567",
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
prefixIcon: Icon(Icons.person),
|
prefixIcon: Icon(Icons.phone),
|
||||||
|
prefixText: "+1 ",
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.phone,
|
||||||
textInputAction: TextInputAction.next,
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(10),
|
||||||
|
],
|
||||||
enabled: !_isLoading,
|
enabled: !_isLoading,
|
||||||
validator: (value) {
|
onFieldSubmitted: (_) => _handleSendOtp(),
|
||||||
if (value == null || value.trim().isEmpty) {
|
),
|
||||||
return "Please enter your email or phone number";
|
const SizedBox(height: 24),
|
||||||
}
|
FilledButton(
|
||||||
return null;
|
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),
|
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(
|
TextFormField(
|
||||||
controller: _passwordController,
|
controller: _otpController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: "Password",
|
labelText: "Login Code",
|
||||||
|
hintText: "123456",
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
prefixIcon: Icon(Icons.lock),
|
prefixIcon: Icon(Icons.lock),
|
||||||
),
|
),
|
||||||
obscureText: true,
|
keyboardType: TextInputType.number,
|
||||||
textInputAction: TextInputAction.done,
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(6),
|
||||||
|
],
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
letterSpacing: 8,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
enabled: !_isLoading,
|
enabled: !_isLoading,
|
||||||
onFieldSubmitted: (_) => _handleLogin(),
|
onFieldSubmitted: (_) => _handleVerifyOtp(),
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return "Please enter your password";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
if (_errorMessage != null)
|
FilledButton(
|
||||||
Container(
|
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),
|
padding: const EdgeInsets.all(12),
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.red.shade50,
|
color: Colors.red.shade50,
|
||||||
borderRadius: BorderRadius.circular(8),
|
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 "package:provider/provider.dart";
|
||||||
|
|
||||||
import "../app/app_router.dart";
|
import "../app/app_router.dart";
|
||||||
|
|
@ -6,6 +7,8 @@ import "../app/app_state.dart";
|
||||||
import "../models/cart.dart";
|
import "../models/cart.dart";
|
||||||
import "../models/menu_item.dart";
|
import "../models/menu_item.dart";
|
||||||
import "../services/api.dart";
|
import "../services/api.dart";
|
||||||
|
import "../services/auth_storage.dart";
|
||||||
|
import "chat_screen.dart";
|
||||||
|
|
||||||
class MenuBrowseScreen extends StatefulWidget {
|
class MenuBrowseScreen extends StatefulWidget {
|
||||||
const MenuBrowseScreen({super.key});
|
const MenuBrowseScreen({super.key});
|
||||||
|
|
@ -78,6 +81,260 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isCallingServer = false;
|
||||||
|
|
||||||
|
/// Show bottom sheet with choice: Server Visit or Chat
|
||||||
|
Future<void> _handleCallServer(AppState appState) async {
|
||||||
|
if (_businessId == null || _servicePointId == null) return;
|
||||||
|
|
||||||
|
// Check for active chat first
|
||||||
|
int? activeTaskId;
|
||||||
|
try {
|
||||||
|
activeTaskId = await Api.getActiveChat(
|
||||||
|
businessId: _businessId!,
|
||||||
|
servicePointId: _servicePointId!,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[Menu] Error checking active chat: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'How can we help?',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
leading: const CircleAvatar(
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
child: Icon(Icons.room_service, color: Colors.white),
|
||||||
|
),
|
||||||
|
title: const Text('Request Server Visit'),
|
||||||
|
subtitle: const Text('Staff will come to your table'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_sendServerRequest(appState);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
// Show either "Rejoin Chat" OR "Chat with Staff" - never both
|
||||||
|
if (activeTaskId != null)
|
||||||
|
ListTile(
|
||||||
|
leading: const CircleAvatar(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
child: Icon(Icons.chat_bubble, color: Colors.white),
|
||||||
|
),
|
||||||
|
title: const Text('Rejoin Chat'),
|
||||||
|
subtitle: const Text('Continue your conversation'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_rejoinChat(activeTaskId!);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ListTile(
|
||||||
|
leading: const CircleAvatar(
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
child: Icon(Icons.chat, color: Colors.white),
|
||||||
|
),
|
||||||
|
title: const Text('Chat with Staff'),
|
||||||
|
subtitle: const Text('Send a message to our team'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_startChat(appState);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if user is logged in, prompt login if not
|
||||||
|
/// Returns true if logged in, false if user needs to log in
|
||||||
|
Future<bool> _ensureLoggedIn() async {
|
||||||
|
final auth = await AuthStorage.loadAuth();
|
||||||
|
if (auth != null && auth.userId > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return false;
|
||||||
|
|
||||||
|
// Show login prompt
|
||||||
|
final shouldLogin = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Sign In Required'),
|
||||||
|
content: const Text('Please sign in to use the chat feature.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Sign In'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldLogin == true && mounted) {
|
||||||
|
Navigator.pushNamed(context, AppRoutes.login);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rejoin an existing active chat
|
||||||
|
Future<void> _rejoinChat(int taskId) async {
|
||||||
|
if (!await _ensureLoggedIn()) return;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ChatScreen(
|
||||||
|
taskId: taskId,
|
||||||
|
userType: 'customer',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a server visit request (ping)
|
||||||
|
Future<void> _sendServerRequest(AppState appState) async {
|
||||||
|
if (_isCallingServer) return;
|
||||||
|
setState(() => _isCallingServer = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Api.callServer(
|
||||||
|
businessId: _businessId!,
|
||||||
|
servicePointId: _servicePointId!,
|
||||||
|
orderId: appState.cartOrderId,
|
||||||
|
userId: appState.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, color: Colors.black),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Expanded(child: Text("Server has been notified", style: TextStyle(color: Colors.black))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: const Color(0xFF90EE90),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error, color: Colors.black),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text("Failed to call server: $e", style: const TextStyle(color: Colors.black))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red.shade100,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isCallingServer = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a new chat with staff
|
||||||
|
Future<void> _startChat(AppState appState) async {
|
||||||
|
if (_isCallingServer) return;
|
||||||
|
|
||||||
|
// Check login first
|
||||||
|
if (!await _ensureLoggedIn()) return;
|
||||||
|
|
||||||
|
setState(() => _isCallingServer = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Reload auth to get userId
|
||||||
|
final auth = await AuthStorage.loadAuth();
|
||||||
|
final userId = auth?.userId;
|
||||||
|
|
||||||
|
// Create new chat
|
||||||
|
final taskId = await Api.createChatTask(
|
||||||
|
businessId: _businessId!,
|
||||||
|
servicePointId: _servicePointId!,
|
||||||
|
orderId: appState.cartOrderId,
|
||||||
|
userId: userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Navigate to chat screen
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ChatScreen(
|
||||||
|
taskId: taskId,
|
||||||
|
userType: 'customer',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error, color: Colors.black),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text("Failed to start chat: $e", style: const TextStyle(color: Colors.black))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red.shade100,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isCallingServer = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _organizeItems() {
|
void _organizeItems() {
|
||||||
_itemsByCategory.clear();
|
_itemsByCategory.clear();
|
||||||
_itemsByParent.clear();
|
_itemsByParent.clear();
|
||||||
|
|
@ -220,32 +477,14 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
// Only show table change button for dine-in orders
|
// Call Server button - only for dine-in orders at a table
|
||||||
if (appState.isDineIn)
|
if (appState.isDineIn && _servicePointId != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.table_restaurant),
|
icon: const Icon(Icons.room_service),
|
||||||
tooltip: "Change Table",
|
tooltip: "Call Server",
|
||||||
onPressed: () {
|
onPressed: () => _handleCallServer(appState),
|
||||||
// Prevent changing tables if there's an active order (dine and dash prevention)
|
|
||||||
if (appState.activeOrderId != null) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text("Cannot Change Table"),
|
|
||||||
content: const Text("Please complete or cancel your current order before changing tables."),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text("OK"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
// Table change button removed - not allowed currently
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Badge(
|
icon: Badge(
|
||||||
label: Text("${appState.cartItemCount}"),
|
label: Text("${appState.cartItemCount}"),
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,13 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_restaurantsFuture = _loadRestaurants();
|
_restaurantsFuture = _loadRestaurants();
|
||||||
|
|
||||||
|
// Clear order type when arriving at restaurant select (no beacon = not dine-in)
|
||||||
|
// This ensures the table change icon doesn't appear for delivery/takeaway orders
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final appState = context.read<AppState>();
|
||||||
|
appState.setOrderType(null);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Restaurant>> _loadRestaurants() async {
|
Future<List<Restaurant>> _loadRestaurants() async {
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,14 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (response.uuid.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = "Server returned empty UUID - please try again";
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_uuid = response.uuid;
|
_uuid = response.uuid;
|
||||||
_phone = phone;
|
_phone = phone;
|
||||||
|
|
@ -86,13 +94,21 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = e.toString().replaceFirst("StateError: ", "");
|
_errorMessage = "Error: ${e.toString().replaceFirst("StateError: ", "")}";
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleVerifyOtp() async {
|
Future<void> _handleVerifyOtp() async {
|
||||||
|
// Validate UUID first
|
||||||
|
if (_uuid.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = "Session expired - UUID is empty. Please go back and resend code.";
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final otp = _otpController.text.trim();
|
final otp = _otpController.text.trim();
|
||||||
|
|
||||||
if (otp.length != 6) {
|
if (otp.length != 6) {
|
||||||
|
|
@ -108,7 +124,9 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
print('[Signup] Calling verifyOtp...');
|
||||||
final response = await Api.verifyOtp(uuid: _uuid, otp: otp);
|
final response = await Api.verifyOtp(uuid: _uuid, otp: otp);
|
||||||
|
print('[Signup] verifyOtp success: userId=${response.userId}, needsProfile=${response.needsProfile}');
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
|
@ -120,18 +138,22 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
userId: response.userId,
|
userId: response.userId,
|
||||||
token: response.token,
|
token: response.token,
|
||||||
);
|
);
|
||||||
|
print('[Signup] Auth saved, token set');
|
||||||
|
|
||||||
if (response.needsProfile) {
|
if (response.needsProfile) {
|
||||||
|
print('[Signup] Profile needed, going to profile step');
|
||||||
// Go to profile step
|
// Go to profile step
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentStep = SignupStep.profile;
|
_currentStep = SignupStep.profile;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
print('[Signup] Profile complete, finishing signup');
|
||||||
// Profile already complete - go to app
|
// Profile already complete - go to app
|
||||||
_completeSignup();
|
_completeSignup();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
print('[Signup] verifyOtp error: $e');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = e.toString().replaceFirst("StateError: ", "");
|
_errorMessage = e.toString().replaceFirst("StateError: ", "");
|
||||||
|
|
@ -164,16 +186,19 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
print('[Signup] Calling completeProfile: firstName=$firstName, lastName=$lastName, email=$email');
|
||||||
await Api.completeProfile(
|
await Api.completeProfile(
|
||||||
firstName: firstName,
|
firstName: firstName,
|
||||||
lastName: lastName,
|
lastName: lastName,
|
||||||
email: email,
|
email: email,
|
||||||
);
|
);
|
||||||
|
print('[Signup] completeProfile success');
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
_completeSignup();
|
_completeSignup();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
print('[Signup] completeProfile error: $e');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = e.toString().replaceFirst("StateError: ", "");
|
_errorMessage = e.toString().replaceFirst("StateError: ", "");
|
||||||
|
|
@ -426,7 +451,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 24),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _otpController,
|
controller: _otpController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import "dart:convert";
|
||||||
import "package:http/http.dart" as http;
|
import "package:http/http.dart" as http;
|
||||||
|
|
||||||
import "../models/cart.dart";
|
import "../models/cart.dart";
|
||||||
|
import "../models/chat_message.dart";
|
||||||
import "../models/menu_item.dart";
|
import "../models/menu_item.dart";
|
||||||
import "../models/order_detail.dart";
|
import "../models/order_detail.dart";
|
||||||
import "../models/order_history.dart";
|
import "../models/order_history.dart";
|
||||||
|
|
@ -52,13 +53,32 @@ class SendOtpResponse {
|
||||||
});
|
});
|
||||||
|
|
||||||
factory SendOtpResponse.fromJson(Map<String, dynamic> json) {
|
factory SendOtpResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
// Try both uppercase and lowercase keys for compatibility
|
||||||
|
final uuid = (json["UUID"] as String?) ?? (json["uuid"] as String?) ?? "";
|
||||||
|
final message = (json["MESSAGE"] as String?) ?? (json["message"] as String?) ?? "";
|
||||||
return SendOtpResponse(
|
return SendOtpResponse(
|
||||||
uuid: (json["UUID"] as String?) ?? "",
|
uuid: uuid,
|
||||||
message: (json["MESSAGE"] as String?) ?? "",
|
message: message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class UserSearchResult {
|
||||||
|
final int userId;
|
||||||
|
final String name;
|
||||||
|
final String phone;
|
||||||
|
final String email;
|
||||||
|
final String avatarUrl;
|
||||||
|
|
||||||
|
const UserSearchResult({
|
||||||
|
required this.userId,
|
||||||
|
required this.name,
|
||||||
|
required this.phone,
|
||||||
|
required this.email,
|
||||||
|
required this.avatarUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class VerifyOtpResponse {
|
class VerifyOtpResponse {
|
||||||
final int userId;
|
final int userId;
|
||||||
final String token;
|
final String token;
|
||||||
|
|
@ -305,11 +325,13 @@ class Api {
|
||||||
required String lastName,
|
required String lastName,
|
||||||
required String email,
|
required String email,
|
||||||
}) async {
|
}) async {
|
||||||
|
print('[API] completeProfile: token=${_userToken?.substring(0, 8) ?? "NULL"}...');
|
||||||
final raw = await _postRaw("/auth/completeProfile.cfm", {
|
final raw = await _postRaw("/auth/completeProfile.cfm", {
|
||||||
"firstName": firstName,
|
"firstName": firstName,
|
||||||
"lastName": lastName,
|
"lastName": lastName,
|
||||||
"email": email,
|
"email": email,
|
||||||
});
|
});
|
||||||
|
print('[API] completeProfile response: ${raw.statusCode} - ${raw.rawBody}');
|
||||||
final j = _requireJson(raw, "CompleteProfile");
|
final j = _requireJson(raw, "CompleteProfile");
|
||||||
|
|
||||||
if (!_ok(j)) {
|
if (!_ok(j)) {
|
||||||
|
|
@ -318,6 +340,8 @@ class Api {
|
||||||
throw StateError("This email is already associated with another account");
|
throw StateError("This email is already associated with another account");
|
||||||
} else if (err == "invalid_email") {
|
} else if (err == "invalid_email") {
|
||||||
throw StateError("Please enter a valid email address");
|
throw StateError("Please enter a valid email address");
|
||||||
|
} else if (err == "unauthorized") {
|
||||||
|
throw StateError("Authentication failed - please try signing up again");
|
||||||
} else {
|
} else {
|
||||||
throw StateError("Failed to save profile: ${j["MESSAGE"] ?? err}");
|
throw StateError("Failed to save profile: ${j["MESSAGE"] ?? err}");
|
||||||
}
|
}
|
||||||
|
|
@ -329,6 +353,59 @@ class Api {
|
||||||
return sendOtp(phone: phone);
|
return sendOtp(phone: phone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Login via OTP (for existing verified accounts)
|
||||||
|
// -------------------------
|
||||||
|
|
||||||
|
/// Send OTP to phone number for LOGIN (existing accounts only)
|
||||||
|
static Future<SendOtpResponse> sendLoginOtp({required String phone}) async {
|
||||||
|
final raw = await _postRaw("/auth/loginOTP.cfm", {"phone": phone});
|
||||||
|
final j = _requireJson(raw, "LoginOTP");
|
||||||
|
|
||||||
|
if (!_ok(j)) {
|
||||||
|
final err = _err(j);
|
||||||
|
if (err == "no_account") {
|
||||||
|
throw StateError("No account found with this phone number. Please sign up first.");
|
||||||
|
} else if (err == "invalid_phone") {
|
||||||
|
throw StateError("Please enter a valid 10-digit phone number");
|
||||||
|
} else {
|
||||||
|
throw StateError("Failed to send code: ${j["MESSAGE"] ?? err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SendOtpResponse.fromJson(j);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify OTP for LOGIN and get auth token
|
||||||
|
static Future<LoginResponse> verifyLoginOtp({
|
||||||
|
required String uuid,
|
||||||
|
required String otp,
|
||||||
|
}) async {
|
||||||
|
final raw = await _postRaw("/auth/verifyLoginOTP.cfm", {
|
||||||
|
"uuid": uuid,
|
||||||
|
"otp": otp,
|
||||||
|
});
|
||||||
|
final j = _requireJson(raw, "VerifyLoginOTP");
|
||||||
|
|
||||||
|
if (!_ok(j)) {
|
||||||
|
final err = _err(j);
|
||||||
|
if (err == "invalid_otp") {
|
||||||
|
throw StateError("Invalid code. Please try again.");
|
||||||
|
} else if (err == "expired") {
|
||||||
|
throw StateError("Session expired. Please request a new code.");
|
||||||
|
} else {
|
||||||
|
throw StateError("Login failed: ${j["MESSAGE"] ?? err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = LoginResponse.fromJson(j);
|
||||||
|
|
||||||
|
// Store token for future requests
|
||||||
|
setAuthToken(response.token);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// Businesses (legacy model name: Restaurant)
|
// Businesses (legacy model name: Restaurant)
|
||||||
// -------------------------
|
// -------------------------
|
||||||
|
|
@ -579,6 +656,36 @@ class Api {
|
||||||
return j;
|
return j;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Tasks / Service Requests
|
||||||
|
// -------------------------
|
||||||
|
|
||||||
|
/// Call server to the table - creates a service request task
|
||||||
|
static Future<void> callServer({
|
||||||
|
required int businessId,
|
||||||
|
required int servicePointId,
|
||||||
|
int? orderId,
|
||||||
|
int? userId,
|
||||||
|
String? message,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
"BusinessID": businessId,
|
||||||
|
"ServicePointID": servicePointId,
|
||||||
|
};
|
||||||
|
if (orderId != null && orderId > 0) body["OrderID"] = orderId;
|
||||||
|
if (userId != null && userId > 0) body["UserID"] = userId;
|
||||||
|
if (message != null && message.isNotEmpty) body["Message"] = message;
|
||||||
|
|
||||||
|
final raw = await _postRaw("/tasks/callServer.cfm", body);
|
||||||
|
final j = _requireJson(raw, "CallServer");
|
||||||
|
|
||||||
|
if (!_ok(j)) {
|
||||||
|
throw StateError(
|
||||||
|
"CallServer failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// Beacons
|
// Beacons
|
||||||
// -------------------------
|
// -------------------------
|
||||||
|
|
@ -854,6 +961,36 @@ class Api {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Search for users by phone, email, or name (for group order invites)
|
||||||
|
static Future<List<UserSearchResult>> searchUsers({
|
||||||
|
required String query,
|
||||||
|
int? currentUserId,
|
||||||
|
}) async {
|
||||||
|
if (query.length < 3) return [];
|
||||||
|
|
||||||
|
final raw = await _postRaw("/users/search.cfm", {
|
||||||
|
"Query": query,
|
||||||
|
"CurrentUserID": currentUserId ?? 0,
|
||||||
|
});
|
||||||
|
final j = _requireJson(raw, "SearchUsers");
|
||||||
|
|
||||||
|
if (!_ok(j)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final usersJson = j["USERS"] as List<dynamic>? ?? [];
|
||||||
|
return usersJson.map((e) {
|
||||||
|
final user = e as Map<String, dynamic>;
|
||||||
|
return UserSearchResult(
|
||||||
|
userId: (user["UserID"] as num).toInt(),
|
||||||
|
name: user["Name"] as String? ?? "",
|
||||||
|
phone: user["Phone"] as String? ?? "",
|
||||||
|
email: user["Email"] as String? ?? "",
|
||||||
|
avatarUrl: user["AvatarUrl"] as String? ?? "",
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
/// Get user profile
|
/// Get user profile
|
||||||
static Future<UserProfile> getProfile() async {
|
static Future<UserProfile> getProfile() async {
|
||||||
final raw = await _getRaw("/auth/profile.cfm");
|
final raw = await _getRaw("/auth/profile.cfm");
|
||||||
|
|
@ -896,6 +1033,142 @@ class Api {
|
||||||
final orderData = j["ORDER"] as Map<String, dynamic>? ?? {};
|
final orderData = j["ORDER"] as Map<String, dynamic>? ?? {};
|
||||||
return OrderDetail.fromJson(orderData);
|
return OrderDetail.fromJson(orderData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Chat
|
||||||
|
// -------------------------
|
||||||
|
|
||||||
|
/// Check if there's an active chat for the service point
|
||||||
|
/// Returns the task ID if found, null otherwise
|
||||||
|
static Future<int?> getActiveChat({
|
||||||
|
required int businessId,
|
||||||
|
required int servicePointId,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
"BusinessID": businessId,
|
||||||
|
"ServicePointID": servicePointId,
|
||||||
|
};
|
||||||
|
|
||||||
|
final raw = await _postRaw("/chat/getActiveChat.cfm", body);
|
||||||
|
final j = _requireJson(raw, "GetActiveChat");
|
||||||
|
|
||||||
|
if (!_ok(j)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasActiveChat = j["HAS_ACTIVE_CHAT"] == true;
|
||||||
|
if (!hasActiveChat) return null;
|
||||||
|
|
||||||
|
final taskId = (j["TASK_ID"] as num?)?.toInt();
|
||||||
|
return (taskId != null && taskId > 0) ? taskId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a chat task and return the task ID
|
||||||
|
static Future<int> createChatTask({
|
||||||
|
required int businessId,
|
||||||
|
required int servicePointId,
|
||||||
|
int? orderId,
|
||||||
|
int? userId,
|
||||||
|
String? initialMessage,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
"BusinessID": businessId,
|
||||||
|
"ServicePointID": servicePointId,
|
||||||
|
};
|
||||||
|
if (orderId != null && orderId > 0) body["OrderID"] = orderId;
|
||||||
|
if (userId != null && userId > 0) body["UserID"] = userId;
|
||||||
|
if (initialMessage != null && initialMessage.isNotEmpty) {
|
||||||
|
body["Message"] = initialMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw = await _postRaw("/tasks/createChat.cfm", body);
|
||||||
|
final j = _requireJson(raw, "CreateChatTask");
|
||||||
|
|
||||||
|
if (!_ok(j)) {
|
||||||
|
throw StateError(
|
||||||
|
"CreateChatTask failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (j["TaskID"] ?? j["TASK_ID"] as num).toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get chat messages for a task
|
||||||
|
/// Returns messages and whether the chat has been closed by the worker
|
||||||
|
static Future<({List<ChatMessage> messages, bool chatClosed})> getChatMessages({
|
||||||
|
required int taskId,
|
||||||
|
int? afterMessageId,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
"TaskID": taskId,
|
||||||
|
};
|
||||||
|
if (afterMessageId != null && afterMessageId > 0) {
|
||||||
|
body["AfterMessageID"] = afterMessageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw = await _postRaw("/chat/getMessages.cfm", body);
|
||||||
|
final j = _requireJson(raw, "GetChatMessages");
|
||||||
|
|
||||||
|
if (!_ok(j)) {
|
||||||
|
throw StateError("GetChatMessages failed: ${_err(j)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
final arr = _pickArray(j, const ["MESSAGES", "messages"]);
|
||||||
|
final messages = arr == null
|
||||||
|
? <ChatMessage>[]
|
||||||
|
: arr.map((e) {
|
||||||
|
final item = e is Map<String, dynamic> ? e : (e as Map).cast<String, dynamic>();
|
||||||
|
return ChatMessage.fromJson(item);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Check if chat has been closed (task completed)
|
||||||
|
final chatClosed = j["CHAT_CLOSED"] == true || j["chat_closed"] == true;
|
||||||
|
|
||||||
|
return (messages: messages, chatClosed: chatClosed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a chat message (HTTP fallback when WebSocket unavailable)
|
||||||
|
static Future<int> sendChatMessage({
|
||||||
|
required int taskId,
|
||||||
|
required String message,
|
||||||
|
int? userId,
|
||||||
|
String? senderType,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
"TaskID": taskId,
|
||||||
|
"Message": message,
|
||||||
|
};
|
||||||
|
if (userId != null) body["UserID"] = userId;
|
||||||
|
if (senderType != null) body["SenderType"] = senderType;
|
||||||
|
|
||||||
|
final raw = await _postRaw("/chat/sendMessage.cfm", body);
|
||||||
|
final j = _requireJson(raw, "SendChatMessage");
|
||||||
|
|
||||||
|
if (!_ok(j)) {
|
||||||
|
throw StateError("SendChatMessage failed: ${_err(j)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((j["MessageID"] ?? j["MESSAGE_ID"]) as num).toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark chat messages as read
|
||||||
|
static Future<void> markChatMessagesRead({
|
||||||
|
required int taskId,
|
||||||
|
required String readerType,
|
||||||
|
}) async {
|
||||||
|
final raw = await _postRaw("/chat/markRead.cfm", {
|
||||||
|
"TaskID": taskId,
|
||||||
|
"ReaderType": readerType,
|
||||||
|
});
|
||||||
|
final j = _requireJson(raw, "MarkChatMessagesRead");
|
||||||
|
|
||||||
|
if (!_ok(j)) {
|
||||||
|
throw StateError("MarkChatMessagesRead failed: ${_err(j)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get auth token for WebSocket authentication
|
||||||
|
static String? get authToken => _userToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
class OrderHistoryResponse {
|
class OrderHistoryResponse {
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,8 @@ class BeaconPermissions {
|
||||||
debugPrint('[BeaconPermissions] ⚠️ Bluetooth is OFF, requesting enable...');
|
debugPrint('[BeaconPermissions] ⚠️ Bluetooth is OFF, requesting enable...');
|
||||||
await requestEnableBluetooth();
|
await requestEnableBluetooth();
|
||||||
|
|
||||||
// Poll for Bluetooth state change (wait up to 10 seconds)
|
// Poll for Bluetooth state change - short wait first
|
||||||
for (int i = 0; i < 20; i++) {
|
for (int i = 0; i < 6; i++) {
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
final newState = await flutterBeacon.bluetoothState;
|
final newState = await flutterBeacon.bluetoothState;
|
||||||
debugPrint('[BeaconPermissions] 📶 Polling Bluetooth state ($i): $newState');
|
debugPrint('[BeaconPermissions] 📶 Polling Bluetooth state ($i): $newState');
|
||||||
|
|
@ -105,6 +105,21 @@ class BeaconPermissions {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If still off after 3 seconds, try opening Bluetooth settings directly
|
||||||
|
debugPrint('[BeaconPermissions] ⚠️ Bluetooth still OFF, opening settings...');
|
||||||
|
await openBluetoothSettings();
|
||||||
|
|
||||||
|
// Poll again for up to 15 seconds (user needs time to toggle in settings)
|
||||||
|
for (int i = 0; i < 30; i++) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
final newState = await flutterBeacon.bluetoothState;
|
||||||
|
debugPrint('[BeaconPermissions] 📶 Polling Bluetooth state after settings ($i): $newState');
|
||||||
|
if (newState == BluetoothState.stateOn) {
|
||||||
|
debugPrint('[BeaconPermissions] ✅ Bluetooth is now ON');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint('[BeaconPermissions] ❌ Bluetooth still OFF after waiting');
|
debugPrint('[BeaconPermissions] ❌ Bluetooth still OFF after waiting');
|
||||||
return false;
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
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 Foundation
|
||||||
|
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
|
import package_info_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
pubspec.lock
56
pubspec.lock
|
|
@ -304,6 +304,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.19.0"
|
version: "0.19.0"
|
||||||
|
js:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: js
|
||||||
|
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.7"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -344,6 +352,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.0.0"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -384,6 +400,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.3.1"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.1"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -565,6 +597,22 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
socket_io_client:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: socket_io_client
|
||||||
|
sha256: ede469f3e4c55e8528b4e023bdedbc20832e8811ab9b61679d1ba3ed5f01f23b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.3+1"
|
||||||
|
socket_io_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: socket_io_common
|
||||||
|
sha256: "2ab92f8ff3ebbd4b353bf4a98bee45cc157e3255464b2f90f66e09c4472047eb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.3"
|
||||||
source_span:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -669,6 +717,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.15.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ dependencies:
|
||||||
flutter_stripe: ^11.4.0
|
flutter_stripe: ^11.4.0
|
||||||
image_picker: ^1.0.7
|
image_picker: ^1.0.7
|
||||||
intl: ^0.19.0
|
intl: ^0.19.0
|
||||||
|
socket_io_client: ^2.0.3+1
|
||||||
|
package_info_plus: ^8.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
@ -35,3 +37,5 @@ flutter_launcher_icons:
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
assets:
|
||||||
|
- assets/images/
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue