dedicated screen for tor countries

This commit is contained in:
Fabian Freund
2026-02-16 07:29:12 +01:00
parent c8d46ad0c2
commit 8758fe821c
5 changed files with 262 additions and 121 deletions
+1
View File
@@ -70,6 +70,7 @@ import 'package:weblibre/features/settings/presentation/screens/tabs_behavior_se
import 'package:weblibre/features/settings/presentation/screens/tracking_protection_exceptions.dart';
import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening.dart';
import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening_group.dart';
import 'package:weblibre/features/tor/presentation/screens/country_picker.dart';
import 'package:weblibre/features/tor/presentation/screens/tor_proxy.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/select_profile.dart';
import 'package:weblibre/features/user/domain/presentation/screens/profile_backup.dart';
+38
View File
@@ -1795,6 +1795,13 @@ RouteBase get $torProxyRoute => GoRouteData.$route(
path: '/tor',
name: 'TorProxyRoute',
factory: $TorProxyRoute._fromState,
routes: [
GoRouteData.$route(
path: 'country_picker',
name: 'TorCountryPickerRoute',
factory: $TorCountryPickerRoute._fromState,
),
],
);
mixin $TorProxyRoute on GoRouteData {
@@ -1816,3 +1823,34 @@ mixin $TorProxyRoute on GoRouteData {
@override
void replace(BuildContext context) => context.replace(location);
}
mixin $TorCountryPickerRoute on GoRouteData {
static TorCountryPickerRoute _fromState(GoRouterState state) =>
TorCountryPickerRoute(
title: state.uri.queryParameters['title']!,
$extra: state.extra as String?,
);
TorCountryPickerRoute get _self => this as TorCountryPickerRoute;
@override
String get location => GoRouteData.$location(
'/tor/country_picker',
queryParams: {'title': _self.title},
);
@override
void go(BuildContext context) => context.go(location, extra: _self.$extra);
@override
Future<T?> push<T>(BuildContext context) =>
context.push<T>(location, extra: _self.$extra);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location, extra: _self.$extra);
@override
void replace(BuildContext context) =>
context.replace(location, extra: _self.$extra);
}
+25 -1
View File
@@ -19,7 +19,16 @@
*/
part of 'routes.dart';
@TypedGoRoute<TorProxyRoute>(name: 'TorProxyRoute', path: '/tor')
@TypedGoRoute<TorProxyRoute>(
name: 'TorProxyRoute',
path: '/tor',
routes: [
TypedGoRoute<TorCountryPickerRoute>(
name: 'TorCountryPickerRoute',
path: 'country_picker',
),
],
)
class TorProxyRoute extends GoRouteData with $TorProxyRoute {
const TorProxyRoute();
@@ -28,3 +37,18 @@ class TorProxyRoute extends GoRouteData with $TorProxyRoute {
return const TorProxyScreen();
}
}
class TorCountryPickerRoute extends GoRouteData with $TorCountryPickerRoute {
final String title;
final String? $extra;
const TorCountryPickerRoute({required this.title, this.$extra});
@override
Widget build(BuildContext context, GoRouterState state) {
return CountryPickerScreen(
title: title,
selectedCountryCode: $extra,
);
}
}
@@ -0,0 +1,140 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:country_codes/country_codes.dart';
import 'package:country_flags/country_flags.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
/// Sentinel value returned when the user selects "Automatic" (no country).
/// Distinguished from `null` which means the user navigated back without
/// making a selection.
const automaticCountry = '';
class CountryPickerScreen extends HookWidget {
const CountryPickerScreen({
required this.title,
this.selectedCountryCode,
});
final String title;
final String? selectedCountryCode;
@override
Widget build(BuildContext context) {
final searchController = useTextEditingController();
final searchQuery = useState('');
final countries = useMemoized(() {
return CountryCodes.countryCodes().map((country) {
final label =
country.localizedName ??
country.name ??
country.alpha2Code ??
country.countryCode ??
'Unnamed Country';
return (alpha2Code: country.alpha2Code, label: label);
}).toList()
..sort((a, b) => a.label.compareTo(b.label));
});
final filteredCountries = useMemoized(
() {
if (searchQuery.value.isEmpty) return countries;
final query = searchQuery.value.toLowerCase();
return countries
.where((c) => c.label.toLowerCase().contains(query))
.toList();
},
[searchQuery.value, countries],
);
return Scaffold(
appBar: AppBar(
title: Text(title),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: TextField(
controller: searchController,
decoration: InputDecoration(
hintText: 'Search countries...',
prefixIcon: const Icon(Icons.search),
suffixIcon: searchQuery.value.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
searchQuery.value = '';
},
)
: null,
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 0),
),
onChanged: (value) => searchQuery.value = value,
),
),
),
),
body: ListView.builder(
itemCount: filteredCountries.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
final isSelected = selectedCountryCode == null;
return ListTile(
leading: const SizedBox(
width: 32,
height: 24,
child: Center(child: Icon(Icons.public)),
),
title: const Text('Automatic'),
trailing: isSelected ? const Icon(Icons.check) : null,
onTap: () => context.pop(automaticCountry),
);
}
final country = filteredCountries[index - 1];
final isSelected = country.alpha2Code == selectedCountryCode;
return ListTile(
leading: country.alpha2Code != null
? CountryFlag.fromCountryCode(
country.alpha2Code!,
theme: const EmojiTheme(size: 28),
)
: const SizedBox(width: 32),
title: Text(country.label),
trailing: isSelected ? const Icon(Icons.check) : null,
onTap: () => context.pop(country.alpha2Code),
);
},
),
);
}
}
@@ -17,7 +17,6 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:country_codes/country_codes.dart';
import 'package:country_flags/country_flags.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -27,6 +26,8 @@ import 'package:nullability/nullability.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/tor/presentation/screens/country_picker.dart';
import 'package:weblibre/features/user/data/models/tor_settings.dart';
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
import 'package:weblibre/presentation/hooks/on_initialization.dart';
@@ -92,29 +93,6 @@ class TorProxyScreen extends HookConsumerWidget {
}
});
final countryDropdownEntries = useMemoized(
() => [
const DropdownMenuEntry(value: null, label: 'Automatic'),
...CountryCodes.countryCodes().map((country) {
final label =
country.localizedName ??
country.name ??
country.alpha2Code ??
country.countryCode ??
'Unnamed Country';
return DropdownMenuEntry(
value: country.alpha2Code,
label: label,
labelWidget: Text(
label,
style: const TextStyle(color: Colors.white),
),
);
}),
],
);
return Scaffold(
body: Theme(
data: Theme.of(context).copyWith(
@@ -485,111 +463,71 @@ class TorProxyScreen extends HookConsumerWidget {
),
),
),
Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 24.0,
top: 8.0,
),
child: DropdownMenu(
enabled: !torIsBusy,
initialSelection: torSettings.entryNodeCountry,
menuHeight: 400,
requestFocusOnTap: true,
label: const Text('Entry Country'),
textStyle: const TextStyle(color: Colors.white),
leadingIcon: torSettings.entryNodeCountry.mapNotNull(
(code) => Padding(
padding: const EdgeInsets.all(8.0),
child: CountryFlag.fromCountryCode(
ListTile(
enabled: !torIsBusy,
leading: torSettings.entryNodeCountry.mapNotNull(
(code) => CountryFlag.fromCountryCode(
code,
theme: const EmojiTheme(size: 28),
),
),
),
trailingIcon: const Icon(
MdiIcons.chevronDown,
color: Colors.white,
),
inputDecorationTheme: const InputDecorationTheme(
labelStyle: TextStyle(color: Colors.white),
iconColor: Colors.white,
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white),
),
),
// menuStyle: MenuStyle(
// backgroundColor:
// WidgetStateProperty.all<Color>(
// AppColors.torPurple,
// ),
// ),
dropdownMenuEntries: countryDropdownEntries,
onSelected: (value) async {
await ref
.read(saveTorSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.entryNodeCountry(value),
);
},
) ??
const Icon(Icons.public, color: Colors.white),
title: const Text('Entry Country'),
subtitle: Text(
torSettings.entryNodeCountry ?? 'Automatic',
),
trailing: const Icon(
MdiIcons.chevronRight,
color: Colors.white,
),
onTap: () async {
final result = await TorCountryPickerRoute(
title: 'Entry Country',
$extra: torSettings.entryNodeCountry,
).push<String>(context);
if (result == null) return;
final value =
result == automaticCountry ? null : result;
await ref
.read(saveTorSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.entryNodeCountry(value),
);
},
),
Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 24.0,
top: 8.0,
),
child: DropdownMenu(
enabled: !torIsBusy,
initialSelection: torSettings.exitNodeCountry,
menuHeight: 400,
requestFocusOnTap: true,
label: const Text('Exit Country'),
textStyle: const TextStyle(color: Colors.white),
leadingIcon: torSettings.exitNodeCountry.mapNotNull(
(code) => Padding(
padding: const EdgeInsets.all(8.0),
child: CountryFlag.fromCountryCode(
ListTile(
enabled: !torIsBusy,
leading: torSettings.exitNodeCountry.mapNotNull(
(code) => CountryFlag.fromCountryCode(
code,
theme: const EmojiTheme(size: 28),
),
),
),
trailingIcon: const Icon(
MdiIcons.chevronDown,
color: Colors.white,
),
inputDecorationTheme: const InputDecorationTheme(
labelStyle: TextStyle(color: Colors.white),
iconColor: Colors.white,
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white),
),
),
// menuStyle: MenuStyle(
// backgroundColor:
// WidgetStateProperty.all<Color>(
// AppColors.torPurple,
// ),
// ),
dropdownMenuEntries: countryDropdownEntries,
onSelected: (value) async {
await ref
.read(saveTorSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.exitNodeCountry(value),
);
},
) ??
const Icon(Icons.public, color: Colors.white),
title: const Text('Exit Country'),
subtitle: Text(
torSettings.exitNodeCountry ?? 'Automatic',
),
trailing: const Icon(
MdiIcons.chevronRight,
color: Colors.white,
),
onTap: () async {
final result = await TorCountryPickerRoute(
title: 'Exit Country',
$extra: torSettings.exitNodeCountry,
).push<String>(context);
if (result == null) return;
final value =
result == automaticCountry ? null : result;
await ref
.read(saveTorSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.exitNodeCountry(value),
);
},
),
],
),