multilevel_navigator 1.0.0 copy "multilevel_navigator: ^1.0.0" to clipboard
multilevel_navigator: ^1.0.0 copied to clipboard

Мультиуровневая адаптивная навигация для Flutter поверх go_router: 4 уровня, автоподбор колонок под экран, resizable-колонки, модалки и сохранение состояния.

📱 Multilevel Navigator #

Адаптивная навигационная система для Flutter с поддержкой 4 уровней навигации

Библиотека для создания адаптивных многоуровневых интерфейсов (list → detail → detail → detail) с автоматической подстройкой количества видимых колонок под ширину экрана. Построена поверх go_router.


✨ Возможности #

  • 4-уровневая навигация (level1 — панель навигации, level2 — список, level3/level4 — детали) с автоматическим расчётом видимости уровней под ширину экрана
  • Resizable-колонки — пользователь тянет разделитель и меняет ширину колонки (SplitPane); можно задать и начальную ширину колонки per-фича
  • Сворачивание level2/level3/level4 — по повторному тапу на активную фичу (Level2CollapseMode) или на мобильных при уходе с уровня (MobileLevelCollapseMode), с сохранением состояния виджета/BLoC
  • Сохранение состояния:
    • запоминает полный URI каждой фичи при переключении между фичами
    • сохраняет виджеты и BLoC между показом/скрытием уровня (PersistentBlocProvider)
  • Модальные окна через query-параметр ?modal=id, с раздельными билдерами под платформу (mobile/desktop/web) и ширину экрана (dialog/bottom sheet)
  • Система табов с автоматической синхронизацией активного таба с URL
  • Авторизация — разделение фич на требующие и не требующие авторизации, редиректы через AuthenticationStateNotifier
  • Dependency Injection — DI-контейнер поверх InheritedWidget, скрытый за MultilevelNavigatorProvider

🏗️ Архитектура #

Структура библиотеки: #

lib/
├── core/                           # Ядро
│   ├── cache/                      # LRU-кэш (внутренний механизм, не публичный API — см. ниже)
│   ├── contracts/                  # AuthenticationStateNotifier, RouterParams
│   ├── models/                     # Level, DisplayMode, VisibilityMap, FeatureMultilevelRules, Level2CollapseMode, MobileLevelCollapseMode
│   ├── providers/                  # PersistentBlocProvider
│   ├── utils/                      # MultilevelDimensions, NavigationUtils, NavigationStorage, PathUtils
│   └── validators/                 # Валидация конфигурации фич
│
├── features/                       # Фичи пакета (Clean Architecture)
│   ├── multilevel_layout/            # Расчёт layout: MultilevelBloc, LevelsWidthBloc, SplitPane
│   ├── modal_system/                # Модальные окна через query-параметр `modal`
│   └── navigation/                 # Табы, back button handler, mixins
│
├── dependency_injection/           # MultilevelNavigatorDependencies (InheritedWidget)
├── configuration/                  # FeatureNavigationConfiguration, FeatureLevelItemConfiguration
├── router/                         # MultilevelNavigator, построение GoRouter
├── extensions/                     # context.goNamed/pop/openModal/switchToFeature/...
└── multilevel_navigator.dart         # Public API (barrel-файл)

🚀 Быстрый старт #

1. Установка #

dependencies:
  multilevel_navigator: ^1.0.0

2. Создание приложения #

import 'package:multilevel_navigator/multilevel_navigator.dart';
import 'package:flutter/material.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Порядок важен: сначала неавторизованные фичи, потом авторизованные
  final features = [
    AuthenticationNavigationConfiguration(),
    ChatsNavigationConfiguration(),
    CallsNavigationConfiguration(),
    ProfileNavigationConfiguration(),
  ];

  final multilevelNavigator = MultilevelNavigator(
    features: features,
    authenticationStateNotifier: AuthenticationStateNotifierImpl(authRepository),
    // navigationPanelBuilder обязателен — библиотека не рисует level1 "из коробки",
    // приложение само строит панель навигации (sidebar/bottom nav) для переданного
    // StatefulNavigationShell.
    navigationPanelBuilder: (navigationShell) => NavigationPanel(navigationShell: navigationShell),
    header: null,   // опциональный header над контентом
    overlay: null,  // опциональный overlay поверх всего layout
  );

  runApp(
    MultilevelNavigatorProvider(
      multilevelNavigator: multilevelNavigator,
      child: MaterialApp.router(
        title: 'My App',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true),
        routerConfig: multilevelNavigator.router,
      ),
    ),
  );
}

Рабочий пример со всеми фичами (chats, calls, profile, authentication, guest) лежит в example/.


📋 Конфигурация фичи #

Каждая фича приложения реализует FeatureNavigationConfiguration:

final class ChatsNavigationConfiguration implements FeatureNavigationConfiguration {
  const ChatsNavigationConfiguration();

  @override
  String? get initialLocation => null; // опциональный начальный путь фичи

  @override
  bool get isVisibleInNavigation => true;

  @override
  bool get requiresAuthentication => true;

  @override
  String name(BuildContext context) => 'Чаты'; // метод, а не геттер — можно локализовать через context

  @override
  String get key => 'chats'; // уникальный ключ фичи (используется в switchToFeature и т.п.)

  @override
  IconData get emoji => Icons.chat;

  @override
  String get basePath => 'chats';

  @override
  FeatureLevelItemConfiguration get level2 => FeatureLevelItemConfiguration(
    path: '',
    name: 'chats_level2',
    builder: (context) => const ChatsLevel2Page(),
    placeholderBuilder: (context) => const ChatLevel2Placeholder(),
  );

  @override
  List<FeatureLevelItemConfiguration> get level3 => [
    FeatureLevelItemConfiguration(
      path: ':chatId',
      name: 'chats_level3_messages',
      builder: (context) => ChatsLevel3MessagesPage(chatId: _chatId(context)),
      placeholderBuilder: (context) => const ChatLevel3Placeholder(),
      parentName: 'chats_level2',
    ),
  ];

  @override
  List<FeatureLevelItemConfiguration> get level4 => const [];

  @override
  List<MultilevelModalBuilderAbstraction> get modals => [ModalCall(), ModalVideo()];

  // Провайдеры, доступные из BuildContext во всех уровнях (level2-4) фичи.
  // При implements (в отличие от extends) этот геттер обязателен.
  @override
  List<SingleChildWidget> get providers => const [];
}

Остальные члены контракта имеют значения по умолчанию (переопределяйте по необходимости):

Член Назначение
defaultLevel2Path путь level2 фичи, по умолчанию /$basePath
rules FeatureMultilevelRules — какие уровни видны при какой глубине навигации в каждом из режимов колонок (по умолчанию FeatureMultilevelDefaultRules)
collapseMode Level2CollapseMode.remove (убрать level2 из дерева) или .hide (скрыть, ширина 0, сохранив state level3/level4) при повторном тапе на активную фичу
collapseAnimationDuration длительность анимации сворачивания level2 (только для .hide)
level3CollapseMode / level4CollapseMode MobileLevelCollapseMode.preserve (по умолчанию, состояние сохраняется) или .discard (обычная навигация назад) при сворачивании level3/level4 на мобильных
initialLevel2Width / initialLevel3Width / initialLevel4Width начальная ширина колонки в пикселях при первом построении layout фичи (до ручного resize); для level3 действует как минимально гарантированная ширина

FeatureLevelItemConfiguration (level2/level3/level4-элемент): path, name (уникальное имя роута), builder, placeholderBuilder (рендерится, когда уровень скрыт по правилам), parentName (связывает уровень с родительским), providers (доступны только в поддереве этого уровня).


🧩 Адаптивность: уровни, брейкпоинты, колонки #

Видимость уровней определяется количеством колонок, которое умещается в ширину экрана (MultilevelDimensions, lib/core/utils/multilevel_dimensions.dart):

level1Width = 50     // панель навигации
level2Width = 420
level3MinWidth = 750 // level3 — гибкая колонка, тянет оставшееся место, это минимум
level4Width = 250

widthForTwoColumns   = 800
widthForThreeColumns = level1Width + level2Width + level3MinWidth   // = 1220
widthForFourColumns  = widthForThreeColumns + level4Width           // = 1470

Пороги трёх- и четырёхколоночного режима вычисляются из ширин колонок, а не заданы отдельными константами — изменение level2Width сдвигает и порог перехода на 3 колонки.

Фактический режим отображения — DisplayMode (5 значений, а не просто «N колонок»):

  • floatingPanel — узкий экран, level1 показан как плавающая панель
  • singleColumn — одна колонка на весь экран
  • singleColumnWithBottomNav — одна колонка + нижняя навигация
  • fixedMultiColumn — несколько колонок фиксированной ширины
  • resizableMultiColumn — несколько колонок с ручным resize (SplitPane)

Какие именно уровни (и плейсхолдеры) видны при данном режиме и текущей глубине навигации (1–4), решает FeatureMultilevelRules.{single,two,three,four}ColumnVisibility(activeDepth) — фича может переопределить rules, чтобы задать собственную матрицу видимости вместо FeatureMultilevelDefaultRules.


💾 Сохранение состояния #

  • URI фичи запоминается при переключении между фичами и восстанавливается при возврате (NavigationStorage, используется автоматически в context.switchToFeature).
  • BLoC уровня, который может временно скрываться по адаптивным правилам, сохраняется через PersistentBlocProvider вместо обычного BlocProvider:
FeatureLevelItemConfiguration(
  path: ':chatId',
  name: 'chats_level3_messages',
  builder: (context) => const ChatsLevel3MessagesPage(),
  placeholderBuilder: (context) => const ChatLevel3Placeholder(),
  providers: [
    PersistentBlocProvider<ChatMessagesBloc>(
      cacheKey: 'chat_messages_${GoRouterState.of(context).pathParameters['chatId']}',
      create: (context) => ChatMessagesBloc()..add(LoadMessages()),
    ),
  ],
)

Инстанс BLoC живёт в кэше на уровне фичи (FeatureBlocCacheScope) и переживает скрытие/показ уровня — состояние не сбрасывается, пока сама фича смонтирована.


🔐 Авторизация #

class AuthenticationStateNotifierImpl extends AuthenticationStateNotifier {
  AuthenticationStateNotifierImpl(this._authRepository) {
    _subscription = _authRepository.authStateStream.listen((_) => notifyListeners());
  }

  final AuthRepository _authRepository;
  StreamSubscription? _subscription;

  @override
  Future<bool> get isAuthenticated => _authRepository.isAuthenticated();

  @override
  Future<bool> get isGuest async => false; // опционально: гостевой режим

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }
}

AuthenticationStateNotifier — это ChangeNotifier с двумя геттерами (isAuthenticated, isGuest), а не с методами. Фичи с requiresAuthentication: false (например, экран логина или гостевой режим) доступны без авторизации; при true — роутер редиректит неавторизованного пользователя. Можно завести любое количество неавторизованных фич.


🪟 Модальные окна #

Модалки открываются через query-параметр ?modal=id и рендерятся как dialog или bottom sheet — раздельно для мобильных/десктопа/веба и для одно-/многоколоночного режима. Рекомендуемый API — MultilevelModalPlatformBuilder:

class ModalCall implements MultilevelModalPlatformBuilder {
  @override
  String get id => 'call';

  @override
  bool get isDismissible => true;

  @override
  PlatformModalBuilders? get mobile => PlatformModalBuilders(
    singleColumn: PlatformWidthModalBuilders(
      bottomSheetBuilder: (context) => const CallBottomSheet(),
    ),
    multiColumn: PlatformWidthModalBuilders(
      dialogBuilder: (context) => const CallDialog(),
    ),
  );

  @override
  PlatformModalBuilders get desktop => PlatformModalBuilders(
    singleColumn: PlatformWidthModalBuilders(dialogBuilder: (context) => const CallDialog()),
    multiColumn: PlatformWidthModalBuilders(dialogBuilder: (context) => const CallDialog()),
  );

  @override
  PlatformModalBuilders? get web => desktop;
}

Регистрируется в modals фичи (см. пример выше) и открывается/закрывается через контекст:

context.openModal('call');
context.closeModal('call');

// с ожиданием результата
final result = await context.openModalAsync<String>('call');
context.closeModalWithResult('call', 'done');

// с произвольными аргументами вместо query-параметров
context.openModalWithArgs('call', callId);
final callId = context.readModalArgs<String>('call'); // внутри билдера модалки

Старый интерфейс MultilevelModalBuilder (единый dialogBuilder/bottomSheetBuilder без разделения по платформе) помечен @Deprecated — используйте MultilevelModalPlatformBuilder в новом коде.


🗂️ Табы #

TabManagerConfiguration автогенерирует уникальное имя query-параметра из типа страницы (например, ChatsLevel2Page → chats_level2_page_tab), так что несколько независимых наборов табов на разных уровнях не конфликтуют:

class ChatsLevel2Page extends StatefulWidget {
  const ChatsLevel2Page({super.key});
  @override
  State<ChatsLevel2Page> createState() => _ChatsLevel2PageState();
}

class _ChatsLevel2PageState extends State<ChatsLevel2Page>
    with TickerProviderStateMixin, TabControllerMixin {
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    initializeTabController(
      TabManagerConfiguration(
        ChatsLevel2Page,
        tabs: const [
          TabConfiguration(name: 'all', title: 'Все', icon: Icons.chat, content: AllChatsTab()),
          TabConfiguration(name: 'unread', title: 'Непрочитанные', icon: Icons.mark_chat_unread, content: UnreadChatsTab()),
        ],
      ),
    );
    syncTabControllerWithQuery();
  }

  @override
  Widget build(BuildContext context) {
    syncTabControllerWithQuery();
    return MultilevelTabContent(
      tabController: tabController,
      configuration: configuration!,
      onTabChanged: switchToTab,
    );
  }
}

🧭 Навигация из кода #

Основные методы доступны как расширения на BuildContext (сохраняют текущие query-параметры/extra/фрагмент при переходе):

context.goNamed('chats_level3_messages', pathParameters: {'chatId': id});
context.pop(level: 3);                 // закрыть level3 и всё, что выше
context.switchToFeature(ChatsNavigationConfiguration()); // с восстановлением сохранённого URI фичи

context.toggleLevel2();                // свернуть/развернуть level2 (Level2CollapseMode.hide)
context.collapseMobileLevel3();        // свернуть level3 на мобильном, сохранив state (или .discard — см. level3CollapseMode)
context.expandMobileLevel3();

⚙️ Внутреннее устройство (не публичный API) #

  • DI — MultilevelNavigatorDependencies (InheritedWidget) собирает MultilevelBloc, back-button handler и другие зависимости; полностью скрыт за MultilevelNavigatorProvider, приложению обращаться к нему напрямую не нужно.
  • Кэш-менеджер (core/cache/*, LRU с TTL/статистикой) используется внутри MultilevelBloc и SplitPaneCalculator для кэширования вычислений видимости/layout/сохранённых ширин колонок. Это внутренняя инфраструктура пакета — классы CacheManagerImpl/CacheKeys/CacheNames не экспортируются из package:multilevel_navigator/multilevel_navigator.dart и не предназначены для использования в коде приложения.

🧪 Тестирование #

Тестами покрыто ядро адаптивности:

test/
├── core/cache/cache_manager_test.dart
├── core/utils/navigation_utils_test.dart
├── core/utils/path_utils_test.dart
├── features/multilevel_layout/application/bloc/multilevel_bloc_test.dart
├── features/multilevel_layout/presentation/widgets/split/split_pane_test.dart
└── router/feature_configuration_resolver_test.dart

Модальная система, табы, DI-контейнер и PersistentBlocProvider тестами пока не покрыты.


📜 История изменений #

Актуальный список изменений по версиям — в CHANGELOG.md.

1
likes
120
points
58
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Мультиуровневая адаптивная навигация для Flutter поверх go_router: 4 уровня, автоподбор колонок под экран, resizable-колонки, модалки и сохранение состояния.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

bloc_concurrency, collection, equatable, flutter, flutter_bloc, go_router, meta, provider, rxdart

More

Packages that depend on multilevel_navigator