refactor: improvements

This commit is contained in:
cogwheel0
2025-09-24 10:52:15 +05:30
parent f6a1b6123b
commit b8c024d0b0
13 changed files with 294 additions and 132 deletions

View File

@@ -20,7 +20,7 @@ class AuthState {
final AuthStatus status; final AuthStatus status;
final String? token; final String? token;
final dynamic user; // Replace with proper User type final User? user;
final String? error; final String? error;
final bool isLoading; final bool isLoading;
@@ -33,7 +33,7 @@ class AuthState {
AuthState copyWith({ AuthState copyWith({
AuthStatus? status, AuthStatus? status,
String? token, String? token,
dynamic user, User? user,
String? error, String? error,
bool? isLoading, bool? isLoading,
bool clearToken = false, bool clearToken = false,
@@ -460,6 +460,7 @@ class AuthStateManager extends Notifier<AuthState> {
// Clear token from storage // Clear token from storage
final storage = ref.read(optimizedStorageServiceProvider); final storage = ref.read(optimizedStorageServiceProvider);
await storage.deleteAuthToken(); await storage.deleteAuthToken();
_updateApiServiceToken(null);
// Update state // Update state
state = state.copyWith( state = state.copyWith(
@@ -497,6 +498,7 @@ class AuthStateManager extends Notifier<AuthState> {
// Clear all local auth data // Clear all local auth data
final storage = ref.read(optimizedStorageServiceProvider); final storage = ref.read(optimizedStorageServiceProvider);
await storage.clearAuthData(); await storage.clearAuthData();
_updateApiServiceToken(null);
// Update state // Update state
state = state.copyWith( state = state.copyWith(
@@ -518,6 +520,7 @@ class AuthStateManager extends Notifier<AuthState> {
clearUser: true, clearUser: true,
error: 'Logout error: $e', error: 'Logout error: $e',
); );
_updateApiServiceToken(null);
} }
} }
@@ -528,8 +531,11 @@ class AuthStateManager extends Notifier<AuthState> {
if (state.token != null) { if (state.token != null) {
final jwtUserInfo = TokenValidator.extractUserInfo(state.token!); final jwtUserInfo = TokenValidator.extractUserInfo(state.token!);
if (jwtUserInfo != null) { if (jwtUserInfo != null) {
DebugLogger.auth('Extracted user info from JWT token'); final userFromJwt = _userFromJwtClaims(jwtUserInfo);
state = state.copyWith(user: 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 // Still try to load from server in background for complete data
Future.microtask(() => _loadServerUserData()); Future.microtask(() => _loadServerUserData());
@@ -569,7 +575,7 @@ class AuthStateManager extends Notifier<AuthState> {
} }
/// Update API service with current token /// Update API service with current token
void _updateApiServiceToken(String token) { void _updateApiServiceToken(String? token) {
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
api?.updateAuthToken(token); api?.updateAuthToken(token);
} }
@@ -689,6 +695,46 @@ class AuthStateManager extends Notifier<AuthState> {
'storageCache': 'Managed by OptimizedStorageService', 'storageCache': 'Managed by OptimizedStorageService',
}; };
} }
User? _userFromJwtClaims(Map<String, dynamic> 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 /// Provider for the unified auth state manager
@@ -707,7 +753,7 @@ final authTokenProvider2 = Provider<String?>((ref) {
return ref.watch(authStateManagerProvider.select((state) => state.token)); return ref.watch(authStateManagerProvider.select((state) => state.token));
}); });
final authUserProvider = Provider<dynamic>((ref) { final authUserProvider = Provider<User?>((ref) {
return ref.watch(authStateManagerProvider.select((state) => state.user)); return ref.watch(authStateManagerProvider.select((state) => state.user));
}); });

View File

@@ -249,10 +249,9 @@ final apiTokenUpdaterProvider = Provider<void>((ref) {
ref.listen<String?>(authTokenProvider3, (previous, next) { ref.listen<String?>(authTokenProvider3, (previous, next) {
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
if (api != null) { if (api != null) {
api.updateAuthToken(next ?? ''); api.updateAuthToken(next);
foundation.debugPrint( final length = next?.length ?? 0;
'DEBUG: Applied auth token to API (len=${next?.length ?? 0})', foundation.debugPrint('DEBUG: Applied auth token to API (len=$length)');
);
} }
}); });
}); });

View File

@@ -160,8 +160,13 @@ final appStartupFlowProvider = Provider<void>((ref) {
// match theme after the first frame. Avoids flicker at startup. // match theme after the first frame. Avoids flicker at startup.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
try { try {
final isDark = final context = NavigationService.context;
WidgetsBinding.instance.window.platformBrightness == Brightness.dark; 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( SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle( SystemUiOverlayStyle(
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark, statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,

View File

@@ -92,7 +92,7 @@ class ApiService {
// Validation interceptor fully removed // Validation interceptor fully removed
} }
void updateAuthToken(String token) { void updateAuthToken(String? token) {
_authInterceptor.updateAuthToken(token); _authInterceptor.updateAuthToken(token);
} }
@@ -317,29 +317,15 @@ class ApiService {
allRegularChats = regularResponse.data as List; allRegularChats = regularResponse.data as List;
} }
final pinnedResponse = await _dio.get('/api/v1/chats/pinned'); final pinnedChatList = await _fetchChatCollection(
final archivedResponse = await _dio.get('/api/v1/chats/all/archived'); '/api/v1/chats/pinned',
debugLabel: 'pinned chats',
debugPrint('DEBUG: Pinned response status: ${pinnedResponse.statusCode}'); );
debugPrint( final archivedChatList = await _fetchChatCollection(
'DEBUG: Archived response status: ${archivedResponse.statusCode}', '/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 regularChatList = allRegularChats;
final pinnedChatList = pinnedResponse.data as List;
final archivedChatList = archivedResponse.data as List;
debugPrint('DEBUG: Found ${regularChatList.length} regular chats'); debugPrint('DEBUG: Found ${regularChatList.length} regular chats');
debugPrint('DEBUG: Found ${pinnedChatList.length} pinned chats'); debugPrint('DEBUG: Found ${pinnedChatList.length} pinned chats');
@@ -377,6 +363,7 @@ class ApiService {
} }
// Process regular conversations (excluding pinned and archived ones) // Process regular conversations (excluding pinned and archived ones)
var loggedSampleChat = false;
for (final chatData in regularChatList) { for (final chatData in regularChatList) {
try { try {
// Debug: Check if conversation has folder_id in raw data // 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 (!loggedSampleChat) {
if (regularChatList.indexOf(chatData) == 0) { loggedSampleChat = true;
debugPrint( debugPrint(
'🔍 DEBUG: Sample chat data fields: ${chatData.keys.toList()}', '🔍 DEBUG: Sample chat data fields: ${chatData.keys.toList()}',
); );
@@ -417,6 +404,29 @@ class ApiService {
return conversations; return conversations;
} }
Future<List<dynamic>> _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<dynamic>();
}
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 <dynamic>[];
}
// Helper method to safely parse timestamps // Helper method to safely parse timestamps
DateTime _parseTimestamp(dynamic timestamp) { DateTime _parseTimestamp(dynamic timestamp) {
if (timestamp == null) return DateTime.now(); if (timestamp == null) return DateTime.now();

View File

@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../providers/app_providers.dart'; import '../providers/app_providers.dart';
@@ -8,6 +7,7 @@ enum ConnectivityStatus { online, offline, checking }
class ConnectivityService { class ConnectivityService {
final Dio _dio; final Dio _dio;
final Ref _ref;
Timer? _connectivityTimer; Timer? _connectivityTimer;
final _connectivityController = final _connectivityController =
StreamController<ConnectivityStatus>.broadcast(); StreamController<ConnectivityStatus>.broadcast();
@@ -16,7 +16,7 @@ class ConnectivityService {
Duration _interval = const Duration(seconds: 10); Duration _interval = const Duration(seconds: 10);
int _lastLatencyMs = -1; int _lastLatencyMs = -1;
ConnectivityService(this._dio) { ConnectivityService(this._dio, this._ref) {
_startConnectivityMonitoring(); _startConnectivityMonitoring();
} }
@@ -45,41 +45,31 @@ class ConnectivityService {
} }
Future<void> _checkConnectivity() async { Future<void> _checkConnectivity() async {
try { final serverReachability = await _probeActiveServer();
// DNS lookup is a lightweight, permission-free reachability check if (serverReachability != null) {
final result = await InternetAddress.lookup( if (serverReachability) {
'google.com',
).timeout(const Duration(seconds: 2));
if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) {
_updateStatus(ConnectivityStatus.online); _updateStatus(ConnectivityStatus.online);
return; } else {
_lastLatencyMs = -1;
_updateStatus(ConnectivityStatus.offline);
} }
} catch (_) { return;
// Swallow and continue to HTTP reachability check
} }
// As a secondary check, hit a public 204 endpoint that returns quickly final fallbackReachability = await _probeAnyKnownServer();
try { if (fallbackReachability != null) {
final start = DateTime.now(); if (fallbackReachability) {
await _dio _updateStatus(ConnectivityStatus.online);
.get( } else {
'https://www.google.com/generate_204', _lastLatencyMs = -1;
options: Options( _updateStatus(ConnectivityStatus.offline);
method: 'GET', }
sendTimeout: const Duration(seconds: 2), return;
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);
} }
// No configured server to probe; assume usable connectivity so setup flows continue.
_lastLatencyMs = -1;
_updateStatus(ConnectivityStatus.online);
} }
void _updateStatus(ConnectivityStatus status) { void _updateStatus(ConnectivityStatus status) {
@@ -120,13 +110,103 @@ class ConnectivityService {
_connectivityTimer?.cancel(); _connectivityTimer?.cancel();
_connectivityController.close(); _connectivityController.close();
} }
Future<bool?> _probeActiveServer() async {
final healthUri = _resolveHealthUri();
if (healthUri == null) return null;
return _probeHealthEndpoint(healthUri, updateLatency: true);
}
Future<bool?> _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<bool?> _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 // Providers
final connectivityServiceProvider = Provider<ConnectivityService>((ref) { final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
// Use a lightweight Dio instance only for connectivity checks // Use a lightweight Dio instance only for connectivity checks
final dio = Dio(); final dio = Dio();
final service = ConnectivityService(dio); final service = ConnectivityService(dio, ref);
ref.onDispose(() => service.dispose()); ref.onDispose(() => service.dispose());
return service; return service;
}); });

View File

@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
/// Applies a single System UI overlay style after first frame to avoid flicker /// Applies a single System UI overlay style after first frame to avoid flicker

View File

@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/auth/auth_state_manager.dart'; import '../../../core/auth/auth_state_manager.dart';
import '../../../core/models/user.dart';
import '../../../core/providers/app_providers.dart'; import '../../../core/providers/app_providers.dart';
/// Unified auth providers using the new auth state manager /// Unified auth providers using the new auth state manager
@@ -75,7 +76,7 @@ final authTokenProvider3 = Provider<String?>((ref) {
return ref.watch(authStateManagerProvider.select((state) => state.token)); return ref.watch(authStateManagerProvider.select((state) => state.token));
}); });
final currentUserProvider2 = Provider<dynamic>((ref) { final currentUserProvider2 = Provider<User?>((ref) {
return ref.watch(authStateManagerProvider.select((state) => state.user)); return ref.watch(authStateManagerProvider.select((state) => state.user));
}); });

View File

@@ -632,7 +632,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
Spacing.lg, Spacing.lg,
), ),
physics: 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 // Modest cache extent to avoid offscreen overwork but keep shimmer smooth
cacheExtent: 300, cacheExtent: 300,
itemCount: 6, itemCount: 6,
@@ -708,6 +708,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
return OptimizedList<ChatMessage>( return OptimizedList<ChatMessage>(
key: const ValueKey('actual_messages'), key: const ValueKey('actual_messages'),
scrollController: _scrollController, scrollController: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
items: messages, items: messages,
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
Spacing.lg, Spacing.lg,
@@ -868,58 +869,68 @@ class _ChatPageState extends ConsumerState<ChatPage> {
data: (user) => user, data: (user) => user,
orElse: () => null, orElse: () => null,
); );
final dynamic authUser = ref.watch(authUserProvider); final authUser = ref.watch(authUserProvider);
final user = userFromProfile ?? authUser; final user = userFromProfile ?? authUser;
final greetingName = deriveUserDisplayName(user); final greetingName = deriveUserDisplayName(user);
return Center( return LayoutBuilder(
child: SingleChildScrollView( builder: (context, constraints) {
padding: const EdgeInsets.all(Spacing.lg), return SingleChildScrollView(
child: Column( physics: const AlwaysScrollableScrollPhysics(),
mainAxisAlignment: MainAxisAlignment.center, padding: const EdgeInsets.all(Spacing.lg),
crossAxisAlignment: CrossAxisAlignment.center, child: ConstrainedBox(
children: [ constraints: BoxConstraints(minHeight: constraints.maxHeight),
// Minimal, clean empty state child: Column(
Container( mainAxisAlignment: MainAxisAlignment.center,
width: Spacing.xxl + Spacing.xxxl, crossAxisAlignment: CrossAxisAlignment.center,
height: Spacing.xxl + Spacing.xxxl, children: [
decoration: BoxDecoration( // Minimal, clean empty state
gradient: LinearGradient( Container(
begin: Alignment.topLeft, width: Spacing.xxl + Spacing.xxxl,
end: Alignment.bottomRight, height: Spacing.xxl + Spacing.xxxl,
colors: [ decoration: BoxDecoration(
context.conduitTheme.buttonPrimary, gradient: LinearGradient(
context.conduitTheme.buttonPrimary.withValues( begin: Alignment.topLeft,
alpha: 0.8, end: Alignment.bottomRight,
colors: [
context.conduitTheme.buttonPrimary,
context.conduitTheme.buttonPrimary.withValues(
alpha: 0.8,
),
],
), ),
], borderRadius: BorderRadius.circular(
), AppBorderRadius.round,
borderRadius: BorderRadius.circular(AppBorderRadius.round), ),
boxShadow: ConduitShadows.glow, boxShadow: ConduitShadows.glow,
), ),
child: Icon( child: Icon(
Platform.isIOS ? CupertinoIcons.chat_bubble_2 : Icons.chat, Platform.isIOS
size: Spacing.xxxl - Spacing.xs, ? CupertinoIcons.chat_bubble_2
color: context.conduitTheme.textInverse, : Icons.chat,
), size: Spacing.xxxl - Spacing.xs,
) color: context.conduitTheme.textInverse,
.animate() ),
.scale(duration: const Duration(milliseconds: 300)) )
.then() .animate()
.shimmer(duration: const Duration(milliseconds: 1200)), .scale(duration: const Duration(milliseconds: 300))
.then()
.shimmer(duration: const Duration(milliseconds: 1200)),
const SizedBox(height: Spacing.xl), const SizedBox(height: Spacing.xl),
Text( Text(
l10n.onboardStartTitle(greetingName), l10n.onboardStartTitle(greetingName),
style: theme.textTheme.headlineSmall?.copyWith( style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: context.conduitTheme.textPrimary, color: context.conduitTheme.textPrimary,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
).animate().fadeIn(delay: const Duration(milliseconds: 150)), ).animate().fadeIn(delay: const Duration(milliseconds: 150)),
], ],
), ),
), ),
);
},
); );
} }

View File

@@ -1218,7 +1218,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
data: (u) => u, data: (u) => u,
orElse: () => null, orElse: () => null,
); );
final dynamic authUser = ref.watch(authUserProvider); final authUser = ref.watch(authUserProvider);
final user = userFromProfile ?? authUser; final user = userFromProfile ?? authUser;
final api = ref.watch(apiServiceProvider); final api = ref.watch(apiServiceProvider);

View File

@@ -73,7 +73,7 @@ class _OnboardingSheetState extends ConsumerState<OnboardingSheet> {
data: (user) => user, data: (user) => user,
orElse: () => null, orElse: () => null,
); );
final dynamic authUser = ref.watch(authUserProvider); final authUser = ref.watch(authUserProvider);
final user = userFromProfile ?? authUser; final user = userFromProfile ?? authUser;
final greetingName = deriveUserDisplayName(user); final greetingName = deriveUserDisplayName(user);
final pages = _buildPages(l10n, greetingName); final pages = _buildPages(l10n, greetingName);

View File

@@ -63,7 +63,19 @@ class TaskQueueNotifier extends Notifier<List<OutboundTask>> {
Future<void> _save() async { Future<void> _save() async {
try { try {
final prefs = ref.read(sharedPreferencesProvider); 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)); await prefs.setString(_prefsKey, jsonEncode(raw));
} catch (e) { } catch (e) {
debugPrint('DEBUG: Failed to persist task queue: $e'); debugPrint('DEBUG: Failed to persist task queue: $e');

View File

@@ -51,15 +51,14 @@ class _ImprovedLoadingStateState extends State<ImprovedLoadingState>
); );
_animationController.forward(); _animationController.forward();
// Announce loading state for screen readers // Announce loading state for screen readers using localized messaging.
if (widget.message != null) { WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return;
SemanticsService.announce( final l10n = AppLocalizations.of(context);
'Loading: ${widget.message}', final announcement = widget.message ?? l10n?.loadingContent ?? 'Loading';
TextDirection.ltr, final direction = Directionality.maybeOf(context) ?? TextDirection.ltr;
); SemanticsService.announce(announcement, direction);
}); });
}
} }
@override @override

View File

@@ -68,7 +68,7 @@ class _WasOfflineNotifier extends Notifier<bool> {
} }
}, },
loading: () {}, loading: () {},
error: (_, __) {}, error: (error, stackTrace) {},
); );
}); });
return false; return false;