feat: enhance user avatar display with S3Image and initial-based fallback, improve socket reconnection logic, and update splash screen layout, assets, and Android permissions.
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
@@ -3,17 +3,21 @@
|
||||
<!-- Gradient background from #c4d9d4 (top) to #f0f5fc (bottom) -->
|
||||
<item android:drawable="@drawable/splash_gradient" />
|
||||
|
||||
<!-- House illustration centered -->
|
||||
<item>
|
||||
<!-- House illustration — above center -->
|
||||
<item
|
||||
android:gravity="center_horizontal"
|
||||
android:top="0dp"
|
||||
android:bottom="80dp">
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@drawable/splash_house" />
|
||||
</item>
|
||||
|
||||
<!-- RE-Quest logo below center -->
|
||||
<!-- RE-Quest logo — below center -->
|
||||
<item
|
||||
android:gravity="center_horizontal|bottom"
|
||||
android:bottom="200dp">
|
||||
android:gravity="center_horizontal"
|
||||
android:top="80dp"
|
||||
android:bottom="0dp">
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@drawable/splash_logo" />
|
||||
|
||||
@@ -3,17 +3,21 @@
|
||||
<!-- Gradient background from #c4d9d4 (top) to #f0f5fc (bottom) -->
|
||||
<item android:drawable="@drawable/splash_gradient" />
|
||||
|
||||
<!-- House illustration centered -->
|
||||
<item>
|
||||
<!-- House illustration — above center -->
|
||||
<item
|
||||
android:gravity="center_horizontal"
|
||||
android:top="0dp"
|
||||
android:bottom="80dp">
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@drawable/splash_house" />
|
||||
</item>
|
||||
|
||||
<!-- RE-Quest logo below center -->
|
||||
<!-- RE-Quest logo — below center -->
|
||||
<item
|
||||
android:gravity="center_horizontal|bottom"
|
||||
android:bottom="200dp">
|
||||
android:gravity="center_horizontal"
|
||||
android:top="80dp"
|
||||
android:bottom="0dp">
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@drawable/splash_logo" />
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 14 KiB |
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
|
||||
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
|
||||
import 'package:real_estate_mobile/features/notifications/presentation/providers/notification_provider.dart';
|
||||
|
||||
@@ -195,22 +196,16 @@ class _MenuDrawer extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: user.avatar != null
|
||||
? Image.network(
|
||||
user.avatar!,
|
||||
child: user.avatar != null &&
|
||||
user.avatar!.isNotEmpty
|
||||
? S3Image(
|
||||
imageUrl: user.avatar!,
|
||||
width: 70,
|
||||
height: 70,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
const Icon(
|
||||
Icons.person,
|
||||
size: 36,
|
||||
color: AppColors.hintText,
|
||||
),
|
||||
errorWidget: (_) => _buildInitialAvatar(user.firstName),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.person,
|
||||
size: 36,
|
||||
color: AppColors.hintText,
|
||||
),
|
||||
: _buildInitialAvatar(user.firstName),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -488,6 +483,28 @@ class _MenuDrawer extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInitialAvatar(String? firstName) {
|
||||
final initial = (firstName != null && firstName.isNotEmpty)
|
||||
? firstName[0].toUpperCase()
|
||||
: '?';
|
||||
return Container(
|
||||
width: 70,
|
||||
height: 70,
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||
child: Center(
|
||||
child: Text(
|
||||
initial,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenuItem(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
|
||||
@@ -20,6 +20,9 @@ class SocketService {
|
||||
io.Socket? _socket;
|
||||
bool _isConnected = false;
|
||||
bool _intentionalDisconnect = false;
|
||||
bool _isReconnecting = false;
|
||||
int _reconnectAttempts = 0;
|
||||
static const _maxReconnectAttempts = 3;
|
||||
String? _currentToken;
|
||||
|
||||
// Stream controllers for events
|
||||
@@ -81,6 +84,8 @@ class SocketService {
|
||||
_socket!.onConnect((_) {
|
||||
_log.i('Socket connected: ${_socket!.id}');
|
||||
_isConnected = true;
|
||||
_reconnectAttempts = 0; // Reset on successful connect
|
||||
_isReconnecting = false;
|
||||
_connectionController.add(true);
|
||||
});
|
||||
|
||||
@@ -89,9 +94,14 @@ class SocketService {
|
||||
_isConnected = false;
|
||||
_connectionController.add(false);
|
||||
|
||||
if (reason == 'io server disconnect' && !_intentionalDisconnect) {
|
||||
_log.w('Server rejected connection - token likely expired');
|
||||
if (reason == 'io server disconnect' && !_intentionalDisconnect && !_isReconnecting) {
|
||||
if (_reconnectAttempts < _maxReconnectAttempts) {
|
||||
_reconnectAttempts++;
|
||||
_log.w('Server rejected connection (attempt $_reconnectAttempts/$_maxReconnectAttempts) - refreshing token...');
|
||||
_reconnectWithFreshToken();
|
||||
} else {
|
||||
_log.e('Max reconnect attempts reached - giving up');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -170,31 +180,52 @@ class SocketService {
|
||||
}
|
||||
|
||||
Future<void> _reconnectWithFreshToken() async {
|
||||
// Wait briefly for any in-flight REST token refresh to complete
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
_isReconnecting = true;
|
||||
// Wait with exponential backoff
|
||||
final delay = Duration(seconds: _reconnectAttempts * 2);
|
||||
_log.i('Waiting ${delay.inSeconds}s before reconnect attempt...');
|
||||
await Future.delayed(delay);
|
||||
|
||||
final token = await SecureStorage.getAccessToken();
|
||||
if (token == null) return;
|
||||
if (token == null) {
|
||||
_log.w('No access token available for socket reconnection');
|
||||
return;
|
||||
}
|
||||
|
||||
// If token hasn't changed, try triggering a refresh via a lightweight API call
|
||||
if (token == _currentToken) {
|
||||
_log.i('Token unchanged, triggering refresh via /auth/me...');
|
||||
try {
|
||||
// Make a lightweight call that triggers the API client's 401 interceptor
|
||||
// which auto-refreshes the token
|
||||
final dio = ApiClient.instance.dio;
|
||||
await dio.get('/auth/me');
|
||||
final refreshedToken = await SecureStorage.getAccessToken();
|
||||
if (refreshedToken == null || refreshedToken == _currentToken) return;
|
||||
_currentToken = refreshedToken;
|
||||
} catch (_) {
|
||||
return; // Token refresh failed — user will need to re-login
|
||||
}
|
||||
if (refreshedToken == null || refreshedToken == _currentToken) {
|
||||
_log.w('Token refresh did not produce a new token');
|
||||
// Still try reconnecting — token might be valid but socket had a glitch
|
||||
} else {
|
||||
_currentToken = token;
|
||||
_log.i('Got fresh token after refresh');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Token refresh failed: $e');
|
||||
// Still try reconnecting with existing token
|
||||
}
|
||||
}
|
||||
|
||||
_socket?.io.options?['auth'] = {'token': _currentToken};
|
||||
_socket?.connect();
|
||||
// Get the latest token (may have been refreshed)
|
||||
final freshToken = await SecureStorage.getAccessToken();
|
||||
if (freshToken == null) return;
|
||||
|
||||
_log.i('Reconnecting socket with fresh token...');
|
||||
// Full reconnect — dispose old socket and create new one
|
||||
_socket?.disconnect();
|
||||
_socket?.dispose();
|
||||
_socket = null;
|
||||
_isConnected = false;
|
||||
_currentToken = null;
|
||||
|
||||
// Small delay before reconnecting
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
await connect();
|
||||
}
|
||||
|
||||
/// Ensure socket is connected — reconnect if needed.
|
||||
@@ -205,6 +236,8 @@ class SocketService {
|
||||
|
||||
void disconnect() {
|
||||
_intentionalDisconnect = true;
|
||||
_isReconnecting = false;
|
||||
_reconnectAttempts = 0;
|
||||
_socket?.disconnect();
|
||||
_socket?.dispose();
|
||||
_socket = null;
|
||||
|
||||
@@ -75,26 +75,41 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
),
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
SizedBox(
|
||||
width: 150,
|
||||
height: 108,
|
||||
child: Image.asset(
|
||||
'assets/images/splash_house.png',
|
||||
width: 150,
|
||||
height: 108,
|
||||
fit: BoxFit.contain,
|
||||
semanticLabel: 'House illustration',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 35),
|
||||
Image.asset(
|
||||
SizedBox(
|
||||
width: 264,
|
||||
height: 55,
|
||||
child: Image.asset(
|
||||
'assets/images/splash_logo.png',
|
||||
width: 264,
|
||||
height: 55,
|
||||
fit: BoxFit.contain,
|
||||
semanticLabel: 'RE-Quest logo',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user