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 = [
ApiConstants.authLogin,
ApiConstants.authRegister,
ApiConstants.authLogout,
ApiConstants.authForgotPassword,
ApiConstants.authResetPassword,
ApiConstants.authVerifyEmail,

View File

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

View File

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

View File

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

View File

@@ -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
// Attempt FCM unregister and API logout (best-effort, don't block)
try {
await PushNotificationService().unregister();
} catch (_) {}
try {
await _repository.logout();
} catch (_) {}
state = const AuthState();
}

View File

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