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 = [
|
||||
ApiConstants.authLogin,
|
||||
ApiConstants.authRegister,
|
||||
ApiConstants.authLogout,
|
||||
ApiConstants.authForgotPassword,
|
||||
ApiConstants.authResetPassword,
|
||||
ApiConstants.authVerifyEmail,
|
||||
|
||||
@@ -196,6 +196,38 @@ class AgentProfile {
|
||||
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) {
|
||||
return AgentProfile(
|
||||
id: json['id'] as String,
|
||||
|
||||
@@ -433,6 +433,13 @@ class AgentDetailNotifier extends StateNotifier<AgentDetailState> {
|
||||
return success;
|
||||
}
|
||||
|
||||
void updateAvailability(bool isAvailable) {
|
||||
if (state.agent == null) return;
|
||||
state = state.copyWith(
|
||||
agent: state.agent!.copyWith(isAvailable: isAvailable),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> cancelConnection() async {
|
||||
final requestId = state.connectionRequestId;
|
||||
if (requestId == null) return false;
|
||||
|
||||
@@ -87,6 +87,8 @@ class _AgentHomeContent extends ConsumerStatefulWidget {
|
||||
class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
bool _isUploadingAvatar = false;
|
||||
bool _isTogglingAvailability = false;
|
||||
bool _showEmail = false;
|
||||
bool _showPhone = false;
|
||||
File? _localAvatarFile;
|
||||
|
||||
Future<void> _pickAndUploadAvatar() async {
|
||||
@@ -179,13 +181,21 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
if (_isTogglingAvailability) return;
|
||||
|
||||
setState(() => _isTogglingAvailability = true);
|
||||
|
||||
// Optimistically update the UI immediately
|
||||
ref
|
||||
.read(agentDetailProvider(widget.agentProfileId).notifier)
|
||||
.updateAvailability(newValue);
|
||||
|
||||
try {
|
||||
final repo = ProfileRepository();
|
||||
await repo.updateProfile('AGENT', {'isAvailable': newValue});
|
||||
// Refresh agent detail to reflect new status
|
||||
ref.invalidate(agentDetailProvider(widget.agentProfileId));
|
||||
} catch (e) {
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
ref
|
||||
.read(agentDetailProvider(widget.agentProfileId).notifier)
|
||||
.updateAvailability(!newValue);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to update availability: $e')),
|
||||
);
|
||||
@@ -475,15 +485,32 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
email.isNotEmpty ? state.maskEmail(email) : '-',
|
||||
email.isNotEmpty
|
||||
? (_showEmail ? email : state.maskEmail(email))
|
||||
: '-',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.visibility_off_outlined,
|
||||
size: 18, color: AppColors.primaryDark),
|
||||
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 SizedBox(height: 14),
|
||||
@@ -498,15 +525,32 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
phone.isNotEmpty ? state.maskPhone(phone) : '-',
|
||||
phone.isNotEmpty
|
||||
? (_showPhone ? phone : state.maskPhone(phone))
|
||||
: '-',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.visibility_off_outlined,
|
||||
size: 18, color: AppColors.primaryDark),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -103,12 +103,15 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
state = state.copyWith(status: AuthStatus.loading);
|
||||
// Disconnect socket before logout
|
||||
// Disconnect socket immediately
|
||||
SocketService().disconnect();
|
||||
// Unregister FCM token before clearing auth
|
||||
await PushNotificationService().unregister();
|
||||
await _repository.logout();
|
||||
// Attempt FCM unregister and API logout (best-effort, don't block)
|
||||
try {
|
||||
await PushNotificationService().unregister();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await _repository.logout();
|
||||
} catch (_) {}
|
||||
state = const AuthState();
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ class SocketService {
|
||||
|
||||
io.Socket? _socket;
|
||||
bool _isConnected = false;
|
||||
bool _intentionalDisconnect = false;
|
||||
String? _currentToken;
|
||||
|
||||
// Stream controllers for events
|
||||
@@ -52,6 +53,7 @@ class SocketService {
|
||||
if (_socket?.connected == true && _currentToken == token) return;
|
||||
|
||||
_currentToken = token;
|
||||
_intentionalDisconnect = false;
|
||||
|
||||
// Strip /api/v1 from base URL for socket connection
|
||||
final apiUrl = AppConfig.apiBaseUrl;
|
||||
@@ -82,7 +84,7 @@ class SocketService {
|
||||
_isConnected = false;
|
||||
_connectionController.add(false);
|
||||
|
||||
if (reason == 'io server disconnect') {
|
||||
if (reason == 'io server disconnect' && !_intentionalDisconnect) {
|
||||
_log.w('Server rejected connection - token likely expired');
|
||||
_reconnectWithFreshToken();
|
||||
}
|
||||
@@ -151,6 +153,7 @@ class SocketService {
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
_intentionalDisconnect = true;
|
||||
_socket?.disconnect();
|
||||
_socket?.dispose();
|
||||
_socket = null;
|
||||
|
||||
Reference in New Issue
Block a user