diff --git a/lib/core/auth/auth_state_manager.dart b/lib/core/auth/auth_state_manager.dart index 33b00ad..678f575 100644 --- a/lib/core/auth/auth_state_manager.dart +++ b/lib/core/auth/auth_state_manager.dart @@ -20,7 +20,7 @@ class AuthState { final AuthStatus status; final String? token; - final dynamic user; // Replace with proper User type + final User? user; final String? error; final bool isLoading; @@ -33,7 +33,7 @@ class AuthState { AuthState copyWith({ AuthStatus? status, String? token, - dynamic user, + User? user, String? error, bool? isLoading, bool clearToken = false, @@ -460,6 +460,7 @@ class AuthStateManager extends Notifier { // Clear token from storage final storage = ref.read(optimizedStorageServiceProvider); await storage.deleteAuthToken(); + _updateApiServiceToken(null); // Update state state = state.copyWith( @@ -497,6 +498,7 @@ class AuthStateManager extends Notifier { // Clear all local auth data final storage = ref.read(optimizedStorageServiceProvider); await storage.clearAuthData(); + _updateApiServiceToken(null); // Update state state = state.copyWith( @@ -518,6 +520,7 @@ class AuthStateManager extends Notifier { clearUser: true, error: 'Logout error: $e', ); + _updateApiServiceToken(null); } } @@ -528,8 +531,11 @@ class AuthStateManager extends Notifier { if (state.token != null) { final jwtUserInfo = TokenValidator.extractUserInfo(state.token!); if (jwtUserInfo != null) { - DebugLogger.auth('Extracted user info from JWT token'); - state = state.copyWith(user: jwtUserInfo); + final userFromJwt = _userFromJwtClaims(jwtUserInfo); + if (userFromJwt != null) { + DebugLogger.auth('Extracted user info from JWT token'); + state = state.copyWith(user: userFromJwt); + } // Still try to load from server in background for complete data Future.microtask(() => _loadServerUserData()); @@ -569,7 +575,7 @@ class AuthStateManager extends Notifier { } /// Update API service with current token - void _updateApiServiceToken(String token) { + void _updateApiServiceToken(String? token) { final api = ref.read(apiServiceProvider); api?.updateAuthToken(token); } @@ -689,6 +695,46 @@ class AuthStateManager extends Notifier { 'storageCache': 'Managed by OptimizedStorageService', }; } + + User? _userFromJwtClaims(Map claims) { + final id = + (claims['sub'] ?? claims['username'] ?? claims['email']) + ?.toString() + .trim() ?? + ''; + final username = + (claims['username'] ?? claims['name'])?.toString().trim() ?? ''; + final emailValue = claims['email']; + final email = emailValue == null ? '' : emailValue.toString().trim(); + + if (id.isEmpty && username.isEmpty && email.isEmpty) { + return null; + } + + String resolvedRole = 'user'; + final roles = claims['roles']; + if (roles is List && roles.isNotEmpty) { + resolvedRole = roles.first.toString(); + } else if (roles is String && roles.isNotEmpty) { + resolvedRole = roles; + } + + return User( + id: id.isNotEmpty + ? id + : (username.isNotEmpty ? username : email.ifEmptyReturn('user')), + username: username.ifEmptyReturn( + email.ifEmptyReturn(id.ifEmptyReturn('user')), + ), + email: email, + role: resolvedRole, + isActive: true, + ); + } +} + +extension _StringFallbackExtension on String { + String ifEmptyReturn(String fallback) => isEmpty ? fallback : this; } /// Provider for the unified auth state manager @@ -707,7 +753,7 @@ final authTokenProvider2 = Provider((ref) { return ref.watch(authStateManagerProvider.select((state) => state.token)); }); -final authUserProvider = Provider((ref) { +final authUserProvider = Provider((ref) { return ref.watch(authStateManagerProvider.select((state) => state.user)); }); diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 4de7131..1aa3d95 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -249,10 +249,9 @@ final apiTokenUpdaterProvider = Provider((ref) { ref.listen(authTokenProvider3, (previous, next) { final api = ref.read(apiServiceProvider); if (api != null) { - api.updateAuthToken(next ?? ''); - foundation.debugPrint( - 'DEBUG: Applied auth token to API (len=${next?.length ?? 0})', - ); + api.updateAuthToken(next); + final length = next?.length ?? 0; + foundation.debugPrint('DEBUG: Applied auth token to API (len=$length)'); } }); }); diff --git a/lib/core/providers/app_startup_providers.dart b/lib/core/providers/app_startup_providers.dart index 658a044..b6fd26f 100644 --- a/lib/core/providers/app_startup_providers.dart +++ b/lib/core/providers/app_startup_providers.dart @@ -160,8 +160,13 @@ final appStartupFlowProvider = Provider((ref) { // match theme after the first frame. Avoids flicker at startup. WidgetsBinding.instance.addPostFrameCallback((_) { try { - final isDark = - WidgetsBinding.instance.window.platformBrightness == Brightness.dark; + final context = NavigationService.context; + final view = context != null ? View.maybeOf(context) : null; + final dispatcher = WidgetsBinding.instance.platformDispatcher; + final platformBrightness = + view?.platformDispatcher.platformBrightness ?? + dispatcher.platformBrightness; + final isDark = platformBrightness == Brightness.dark; SystemChrome.setSystemUIOverlayStyle( SystemUiOverlayStyle( statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark, diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index df67b30..648a38c 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -92,7 +92,7 @@ class ApiService { // Validation interceptor fully removed } - void updateAuthToken(String token) { + void updateAuthToken(String? token) { _authInterceptor.updateAuthToken(token); } @@ -317,29 +317,15 @@ class ApiService { allRegularChats = regularResponse.data as List; } - final pinnedResponse = await _dio.get('/api/v1/chats/pinned'); - final archivedResponse = await _dio.get('/api/v1/chats/all/archived'); - - debugPrint('DEBUG: Pinned response status: ${pinnedResponse.statusCode}'); - debugPrint( - 'DEBUG: Archived response status: ${archivedResponse.statusCode}', + final pinnedChatList = await _fetchChatCollection( + '/api/v1/chats/pinned', + debugLabel: 'pinned chats', + ); + final archivedChatList = await _fetchChatCollection( + '/api/v1/chats/all/archived', + debugLabel: 'archived chats', ); - - if (pinnedResponse.data is! List) { - throw Exception( - 'Expected array of pinned chats, got ${pinnedResponse.data.runtimeType}', - ); - } - - if (archivedResponse.data is! List) { - throw Exception( - 'Expected array of archived chats, got ${archivedResponse.data.runtimeType}', - ); - } - final regularChatList = allRegularChats; - final pinnedChatList = pinnedResponse.data as List; - final archivedChatList = archivedResponse.data as List; debugPrint('DEBUG: Found ${regularChatList.length} regular chats'); debugPrint('DEBUG: Found ${pinnedChatList.length} pinned chats'); @@ -377,6 +363,7 @@ class ApiService { } // Process regular conversations (excluding pinned and archived ones) + var loggedSampleChat = false; for (final chatData in regularChatList) { try { // Debug: Check if conversation has folder_id in raw data @@ -387,8 +374,8 @@ class ApiService { ); } - // Debug: Check what fields are available in the chat data - if (regularChatList.indexOf(chatData) == 0) { + if (!loggedSampleChat) { + loggedSampleChat = true; debugPrint( '🔍 DEBUG: Sample chat data fields: ${chatData.keys.toList()}', ); @@ -417,6 +404,29 @@ class ApiService { return conversations; } + Future> _fetchChatCollection( + String path, { + required String debugLabel, + }) async { + try { + final response = await _dio.get(path); + DebugLogger.log('$debugLabel response status: ${response.statusCode}'); + if (response.data is List) { + return (response.data as List).cast(); + } + DebugLogger.warning( + 'Expected array for $debugLabel, got ${response.data.runtimeType}', + ); + } on DioException catch (e) { + DebugLogger.warning( + 'Skipping $debugLabel due to network error: ${e.message}', + ); + } catch (e) { + DebugLogger.warning('Skipping $debugLabel due to error: $e'); + } + return []; + } + // Helper method to safely parse timestamps DateTime _parseTimestamp(dynamic timestamp) { if (timestamp == null) return DateTime.now(); diff --git a/lib/core/services/connectivity_service.dart b/lib/core/services/connectivity_service.dart index d23590c..40910d0 100644 --- a/lib/core/services/connectivity_service.dart +++ b/lib/core/services/connectivity_service.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dio/dio.dart'; import '../providers/app_providers.dart'; @@ -8,6 +7,7 @@ enum ConnectivityStatus { online, offline, checking } class ConnectivityService { final Dio _dio; + final Ref _ref; Timer? _connectivityTimer; final _connectivityController = StreamController.broadcast(); @@ -16,7 +16,7 @@ class ConnectivityService { Duration _interval = const Duration(seconds: 10); int _lastLatencyMs = -1; - ConnectivityService(this._dio) { + ConnectivityService(this._dio, this._ref) { _startConnectivityMonitoring(); } @@ -45,41 +45,31 @@ class ConnectivityService { } Future _checkConnectivity() async { - try { - // DNS lookup is a lightweight, permission-free reachability check - final result = await InternetAddress.lookup( - 'google.com', - ).timeout(const Duration(seconds: 2)); - - if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) { + final serverReachability = await _probeActiveServer(); + if (serverReachability != null) { + if (serverReachability) { _updateStatus(ConnectivityStatus.online); - return; + } else { + _lastLatencyMs = -1; + _updateStatus(ConnectivityStatus.offline); } - } catch (_) { - // Swallow and continue to HTTP reachability check + return; } - // As a secondary check, hit a public 204 endpoint that returns quickly - try { - final start = DateTime.now(); - await _dio - .get( - 'https://www.google.com/generate_204', - options: Options( - method: 'GET', - sendTimeout: const Duration(seconds: 2), - receiveTimeout: const Duration(seconds: 2), - followRedirects: false, - validateStatus: (status) => status != null && status < 400, - ), - ) - .timeout(const Duration(seconds: 2)); - _lastLatencyMs = DateTime.now().difference(start).inMilliseconds; - _updateStatus(ConnectivityStatus.online); - } catch (_) { - _lastLatencyMs = -1; - _updateStatus(ConnectivityStatus.offline); + final fallbackReachability = await _probeAnyKnownServer(); + if (fallbackReachability != null) { + if (fallbackReachability) { + _updateStatus(ConnectivityStatus.online); + } else { + _lastLatencyMs = -1; + _updateStatus(ConnectivityStatus.offline); + } + return; } + + // No configured server to probe; assume usable connectivity so setup flows continue. + _lastLatencyMs = -1; + _updateStatus(ConnectivityStatus.online); } void _updateStatus(ConnectivityStatus status) { @@ -120,13 +110,103 @@ class ConnectivityService { _connectivityTimer?.cancel(); _connectivityController.close(); } + + Future _probeActiveServer() async { + final healthUri = _resolveHealthUri(); + if (healthUri == null) return null; + + return _probeHealthEndpoint(healthUri, updateLatency: true); + } + + Future _probeAnyKnownServer() async { + try { + final configs = await _ref.read(serverConfigsProvider.future); + for (final config in configs) { + final uri = _buildHealthUri(config.url); + if (uri == null) continue; + final result = await _probeHealthEndpoint(uri); + if (result != null) { + return result; + } + } + } catch (_) {} + return null; + } + + Future _probeHealthEndpoint( + Uri uri, { + bool updateLatency = false, + }) async { + try { + final start = DateTime.now(); + final response = await _dio + .getUri( + uri, + options: Options( + method: 'GET', + sendTimeout: const Duration(seconds: 3), + receiveTimeout: const Duration(seconds: 3), + followRedirects: false, + validateStatus: (status) => status != null && status < 500, + ), + ) + .timeout(const Duration(seconds: 4)); + + final isHealthy = + response.statusCode == 200 && _responseIndicatesHealth(response.data); + if (isHealthy && updateLatency) { + _lastLatencyMs = DateTime.now().difference(start).inMilliseconds; + } + return isHealthy; + } catch (_) { + // Treat as unreachable. + return false; + } + } + + Uri? _resolveHealthUri() { + final api = _ref.read(apiServiceProvider); + if (api != null) { + return _buildHealthUri(api.baseUrl); + } + + final activeServer = _ref.read(activeServerProvider); + return activeServer.maybeWhen( + data: (server) => server != null ? _buildHealthUri(server.url) : null, + orElse: () => null, + ); + } + + Uri? _buildHealthUri(String baseUrl) { + if (baseUrl.isEmpty) return null; + + Uri? parsed = Uri.tryParse(baseUrl.trim()); + if (parsed == null) return null; + + if (!parsed.hasScheme) { + parsed = + Uri.tryParse('https://$baseUrl') ?? Uri.tryParse('http://$baseUrl'); + } + if (parsed == null) return null; + + return parsed.resolve('health'); + } + + bool _responseIndicatesHealth(dynamic data) { + if (data is Map) { + final dynamic status = data['status']; + if (status is bool) return status; + if (status is num) return status != 0; + } + return true; + } } // Providers final connectivityServiceProvider = Provider((ref) { // Use a lightweight Dio instance only for connectivity checks final dio = Dio(); - final service = ConnectivityService(dio); + final service = ConnectivityService(dio, ref); ref.onDispose(() => service.dispose()); return service; }); diff --git a/lib/core/utils/system_ui_style.dart b/lib/core/utils/system_ui_style.dart index c53b6a4..a4f6e50 100644 --- a/lib/core/utils/system_ui_style.dart +++ b/lib/core/utils/system_ui_style.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; /// Applies a single System UI overlay style after first frame to avoid flicker diff --git a/lib/features/auth/providers/unified_auth_providers.dart b/lib/features/auth/providers/unified_auth_providers.dart index a8b0a59..432274f 100644 --- a/lib/features/auth/providers/unified_auth_providers.dart +++ b/lib/features/auth/providers/unified_auth_providers.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/auth/auth_state_manager.dart'; +import '../../../core/models/user.dart'; import '../../../core/providers/app_providers.dart'; /// Unified auth providers using the new auth state manager @@ -75,7 +76,7 @@ final authTokenProvider3 = Provider((ref) { return ref.watch(authStateManagerProvider.select((state) => state.token)); }); -final currentUserProvider2 = Provider((ref) { +final currentUserProvider2 = Provider((ref) { return ref.watch(authStateManagerProvider.select((state) => state.user)); }); diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 4e3d067..4ff07f4 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -632,7 +632,7 @@ class _ChatPageState extends ConsumerState { Spacing.lg, ), physics: - const NeverScrollableScrollPhysics(), // Prevent scrolling during load + const AlwaysScrollableScrollPhysics(), // Allow pull-to-refresh while loading // Modest cache extent to avoid offscreen overwork but keep shimmer smooth cacheExtent: 300, itemCount: 6, @@ -708,6 +708,7 @@ class _ChatPageState extends ConsumerState { return OptimizedList( key: const ValueKey('actual_messages'), scrollController: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), items: messages, padding: const EdgeInsets.fromLTRB( Spacing.lg, @@ -868,58 +869,68 @@ class _ChatPageState extends ConsumerState { data: (user) => user, orElse: () => null, ); - final dynamic authUser = ref.watch(authUserProvider); + final authUser = ref.watch(authUserProvider); final user = userFromProfile ?? authUser; final greetingName = deriveUserDisplayName(user); - return Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(Spacing.lg), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Minimal, clean empty state - Container( - width: Spacing.xxl + Spacing.xxxl, - height: Spacing.xxl + Spacing.xxxl, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - context.conduitTheme.buttonPrimary, - context.conduitTheme.buttonPrimary.withValues( - alpha: 0.8, + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(Spacing.lg), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Minimal, clean empty state + Container( + width: Spacing.xxl + Spacing.xxxl, + height: Spacing.xxl + Spacing.xxxl, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + context.conduitTheme.buttonPrimary, + context.conduitTheme.buttonPrimary.withValues( + alpha: 0.8, + ), + ], ), - ], - ), - borderRadius: BorderRadius.circular(AppBorderRadius.round), - boxShadow: ConduitShadows.glow, - ), - child: Icon( - Platform.isIOS ? CupertinoIcons.chat_bubble_2 : Icons.chat, - size: Spacing.xxxl - Spacing.xs, - color: context.conduitTheme.textInverse, - ), - ) - .animate() - .scale(duration: const Duration(milliseconds: 300)) - .then() - .shimmer(duration: const Duration(milliseconds: 1200)), + borderRadius: BorderRadius.circular( + AppBorderRadius.round, + ), + boxShadow: ConduitShadows.glow, + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.chat_bubble_2 + : Icons.chat, + size: Spacing.xxxl - Spacing.xs, + color: context.conduitTheme.textInverse, + ), + ) + .animate() + .scale(duration: const Duration(milliseconds: 300)) + .then() + .shimmer(duration: const Duration(milliseconds: 1200)), - const SizedBox(height: Spacing.xl), + const SizedBox(height: Spacing.xl), - Text( - l10n.onboardStartTitle(greetingName), - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w600, - color: context.conduitTheme.textPrimary, - ), - textAlign: TextAlign.center, - ).animate().fadeIn(delay: const Duration(milliseconds: 150)), - ], - ), - ), + Text( + l10n.onboardStartTitle(greetingName), + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + color: context.conduitTheme.textPrimary, + ), + textAlign: TextAlign.center, + ).animate().fadeIn(delay: const Duration(milliseconds: 150)), + ], + ), + ), + ); + }, ); } diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 54c8ce0..cd605fe 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -1218,7 +1218,7 @@ class _ChatsDrawerState extends ConsumerState { data: (u) => u, orElse: () => null, ); - final dynamic authUser = ref.watch(authUserProvider); + final authUser = ref.watch(authUserProvider); final user = userFromProfile ?? authUser; final api = ref.watch(apiServiceProvider); diff --git a/lib/features/onboarding/views/onboarding_sheet.dart b/lib/features/onboarding/views/onboarding_sheet.dart index ad8dc5c..bbd6f6a 100644 --- a/lib/features/onboarding/views/onboarding_sheet.dart +++ b/lib/features/onboarding/views/onboarding_sheet.dart @@ -73,7 +73,7 @@ class _OnboardingSheetState extends ConsumerState { data: (user) => user, orElse: () => null, ); - final dynamic authUser = ref.watch(authUserProvider); + final authUser = ref.watch(authUserProvider); final user = userFromProfile ?? authUser; final greetingName = deriveUserDisplayName(user); final pages = _buildPages(l10n, greetingName); diff --git a/lib/shared/services/tasks/task_queue.dart b/lib/shared/services/tasks/task_queue.dart index 81cecec..4706539 100644 --- a/lib/shared/services/tasks/task_queue.dart +++ b/lib/shared/services/tasks/task_queue.dart @@ -63,7 +63,19 @@ class TaskQueueNotifier extends Notifier> { Future _save() async { try { final prefs = ref.read(sharedPreferencesProvider); - final raw = state.map((t) => t.toJson()).toList(growable: false); + final retained = [ + for (final task in state) + if (task.status == TaskStatus.queued || + task.status == TaskStatus.running) + task, + ]; + + if (retained.length != state.length) { + // Remove completed entries from state to keep the in-memory queue lean. + state = retained; + } + + final raw = retained.map((t) => t.toJson()).toList(growable: false); await prefs.setString(_prefsKey, jsonEncode(raw)); } catch (e) { debugPrint('DEBUG: Failed to persist task queue: $e'); diff --git a/lib/shared/widgets/improved_loading_states.dart b/lib/shared/widgets/improved_loading_states.dart index 8dc1db0..734ec67 100644 --- a/lib/shared/widgets/improved_loading_states.dart +++ b/lib/shared/widgets/improved_loading_states.dart @@ -51,15 +51,14 @@ class _ImprovedLoadingStateState extends State ); _animationController.forward(); - // Announce loading state for screen readers - if (widget.message != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - SemanticsService.announce( - 'Loading: ${widget.message}', - TextDirection.ltr, - ); - }); - } + // Announce loading state for screen readers using localized messaging. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final l10n = AppLocalizations.of(context); + final announcement = widget.message ?? l10n?.loadingContent ?? 'Loading'; + final direction = Directionality.maybeOf(context) ?? TextDirection.ltr; + SemanticsService.announce(announcement, direction); + }); } @override diff --git a/lib/shared/widgets/offline_indicator.dart b/lib/shared/widgets/offline_indicator.dart index f05b8b5..c1cbda5 100644 --- a/lib/shared/widgets/offline_indicator.dart +++ b/lib/shared/widgets/offline_indicator.dart @@ -68,7 +68,7 @@ class _WasOfflineNotifier extends Notifier { } }, loading: () {}, - error: (_, __) {}, + error: (error, stackTrace) {}, ); }); return false;