diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index 624c415e..03c27cda 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -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'; diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index 76f5ad17..044e2583 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -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 push(BuildContext context) => + context.push(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); +} diff --git a/app/lib/core/routing/routes.tor.dart b/app/lib/core/routing/routes.tor.dart index 5020e655..1f60e432 100644 --- a/app/lib/core/routing/routes.tor.dart +++ b/app/lib/core/routing/routes.tor.dart @@ -19,7 +19,16 @@ */ part of 'routes.dart'; -@TypedGoRoute(name: 'TorProxyRoute', path: '/tor') +@TypedGoRoute( + name: 'TorProxyRoute', + path: '/tor', + routes: [ + TypedGoRoute( + 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, + ); + } +} diff --git a/app/lib/features/tor/presentation/screens/country_picker.dart b/app/lib/features/tor/presentation/screens/country_picker.dart new file mode 100644 index 00000000..d5a52c8b --- /dev/null +++ b/app/lib/features/tor/presentation/screens/country_picker.dart @@ -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 . + */ +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), + ); + }, + ), + ); + } +} diff --git a/app/lib/features/tor/presentation/screens/tor_proxy.dart b/app/lib/features/tor/presentation/screens/tor_proxy.dart index b8e540f6..eb6084f3 100644 --- a/app/lib/features/tor/presentation/screens/tor_proxy.dart +++ b/app/lib/features/tor/presentation/screens/tor_proxy.dart @@ -17,7 +17,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -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( - // 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(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( - // 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(context); + if (result == null) return; + final value = + result == automaticCountry ? null : result; + await ref + .read(saveTorSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .exitNodeCountry(value), + ); + }, ), ], ),