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

  1. Добавить импорт:
import '/custom_code/ConnectivityService.dart';
  1. В функции main() добавить инициализацию:
await ConnectivityService().initialize();
  1. В методе build() класса _MyAppState обернуть MaterialApp.router в Stack:
return Directionality(
  textDirection: ui.TextDirection.ltr,
  child: Stack(
    children: [
      MaterialApp.router(...),
      const NoInternetBanner(),
    ],
  ),
);

Шаг 4: Проверить работу

  1. Запустить приложение
  2. Включить режим "В самолёте" или отключить WiFi
  3. Баннер должен появиться внизу экрана
  4. Включить интернет обратно - баннер должен исчезнуть

Кастомизация

Изменить позицию баннера (сверху)

В 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(...),
);

Ограничения

  1. connectivity_plus определяет только наличие WiFi/Mobile подключения, но не гарантирует реальный доступ в интернет (например, WiFi без интернета не будет обнаружен как проблема)

  2. Баннер находится под модальными диалогами (showDialog, showModalBottomSheet), так как они используют Overlay


Автор

Решение разработано для проекта Hoof Master (vetMaster).

Дата: Январь 2026