Files
umbrix/lib/features/common/adaptive_root_scaffold.dart
Umbrix Developer 43ab81e8d1 fix: icon permissions and GTK single instance
- Use GTK default flags for single instance
- Fix icon path to absolute /usr/share/icons
- Add postinstall chmod 644 for icon
- Remove Dart-level single instance code
2026-01-17 20:10:04 +03:00

377 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: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';
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 _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);
},
);
}
}