connectivity-banner-solution
Глобальный баннер отсутствия интернета во Flutter/FlutterFlow
Описание решения
Реализация глобального баннера, который отображается поверх всего приложения при потере интернет-соединения. Решение разработано для проектов на FlutterFlow, но подходит для любого Flutter-приложения.
Особенности
- Баннер отображается на любом экране приложения
- Автоматически появляется при потере связи и исчезает при восстановлении
- Учитывает режим офлайн-работы - если пользователь сам выбрал работу без интернета, баннер не показывается
- Использует паттерн Singleton для сервиса
- Не требует изменений в каждой странице приложения
- Учитывает SafeArea (notch, gesture bar на iPhone)
Архитектура
┌─────────────────────────────────────────────────────────────┐
│ Stack │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ MaterialApp.router │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ Все страницы │ │ │
│ │ │ приложения │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ NoInternetBanner │ │
│ │ (показывается только при потере сети) │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Логика отображения баннера
FFAppState.isOnline == false → Баннер НЕ показывается
(пользователь сам выбрал офлайн-режим)
FFAppState.isOnline == true → Проверяем реальное подключение:
├── Есть интернет → Баннер НЕ показывается
└── Нет интернета → Баннер ПОКАЗЫВАЕТСЯ
Требования
Зависимости в pubspec.yaml
dependencies:
connectivity_plus: ^7.0.0
provider: ^6.1.5
Полный код решения
Файл: lib/custom_code/ConnectivityService.dart
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '/app_state.dart';
/// ============================================================================
/// ConnectivityService - синглтон-сервис для отслеживания состояния сети
/// ============================================================================
/// Этот сервис использует пакет connectivity_plus для мониторинга
/// подключения к интернету. Он предоставляет:
/// - Stream для подписки на изменения состояния сети
/// - Текущее состояние подключения через геттер isConnected
/// ============================================================================
class ConnectivityService {
/// Приватный конструктор для реализации паттерна Singleton
ConnectivityService._internal();
/// Единственный экземпляр сервиса (Singleton)
static final ConnectivityService _instance = ConnectivityService._internal();
/// Фабричный конструктор возвращает единственный экземпляр
factory ConnectivityService() => _instance;
/// Экземпляр Connectivity из пакета connectivity_plus
final Connectivity _connectivity = Connectivity();
/// StreamController для трансляции изменений состояния сети
/// broadcast() позволяет иметь несколько подписчиков
final StreamController<bool> _connectionController =
StreamController<bool>.broadcast();
/// Публичный Stream для подписки на изменения состояния сети
Stream<bool> get connectionStream => _connectionController.stream;
/// Текущее состояние подключения (true = есть интернет)
bool _isConnected = true;
/// Геттер для получения текущего состояния подключения
bool get isConnected => _isConnected;
/// Флаг инициализации, чтобы не инициализировать повторно
bool _isInitialized = false;
/// Подписка на изменения connectivity_plus
StreamSubscription<List<ConnectivityResult>>? _subscription;
/// ============================================================================
/// Инициализация сервиса
/// ============================================================================
/// Вызывается один раз при запуске приложения.
/// Проверяет начальное состояние сети и подписывается на изменения.
/// ============================================================================
Future<void> initialize() async {
/// Защита от повторной инициализации
if (_isInitialized) return;
_isInitialized = true;
/// Проверяем начальное состояние сети
try {
final result = await _connectivity.checkConnectivity();
_updateConnectionStatus(result);
} catch (e) {
/// В случае ошибки считаем что сети нет
_isConnected = false;
_connectionController.add(false);
}
/// Подписываемся на изменения состояния сети
_subscription = _connectivity.onConnectivityChanged.listen(
_updateConnectionStatus,
onError: (error) {
/// При ошибке в стриме считаем что сети нет
_isConnected = false;
_connectionController.add(false);
},
);
}
/// ============================================================================
/// Обновление состояния подключения
/// ============================================================================
/// Вызывается при каждом изменении состояния сети.
/// Параметр results - список текущих типов подключения.
/// ConnectivityResult.none означает отсутствие подключения.
/// ============================================================================
void _updateConnectionStatus(List<ConnectivityResult> results) {
/// Проверяем есть ли хоть одно активное подключение
/// (WiFi, Mobile, Ethernet и т.д.)
final wasConnected = _isConnected;
_isConnected = results.any((result) => result != ConnectivityResult.none);
/// Отправляем в стрим только если состояние изменилось
/// Это предотвращает лишние перерисовки UI
if (wasConnected != _isConnected) {
_connectionController.add(_isConnected);
}
}
/// ============================================================================
/// Освобождение ресурсов
/// ============================================================================
/// Вызывается при закрытии приложения (обычно не требуется для синглтона)
/// ============================================================================
void dispose() {
_subscription?.cancel();
_connectionController.close();
}
}
/// ============================================================================
/// NoInternetBanner - виджет баннера об отсутствии интернета
/// ============================================================================
/// Отображается внизу экрана когда нет подключения к сети.
/// Использует StreamBuilder для реактивного обновления UI.
///
/// ВАЖНО: Баннер показывается ТОЛЬКО если:
/// - Нет реального подключения к интернету (ConnectivityService)
/// - И пользователь НЕ перешёл в офлайн-режим (FFAppState.isOnline == true)
///
/// Если пользователь сам выбрал офлайн-режим (isOnline == false),
/// баннер НЕ показывается - пользователь знает что работает без сети.
/// ============================================================================
class NoInternetBanner extends StatelessWidget {
const NoInternetBanner({Key? key}) : super(key: key);
Widget build(BuildContext context) {
/// Получаем экземпляр сервиса (синглтон)
final connectivityService = ConnectivityService();
/// Consumer подписывается на изменения FFAppState
/// и перестраивает виджет когда isOnline меняется
return Consumer<FFAppState>(
builder: (context, appState, child) {
/// Если пользователь сам выбрал офлайн-режим - не показываем баннер
/// Он знает что работает без интернета
if (!appState.isOnline) {
return const SizedBox.shrink();
}
/// Если пользователь в онлайн-режиме - проверяем реальное подключение
return StreamBuilder<bool>(
/// Подписываемся на стрим изменений состояния сети
stream: connectivityService.connectionStream,
/// Начальное значение берём из текущего состояния сервиса
initialData: connectivityService.isConnected,
builder: (context, snapshot) {
/// Если есть интернет - ничего не показываем
final hasConnection = snapshot.data ?? true;
if (hasConnection) {
return const SizedBox.shrink();
}
/// Если нет интернета - показываем баннер внизу экрана
return Positioned(
left: 0,
right: 0,
bottom: 0,
/// SafeArea учитывает системные отступы (gesture bar на iPhone и т.д.)
child: SafeArea(
top: false,
child: Material(
/// Красный фон для привлечения внимания
color: Colors.red.shade700,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
/// Иконка отсутствия WiFi
Icon(
Icons.wifi_off,
color: Colors.white,
size: 20,
),
SizedBox(width: 8),
/// Текст сообщения
Text(
'Нет подключения к интернету',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
);
},
);
},
);
}
}
Файл: lib/main.dart
import 'dart:ui' as ui;
import '/custom_code/actions/index.dart' as actions;
import 'package:provider/provider.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'auth/supabase_auth/supabase_user_provider.dart';
import 'auth/supabase_auth/auth_util.dart';
import '/backend/supabase/supabase.dart';
import '/backend/sqlite/sqlite_manager.dart';
import 'flutter_flow/flutter_flow_util.dart';
import 'flutter_flow/internationalization.dart';
/// Импорт сервиса отслеживания подключения к интернету
import '/custom_code/ConnectivityService.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
GoRouter.optionURLReflectsImperativeAPIs = true;
usePathUrlStrategy();
final environmentValues = FFDevEnvironmentValues();
await environmentValues.initialize();
// Start initial custom actions code
await actions.lockOrientation();
// End initial custom actions code
await SupaFlow.initialize();
await SQLiteManager.initialize();
/// ============================================================================
/// Инициализация сервиса отслеживания подключения к интернету
/// ============================================================================
/// Важно вызвать до runApp(), чтобы сервис начал слушать изменения сети
/// сразу при запуске приложения
/// ============================================================================
await ConnectivityService().initialize();
final appState = FFAppState(); // Initialize FFAppState
await appState.initializePersistedState();
runApp(ChangeNotifierProvider(
create: (context) => appState,
child: MyApp(),
));
}
class MyApp extends StatefulWidget {
State<MyApp> createState() => _MyAppState();
static _MyAppState of(BuildContext context) =>
context.findAncestorStateOfType<_MyAppState>()!;
}
class MyAppScrollBehavior extends MaterialScrollBehavior {
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
};
}
class _MyAppState extends State<MyApp> {
Locale? _locale;
ThemeMode _themeMode = ThemeMode.system;
late AppStateNotifier _appStateNotifier;
late GoRouter _router;
late Stream<BaseAuthUser> userStream;
void initState() {
super.initState();
_appStateNotifier = AppStateNotifier.instance;
_router = createRouter(_appStateNotifier);
userStream = hoofMasterSupabaseUserStream()
..listen((user) {
_appStateNotifier.update(user);
});
jwtTokenStream.listen((_) {});
Future.delayed(
Duration(milliseconds: 1000),
() => _appStateNotifier.stopShowingSplashImage(),
);
}
void setLocale(String language) {
safeSetState(() => _locale = createLocale(language));
}
void setThemeMode(ThemeMode mode) => safeSetState(() {
_themeMode = mode;
});
Widget build(BuildContext context) {
/// ========================================================================
/// Stack оборачивает MaterialApp.router для отображения баннера
/// об отсутствии интернета поверх всего приложения
/// ========================================================================
/// Directionality нужен потому что Stack находится выше MaterialApp,
/// и без него виджеты не знают направление текста (LTR/RTL)
/// ========================================================================
return Directionality(
textDirection: ui.TextDirection.ltr,
child: Stack(
children: [
/// Основное приложение
MaterialApp.router(
debugShowCheckedModeBanner: false,
title: 'vetMaster',
scrollBehavior: MyAppScrollBehavior(),
localizationsDelegates: [
FFLocalizationsDelegate(),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
FallbackMaterialLocalizationDelegate(),
FallbackCupertinoLocalizationDelegate(),
],
locale: _locale,
supportedLocales: const [
Locale('ru'),
],
theme: ThemeData(
brightness: Brightness.light,
useMaterial3: false,
),
themeMode: _themeMode,
routerConfig: _router,
),
/// ====================================================================
/// Баннер об отсутствии интернета
/// ====================================================================
/// NoInternetBanner сам отслеживает состояние сети через
/// ConnectivityService и показывается только когда нет подключения.
/// Positioned внутри NoInternetBanner размещает его внизу экрана.
/// ====================================================================
const NoInternetBanner(),
],
),
);
}
}
Инструкция по интеграции
Шаг 1: Добавить зависимость
В pubspec.yaml добавить (если ещё нет):
dependencies:
connectivity_plus: ^7.0.0
Выполнить:
flutter pub get
Шаг 2: Создать файл сервиса
Создать файл lib/custom_code/ConnectivityService.dart с кодом выше.
Шаг 3: Модифицировать main.dart
- Добавить импорт:
import '/custom_code/ConnectivityService.dart';
- В функции
main()добавить инициализацию:
await ConnectivityService().initialize();
- В методе
build()класса_MyAppStateобернутьMaterialApp.routerвStack:
return Directionality(
textDirection: ui.TextDirection.ltr,
child: Stack(
children: [
MaterialApp.router(...),
const NoInternetBanner(),
],
),
);
Шаг 4: Проверить работу
- Запустить приложение
- Включить режим "В самолёте" или отключить WiFi
- Баннер должен появиться внизу экрана
- Включить интернет обратно - баннер должен исчезнуть
Кастомизация
Изменить позицию баннера (сверху)
В NoInternetBanner изменить Positioned:
return Positioned(
left: 0,
right: 0,
top: 0, // вместо bottom: 0
child: SafeArea(
bottom: false, // вместо top: false
// ...
),
);
Изменить внешний вид
Material(
color: Colors.orange, // другой цвет
elevation: 4, // добавить тень
child: Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.cloud_off, color: Colors.white),
SizedBox(width: 12),
Expanded(
child: Text(
'Проверьте подключение к интернету',
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
],
),
),
)
Добавить анимацию появления
Обернуть в AnimatedSwitcher или использовать AnimatedPositioned:
return AnimatedPositioned(
duration: Duration(milliseconds: 300),
left: 0,
right: 0,
bottom: hasConnection ? -100 : 0, // анимация снизу вверх
child: SafeArea(...),
);
Ограничения
-
connectivity_plus определяет только наличие WiFi/Mobile подключения, но не гарантирует реальный доступ в интернет (например, WiFi без интернета не будет обнаружен как проблема)
-
Баннер находится под модальными диалогами (
showDialog,showModalBottomSheet), так как они используютOverlay
Автор
Решение разработано для проекта Hoof Master (vetMaster).
Дата: Январь 2026