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:
pradeepkumar
2026-03-15 00:35:39 +05:30
parent 54fdd66270
commit 21f669fca5
6 changed files with 104 additions and 14 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
), ),
), ),
const Icon(Icons.visibility_off_outlined, if (email.isNotEmpty)
size: 18, color: AppColors.primaryDark), 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), 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,
), ),
), ),
const Icon(Icons.visibility_off_outlined, if (phone.isNotEmpty)
size: 18, color: AppColors.primaryDark), 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,
),
),
),
], ],
), ),
], ],

View File

@@ -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)
await PushNotificationService().unregister(); try {
await _repository.logout(); await PushNotificationService().unregister();
} catch (_) {}
try {
await _repository.logout();
} catch (_) {}
state = const AuthState(); state = const AuthState();
} }

View File

@@ -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;