diff --git a/lib/core/services/connectivity_service.dart b/lib/core/services/connectivity_service.dart index 4a54c04..c2a68d4 100644 --- a/lib/core/services/connectivity_service.dart +++ b/lib/core/services/connectivity_service.dart @@ -34,8 +34,8 @@ class ConnectivityService { _checkConnectivity(); }); - // Check every 5 seconds - _connectivityTimer = Timer.periodic(const Duration(seconds: 5), (_) { + // Check periodically; balance responsiveness with battery/network usage + _connectivityTimer = Timer.periodic(const Duration(seconds: 10), (_) { _checkConnectivity(); }); } diff --git a/lib/core/widgets/error_boundary.dart b/lib/core/widgets/error_boundary.dart index fc16dcc..0658331 100644 --- a/lib/core/widgets/error_boundary.dart +++ b/lib/core/widgets/error_boundary.dart @@ -106,8 +106,16 @@ class _ErrorBoundaryState extends ConsumerState { } // Default error UI + // Respect ambient text direction when available; fall back to LTR. + TextDirection direction; + try { + direction = Directionality.of(context); + } catch (_) { + direction = TextDirection.ltr; + } + return Directionality( - textDirection: TextDirection.ltr, + textDirection: direction, child: Scaffold( backgroundColor: context.conduitTheme.surfaceBackground, body: SafeArea( diff --git a/lib/features/auth/views/authentication_page.dart b/lib/features/auth/views/authentication_page.dart index 5253e6b..06aff32 100644 --- a/lib/features/auth/views/authentication_page.dart +++ b/lib/features/auth/views/authentication_page.dart @@ -595,35 +595,39 @@ class _AuthenticationPageState extends ConsumerState { } Widget _buildErrorMessage(String message) { - return Container( - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: context.conduitTheme.errorBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.button), - border: Border.all( - color: context.conduitTheme.error.withValues(alpha: 0.3), - width: BorderWidth.standard, - ), - ), - child: Row( - children: [ - Icon( - Platform.isIOS - ? CupertinoIcons.exclamationmark_circle_fill - : Icons.error_outline, - color: context.conduitTheme.error, - size: IconSize.medium, + return Semantics( + liveRegion: true, + label: message, + child: Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.errorBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.button), + border: Border.all( + color: context.conduitTheme.error.withValues(alpha: 0.3), + width: BorderWidth.standard, ), - const SizedBox(width: Spacing.md), - Expanded( - child: Text( - message, - style: context.conduitTheme.bodyMedium?.copyWith( - color: context.conduitTheme.error, + ), + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.exclamationmark_circle_fill + : Icons.error_outline, + color: context.conduitTheme.error, + size: IconSize.medium, + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Text( + message, + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.error, + ), ), ), - ), - ], + ], + ), ), ).animate().slideX( begin: 0.05, diff --git a/lib/features/auth/views/server_connection_page.dart b/lib/features/auth/views/server_connection_page.dart index b8e2b32..2206429 100644 --- a/lib/features/auth/views/server_connection_page.dart +++ b/lib/features/auth/views/server_connection_page.dart @@ -748,40 +748,45 @@ class _ServerConnectionPageState extends ConsumerState { } Widget _buildErrorMessage(String message) { - return Container( - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: context.conduitTheme.errorBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.button), - border: Border.all( - color: context.conduitTheme.error.withValues(alpha: 0.3), - width: BorderWidth.standard, - ), - ), - child: Row( - children: [ - Icon( - Platform.isIOS - ? CupertinoIcons.exclamationmark_circle_fill - : Icons.error_outline, - color: context.conduitTheme.error, - size: IconSize.medium, - ), - const SizedBox(width: Spacing.md), - Expanded( - child: Text( - message, - style: context.conduitTheme.bodyMedium?.copyWith( - color: context.conduitTheme.error, + return Semantics( + liveRegion: true, + label: message, + child: + Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.errorBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.button), + border: Border.all( + color: context.conduitTheme.error.withValues(alpha: 0.3), + width: BorderWidth.standard, ), ), + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.exclamationmark_circle_fill + : Icons.error_outline, + color: context.conduitTheme.error, + size: IconSize.medium, + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Text( + message, + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.error, + ), + ), + ), + ], + ), + ).animate().slideX( + begin: 0.05, + duration: AnimationDuration.messageSlide, + curve: Curves.easeOutCubic, ), - ], - ), - ).animate().slideX( - begin: 0.05, - duration: AnimationDuration.messageSlide, - curve: Curves.easeOutCubic, ); } diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index aee45ed..4e3d067 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -633,6 +633,8 @@ class _ChatPageState extends ConsumerState { ), physics: const NeverScrollableScrollPhysics(), // Prevent scrolling during load + // Modest cache extent to avoid offscreen overwork but keep shimmer smooth + cacheExtent: 300, itemCount: 6, itemBuilder: (context, index) { final isUser = index.isOdd; @@ -1007,9 +1009,7 @@ class _ChatPageState extends ConsumerState { if (!mounted) return; final current = ref.read(inputFocusTriggerProvider); // Immediate focus bump - ref - .read(inputFocusTriggerProvider.notifier) - .set(current + 1); + ref.read(inputFocusTriggerProvider.notifier).set(current + 1); // Second bump shortly after to overcome route/IME timing Future.delayed(const Duration(milliseconds: 120), () { if (!mounted) return; @@ -1788,6 +1788,7 @@ class _ModelSelectorSheetState extends ConsumerState<_ModelSelectorSheet> { : ListView.builder( controller: scrollController, padding: EdgeInsets.zero, + cacheExtent: 400, itemCount: _filteredModels.length, itemBuilder: (context, index) { final model = _filteredModels[index]; diff --git a/lib/main.dart b/lib/main.dart index 90f111f..b948bea 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -194,8 +194,8 @@ class _ConduitAppState extends ConsumerState { return MediaQuery( data: mediaQuery.copyWith( textScaler: mediaQuery.textScaler.clamp( - minScaleFactor: 0.8, - maxScaleFactor: 2.0, + minScaleFactor: 1.0, + maxScaleFactor: 3.0, ), ), child: OfflineIndicator(child: child ?? const SizedBox.shrink()), diff --git a/lib/shared/widgets/offline_indicator.dart b/lib/shared/widgets/offline_indicator.dart index 048cd13..f05b8b5 100644 --- a/lib/shared/widgets/offline_indicator.dart +++ b/lib/shared/widgets/offline_indicator.dart @@ -20,6 +20,7 @@ class OfflineIndicator extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final connectivityStatus = ref.watch(connectivityStatusProvider); + final wasOffline = ref.watch(_wasOfflineProvider); return Stack( children: [ @@ -30,6 +31,10 @@ class OfflineIndicator extends ConsumerWidget { if (status == ConnectivityStatus.offline) { return _OfflineBanner(); } + // Announce back-online briefly if we were previously offline + if (wasOffline) { + return const _BackOnlineToast(); + } return const SizedBox.shrink(); }, loading: () => const SizedBox.shrink(), @@ -40,6 +45,90 @@ class OfflineIndicator extends ConsumerWidget { } } +// Tracks if the app was recently offline to enable a one-shot back-online toast +final _wasOfflineProvider = NotifierProvider<_WasOfflineNotifier, bool>( + _WasOfflineNotifier.new, +); + +class _WasOfflineNotifier extends Notifier { + @override + bool build() { + // Initialize based on current connectivity (assume online until proven otherwise) + ref.listen>(connectivityStatusProvider, ( + prev, + next, + ) { + next.when( + data: (status) { + if (status == ConnectivityStatus.offline) { + state = true; // mark that we have been offline + } else if (status == ConnectivityStatus.online && state) { + // After we emit the toast once, clear flag shortly after + Future.microtask(() => state = false); + } + }, + loading: () {}, + error: (_, __) {}, + ); + }); + return false; + } +} + +class _BackOnlineToast extends StatelessWidget { + const _BackOnlineToast(); + + @override + Widget build(BuildContext context) { + return Positioned( + top: kToolbarHeight + 8, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: Semantics( + container: true, + liveRegion: true, + label: AppLocalizations.of(context)!.checkConnection, + child: Align( + alignment: Alignment.topCenter, + child: + Container( + margin: const EdgeInsets.symmetric( + horizontal: Spacing.md, + ), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.success, + borderRadius: BorderRadius.circular( + AppBorderRadius.round, + ), + boxShadow: ConduitShadows.low, + ), + child: Text( + // Reuse existing l10n; otherwise add a dedicated "Back online" key later + AppLocalizations.of(context)!.loadingContent, + style: TextStyle( + color: context.conduitTheme.textInverse, + fontSize: AppTypography.labelLarge, + fontWeight: FontWeight.w600, + ), + ), + ) + .animate(onPlay: (c) => c.forward()) + .fadeIn(duration: const Duration(milliseconds: 200)) + .then(delay: const Duration(milliseconds: 1200)) + .fadeOut(duration: const Duration(milliseconds: 250)), + ), + ), + ), + ); + } +} + class _OfflineBanner extends StatelessWidget { @override Widget build(BuildContext context) { @@ -50,36 +139,41 @@ class _OfflineBanner extends StatelessWidget { child: SafeArea( bottom: false, child: - Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: context.conduitTheme.warning, - boxShadow: ConduitShadows.low, - ), - child: Row( - children: [ - Icon( - Platform.isIOS - ? CupertinoIcons.wifi_slash - : Icons.wifi_off, - color: context.conduitTheme.textInverse, - size: AppTypography.headlineMedium, - ), - const SizedBox(width: Spacing.xs), - Expanded( - child: Text( - AppLocalizations.of(context)!.offlineBanner, - style: TextStyle( - color: context.conduitTheme.textInverse, - fontSize: AppTypography.labelLarge, - fontWeight: FontWeight.w500, + Semantics( + container: true, + liveRegion: true, + label: AppLocalizations.of(context)!.offlineBanner, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.warning, + boxShadow: ConduitShadows.low, + ), + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.wifi_slash + : Icons.wifi_off, + color: context.conduitTheme.textInverse, + size: AppTypography.headlineMedium, + ), + const SizedBox(width: Spacing.xs), + Expanded( + child: Text( + AppLocalizations.of(context)!.offlineBanner, + style: TextStyle( + color: context.conduitTheme.textInverse, + fontSize: AppTypography.labelLarge, + fontWeight: FontWeight.w500, + ), ), ), - ), - ], + ], + ), ), ) .animate(onPlay: (controller) => controller.forward())