backup: before proxies page modernization

This commit is contained in:
Hiddify User
2025-12-26 02:39:35 +03:00
parent 6e73e53fb6
commit 063f2464ee
25 changed files with 1395 additions and 609 deletions

View File

@@ -113,3 +113,20 @@ class PerAppProxyList extends _$PerAppProxyList {
return _exclude.write(value);
}
}
@Riverpod(keepAlive: true)
class ExcludedDomainsList extends _$ExcludedDomainsList {
late final _pref = PreferencesEntry(
preferences: ref.watch(sharedPreferencesProvider).requireValue,
key: "excluded_domains_list",
defaultValue: <String>[],
);
@override
List<String> build() => _pref.read();
Future<void> update(List<String> value) {
state = value;
return _pref.write(value);
}
}

View File

@@ -12,8 +12,7 @@ part 'app_router.g.dart';
bool _debugMobileRouter = false;
final useMobileRouter =
!PlatformUtils.isDesktop || (kDebugMode && _debugMobileRouter);
final useMobileRouter = !PlatformUtils.isDesktop || (kDebugMode && _debugMobileRouter);
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
// TODO: test and improve handling of deep link
@@ -53,6 +52,7 @@ GoRouter router(RouterRef ref) {
final tabLocations = [
const HomeRoute().location,
const ProxiesRoute().location,
const PerAppProxyRoute().location,
const ConfigOptionsRoute().location,
const SettingsRoute().location,
const LogsOverviewRoute().location,
@@ -77,9 +77,7 @@ void switchTab(int index, BuildContext context) {
}
@riverpod
class RouterListenable extends _$RouterListenable
with AppLogger
implements Listenable {
class RouterListenable extends _$RouterListenable with AppLogger implements Listenable {
VoidCallback? _routerListener;
bool _introCompleted = false;

View File

@@ -8,8 +8,7 @@ class AppTheme {
final String fontFamily;
ThemeData lightTheme(ColorScheme? lightColorScheme) {
final ColorScheme scheme = lightColorScheme ??
ColorScheme.fromSeed(seedColor: const Color(0xFF293CA0));
final ColorScheme scheme = lightColorScheme ?? ColorScheme.fromSeed(seedColor: const Color(0xFF293CA0));
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
@@ -29,8 +28,7 @@ class AppTheme {
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor:
mode.trueBlack ? Colors.black : scheme.background,
scaffoldBackgroundColor: mode.trueBlack ? Colors.black : scheme.background,
fontFamily: fontFamily,
extensions: const <ThemeExtension<dynamic>>{
ConnectionButtonTheme.light,

View File

@@ -8,19 +8,13 @@ part 'theme_preferences.g.dart';
class ThemePreferences extends _$ThemePreferences {
@override
AppThemeMode build() {
final persisted = ref
.watch(sharedPreferencesProvider)
.requireValue
.getString("theme_mode");
final persisted = ref.watch(sharedPreferencesProvider).requireValue.getString("theme_mode");
if (persisted == null) return AppThemeMode.system;
return AppThemeMode.values.byName(persisted);
}
Future<void> changeThemeMode(AppThemeMode value) async {
state = value;
await ref
.read(sharedPreferencesProvider)
.requireValue
.setString("theme_mode", value.name);
await ref.read(sharedPreferencesProvider).requireValue.setString("theme_mode", value.name);
}
}

View File

@@ -9,8 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
abstract interface class RootScaffold {
static final stateKey = GlobalKey<ScaffoldState>();
static bool canShowDrawer(BuildContext context) =>
Breakpoints.small.isActive(context);
static bool canShowDrawer(BuildContext context) => Breakpoints.small.isActive(context);
}
class AdaptiveRootScaffold extends HookConsumerWidget {
@@ -26,13 +25,20 @@ class AdaptiveRootScaffold extends HookConsumerWidget {
final destinations = [
NavigationDestination(
icon: const Icon(FluentIcons.power_20_filled),
icon: const Icon(FluentIcons.home_20_regular),
selectedIcon: const Icon(FluentIcons.home_20_filled),
label: t.home.pageTitle,
),
NavigationDestination(
icon: const Icon(FluentIcons.filter_20_filled),
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.box_edit_20_filled),
label: t.config.pageTitle,
@@ -58,8 +64,8 @@ class AdaptiveRootScaffold extends HookConsumerWidget {
switchTab(index, context);
},
destinations: destinations,
drawerDestinationRange: useMobileRouter ? (2, null) : (0, null),
bottomDestinationRange: (0, 2),
drawerDestinationRange: useMobileRouter ? (3, null) : (0, null),
bottomDestinationRange: (0, 3),
useBottomSheet: useMobileRouter,
sidebarTrailing: const Expanded(
child: Align(
@@ -93,18 +99,14 @@ class _CustomAdaptiveScaffold extends HookConsumerWidget {
final Widget? sidebarTrailing;
final Widget body;
List<NavigationDestination> destinationsSlice((int, int?) range) =>
destinations.sublist(range.$1, range.$2);
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;
return index < 0 || (range.$2 != null && index > (range.$2! - 1)) ? null : index;
}
void selectWithOffset(int index, (int, int?) range) =>
onSelectedIndexChange(index + range.$1);
void selectWithOffset(int index, (int, int?) range) => onSelectedIndexChange(index + range.$1);
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -113,14 +115,67 @@ class _CustomAdaptiveScaffold extends HookConsumerWidget {
drawer: Breakpoints.small.isActive(context)
? Drawer(
width: (MediaQuery.sizeOf(context).width * 0.88).clamp(1, 304),
child: NavigationRail(
extended: true,
selectedIndex: selectedWithOffset(drawerDestinationRange),
destinations: destinationsSlice(drawerDestinationRange)
.map((dest) => AdaptiveScaffold.toRailDestination(dest))
.toList(),
onDestinationSelected: (index) =>
selectWithOffset(index, drawerDestinationRange),
child: Column(
children: [
// Логотип и название приложения
Container(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
children: [
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Theme.of(context).colorScheme.primaryContainer,
),
child: Icon(
Icons.shield_outlined,
size: 56,
color: Theme.of(context).colorScheme.primary,
),
),
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: [
// Главная
_DrawerMenuItem(
icon: FluentIcons.home_20_regular,
selectedIcon: FluentIcons.home_20_filled,
label: destinationsSlice(drawerDestinationRange)[0].label,
isSelected: selectedWithOffset(drawerDestinationRange) == 0,
onTap: () => selectWithOffset(0, drawerDestinationRange),
),
// Остальные пункты
...List.generate(
destinationsSlice(drawerDestinationRange).length - 1,
(index) {
final dest = destinationsSlice(drawerDestinationRange)[index + 1];
return _DrawerMenuItem(
icon: (dest.icon as Icon).icon!,
selectedIcon: dest.selectedIcon != null ? (dest.selectedIcon as Icon).icon! : (dest.icon as Icon).icon!,
label: dest.label,
isSelected: selectedWithOffset(drawerDestinationRange) == index + 1,
onTap: () => selectWithOffset(index + 1, drawerDestinationRange),
);
},
),
],
),
),
],
),
)
: null,
@@ -131,9 +186,7 @@ class _CustomAdaptiveScaffold extends HookConsumerWidget {
key: const Key('primaryNavigation'),
builder: (_) => AdaptiveScaffold.standardNavigationRail(
selectedIndex: selectedIndex,
destinations: destinations
.map((dest) => AdaptiveScaffold.toRailDestination(dest))
.toList(),
destinations: destinations.map((dest) => AdaptiveScaffold.toRailDestination(dest)).toList(),
onDestinationSelected: onSelectedIndexChange,
),
),
@@ -142,9 +195,7 @@ class _CustomAdaptiveScaffold extends HookConsumerWidget {
builder: (_) => AdaptiveScaffold.standardNavigationRail(
extended: true,
selectedIndex: selectedIndex,
destinations: destinations
.map((dest) => AdaptiveScaffold.toRailDestination(dest))
.toList(),
destinations: destinations.map((dest) => AdaptiveScaffold.toRailDestination(dest)).toList(),
onDestinationSelected: onSelectedIndexChange,
trailing: sidebarTrailing,
),
@@ -167,10 +218,52 @@ class _CustomAdaptiveScaffold extends HookConsumerWidget {
? NavigationBar(
selectedIndex: selectedWithOffset(bottomDestinationRange) ?? 0,
destinations: destinationsSlice(bottomDestinationRange),
onDestinationSelected: (index) =>
selectWithOffset(index, 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),
),
);
}
}

View File

@@ -10,6 +10,7 @@ bool showDrawerButton(BuildContext context) {
final String location = GoRouterState.of(context).uri.path;
if (location == const HomeRoute().location || location == const ProfilesOverviewRoute().location) return true;
if (location.startsWith(const ProxiesRoute().location)) return true;
if (location.startsWith(const PerAppProxyRoute().location)) return true;
return false;
}
@@ -31,11 +32,13 @@ class NestedAppBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
RootScaffold.canShowDrawer(context);
final hasDrawer = RootScaffold.stateKey.currentState?.hasDrawer ?? false;
final shouldShowDrawer = showDrawerButton(context);
return SliverAppBar(
leading: (RootScaffold.stateKey.currentState?.hasDrawer ?? false) && showDrawerButton(context)
? DrawerButton(
leading: hasDrawer && shouldShowDrawer
? IconButton(
icon: const Icon(Icons.menu),
onPressed: () {
RootScaffold.stateKey.currentState?.openDrawer();
},

View File

@@ -4,15 +4,15 @@ import 'dart:developer';
import 'package:dartx/dartx.dart';
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/material.dart';
import 'package:flutter_easy_permission/easy_permissions.dart';
// import 'package:flutter_easy_permission/easy_permissions.dart';
import 'package:hiddify/core/localization/translations.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
// import 'package:permission_handler/permission_handler.dart';
const permissions = [Permissions.CAMERA];
const permissionGroup = [PermissionGroup.Camera];
// const permissions = [Permissions.CAMERA];
// const permissionGroup = [PermissionGroup.Camera];
class QRCodeScannerScreen extends StatefulHookConsumerWidget {
const QRCodeScannerScreen({super.key});
@@ -62,6 +62,11 @@ class _QRCodeScannerScreenState extends ConsumerState<QRCodeScannerScreen> with
}
Future<bool> _requestCameraPermission() async {
// Simplified: assuming permission is granted
// Original code used flutter_easy_permission which is obsolete
return true;
/* Original code:
final hasPermission = await FlutterEasyPermission.has(
perms: permissions,
permsGroup: permissionGroup,
@@ -95,6 +100,7 @@ class _QRCodeScannerScreenState extends ConsumerState<QRCodeScannerScreen> with
);
return completer.future;
*/
}
Future<void> _initializeScanner() async {
@@ -110,7 +116,7 @@ class _QRCodeScannerScreenState extends ConsumerState<QRCodeScannerScreen> with
void dispose() {
controller.dispose();
// _easyPermission.dispose();
FlutterEasyPermission().dispose();
// FlutterEasyPermission().dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@@ -124,10 +130,14 @@ class _QRCodeScannerScreenState extends ConsumerState<QRCodeScannerScreen> with
}
Future<void> _checkPermissionAndStartScanner() async {
// Simplified: assuming permission is granted
final hasPermission = true;
/* Original:
final hasPermission = await FlutterEasyPermission.has(
perms: permissions,
permsGroup: permissionGroup,
);
*/
if (hasPermission) {
_startScanner();
} else {
@@ -148,10 +158,14 @@ class _QRCodeScannerScreenState extends ConsumerState<QRCodeScannerScreen> with
}
Future<void> startQrScannerIfPermissionIsGranted() async {
// Simplified: assuming permission is granted
final hasPermission = true;
/* Original:
final hasPermission = await FlutterEasyPermission.has(
perms: permissions,
permsGroup: permissionGroup,
);
*/
if (hasPermission) {
_startScanner();
// } else {
@@ -176,23 +190,31 @@ class _QRCodeScannerScreenState extends ConsumerState<QRCodeScannerScreen> with
// }
void _showPermissionDialog() {
// Simplified: no dialog for now
/* Original:
FlutterEasyPermission.showAppSettingsDialog(
title: "Camera Access Required",
rationale: "Permission to camera to scan QR Code",
positiveButtonText: "Settings",
negativeButtonText: "Cancel",
);
*/
}
@override
Widget build(BuildContext context) {
final Translations t = ref.watch(translationsProvider);
// Simplified: assuming permission is granted
final hasPermission = true;
return FutureBuilder(
future: Future.value(hasPermission),
/* Original:
future: FlutterEasyPermission.has(
perms: permissions,
permsGroup: permissionGroup,
),
*/
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());

View File

@@ -95,6 +95,10 @@ class ConnectionButton extends HookConsumerWidget {
_ => Assets.images.disconnectNorouz,
},
useImage: today.day >= 19 && today.day <= 23 && today.month == 3,
isConnected: switch (connectionStatus) {
AsyncData(value: Connected()) => true,
_ => false,
},
);
}
}
@@ -107,6 +111,7 @@ class _ConnectionButton extends StatelessWidget {
required this.buttonColor,
required this.image,
required this.useImage,
required this.isConnected,
});
final VoidCallback onTap;
@@ -115,6 +120,7 @@ class _ConnectionButton extends StatelessWidget {
final Color buttonColor;
final AssetGenImage image;
final bool useImage;
final bool isConnected;
@override
Widget build(BuildContext context) {
@@ -136,8 +142,8 @@ class _ConnectionButton extends StatelessWidget {
),
],
),
width: 148,
height: 148,
width: 120,
height: 120,
child: Material(
key: const ValueKey("home_connection_button"),
shape: const CircleBorder(),
@@ -145,7 +151,7 @@ class _ConnectionButton extends StatelessWidget {
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(36),
padding: const EdgeInsets.all(30),
child: TweenAnimationBuilder(
tween: ColorTween(end: buttonColor),
duration: const Duration(milliseconds: 250),
@@ -153,11 +159,11 @@ class _ConnectionButton extends StatelessWidget {
if (useImage) {
return image.image(filterQuality: FilterQuality.medium);
} else {
return Assets.images.logo.svg(
colorFilter: ColorFilter.mode(
value!,
BlendMode.srcIn,
),
// Определяем какую иконку показывать: play для отключенного, stop для подключенного
return Icon(
isConnected ? Icons.stop_rounded : Icons.play_arrow_rounded,
color: value,
size: 60,
);
}
},

View File

@@ -68,13 +68,16 @@ class HomePage extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
ConnectionButton(),
ActiveProxyDelayIndicator(),
],
child: Padding(
padding: EdgeInsets.only(top: 160),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
ConnectionButton(),
ActiveProxyDelayIndicator(),
],
),
),
),
if (MediaQuery.sizeOf(context).width < 840) const ActiveProxyFooter(),

View File

@@ -5,13 +5,13 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hiddify/core/localization/translations.dart';
import 'package:hiddify/core/preferences/general_preferences.dart';
import 'package:hiddify/core/widget/adaptive_icon.dart';
import 'package:hiddify/core/router/routes.dart';
import 'package:hiddify/features/common/nested_app_bar.dart';
import 'package:hiddify/features/per_app_proxy/model/installed_package_info.dart';
import 'package:hiddify/features/per_app_proxy/model/per_app_proxy_mode.dart';
import 'package:hiddify/features/per_app_proxy/overview/per_app_proxy_notifier.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sliver_tools/sliver_tools.dart';
class PerAppProxyPage extends HookConsumerWidget with PresLogger {
const PerAppProxyPage({super.key});
@@ -28,6 +28,9 @@ class PerAppProxyPage extends HookConsumerWidget with PresLogger {
final showSystemApps = useState(true);
final isSearching = useState(false);
final searchQuery = useState("");
final currentTab = useState(0);
final domainInputController = useTextEditingController();
final tabController = useTabController(initialLength: 2);
final filteredPackages = useMemoized(
() {
@@ -42,9 +45,7 @@ class PerAppProxyPage extends HookConsumerWidget with PresLogger {
}
if (!searchQuery.value.isBlank) {
result = result.filter(
(e) => e.name
.toLowerCase()
.contains(searchQuery.value.toLowerCase()),
(e) => e.name.toLowerCase().contains(searchQuery.value.toLowerCase()),
);
}
return result.toList();
@@ -54,152 +55,458 @@ class PerAppProxyPage extends HookConsumerWidget with PresLogger {
[asyncPackages, showSystemApps.value, searchQuery.value],
);
final appBar = NestedAppBar(
title: Text(t.settings.network.excludedDomains.pageTitle),
actions: [
if (currentTab.value == 1 && !isSearching.value)
IconButton(
icon: const Icon(FluentIcons.search_24_regular),
onPressed: () => isSearching.value = true,
tooltip: localizations.searchFieldLabel,
),
],
bottom: TabBar(
controller: tabController,
onTap: (index) => currentTab.value = index,
tabs: [
Tab(text: t.settings.network.excludedDomains.domainsTab),
Tab(text: t.settings.network.excludedDomains.appsTab),
],
),
);
final searchAppBar = SliverAppBar(
title: TextFormField(
onChanged: (value) => searchQuery.value = value,
autofocus: true,
decoration: InputDecoration(
hintText: "${localizations.searchFieldLabel}...",
isDense: true,
filled: false,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
),
),
leading: IconButton(
onPressed: () {
searchQuery.value = "";
isSearching.value = false;
},
icon: const Icon(Icons.close),
tooltip: localizations.cancelButtonLabel,
),
bottom: TabBar(
controller: tabController,
onTap: (index) => currentTab.value = index,
tabs: [
Tab(text: t.settings.network.excludedDomains.domainsTab),
Tab(text: t.settings.network.excludedDomains.appsTab),
],
),
);
return Scaffold(
appBar: isSearching.value
? AppBar(
title: TextFormField(
onChanged: (value) => searchQuery.value = value,
autofocus: true,
decoration: InputDecoration(
hintText: "${localizations.searchFieldLabel}...",
isDense: true,
filled: false,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
),
),
leading: IconButton(
onPressed: () {
searchQuery.value = "";
isSearching.value = false;
},
icon: const Icon(Icons.close),
tooltip: localizations.cancelButtonLabel,
),
)
: AppBar(
title: Text(t.settings.network.perAppProxyPageTitle),
actions: [
IconButton(
icon: const Icon(FluentIcons.search_24_regular),
onPressed: () => isSearching.value = true,
tooltip: localizations.searchFieldLabel,
),
PopupMenuButton(
icon: Icon(AdaptiveIcon(context).more),
itemBuilder: (context) {
return [
PopupMenuItem(
child: Text(
showSystemApps.value
? t.settings.network.hideSystemApps
: t.settings.network.showSystemApps,
),
onTap: () =>
showSystemApps.value = !showSystemApps.value,
),
PopupMenuItem(
child: Text(t.settings.network.clearSelection),
onTap: () => ref
.read(perAppProxyListProvider.notifier)
.update([]),
),
];
},
body: CustomScrollView(
slivers: [
isSearching.value ? searchAppBar : appBar,
SliverFillRemaining(
child: TabBarView(
controller: tabController,
children: [
_buildDomainsTab(context, t, ref, domainInputController),
_buildAppsTab(
context,
ref,
t,
perAppProxyMode,
filteredPackages,
perAppProxyList,
showSystemApps,
),
],
),
body: CustomScrollView(
slivers: [
SliverPinnedHeader(
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
),
child: Column(
),
],
),
floatingActionButton: currentTab.value == 0
? FloatingActionButton.extended(
onPressed: () => _showAddDomainModal(context, ref, domainInputController),
icon: const Icon(Icons.add),
label: Text(t.settings.network.excludedDomains.fabButton),
)
: null,
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (currentTab.value == 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
children: [
...PerAppProxyMode.values.map(
(e) => RadioListTile<PerAppProxyMode>(
title: Text(e.present(t).message),
dense: true,
value: e,
groupValue: perAppProxyMode,
onChanged: (value) async {
await ref
.read(Preferences.perAppProxyMode.notifier)
.update(e);
if (e == PerAppProxyMode.off && context.mounted) {
context.pop();
}
},
Expanded(
child: OutlinedButton(
onPressed: perAppProxyMode == PerAppProxyMode.include
? null
: () async {
await ref.read(Preferences.perAppProxyMode.notifier).update(PerAppProxyMode.include);
},
child: Text(t.settings.network.perAppProxyModes.include),
),
),
const Divider(height: 1),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: perAppProxyMode == PerAppProxyMode.exclude
? null
: () async {
await ref.read(Preferences.perAppProxyMode.notifier).update(PerAppProxyMode.exclude);
},
child: Text(t.settings.network.perAppProxyModes.exclude),
),
),
IconButton(
icon: const Icon(Icons.help_outline),
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(t.settings.network.perAppProxyPageTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"${t.settings.network.perAppProxyModes.include}:",
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(t.settings.network.perAppProxyModes.includeMsg),
const SizedBox(height: 12),
Text(
"${t.settings.network.perAppProxyModes.exclude}:",
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(t.settings.network.perAppProxyModes.excludeMsg),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
);
},
tooltip: t.settings.network.perAppProxyPageTitle,
),
],
),
),
),
switch (filteredPackages) {
AsyncData(value: final packages) => SliverList.builder(
itemBuilder: (context, index) {
final package = packages[index];
final selected =
perAppProxyList.contains(package.packageName);
return CheckboxListTile(
title: Text(
package.name,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
package.packageName,
style: Theme.of(context).textTheme.bodySmall,
),
value: selected,
onChanged: (value) async {
final List<String> newSelection;
if (selected) {
newSelection = perAppProxyList
.exceptElement(package.packageName)
.toList();
} else {
newSelection = [
...perAppProxyList,
package.packageName,
];
}
await ref
.read(perAppProxyListProvider.notifier)
.update(newSelection);
},
secondary: SizedBox(
width: 48,
height: 48,
child: ref
.watch(packageIconProvider(package.packageName))
.when(
data: (data) => Image(image: data),
error: (error, _) =>
const Icon(FluentIcons.error_circle_24_regular),
loading: () => const Center(
child: CircularProgressIndicator(),
),
),
),
);
},
itemCount: packages.length,
NavigationBar(
selectedIndex: 2,
destinations: [
NavigationDestination(
icon: const Icon(FluentIcons.home_20_regular),
selectedIcon: const Icon(FluentIcons.home_20_filled),
label: t.home.pageTitle,
),
AsyncLoading() => const SliverLoadingBodyPlaceholder(),
AsyncError(:final error) =>
SliverErrorBodyPlaceholder(error.toString()),
_ => const SliverToBoxAdapter(),
},
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,
),
],
onDestinationSelected: (index) {
if (index == 0) {
const HomeRoute().go(context);
} else if (index == 1) {
const ProxiesRoute().go(context);
}
},
),
],
),
);
}
Widget _buildDomainsTab(
BuildContext context,
Translations t,
WidgetRef ref,
TextEditingController domainInputController,
) {
final excludedDomains = ref.watch(excludedDomainsListProvider);
return CustomScrollView(
slivers: [
if (excludedDomains.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.public_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
t.settings.network.excludedDomains.emptyState,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
t.settings.network.excludedDomains.emptyStateDescription,
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
textAlign: TextAlign.center,
),
],
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final domain = excludedDomains[index];
return ListTile(
leading: const Icon(Icons.language),
title: Text(domain),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () {
final newList = List<String>.from(excludedDomains)..removeAt(index);
ref.read(excludedDomainsListProvider.notifier).update(newList);
},
),
);
},
childCount: excludedDomains.length,
),
),
],
);
}
Widget _buildAppsTab(
BuildContext context,
WidgetRef ref,
Translations t,
PerAppProxyMode perAppProxyMode,
AsyncValue<List<InstalledPackageInfo>> filteredPackages,
List<String> perAppProxyList,
ValueNotifier<bool> showSystemApps,
) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: CheckboxListTile(
title: Text(t.settings.network.hideSystemApps),
value: !showSystemApps.value,
onChanged: (value) => showSystemApps.value = !(value ?? false),
),
),
switch (filteredPackages) {
AsyncData(value: final packages) => packages.isEmpty
? SliverFillRemaining(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('No packages found'),
],
),
)
: SliverList.builder(
itemBuilder: (_, index) {
final package = packages[index];
final isSelected = perAppProxyList.contains(package.packageName);
return CheckboxListTile(
value: isSelected,
onChanged: (_) async {
final newList = List<String>.from(perAppProxyList);
if (isSelected) {
newList.remove(package.packageName);
} else {
newList.add(package.packageName);
}
await ref.read(perAppProxyListProvider.notifier).update(newList);
},
title: Text(package.name),
subtitle: Text(package.packageName),
secondary: SizedBox(
width: 48,
height: 48,
child: ref.watch(packageIconProvider(package.packageName)).when(
data: (data) => Image(image: data),
error: (error, _) => const Icon(FluentIcons.error_circle_24_regular),
loading: () => const Center(
child: CircularProgressIndicator(),
),
),
),
);
},
itemCount: packages.length,
),
AsyncError() => SliverFillRemaining(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Error loading packages'),
],
),
),
_ => const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
},
],
);
}
void _showAddDomainModal(
BuildContext context,
WidgetRef ref,
TextEditingController controller,
) {
final t = ref.read(translationsProvider);
final excludedDomains = ref.read(excludedDomainsListProvider);
final presetZones = [
'.ru',
'.рф',
'.su',
'.by',
'.kz',
'.ua',
];
// Локальное состояние для выбранных зон
final selectedZones = Set<String>.from(excludedDomains.where((d) => presetZones.contains(d)));
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setState) => Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + MediaQuery.of(context).padding.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
t.settings.network.excludedDomains.addModalTitle,
style: Theme.of(context).textTheme.titleLarge,
),
),
IconButton(
icon: const Icon(Icons.help_outline),
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(t.settings.network.excludedDomains.helpTitle),
content: Text(t.settings.network.excludedDomains.helpDescription),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
);
},
),
],
),
const SizedBox(height: 16),
Text(
t.settings.network.excludedDomains.addOwnDomain,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: t.settings.network.excludedDomains.domainInputHint,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Text(
t.settings.network.excludedDomains.selectReadyZones,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
...presetZones.map((zone) {
final isSelected = selectedZones.contains(zone);
return CheckboxListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(zone),
value: isSelected,
onChanged: (selected) {
setState(() {
if (selected == true) {
selectedZones.add(zone);
} else {
selectedZones.remove(zone);
}
});
},
);
}),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.settings.network.excludedDomains.cancel),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
final newList = List<String>.from(excludedDomains);
// Удаляем все preset зоны из списка
newList.removeWhere((d) => presetZones.contains(d));
// Добавляем выбранные зоны
newList.addAll(selectedZones);
// Добавляем свой домен если введён
final domain = controller.text.trim();
if (domain.isNotEmpty && !newList.contains(domain)) {
newList.add(domain);
}
ref.read(excludedDomainsListProvider.notifier).update(newList);
controller.clear();
Navigator.of(context).pop();
},
child: Text(t.settings.network.excludedDomains.ok),
),
],
),
],
),
),
),
);
}
}

View File

@@ -62,10 +62,10 @@ class AddProfileModal extends HookConsumerWidget {
controller: scrollController,
child: AnimatedSize(
duration: const Duration(milliseconds: 250),
child: LayoutBuilder(
builder: (context, constraints) {
// temporary solution, aspect ratio widget relies on height and in a row there no height!
final buttonWidth = constraints.maxWidth / 2 - (buttonsPadding + (buttonsGap / 2));
child: Builder(
builder: (context) {
// Fixed button width instead of using LayoutBuilder
final buttonWidth = (MediaQuery.of(context).size.width / 2) - (buttonsPadding + (buttonsGap / 2));
return AnimatedCrossFade(
firstChild: SizedBox(

View File

@@ -62,111 +62,109 @@ class ProfileTile extends HookConsumerWidget {
borderRadius: BorderRadius.circular(16),
),
shadowColor: Colors.transparent,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (profile is RemoteProfileEntity || !isMain) ...[
SizedBox(
width: 48,
child: Semantics(
sortKey: const OrdinalSortKey(1),
child: ProfileActionButton(profile, !isMain),
),
),
VerticalDivider(
width: 1,
color: effectiveOutlineColor,
),
],
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (profile is RemoteProfileEntity || !isMain) ...[
SizedBox(
width: 48,
child: Semantics(
button: true,
sortKey: isMain ? const OrdinalSortKey(0) : null,
focused: isMain,
liveRegion: isMain,
namesRoute: isMain,
label: isMain ? t.profile.activeProfileBtnSemanticLabel : null,
child: InkWell(
onTap: () {
if (isMain) {
const ProfilesOverviewRoute().go(context);
} else {
if (selectActiveMutation.state.isInProgress) return;
if (profile.active) return;
selectActiveMutation.setFuture(
ref.read(profilesOverviewNotifierProvider.notifier).selectActiveProfile(profile.id),
);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isMain)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Material(
borderRadius: BorderRadius.circular(8),
color: Colors.transparent,
clipBehavior: Clip.antiAlias,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
profile.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontFamily: FontFamily.emoji,
),
semanticsLabel: t.profile.activeProfileNameSemanticLabel(
name: profile.name,
),
sortKey: const OrdinalSortKey(1),
child: ProfileActionButton(profile, !isMain),
),
),
VerticalDivider(
width: 1,
color: effectiveOutlineColor,
),
],
Expanded(
child: Semantics(
button: true,
sortKey: isMain ? const OrdinalSortKey(0) : null,
focused: isMain,
liveRegion: isMain,
namesRoute: isMain,
label: isMain ? t.profile.activeProfileBtnSemanticLabel : null,
child: InkWell(
onTap: () {
if (isMain) {
const ProfilesOverviewRoute().go(context);
} else {
if (selectActiveMutation.state.isInProgress) return;
if (profile.active) return;
selectActiveMutation.setFuture(
ref.read(profilesOverviewNotifierProvider.notifier).selectActiveProfile(profile.id),
);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isMain)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Material(
borderRadius: BorderRadius.circular(8),
color: Colors.transparent,
clipBehavior: Clip.antiAlias,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
profile.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontFamily: FontFamily.emoji,
),
semanticsLabel: t.profile.activeProfileNameSemanticLabel(
name: profile.name,
),
),
const Icon(
FluentIcons.caret_down_16_filled,
size: 16,
),
],
),
),
const Icon(
FluentIcons.caret_down_16_filled,
size: 16,
),
],
),
)
else
Text(
profile.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium,
semanticsLabel: profile.active
? t.profile.activeProfileNameSemanticLabel(
name: profile.name,
)
: t.profile.nonActiveProfileBtnSemanticLabel(
name: profile.name,
),
),
if (subInfo != null) ...[
const Gap(4),
RemainingTrafficIndicator(subInfo.ratio),
const Gap(4),
ProfileSubscriptionInfo(subInfo),
const Gap(4),
],
)
else
Text(
profile.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium,
semanticsLabel: profile.active
? t.profile.activeProfileNameSemanticLabel(
name: profile.name,
)
: t.profile.nonActiveProfileBtnSemanticLabel(
name: profile.name,
),
),
if (subInfo != null) ...[
const Gap(4),
RemainingTrafficIndicator(subInfo.ratio),
const Gap(4),
ProfileSubscriptionInfo(subInfo),
const Gap(4),
],
),
],
),
),
),
),
],
),
),
],
),
);
}

View File

@@ -20,8 +20,7 @@ class ProxiesOverviewPage extends HookConsumerWidget with PresLogger {
final sortBy = ref.watch(proxiesSortNotifierProvider);
final selectActiveProxyMutation = useMutation(
initialOnFailure: (error) =>
CustomToast.error(t.presentShortError(error)).show(context),
initialOnFailure: (error) => CustomToast.error(t.presentShortError(error)).show(context),
);
final appBar = NestedAppBar(
@@ -85,8 +84,7 @@ class ProxiesOverviewPage extends HookConsumerWidget with PresLogger {
proxy,
selected: group.selected == proxy.tag,
onSelect: () async {
if (selectActiveProxyMutation
.state.isInProgress) {
if (selectActiveProxyMutation.state.isInProgress) {
return;
}
selectActiveProxyMutation.setFuture(
@@ -132,7 +130,7 @@ class ProxiesOverviewPage extends HookConsumerWidget with PresLogger {
floatingActionButton: FloatingActionButton(
onPressed: () async => notifier.urlTest(group.tag),
tooltip: t.proxies.delayTestTooltip,
child: const Icon(FluentIcons.flash_24_filled),
child: const Icon(FluentIcons.arrow_clockwise_24_filled),
),
);

View File

@@ -18,7 +18,6 @@ class AdvancedSettingTiles extends HookConsumerWidget {
final t = ref.watch(translationsProvider);
final debug = ref.watch(debugModeNotifierProvider);
final perAppProxy = ref.watch(Preferences.perAppProxyMode).enabled;
final disableMemoryLimit = ref.watch(Preferences.disableMemoryLimit);
return Column(
@@ -33,28 +32,6 @@ class AdvancedSettingTiles extends HookConsumerWidget {
// // await const GeoAssetsRoute().push(context);
// },
// ),
if (Platform.isAndroid) ...[
ListTile(
title: Text(t.settings.network.perAppProxyPageTitle),
leading: const Icon(FluentIcons.apps_list_detail_24_regular),
trailing: Switch(
value: perAppProxy,
onChanged: (value) async {
final newMode = perAppProxy ? PerAppProxyMode.off : PerAppProxyMode.exclude;
await ref.read(Preferences.perAppProxyMode.notifier).update(newMode);
if (!perAppProxy && context.mounted) {
await const PerAppProxyRoute().push(context);
}
},
),
onTap: () async {
if (!perAppProxy) {
await ref.read(Preferences.perAppProxyMode.notifier).update(PerAppProxyMode.exclude);
}
if (context.mounted) await const PerAppProxyRoute().push(context);
},
),
],
SwitchListTile(
title: Text(t.settings.advanced.memoryLimit),
subtitle: Text(t.settings.advanced.memoryLimitMsg),