feat: enhance routing and connectivity handling

- Added a new route for connection issues, allowing users to navigate to a dedicated page when the server is unreachable.
- Updated the RouterNotifier to manage navigation based on server connectivity status and authentication state.
- Improved the handling of offline scenarios by integrating connectivity checks into the routing logic.
- Enhanced localization support for connection issue messages in multiple languages.
- Refactored the OfflineIndicator widget to streamline the display of connectivity status without unnecessary complexity.
This commit is contained in:
cogwheel0
2025-10-01 23:26:12 +05:30
parent d899ca5f70
commit ebe6cec17c
16 changed files with 523 additions and 148 deletions

View File

@@ -4,11 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/app_providers.dart';
import '../services/connectivity_service.dart';
import '../services/navigation_service.dart';
import '../utils/debug_logger.dart';
import '../../features/auth/providers/unified_auth_providers.dart';
import '../../features/auth/views/authentication_page.dart';
import '../../features/auth/views/connect_signin_page.dart';
import '../../features/auth/views/connection_issue_page.dart';
import '../../features/auth/views/server_connection_page.dart';
import '../../features/chat/views/chat_page.dart';
import '../../features/navigation/views/splash_launcher_page.dart';
@@ -67,19 +69,34 @@ class RouterNotifier extends ChangeNotifier {
}
if (activeServerAsync.hasError) {
return location == Routes.serverConnection
? null
: Routes.serverConnection;
return location == Routes.connectionIssue ? null : Routes.connectionIssue;
}
final activeServer = activeServerAsync.asData?.value;
if (activeServer == null) {
final hasActiveServer = activeServer != null;
if (!hasActiveServer) {
// Allow auth-related routes while no server configured
if (_isAuthLocation(location)) return null;
return Routes.serverConnection;
}
if (location == Routes.serverConnection) {
return Routes.chat;
}
final authState = ref.read(authNavigationStateProvider);
final connectivityAsync = ref.read(connectivityStatusProvider);
final connectivity = connectivityAsync.asData?.value;
final shouldShowConnectionIssue =
!reviewerMode &&
connectivity == ConnectivityStatus.offline &&
authState != AuthNavigationState.needsLogin;
if (shouldShowConnectionIssue) {
return location == Routes.connectionIssue ? null : Routes.connectionIssue;
}
switch (authState) {
case AuthNavigationState.loading:
// Keep user on auth routes while loading to prevent bounce
@@ -87,12 +104,16 @@ class RouterNotifier extends ChangeNotifier {
// Otherwise keep splash during session establishment
return location == Routes.splash ? null : Routes.splash;
case AuthNavigationState.needsLogin:
if (location == Routes.connectionIssue) return null;
return null;
case AuthNavigationState.error:
if (_isAuthLocation(location)) return null;
return Routes.serverConnection;
if (location == Routes.connectionIssue) return null;
return null;
case AuthNavigationState.authenticated:
// Avoid unnecessary redirects if already on a non-auth route
if (_isAuthLocation(location) || location == Routes.splash) {
if (_isAuthLocation(location) ||
location == Routes.splash ||
location == Routes.connectionIssue) {
return Routes.chat;
}
return null;
@@ -102,7 +123,8 @@ class RouterNotifier extends ChangeNotifier {
bool _isAuthLocation(String location) {
return location == Routes.serverConnection ||
location == Routes.login ||
location == Routes.authentication;
location == Routes.authentication ||
location == Routes.connectionIssue;
}
@override
@@ -145,6 +167,11 @@ final goRouterProvider = Provider<GoRouter>((ref) {
name: RouteNames.serverConnection,
builder: (context, state) => const ServerConnectionPage(),
),
GoRoute(
path: Routes.connectionIssue,
name: RouteNames.connectionIssue,
builder: (context, state) => const ConnectionIssuePage(),
),
GoRoute(
path: Routes.authentication,
name: RouteNames.authentication,

View File

@@ -95,6 +95,7 @@ class Routes {
static const String chat = '/chat';
static const String login = '/login';
static const String serverConnection = '/server-connection';
static const String connectionIssue = '/connection-issue';
static const String authentication = '/authentication';
static const String profile = '/profile';
static const String appCustomization = '/profile/customization';
@@ -106,6 +107,7 @@ class RouteNames {
static const String chat = 'chat';
static const String login = 'login';
static const String serverConnection = 'server-connection';
static const String connectionIssue = 'connection-issue';
static const String authentication = 'authentication';
static const String profile = 'profile';
static const String appCustomization = 'app-customization';

View File

@@ -0,0 +1,314 @@
import 'dart:io' show Platform;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/models/server_config.dart';
import '../../../core/providers/app_providers.dart';
import '../../../core/services/connectivity_service.dart';
import '../../../core/services/navigation_service.dart';
import '../../../core/widgets/error_boundary.dart';
import '../../../l10n/app_localizations.dart';
import '../../../shared/theme/theme_extensions.dart';
import '../../../shared/widgets/conduit_components.dart';
import '../providers/unified_auth_providers.dart';
class ConnectionIssuePage extends ConsumerStatefulWidget {
const ConnectionIssuePage({super.key});
@override
ConsumerState<ConnectionIssuePage> createState() =>
_ConnectionIssuePageState();
}
class _ConnectionIssuePageState extends ConsumerState<ConnectionIssuePage> {
bool _isRetrying = false;
bool _isLoggingOut = false;
String? _statusMessage;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final connectivityAsync = ref.watch(connectivityStatusProvider);
final connectivity = connectivityAsync.asData?.value;
final activeServerAsync = ref.watch(activeServerProvider);
final activeServer = activeServerAsync.asData?.value;
return ErrorBoundary(
child: Scaffold(
backgroundColor: context.conduitTheme.surfaceBackground,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.pagePadding,
vertical: Spacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: ConduitIconButton(
icon: Platform.isIOS
? CupertinoIcons.gear_alt_fill
: Icons.settings_ethernet,
onPressed: () => context.go(Routes.serverConnection),
tooltip: l10n.backToServerSetup,
),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(context, l10n, connectivity),
if (activeServer != null) ...[
const SizedBox(height: Spacing.sm),
_buildServerDetails(context, activeServer),
],
const SizedBox(height: Spacing.md),
Text(
l10n.connectionIssueSubtitle,
textAlign: TextAlign.center,
style: context.conduitTheme.bodyMedium?.copyWith(
color: context.conduitTheme.textSecondary,
height: 1.45,
),
),
],
),
),
),
),
_buildActions(context, l10n),
if (_statusMessage != null) ...[
const SizedBox(height: Spacing.sm),
_buildStatusMessage(context, _statusMessage!),
],
],
),
),
),
),
);
}
Widget _buildHeader(
BuildContext context,
AppLocalizations l10n,
ConnectivityStatus? connectivity,
) {
final iconColor = context.conduitTheme.error;
final statusText = _statusLabel(connectivity, l10n);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: context.conduitTheme.surfaceContainerHighest,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 18,
offset: const Offset(0, 12),
),
],
),
child: Icon(
Platform.isIOS
? CupertinoIcons.wifi_exclamationmark
: Icons.wifi_off_rounded,
color: iconColor,
size: 34,
),
),
const SizedBox(height: Spacing.md),
Text(
l10n.connectionIssueTitle,
textAlign: TextAlign.center,
style: context.conduitTheme.headingMedium?.copyWith(
fontWeight: FontWeight.w700,
color: context.conduitTheme.textPrimary,
),
),
if (statusText != null) ...[
const SizedBox(height: Spacing.xs),
Text(
statusText,
textAlign: TextAlign.center,
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
],
);
}
Widget _buildServerDetails(BuildContext context, ServerConfig server) {
final host = _resolveHost(server);
return Column(
children: [
Text(
host,
textAlign: TextAlign.center,
style: context.conduitTheme.bodyMedium?.copyWith(
color: context.conduitTheme.textPrimary,
fontFamily: 'monospace',
),
),
const SizedBox(height: Spacing.xs),
Text(
server.url,
textAlign: TextAlign.center,
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
],
);
}
Widget _buildActions(BuildContext context, AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ConduitButton(
text: l10n.retry,
onPressed: _isRetrying || _isLoggingOut ? null : () => _retry(l10n),
isLoading: _isRetrying,
icon: Platform.isIOS
? CupertinoIcons.refresh
: Icons.refresh_rounded,
isFullWidth: true,
),
const SizedBox(height: Spacing.sm),
ConduitButton(
text: l10n.signOut,
onPressed: _isRetrying || _isLoggingOut
? null
: () => _logout(l10n),
isLoading: _isLoggingOut,
isSecondary: true,
icon: Platform.isIOS
? CupertinoIcons.arrow_turn_up_left
: Icons.logout,
isFullWidth: true,
isCompact: true,
),
],
),
);
}
Widget _buildStatusMessage(BuildContext context, String message) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
child: Text(
message,
textAlign: TextAlign.center,
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
);
}
Future<void> _retry(AppLocalizations l10n) async {
setState(() {
_isRetrying = true;
_statusMessage = null;
});
try {
final service = ref.read(connectivityServiceProvider);
final isOnline = await service.checkConnectivity();
if (!mounted) return;
if (isOnline) {
await ref.read(authActionsProvider).refresh();
} else {
setState(() {
_statusMessage = l10n.stillOfflineMessage;
});
}
} catch (_) {
if (!mounted) return;
setState(() {
_statusMessage = l10n.couldNotConnectGeneric;
});
} finally {
if (mounted) {
setState(() {
_isRetrying = false;
});
}
}
}
Future<void> _logout(AppLocalizations l10n) async {
setState(() {
_isLoggingOut = true;
_statusMessage = null;
});
try {
await ref.read(authActionsProvider).logout();
} catch (_) {
if (!mounted) return;
setState(() {
_statusMessage = l10n.couldNotConnectGeneric;
});
} finally {
if (mounted) {
setState(() {
_isLoggingOut = false;
});
}
}
}
String _resolveHost(ServerConfig? config) {
final url = config?.url;
if (url == null || url.isEmpty) {
return 'Open WebUI';
}
try {
final uri = Uri.parse(url);
if (uri.host.isNotEmpty) {
return uri.host;
}
return url;
} catch (_) {
return url;
}
}
String? _statusLabel(ConnectivityStatus? status, AppLocalizations l10n) {
switch (status) {
case ConnectivityStatus.online:
return l10n.connectedToServer;
case ConnectivityStatus.offline:
return l10n.pleaseCheckConnection;
case ConnectivityStatus.checking:
case null:
return null;
}
}
}

View File

@@ -1158,8 +1158,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
backgroundColor: context.conduitTheme.surfaceBackground,
// Left navigation drawer with draggable edge open (native, finger-following)
drawerEnableOpenDragGesture: true,
drawerDragStartBehavior: DragStartBehavior.down,
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.5,
drawerDragStartBehavior: DragStartBehavior.start,
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.75,
drawerScrimColor: Colors.black.withValues(alpha: 0.32),
drawer: Drawer(
width: (MediaQuery.of(context).size.width * 0.80).clamp(
@@ -1167,7 +1167,13 @@ class _ChatPageState extends ConsumerState<ChatPage> {
420.0,
),
backgroundColor: context.conduitTheme.surfaceBackground,
child: const SafeArea(child: ChatsDrawer()),
child: SafeArea(
top: true,
bottom: true,
left: false,
right: false,
child: const ChatsDrawer(),
),
),
appBar: AppBar(
backgroundColor: context.conduitTheme.surfaceBackground,

View File

@@ -149,8 +149,8 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Spacing.md,
0,
Spacing.inputPadding,
Spacing.sm,
Spacing.md,
Spacing.sm,
),

View File

@@ -8,6 +8,18 @@
"loadingProfile": "Profil wird geladen...",
"unableToLoadProfile": "Profil konnte nicht geladen werden",
"pleaseCheckConnection": "Bitte überprüfe deine Verbindung und versuche es erneut",
"connectionIssueTitle": "Server nicht erreichbar",
"@connectionIssueTitle": {
"description": "Titel, wenn der konfigurierte Server nicht erreichbar ist"
},
"connectionIssueSubtitle": "Verbindung wiederherstellen oder abmelden, um einen anderen Server zu wählen.",
"@connectionIssueSubtitle": {
"description": "Untertitel mit den verfügbaren Aktionen, wenn der Server nicht erreichbar ist"
},
"stillOfflineMessage": "Der Server ist weiterhin nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.",
"@stillOfflineMessage": {
"description": "Statusnachricht nach einem erneuten Versuch ohne wiederhergestellte Verbindung"
},
"account": "Konto",
"signOut": "Abmelden",
"endYourSession": "Sitzung beenden",
@@ -129,7 +141,6 @@
"invalidImageFormat": "Ungültiges Bildformat",
"emptyImageData": "Leere Bilddaten"
,
"offlineBanner": "Du bist offline. Einige Funktionen sind eingeschränkt.",
"featureRequiresInternet": "Diese Funktion erfordert eine Internetverbindung",
"messagesWillSendWhenOnline": "Nachrichten werden gesendet, sobald du wieder online bist",
"confirm": "Bestätigen",

View File

@@ -15,6 +15,18 @@
"unableToLoadProfile": "Unable to load profile",
"@unableToLoadProfile": {"description": "Error title shown when profile request fails."},
"pleaseCheckConnection": "Please check your connection and try again",
"connectionIssueTitle": "Can't reach your server",
"@connectionIssueTitle": {
"description": "Title shown when the configured server is unreachable"
},
"connectionIssueSubtitle": "Reconnect to continue or sign out to choose a different server.",
"@connectionIssueSubtitle": {
"description": "Subtitle explaining available actions when the server cannot be reached"
},
"stillOfflineMessage": "We still can't reach the server. Double-check your connection and try again.",
"@stillOfflineMessage": {
"description": "Status message after a retry when connectivity has not been restored"
},
"@pleaseCheckConnection": {"description": "Generic connectivity hint after an error."},
"account": "Account",
"@account": {"description": "Section header for account-related options."},
@@ -282,8 +294,6 @@
"emptyImageData": "Empty image data",
"@emptyImageData": {"description": "Error when image data buffer is empty."}
,
"offlineBanner": "You're offline. Some features may be limited.",
"@offlineBanner": {"description": "Banner warning when device is offline."},
"featureRequiresInternet": "This feature requires an internet connection",
"@featureRequiresInternet": {"description": "Informational text explaining internet requirement."},
"messagesWillSendWhenOnline": "Messages will be sent when you're back online",

View File

@@ -8,6 +8,18 @@
"loadingProfile": "Chargement du profil...",
"unableToLoadProfile": "Impossible de charger le profil",
"pleaseCheckConnection": "Veuillez vérifier votre connexion et réessayer",
"connectionIssueTitle": "Impossible d'atteindre votre serveur",
"@connectionIssueTitle": {
"description": "Titre affiché lorsque le serveur configuré est injoignable"
},
"connectionIssueSubtitle": "Reconnectez-vous pour continuer ou déconnectez-vous pour choisir un autre serveur.",
"@connectionIssueSubtitle": {
"description": "Sous-titre expliquant les actions possibles quand le serveur est injoignable"
},
"stillOfflineMessage": "Nous ne pouvons toujours pas joindre le serveur. Vérifiez votre connexion et réessayez.",
"@stillOfflineMessage": {
"description": "Message d'état après une tentative de reconnexion sans succès"
},
"account": "Compte",
"signOut": "Se déconnecter",
"endYourSession": "Terminer votre session",
@@ -129,7 +141,6 @@
"invalidImageFormat": "Format d'image invalide",
"emptyImageData": "Données d'image vides"
,
"offlineBanner": "Vous êtes hors ligne. Certaines fonctions peuvent être limitées.",
"featureRequiresInternet": "Cette fonctionnalité nécessite une connexion Internet",
"messagesWillSendWhenOnline": "Les messages seront envoyés lorsque vous serez de nouveau en ligne",
"confirm": "Confirmer",

View File

@@ -8,6 +8,18 @@
"loadingProfile": "Caricamento profilo...",
"unableToLoadProfile": "Impossibile caricare il profilo",
"pleaseCheckConnection": "Controlla la connessione e riprova",
"connectionIssueTitle": "Impossibile raggiungere il server",
"@connectionIssueTitle": {
"description": "Titolo mostrato quando il server configurato non è raggiungibile"
},
"connectionIssueSubtitle": "Riconnettiti per continuare oppure esci per scegliere un server diverso.",
"@connectionIssueSubtitle": {
"description": "Sottotitolo che spiega le azioni disponibili quando il server non è raggiungibile"
},
"stillOfflineMessage": "Non riusciamo ancora a raggiungere il server. Controlla la connessione e riprova.",
"@stillOfflineMessage": {
"description": "Messaggio di stato dopo un tentativo di riconnessione senza successo"
},
"account": "Account",
"signOut": "Esci",
"endYourSession": "Termina la sessione",
@@ -129,7 +141,6 @@
"invalidImageFormat": "Formato immagine non valido",
"emptyImageData": "Dati immagine vuoti"
,
"offlineBanner": "Sei offline. Alcune funzioni potrebbero essere limitate.",
"featureRequiresInternet": "Questa funzione richiede una connessione Internet",
"messagesWillSendWhenOnline": "I messaggi verranno inviati quando tornerai online",
"confirm": "Conferma",

View File

@@ -150,6 +150,24 @@ abstract class AppLocalizations {
/// **'Please check your connection and try again'**
String get pleaseCheckConnection;
/// Title shown when the configured server is unreachable
///
/// In en, this message translates to:
/// **'Can\'t reach your server'**
String get connectionIssueTitle;
/// Subtitle explaining available actions when the server cannot be reached
///
/// In en, this message translates to:
/// **'Reconnect to continue or sign out to choose a different server.'**
String get connectionIssueSubtitle;
/// Status message after a retry when connectivity has not been restored
///
/// In en, this message translates to:
/// **'We still can\'t reach the server. Double-check your connection and try again.'**
String get stillOfflineMessage;
/// Section header for account-related options.
///
/// In en, this message translates to:
@@ -864,12 +882,6 @@ abstract class AppLocalizations {
/// **'Empty image data'**
String get emptyImageData;
/// Banner warning when device is offline.
///
/// In en, this message translates to:
/// **'You\'re offline. Some features may be limited.'**
String get offlineBanner;
/// Informational text explaining internet requirement.
///
/// In en, this message translates to:

View File

@@ -33,6 +33,17 @@ class AppLocalizationsDe extends AppLocalizations {
String get pleaseCheckConnection =>
'Bitte überprüfe deine Verbindung und versuche es erneut';
@override
String get connectionIssueTitle => 'Server nicht erreichbar';
@override
String get connectionIssueSubtitle =>
'Verbindung wiederherstellen oder abmelden, um einen anderen Server zu wählen.';
@override
String get stillOfflineMessage =>
'Der Server ist weiterhin nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.';
@override
String get account => 'Konto';
@@ -434,10 +445,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get emptyImageData => 'Leere Bilddaten';
@override
String get offlineBanner =>
'Du bist offline. Einige Funktionen sind eingeschränkt.';
@override
String get featureRequiresInternet =>
'Diese Funktion erfordert eine Internetverbindung';

View File

@@ -33,6 +33,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get pleaseCheckConnection =>
'Please check your connection and try again';
@override
String get connectionIssueTitle => 'Can\'t reach your server';
@override
String get connectionIssueSubtitle =>
'Reconnect to continue or sign out to choose a different server.';
@override
String get stillOfflineMessage =>
'We still can\'t reach the server. Double-check your connection and try again.';
@override
String get account => 'Account';
@@ -430,9 +441,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get emptyImageData => 'Empty image data';
@override
String get offlineBanner => 'You\'re offline. Some features may be limited.';
@override
String get featureRequiresInternet =>
'This feature requires an internet connection';

View File

@@ -33,6 +33,17 @@ class AppLocalizationsFr extends AppLocalizations {
String get pleaseCheckConnection =>
'Veuillez vérifier votre connexion et réessayer';
@override
String get connectionIssueTitle => 'Impossible d\'atteindre votre serveur';
@override
String get connectionIssueSubtitle =>
'Reconnectez-vous pour continuer ou déconnectez-vous pour choisir un autre serveur.';
@override
String get stillOfflineMessage =>
'Nous ne pouvons toujours pas joindre le serveur. Vérifiez votre connexion et réessayez.';
@override
String get account => 'Compte';
@@ -439,10 +450,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get emptyImageData => 'Données d\'image vides';
@override
String get offlineBanner =>
'Vous êtes hors ligne. Certaines fonctions peuvent être limitées.';
@override
String get featureRequiresInternet =>
'Cette fonctionnalité nécessite une connexion Internet';

View File

@@ -32,6 +32,17 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get pleaseCheckConnection => 'Controlla la connessione e riprova';
@override
String get connectionIssueTitle => 'Impossibile raggiungere il server';
@override
String get connectionIssueSubtitle =>
'Riconnettiti per continuare oppure esci per scegliere un server diverso.';
@override
String get stillOfflineMessage =>
'Non riusciamo ancora a raggiungere il server. Controlla la connessione e riprova.';
@override
String get account => 'Account';
@@ -431,10 +442,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get emptyImageData => 'Dati immagine vuoti';
@override
String get offlineBanner =>
'Sei offline. Alcune funzioni potrebbero essere limitate.';
@override
String get featureRequiresInternet =>
'Questa funzione richiede una connessione Internet';

View File

@@ -535,13 +535,13 @@ class ConduitThemeExtension extends ThemeExtension<ConduitThemeExtension> {
buttonDisabledText: AppTheme.neutral400,
// Status and feedback colors - Enhanced visibility
success: Color(0xFF22C55E),
success: Color(0xFF34D399),
successBackground: Color(0xFF14532D),
error: Color(0xFFEF4444),
error: Color(0xFFFCA5A5),
errorBackground: Color(0xFF7F1D1D),
warning: Color(0xFFF59E0B),
warningBackground: Color(0xFF7C2D12),
info: Color(0xFF38BDF8),
warning: Color(0xFFFBBF24),
warningBackground: Color(0xFF451A03),
info: Color(0xFF93C5FD),
infoBackground: Color(0xFF0C4A6E),
// Navigation and UI element colors - Enhanced contrast
@@ -549,7 +549,7 @@ class ConduitThemeExtension extends ThemeExtension<ConduitThemeExtension> {
navigationBackground: Color(0xFF0A0D0C),
navigationSelected: AppTheme.brandPrimary,
navigationUnselected: AppTheme.neutral300,
navigationSelectedBackground: AppTheme.brandPrimary,
navigationSelectedBackground: Color(0xFF312E81),
// Loading and animation colors - Enhanced visibility
shimmerBase: Color(0xFF121514),
@@ -660,21 +660,21 @@ class ConduitThemeExtension extends ThemeExtension<ConduitThemeExtension> {
buttonDisabledText: AppTheme.neutral500,
// Status and feedback colors - Enhanced visibility
success: Color(0xFF16A34A),
successBackground: Color(0xFFEFFBF3),
error: Color(0xFFDC2626),
errorBackground: Color(0xFFFDECEC),
warning: Color(0xFFD97706),
warningBackground: Color(0xFFFEF6E7),
info: Color(0xFF0284C7),
infoBackground: Color(0xFFE8F4FD),
success: Color(0xFF166534),
successBackground: Color(0xFFECFDF3),
error: Color(0xFFB91C1C),
errorBackground: Color(0xFFFEE2E2),
warning: Color(0xFF92400E),
warningBackground: Color(0xFFFEF3C7),
info: Color(0xFF1D4ED8),
infoBackground: Color(0xFFDBEAFE),
// Navigation and UI element colors - Enhanced contrast
dividerColor: AppTheme.neutral100,
navigationBackground: AppTheme.neutral50,
navigationSelected: AppTheme.brandPrimary,
navigationUnselected: AppTheme.neutral600,
navigationSelectedBackground: AppTheme.brandPrimary,
navigationSelectedBackground: Color(0xFFE0E7FF),
// Loading and animation colors - Enhanced visibility
shimmerBase: Color(0xFFF3F4F6),

View File

@@ -15,13 +15,8 @@ part 'offline_indicator.g.dart';
class OfflineIndicator extends ConsumerWidget {
final Widget child;
final bool showBanner;
const OfflineIndicator({
super.key,
required this.child,
this.showBanner = true,
});
const OfflineIndicator({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -33,26 +28,22 @@ class OfflineIndicator extends ConsumerWidget {
orElse: () => false,
);
return Stack(
children: [
child,
if (showBanner)
connectivityStatus.when(
final overlay = connectivityStatus.when(
data: (status) {
if (status == ConnectivityStatus.offline || socketOffline) {
return _OfflineBanner();
if ((status == ConnectivityStatus.offline || socketOffline) &&
!wasOffline) {
return const SizedBox.shrink();
}
// Announce back-online briefly if we were previously offline
if (wasOffline) {
return const _BackOnlineToast();
}
return const SizedBox.shrink();
},
loading: () => const SizedBox.shrink(),
error: (_, _) => _OfflineBanner(),
),
],
error: (unusedError, unusedStackTrace) => const SizedBox.shrink(),
);
return Stack(children: [child, overlay]);
}
}
@@ -137,65 +128,6 @@ class _BackOnlineToast extends StatelessWidget {
}
}
class _OfflineBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
bottom: false,
child:
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())
.slideY(
begin: -1,
end: 0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
),
),
);
}
}
// Inline offline indicator for specific features
class InlineOfflineIndicator extends ConsumerWidget {
final String message;
@@ -217,16 +149,20 @@ class InlineOfflineIndicator extends ConsumerWidget {
return const SizedBox.shrink();
}
final theme = context.conduitTheme;
final surfaceColor = backgroundColor ?? theme.warningBackground;
final borderAlpha = Theme.of(context).brightness == Brightness.dark
? 0.45
: 0.3;
return Container(
margin: const EdgeInsets.all(Spacing.md),
padding: const EdgeInsets.all(Spacing.md),
decoration: BoxDecoration(
color:
backgroundColor ??
context.conduitTheme.warning.withValues(alpha: 0.1),
color: surfaceColor,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: context.conduitTheme.warning.withValues(alpha: 0.3),
color: theme.warning.withValues(alpha: borderAlpha),
width: BorderWidth.regular,
),
),
@@ -235,7 +171,7 @@ class InlineOfflineIndicator extends ConsumerWidget {
Icon(
icon ??
(Platform.isIOS ? CupertinoIcons.wifi_slash : Icons.wifi_off),
color: context.conduitTheme.warning,
color: theme.warning,
size: Spacing.lg,
),
const SizedBox(width: Spacing.xs),
@@ -245,7 +181,7 @@ class InlineOfflineIndicator extends ConsumerWidget {
? message
: AppLocalizations.of(context)!.featureRequiresInternet,
style: TextStyle(
color: context.conduitTheme.warning,
color: theme.warning,
fontSize: AppTypography.labelLarge,
fontWeight: FontWeight.w500,
),
@@ -299,16 +235,22 @@ class ChatOfflineOverlay extends ConsumerWidget {
return const SizedBox.shrink();
}
final theme = context.conduitTheme;
final surfaceColor = theme.warningBackground;
final borderAlpha = Theme.of(context).brightness == Brightness.dark
? 0.5
: 0.35;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
decoration: BoxDecoration(
color: context.conduitTheme.warning.withValues(alpha: 0.2),
color: surfaceColor,
border: Border(
top: BorderSide(
color: context.conduitTheme.warning.withValues(alpha: 0.5),
color: theme.warning.withValues(alpha: borderAlpha),
width: BorderWidth.regular,
),
),
@@ -318,14 +260,14 @@ class ChatOfflineOverlay extends ConsumerWidget {
children: [
Icon(
Platform.isIOS ? CupertinoIcons.wifi_slash : Icons.wifi_off,
color: context.conduitTheme.warning,
color: theme.warning,
size: Spacing.md,
),
const SizedBox(width: Spacing.sm),
Text(
AppLocalizations.of(context)!.messagesWillSendWhenOnline,
style: TextStyle(
color: context.conduitTheme.warning,
color: theme.warning,
fontSize: AppTypography.bodySmall,
fontWeight: FontWeight.w500,
),