feat: Implement optimistic UI for agent availability toggle, add email and phone visibility toggles, and improve the logout process with immediate socket disconnection and best-effort API calls.
This commit is contained in:
@@ -208,6 +208,7 @@ class ApiClient {
|
|||||||
const authPaths = [
|
const authPaths = [
|
||||||
ApiConstants.authLogin,
|
ApiConstants.authLogin,
|
||||||
ApiConstants.authRegister,
|
ApiConstants.authRegister,
|
||||||
|
ApiConstants.authLogout,
|
||||||
ApiConstants.authForgotPassword,
|
ApiConstants.authForgotPassword,
|
||||||
ApiConstants.authResetPassword,
|
ApiConstants.authResetPassword,
|
||||||
ApiConstants.authVerifyEmail,
|
ApiConstants.authVerifyEmail,
|
||||||
|
|||||||
@@ -196,6 +196,38 @@ class AgentProfile {
|
|||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AgentProfile copyWith({bool? isAvailable}) {
|
||||||
|
return AgentProfile(
|
||||||
|
id: id,
|
||||||
|
userId: userId,
|
||||||
|
firstName: firstName,
|
||||||
|
lastName: lastName,
|
||||||
|
headline: headline,
|
||||||
|
bio: bio,
|
||||||
|
avatar: avatar,
|
||||||
|
companyName: companyName,
|
||||||
|
phone: phone,
|
||||||
|
website: website,
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
country: country,
|
||||||
|
averageRating: averageRating,
|
||||||
|
totalReviews: totalReviews,
|
||||||
|
isVerified: isVerified,
|
||||||
|
isFeatured: isFeatured,
|
||||||
|
isActive: isActive,
|
||||||
|
isAvailable: isAvailable ?? this.isAvailable,
|
||||||
|
email: email,
|
||||||
|
agentType: agentType,
|
||||||
|
user: user,
|
||||||
|
fieldValues: fieldValues,
|
||||||
|
createdAt: createdAt,
|
||||||
|
legacySpecializations: legacySpecializations,
|
||||||
|
languages: languages,
|
||||||
|
serviceAreas: serviceAreas,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
factory AgentProfile.fromJson(Map<String, dynamic> json) {
|
factory AgentProfile.fromJson(Map<String, dynamic> json) {
|
||||||
return AgentProfile(
|
return AgentProfile(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
|
|||||||
@@ -433,6 +433,13 @@ class AgentDetailNotifier extends StateNotifier<AgentDetailState> {
|
|||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updateAvailability(bool isAvailable) {
|
||||||
|
if (state.agent == null) return;
|
||||||
|
state = state.copyWith(
|
||||||
|
agent: state.agent!.copyWith(isAvailable: isAvailable),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> cancelConnection() async {
|
Future<bool> cancelConnection() async {
|
||||||
final requestId = state.connectionRequestId;
|
final requestId = state.connectionRequestId;
|
||||||
if (requestId == null) return false;
|
if (requestId == null) return false;
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ class _AgentHomeContent extends ConsumerStatefulWidget {
|
|||||||
class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||||
bool _isUploadingAvatar = false;
|
bool _isUploadingAvatar = false;
|
||||||
bool _isTogglingAvailability = false;
|
bool _isTogglingAvailability = false;
|
||||||
|
bool _showEmail = false;
|
||||||
|
bool _showPhone = false;
|
||||||
File? _localAvatarFile;
|
File? _localAvatarFile;
|
||||||
|
|
||||||
Future<void> _pickAndUploadAvatar() async {
|
Future<void> _pickAndUploadAvatar() async {
|
||||||
@@ -179,13 +181,21 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
|||||||
if (_isTogglingAvailability) return;
|
if (_isTogglingAvailability) return;
|
||||||
|
|
||||||
setState(() => _isTogglingAvailability = true);
|
setState(() => _isTogglingAvailability = true);
|
||||||
|
|
||||||
|
// Optimistically update the UI immediately
|
||||||
|
ref
|
||||||
|
.read(agentDetailProvider(widget.agentProfileId).notifier)
|
||||||
|
.updateAvailability(newValue);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final repo = ProfileRepository();
|
final repo = ProfileRepository();
|
||||||
await repo.updateProfile('AGENT', {'isAvailable': newValue});
|
await repo.updateProfile('AGENT', {'isAvailable': newValue});
|
||||||
// Refresh agent detail to reflect new status
|
|
||||||
ref.invalidate(agentDetailProvider(widget.agentProfileId));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Revert on failure
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
ref
|
||||||
|
.read(agentDetailProvider(widget.agentProfileId).notifier)
|
||||||
|
.updateAvailability(!newValue);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Failed to update availability: $e')),
|
SnackBar(content: Text('Failed to update availability: $e')),
|
||||||
);
|
);
|
||||||
@@ -475,15 +485,32 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
|||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
email.isNotEmpty ? state.maskEmail(email) : '-',
|
email.isNotEmpty
|
||||||
|
? (_showEmail ? email : state.maskEmail(email))
|
||||||
|
: '-',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'SourceSerif4',
|
fontFamily: 'SourceSerif4',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.primaryDark),
|
color: AppColors.primaryDark),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (email.isNotEmpty)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _showEmail = !_showEmail),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: Icon(
|
||||||
|
_showEmail
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(Icons.visibility_off_outlined,
|
|
||||||
size: 18, color: AppColors.primaryDark),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
@@ -498,15 +525,32 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
|||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
phone.isNotEmpty ? state.maskPhone(phone) : '-',
|
phone.isNotEmpty
|
||||||
|
? (_showPhone ? phone : state.maskPhone(phone))
|
||||||
|
: '-',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'SourceSerif4',
|
fontFamily: 'SourceSerif4',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.primaryDark),
|
color: AppColors.primaryDark),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (phone.isNotEmpty)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _showPhone = !_showPhone),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: Icon(
|
||||||
|
_showPhone
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(Icons.visibility_off_outlined,
|
|
||||||
size: 18, color: AppColors.primaryDark),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -103,12 +103,15 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
state = state.copyWith(status: AuthStatus.loading);
|
// Disconnect socket immediately
|
||||||
// Disconnect socket before logout
|
|
||||||
SocketService().disconnect();
|
SocketService().disconnect();
|
||||||
// Unregister FCM token before clearing auth
|
// Attempt FCM unregister and API logout (best-effort, don't block)
|
||||||
|
try {
|
||||||
await PushNotificationService().unregister();
|
await PushNotificationService().unregister();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
await _repository.logout();
|
await _repository.logout();
|
||||||
|
} catch (_) {}
|
||||||
state = const AuthState();
|
state = const AuthState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class SocketService {
|
|||||||
|
|
||||||
io.Socket? _socket;
|
io.Socket? _socket;
|
||||||
bool _isConnected = false;
|
bool _isConnected = false;
|
||||||
|
bool _intentionalDisconnect = false;
|
||||||
String? _currentToken;
|
String? _currentToken;
|
||||||
|
|
||||||
// Stream controllers for events
|
// Stream controllers for events
|
||||||
@@ -52,6 +53,7 @@ class SocketService {
|
|||||||
if (_socket?.connected == true && _currentToken == token) return;
|
if (_socket?.connected == true && _currentToken == token) return;
|
||||||
|
|
||||||
_currentToken = token;
|
_currentToken = token;
|
||||||
|
_intentionalDisconnect = false;
|
||||||
|
|
||||||
// Strip /api/v1 from base URL for socket connection
|
// Strip /api/v1 from base URL for socket connection
|
||||||
final apiUrl = AppConfig.apiBaseUrl;
|
final apiUrl = AppConfig.apiBaseUrl;
|
||||||
@@ -82,7 +84,7 @@ class SocketService {
|
|||||||
_isConnected = false;
|
_isConnected = false;
|
||||||
_connectionController.add(false);
|
_connectionController.add(false);
|
||||||
|
|
||||||
if (reason == 'io server disconnect') {
|
if (reason == 'io server disconnect' && !_intentionalDisconnect) {
|
||||||
_log.w('Server rejected connection - token likely expired');
|
_log.w('Server rejected connection - token likely expired');
|
||||||
_reconnectWithFreshToken();
|
_reconnectWithFreshToken();
|
||||||
}
|
}
|
||||||
@@ -151,6 +153,7 @@ class SocketService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void disconnect() {
|
void disconnect() {
|
||||||
|
_intentionalDisconnect = true;
|
||||||
_socket?.disconnect();
|
_socket?.disconnect();
|
||||||
_socket?.dispose();
|
_socket?.dispose();
|
||||||
_socket = null;
|
_socket = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user