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

@@ -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),
),
],
),
],
),
),
),
);
}
}