Files
umbrix/lib/features/common/adaptive_root_scaffold.dart
2026-01-15 12:28:40 +03:00

373 lines
13 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:hiddify/gen/assets.gen.dart';
import 'package:hiddify/core/localization/translations.dart';
import 'package:hiddify/core/router/router.dart';
import 'package:hiddify/features/stats/widget/side_bar_stats_overview.dart';
import 'package:hiddify/core/router/routes.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hiddify/core/theme/theme_preferences.dart';
import 'package:hiddify/core/theme/app_theme_mode.dart';
import 'package:hiddify/core/localization/locale_preferences.dart';
import 'package:hiddify/core/localization/locale_extensions.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 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: useMobileRouter ? (3, null) : (0, null),
bottomDestinationRange: (0, 3),
useBottomSheet: useMobileRouter,
sidebarTrailing: const Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: SideBarStatsOverview(),
),
),
body: 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: Breakpoints.small.isActive(context)
? 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 _DrawerThemeItem(),
const _DrawerLanguageItem(),
const _DrawerLicensesItem(),
],
),
),
],
),
)
: null,
body: AdaptiveLayout(
primaryNavigation: SlotLayout(
config: <Breakpoint, SlotLayoutConfig>{
Breakpoints.medium: SlotLayout.from(
key: const Key('primaryNavigation'),
builder: (_) => AdaptiveScaffold.standardNavigationRail(
selectedIndex: selectedIndex,
destinations: destinations.map((dest) => AdaptiveScaffold.toRailDestination(dest)).toList(),
onDestinationSelected: onSelectedIndexChange,
),
),
Breakpoints.large: SlotLayout.from(
key: const Key('primaryNavigation1'),
builder: (_) => AdaptiveScaffold.standardNavigationRail(
extended: true,
selectedIndex: selectedIndex,
destinations: destinations.map((dest) => AdaptiveScaffold.toRailDestination(dest)).toList(),
onDestinationSelected: onSelectedIndexChange,
trailing: sidebarTrailing,
),
),
},
),
body: SlotLayout(
config: <Breakpoint, SlotLayoutConfig?>{
Breakpoints.standard: SlotLayout.from(
key: const Key('body'),
inAnimation: AdaptiveScaffold.fadeIn,
outAnimation: AdaptiveScaffold.fadeOut,
builder: (context) => body,
),
},
),
),
// AdaptiveLayout bottom sheet has accessibility issues
bottomNavigationBar: useBottomSheet && Breakpoints.small.isActive(context)
? NavigationBar(
selectedIndex: selectedWithOffset(bottomDestinationRange) ?? 0,
destinations: destinationsSlice(bottomDestinationRange),
onDestinationSelected: (index) => selectWithOffset(index, bottomDestinationRange),
)
: null,
);
}
}
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);
},
);
}
}