Files
umbrix/lib/features/common/adaptive_root_scaffold.dart
Umbrix Developer 58cce2e83c
Some checks failed
CI / run (push) Has been cancelled
Upload store MSIX to release / upload-store-msix-to-release (push) Has been cancelled
v1.7.9: Bug report system with Telegram integration - stable state before auto-diagnostics
2026-01-22 14:16:14 +03:00

402 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/material.dart';
import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
import 'package:go_router/go_router.dart';
import 'package:umbrix/gen/assets.gen.dart';
import 'package:umbrix/core/localization/translations.dart';
import 'package:umbrix/core/router/router.dart';
import 'package:umbrix/features/stats/widget/side_bar_stats_overview.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:umbrix/core/theme/theme_preferences.dart';
import 'package:umbrix/core/theme/app_theme_mode.dart';
import 'package:umbrix/core/localization/locale_preferences.dart';
import 'package:umbrix/core/localization/locale_extensions.dart';
import 'package:umbrix/utils/utils.dart';
import 'package:umbrix/features/bug_report/widget/bug_report_dialog.dart';
abstract interface class RootScaffold {
static final stateKey = GlobalKey<ScaffoldState>();
static bool canShowDrawer(BuildContext context) => Breakpoints.small.isActive(context);
}
class AdaptiveRootScaffold extends HookConsumerWidget {
const AdaptiveRootScaffold(this.navigator, {super.key});
final Widget navigator;
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
final interceptBackToHome = !PlatformUtils.isDesktop;
final proxiesLocation = const ProxiesRoute().location;
final perAppProxyLocation = const PerAppProxyRoute().location;
final selectedIndex = getCurrentIndex(context);
final destinations = [
NavigationDestination(
icon: const Icon(FluentIcons.home_20_regular),
selectedIcon: const Icon(FluentIcons.home_20_filled),
label: t.home.pageTitle,
),
NavigationDestination(
icon: const Icon(FluentIcons.list_20_regular),
selectedIcon: const Icon(FluentIcons.list_20_filled),
label: t.proxies.pageTitle,
),
NavigationDestination(
icon: const Icon(FluentIcons.more_vertical_20_regular),
selectedIcon: const Icon(FluentIcons.more_vertical_20_filled),
label: t.settings.network.excludedDomains.pageTitle,
),
NavigationDestination(
icon: const Icon(FluentIcons.settings_20_filled),
label: t.settings.pageTitle,
),
NavigationDestination(
icon: const Icon(FluentIcons.info_20_regular),
selectedIcon: const Icon(FluentIcons.info_20_filled),
label: t.about.pageTitle,
),
];
return _CustomAdaptiveScaffold(
selectedIndex: selectedIndex,
onSelectedIndexChange: (index) {
RootScaffold.stateKey.currentState?.closeDrawer();
switchTab(index, context);
},
destinations: destinations,
drawerDestinationRange: (3, null), // Настройки и О программе всегда в drawer
bottomDestinationRange: (0, 3), // Первые 3 пункта в bottom nav
useBottomSheet: useMobileRouter,
sidebarTrailing: const Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: SideBarStatsOverview(),
),
),
body: BackButtonListener(
onBackButtonPressed: () async {
if (!interceptBackToHome) return false;
final location = GoRouterState.of(context).uri.path;
final shouldGoHome = location.startsWith(proxiesLocation) || location.startsWith(perAppProxyLocation);
assert(() {
debugPrint(
'BACK_INTERCEPT AdaptiveRootScaffold location=$location shouldGoHome=$shouldGoHome',
);
return true;
}());
if (shouldGoHome) {
const HomeRoute().go(context);
return true;
}
return false;
},
child: navigator,
),
);
}
}
class _CustomAdaptiveScaffold extends HookConsumerWidget {
const _CustomAdaptiveScaffold({
required this.selectedIndex,
required this.onSelectedIndexChange,
required this.destinations,
required this.drawerDestinationRange,
required this.bottomDestinationRange,
this.useBottomSheet = false,
this.sidebarTrailing,
required this.body,
});
final int selectedIndex;
final Function(int) onSelectedIndexChange;
final List<NavigationDestination> destinations;
final (int, int?) drawerDestinationRange;
final (int, int?) bottomDestinationRange;
final bool useBottomSheet;
final Widget? sidebarTrailing;
final Widget body;
List<NavigationDestination> destinationsSlice((int, int?) range) => destinations.sublist(range.$1, range.$2);
int? selectedWithOffset((int, int?) range) {
final index = selectedIndex - range.$1;
return index < 0 || (range.$2 != null && index > (range.$2! - 1)) ? null : index;
}
void selectWithOffset(int index, (int, int?) range) => onSelectedIndexChange(index + range.$1);
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
key: RootScaffold.stateKey,
drawer: Drawer(
width: (MediaQuery.sizeOf(context).width * 0.88).clamp(1, 304),
child: Column(
children: [
// Логотип и название приложения
Container(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
children: [
Container(
width: 80,
height: 80,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.primaryContainer,
),
child: Assets.images.umbrixLogo.image(
fit: BoxFit.contain,
),
),
const SizedBox(height: 16),
Text(
'Umbrix',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
// Список пунктов меню
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
// О программе
Builder(
builder: (context) {
final t = ref.watch(translationsProvider);
return _DrawerMenuItem(
icon: FluentIcons.info_24_regular,
selectedIcon: FluentIcons.info_24_filled,
label: t.about.pageTitle,
isSelected: false,
onTap: () {
RootScaffold.stateKey.currentState?.closeDrawer();
const AboutRoute().push(context);
},
);
},
),
// Настройки
Builder(
builder: (context) {
final t = ref.watch(translationsProvider);
return _DrawerMenuItem(
icon: FluentIcons.settings_24_regular,
selectedIcon: FluentIcons.settings_24_filled,
label: t.settings.pageTitle,
isSelected: false,
onTap: () {
RootScaffold.stateKey.currentState?.closeDrawer();
const SettingsRoute().push(context);
},
);
},
),
const SizedBox(height: 16),
const Divider(),
const _DrawerBugReportItem(),
const _DrawerThemeItem(),
const _DrawerLanguageItem(),
const _DrawerLicensesItem(),
],
),
),
],
),
),
body: AdaptiveLayout(
primaryNavigation: SlotLayout(
config: <Breakpoint, SlotLayoutConfig>{
// Убираем боковую навигацию для Desktop
},
),
body: SlotLayout(
config: <Breakpoint, SlotLayoutConfig?>{
Breakpoints.standard: SlotLayout.from(
key: const Key('body'),
inAnimation: AdaptiveScaffold.fadeIn,
outAnimation: AdaptiveScaffold.fadeOut,
builder: (context) => body,
),
},
),
),
// Нижняя навигация - первые 3 пункта для всех платформ
bottomNavigationBar: NavigationBar(
selectedIndex: selectedWithOffset(bottomDestinationRange) ?? 0,
destinations: destinationsSlice(bottomDestinationRange),
onDestinationSelected: (index) => selectWithOffset(index, bottomDestinationRange),
),
);
}
}
class _DrawerMenuItem extends StatelessWidget {
const _DrawerMenuItem({
required this.icon,
required this.selectedIcon,
required this.label,
required this.isSelected,
required this.onTap,
});
final IconData icon;
final IconData selectedIcon;
final String label;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: ListTile(
leading: Icon(
isSelected ? selectedIcon : icon,
size: 24,
),
title: Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
selected: isSelected,
selectedTileColor: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
),
);
}
}
// Виджет для выбора темы в боковом меню
class _DrawerThemeItem extends ConsumerWidget {
const _DrawerThemeItem();
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
final themeMode = ref.watch(themePreferencesProvider);
return ListTile(
leading: const Icon(FluentIcons.weather_moon_20_regular, size: 24),
title: Text(t.settings.general.themeMode),
subtitle: Text(themeMode.present(t)),
onTap: () async {
final selectedThemeMode = await showDialog<AppThemeMode>(
context: context,
builder: (context) => SimpleDialog(
title: Text(t.settings.general.themeMode),
children: AppThemeMode.values
.map((e) => RadioListTile(
title: Text(e.present(t)),
value: e,
groupValue: themeMode,
onChanged: Navigator.of(context).maybePop,
))
.toList(),
),
);
if (selectedThemeMode != null) {
await ref.read(themePreferencesProvider.notifier).changeThemeMode(selectedThemeMode);
}
},
);
}
}
// Виджет для выбора языка в боковом меню
class _DrawerLanguageItem extends ConsumerWidget {
const _DrawerLanguageItem();
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
final locale = ref.watch(localePreferencesProvider);
return ListTile(
leading: const Icon(FluentIcons.local_language_24_regular, size: 24),
title: Text(t.settings.general.locale),
subtitle: Text(locale.localeName),
onTap: () async {
final selectedLocale = await showDialog<AppLocale>(
context: context,
builder: (context) => SimpleDialog(
title: Text(t.settings.general.locale),
children: AppLocale.values
.map((e) => RadioListTile(
title: Text(e.localeName),
value: e,
groupValue: locale,
onChanged: Navigator.of(context).maybePop,
))
.toList(),
),
);
if (selectedLocale != null) {
await ref.read(localePreferencesProvider.notifier).changeLocale(selectedLocale);
}
},
);
}
}
// Виджет для открытия лицензий
class _DrawerLicensesItem extends ConsumerWidget {
const _DrawerLicensesItem();
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
return ListTile(
leading: const Icon(FluentIcons.document_text_20_regular, size: 24),
title: Text(MaterialLocalizations.of(context).licensesPageTitle),
onTap: () {
showLicensePage(context: context);
},
);
}
}
// Виджет для отправки багрепорта
class _DrawerBugReportItem extends ConsumerWidget {
const _DrawerBugReportItem();
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
return ListTile(
leading: const Icon(FluentIcons.bug_20_regular, size: 24),
title: Text(t.bugReport.title),
subtitle: Text(
t.bugReport.description,
style: Theme.of(context).textTheme.bodySmall,
),
onTap: () {
RootScaffold.stateKey.currentState?.closeDrawer();
BugReportDialog.show(context);
},
);
}
}