From 964e9fa5e3aea877ac5141c1c4a7efeb849572ee Mon Sep 17 00:00:00 2001 From: Mohammad Hosein Abedini Date: Tue, 4 Aug 2026 18:17:40 +0330 Subject: [PATCH 1/5] feat: show the current IP country flag on the tray icon Optional (off by default) tray icon that reflects the country of the current public IP, so VPN/proxy exit nodes can be verified without opening the app. The flags shipped by country_flags are vectors and the tray only accepts a file path, so FlagTrayIcon rasterises them on demand into the app support directory: a multi resolution .ico on Windows, a .png on Linux. Icons are rendered once per country per run and reused afterwards. When the country is unknown, or the flag cannot be rendered, the tray falls back to the bundled IRNet icons. A leak keeps being reported while a flag is shown by badging it with a red dot. macOS is excluded: system_tray loads macOS icons through the asset bundle, so it cannot display an icon generated at runtime. --- CHANGELOG.md | 3 + README.md | 1 + lib/bloc.dart | 9 +- lib/data/shared_preferences.dart | 9 ++ lib/ui/settings_widgets.dart | 37 +++++- lib/utils/flag_tray_icon.dart | 198 +++++++++++++++++++++++++++++++ lib/utils/system_tray.dart | 14 ++- pubspec.lock | 26 ++-- pubspec.yaml | 3 + test/flag_tray_icon_test.dart | 97 +++++++++++++++ 10 files changed, 376 insertions(+), 21 deletions(-) create mode 100644 lib/utils/flag_tray_icon.dart create mode 100644 test/flag_tray_icon_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 4883b39..b3883f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# Unreleased +- [windows][linux] optionally show the flag of the current IP country as the system tray icon + # 1.5.0 - [all] redesign ui with a modern dark theme diff --git a/README.md b/README.md index 6df0f0b..ce50b91 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Utility for power users that want to see VPN connection details - Show location of connection in map - Leak detection on your urls - SysTray icon without the app being open +- Show the flag of the current IP country on the SysTray icon (optional, Windows & Linux) - Start by startup - Ability to minimize and hide from taskbar - Show details of your ISP diff --git a/lib/bloc.dart b/lib/bloc.dart index 8408ce7..4335e40 100644 --- a/lib/bloc.dart +++ b/lib/bloc.dart @@ -365,6 +365,12 @@ class AppBloc with AppSystemTray { _handleRefresh(); } + /// Applies a tray icon setting (country flag, leak status) right away instead + /// of waiting for the next IP check. + void onSysTrayIconSettingChanged() { + _updateCountryTrayIcon(); + } + @override void onSystemTrayRefreshButtonClick() { _handleRefresh(); @@ -386,6 +392,7 @@ class AppBloc with AppSystemTray { return; } final country = json['country']; + final countryCode = json['countryCode']; bool isIran = country == 'Iran'; var tooltip = ''; if (_foundALeakedSite) { @@ -393,7 +400,7 @@ class AppBloc with AppSystemTray { } else { tooltip = 'IRNet: $country'; } - updateIconWhenCountryLoaded(_foundALeakedSite, isIran, tooltip); + updateIconWhenCountryLoaded(_foundALeakedSite, isIran, countryCode, tooltip); debugPrint('Country => $country'); } diff --git a/lib/data/shared_preferences.dart b/lib/data/shared_preferences.dart index f0c88dc..61b55cf 100644 --- a/lib/data/shared_preferences.dart +++ b/lib/data/shared_preferences.dart @@ -57,6 +57,14 @@ class AppSharedPreferences { (await _preference).setBool(_keyShowLeakInSysTray, value); } + static Future get showCountryFlagInSysTray async { + return (await _preference).getBool(_keyShowCountryFlagInSysTray) ?? false; + } + + static Future setShowCountryFlagInSysTray(bool value) async { + (await _preference).setBool(_keyShowCountryFlagInSysTray, value); + } + static Future get isLeakPrePopulated async { return (await _preference).getBool(_keyIsLeakPrePopulated) ?? false; } @@ -93,6 +101,7 @@ class AppSharedPreferences { static const _keyIsLeakPrePopulated = 'isLeakPrePopulated'; static const _keyShowLeakInSysTray = 'showLeakInSysTray'; + static const _keyShowCountryFlagInSysTray = 'showCountryFlagInSysTray'; static const _keyLeakCheckList = 'leakChecklist'; static const _keyKerioIP = 'kerioIP'; static const _keyKerioUsername = 'kerioUsername'; diff --git a/lib/ui/settings_widgets.dart b/lib/ui/settings_widgets.dart index 1cc5707..e1e2edc 100644 --- a/lib/ui/settings_widgets.dart +++ b/lib/ui/settings_widgets.dart @@ -2,6 +2,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:ir_net/data/shared_preferences.dart'; +import 'package:ir_net/main.dart'; +import 'package:ir_net/utils/flag_tray_icon.dart'; import 'package:launch_at_startup/launch_at_startup.dart'; import 'components.dart'; @@ -31,7 +33,17 @@ class _SettingsViewState extends State { title: 'Show leak detection on tray icon', subtitle: 'Reflect leak status in the system tray', value: AppSharedPreferences.showLeakInSysTray, - onChanged: AppSharedPreferences.setShowLeakInSysTray, + onChanged: _trayIconSetting(AppSharedPreferences.setShowLeakInSysTray), + ), + _futureToggle( + title: 'Show country flag on tray icon', + subtitle: 'Use the flag of the detected IP country as the tray icon', + value: AppSharedPreferences.showCountryFlagInSysTray, + onChanged: + _trayIconSetting(AppSharedPreferences.setShowCountryFlagInSysTray), + // The macOS tray only reads icons that ship with the app. + enabled: FlagTrayIcon.isSupported, + badge: 'Windows & Linux', ), _launchAtStartup(), ]), @@ -72,11 +84,23 @@ class _SettingsViewState extends State { ); } + /// Redraws the tray icon as soon as a setting that affects it is saved, + /// instead of waiting for the next IP check. + Future Function(bool) _trayIconSetting( + Future Function(bool) save) { + return (value) async { + await save(value); + bloc.onSysTrayIconSettingChanged(); + }; + } + Widget _futureToggle({ required String title, required String subtitle, required Future value, required Future Function(bool) onChanged, + bool enabled = true, + String? badge, }) { return FutureBuilder( future: value, @@ -85,12 +109,15 @@ class _SettingsViewState extends State { return _SettingRow( title: title, subtitle: subtitle, + badge: badge, trailing: AppSwitch( value: v, - onChanged: (next) async { - await onChanged(next); - if (mounted) setState(() {}); - }, + onChanged: enabled + ? (next) async { + await onChanged(next); + if (mounted) setState(() {}); + } + : null, ), ); }, diff --git a/lib/utils/flag_tray_icon.dart b/lib/utils/flag_tray_icon.dart new file mode 100644 index 0000000..4fee9ae --- /dev/null +++ b/lib/utils/flag_tray_icon.dart @@ -0,0 +1,198 @@ +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:country_flags/country_flags.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image/image.dart' as img; +import 'package:ir_net/utils/platform.dart'; +import 'package:jovial_svg/jovial_svg.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Turns the flag of a country into an icon file the system tray can display. +/// +/// The tray plugin only accepts a path on disk, so the vector flag shipped by +/// `country_flags` is rasterised on demand and written to the app support +/// directory. Windows needs a multi resolution `.ico`, the other desktops read +/// a `.png` (same convention as the bundled `assets/*.ico|png` icons). +class FlagTrayIcon { + FlagTrayIcon._(); + + /// Resolutions packed into the Windows icon: the tray asks for 16px at 100% + /// scaling and up to 48px on high DPI displays. + static const _icoSizes = [16, 24, 32, 48]; + static const _pngSize = 44; + static const _cacheFolder = 'tray_flags'; + + /// Rendered icons of this run, keyed by [_cacheKey]. + static final Map _paths = {}; + static Directory? _directory; + + /// Whether the tray of this platform can show a generated icon. + /// + /// macOS is left out on purpose: `system_tray` reads macOS icons through the + /// asset bundle (it sends them base64 encoded), so it cannot display an icon + /// that was written to disk at runtime. Windows and Linux hand the path to + /// `LoadImage` / `app_indicator_set_icon_full`, which read any file. + static bool get isSupported => + PlatformUtils.isDesktop && !Platform.isMacOS; + + /// Path to the tray icon of [countryCode] (ISO 3166 alpha-2 or alpha-3), or + /// null when the country has no flag or the icon could not be rendered — the + /// caller is expected to fall back to the default IRNet icons then. + /// + /// When [leaked] is true the flag is badged with a red dot so the leak + /// warning is not lost while the flag occupies the tray. + static Future pathFor(String? countryCode, + {bool leaked = false}) async { + if (!isSupported) { + return null; + } + if (countryCode == null || countryCode.trim().isEmpty) { + return null; + } + final flagCode = FlagCode.fromCountryCode(countryCode.trim().toUpperCase()); + if (flagCode == null) { + return null; + } + final key = _cacheKey(flagCode, leaked); + final cached = _paths[key]; + if (cached != null) { + return cached; + } + try { + final path = await _render(flagCode, key, leaked); + _paths[key] = path; + return path; + } on Exception catch (ex) { + // A missing flag asset or an unwritable cache directory must not take + // the tray icon down with it. + debugPrint('Could not render the tray flag of $countryCode => $ex'); + return null; + } + } + + static String _cacheKey(String flagCode, bool leaked) { + return leaked ? '${flagCode}_leaked' : flagCode; + } + + static Future _render( + String flagCode, String key, bool leaked) async { + final directory = await _iconDirectory(); + final extension = Platform.isWindows ? 'ico' : 'png'; + final file = + File('${directory.path}${Platform.pathSeparator}$key.$extension'); + await file.writeAsBytes( + await encodeIcon(flagCode, leaked: leaked), + flush: true, + ); + return file.path; + } + + /// Encodes the flag of [flagCode] (the lowercase asset name used by + /// `country_flags`) as tray icon bytes: an `.ico` on Windows, a `.png` + /// elsewhere. + @visibleForTesting + static Future encodeIcon(String flagCode, + {bool leaked = false}) async { + final flag = await ScalableImage.fromSIAsset( + rootBundle, + 'packages/country_flags/res/si/$flagCode.si', + ); + await flag.prepareImages(); + try { + if (Platform.isWindows) { + final frames = []; + for (final size in _icoSizes) { + frames.add(await _rasterize(flag, size, leaked)); + } + return img.IcoEncoder().encodeImages(frames); + } + return img.encodePng(await _rasterize(flag, _pngSize, leaked)); + } finally { + flag.unprepareImages(); + } + } + + /// Draws [flag] centered in a transparent square of [size] pixels, keeping + /// its aspect ratio, and hands the pixels over to the `image` encoders. + static Future _rasterize( + ScalableImage flag, int size, bool leaked) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final viewport = flag.viewport; + final scale = math.min(size / viewport.width, size / viewport.height); + final bounds = Rect.fromLTWH( + (size - viewport.width * scale) / 2, + (size - viewport.height * scale) / 2, + viewport.width * scale, + viewport.height * scale, + ); + canvas.save(); + canvas.translate(bounds.left, bounds.top); + canvas.scale(scale); + flag.paint(canvas); + canvas.restore(); + _paintBorder(canvas, bounds, size); + if (leaked) { + _paintLeakBadge(canvas, size.toDouble()); + } + final picture = recorder.endRecording(); + final image = await picture.toImage(size, size); + picture.dispose(); + try { + final pixels = + await image.toByteData(format: ui.ImageByteFormat.rawStraightRgba); + return img.Image.fromBytes( + width: size, + height: size, + bytes: pixels!.buffer, + numChannels: 4, + ); + } finally { + image.dispose(); + } + } + + /// Keeps mostly white flags (Japan, Finland, ...) readable on light taskbars. + static void _paintBorder(Canvas canvas, Rect bounds, int size) { + final width = math.max(1, size / 24).toDouble(); + canvas.drawRect( + bounds.deflate(width / 2), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = width + ..color = const Color(0x59000000), + ); + } + + static void _paintLeakBadge(Canvas canvas, double size) { + final radius = size * 0.26; + final center = Offset(size - radius, size - radius); + canvas.drawCircle( + center, + radius, + Paint()..color = const Color(0xFF0B0F14), + ); + canvas.drawCircle( + center, + radius * 0.72, + Paint()..color = const Color(0xFFEF4444), + ); + } + + static Future _iconDirectory() async { + var directory = _directory; + if (directory != null) { + return directory; + } + final support = await getApplicationSupportDirectory(); + directory = Directory( + '${support.path}${Platform.pathSeparator}$_cacheFolder', + ); + await directory.create(recursive: true); + _directory = directory; + return directory; + } +} diff --git a/lib/utils/system_tray.dart b/lib/utils/system_tray.dart index e68fda6..39c5d8e 100644 --- a/lib/utils/system_tray.dart +++ b/lib/utils/system_tray.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:ir_net/data/shared_preferences.dart'; +import 'package:ir_net/utils/flag_tray_icon.dart'; import 'package:ir_net/utils/platform.dart'; import 'package:system_tray/system_tray.dart'; @@ -25,9 +26,18 @@ mixin AppSystemTray { } void updateIconWhenCountryLoaded( - bool foundLeak, bool isIran, String tooltip) async { + bool foundLeak, bool isIran, String? countryCode, String tooltip) async { + final showLeak = foundLeak && (await AppSharedPreferences.showLeakInSysTray); + if (await AppSharedPreferences.showCountryFlagInSysTray) { + final flagIcon = await FlagTrayIcon.pathFor(countryCode, leaked: showLeak); + if (flagIcon != null) { + updateSysTrayIcon(tooltip, flagIcon); + return; + } + // Unknown country: keep the default icons below. + } var globIcon = _getIcon('assets/globe'); - if (foundLeak && (await AppSharedPreferences.showLeakInSysTray)) { + if (showLeak) { globIcon = _getIcon('assets/globe_leaked'); } final iconPath = isIran ? _getIcon('assets/iran') : globIcon; diff --git a/pubspec.lock b/pubspec.lock index 971633f..16bf2e6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -273,7 +273,7 @@ packages: source: hosted version: "4.0.2" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce @@ -305,7 +305,7 @@ packages: source: hosted version: "0.9.2" jovial_svg: - dependency: transitive + dependency: "direct main" description: name: jovial_svg sha256: "08dd24b800d48796c9c0227acb96eb00c6cacccb1d7de58d79fc924090049868" @@ -388,26 +388,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" msix: dependency: "direct dev" description: @@ -785,10 +785,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.11" touch_mouse_behavior: dependency: "direct main" description: @@ -966,5 +966,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index 33f869f..18d3dff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,9 @@ dependencies: window_manager: ^0.3.9 flutter_speedtest: ^0.2.0 country_flags: ^4.0.0 + # Rasterising country flags into system tray icons (see utils/flag_tray_icon.dart) + jovial_svg: ^1.1.28 + image: ^4.8.0 dev_dependencies: sentry_dart_plugin: ^3.2.0 diff --git a/test/flag_tray_icon_test.dart b/test/flag_tray_icon_test.dart new file mode 100644 index 0000000..beaa06e --- /dev/null +++ b/test/flag_tray_icon_test.dart @@ -0,0 +1,97 @@ +// Verifies the country flag tray icons: the vector flag shipped by +// `country_flags` has to come out of the encoder as a valid icon, letterboxed +// into a square, and badged when a leak was found. `pathFor` is also expected +// to give up quietly for unknown countries so the tray can fall back to the +// bundled IRNet icons. + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:ir_net/utils/flag_tray_icon.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // The encoder writes .ico on Windows and .png everywhere else. + img.Image decode(Uint8List bytes) { + final decoded = Platform.isWindows + ? img.IcoDecoder().decode(bytes) + : img.PngDecoder().decode(bytes); + expect(decoded, isNotNull, reason: 'the tray icon must be readable'); + return decoded!; + } + + test('encodes the flag of a country as a tray icon', () async { + final bytes = await FlagTrayIcon.encodeIcon('ir'); + expect(bytes, isNotEmpty); + + final icon = decode(bytes); + expect(icon.width, icon.height, reason: 'tray icons are square'); + + // The Iranian flag: green, white and red bands must all have made it in. + var green = 0; + var white = 0; + var red = 0; + for (final pixel in icon) { + if (pixel.a < 250) continue; + final r = pixel.r; + final g = pixel.g; + final b = pixel.b; + if (g > 100 && r < 100 && b < 120) green++; + if (r > 200 && g > 200 && b > 200) white++; + if (r > 120 && g < 100 && b < 100) red++; + } + expect(green, greaterThan(20)); + expect(white, greaterThan(20)); + expect(red, greaterThan(20)); + }); + + test('letterboxes the flag so its aspect ratio is kept', () async { + final icon = decode(await FlagTrayIcon.encodeIcon('ir')); + // Flags are wider than they are tall, so the top and bottom of the square + // stay empty while the middle is painted. + expect(icon.getPixel(0, 0).a, 0); + expect(icon.getPixel(icon.width - 1, icon.height - 1).a, 0); + expect(icon.getPixel(icon.width ~/ 2, icon.height ~/ 2).a, 255); + }); + + test('badges the flag when a leak was found', () async { + // Japan: white where the badge lands, so the red dot cannot be mistaken + // for the flag itself. + final clean = decode(await FlagTrayIcon.encodeIcon('jp')); + final leaked = decode(await FlagTrayIcon.encodeIcon('jp', leaked: true)); + + // Center of the badge, which is drawn one radius away from the corner. + final x = (leaked.width * 0.72).round(); + final y = (leaked.height * 0.72).round(); + + final before = clean.getPixel(x, y); + expect(before.r, greaterThan(200)); + expect(before.g, greaterThan(200)); + + final badge = leaked.getPixel(x, y); + expect(badge.a, 255); + expect(badge.r, greaterThan(200)); + expect(badge.g, lessThan(100)); + expect(badge.b, lessThan(100)); + }); + + test('packs several resolutions into the Windows icon', () async { + if (!Platform.isWindows) { + return; + } + final decoder = img.IcoDecoder() + ..startDecode(await FlagTrayIcon.encodeIcon('ir')); + expect(decoder.numFrames(), 4); + }); + + test('gives up on countries without a flag so the tray can fall back', + () async { + expect(await FlagTrayIcon.pathFor(null), isNull); + expect(await FlagTrayIcon.pathFor(''), isNull); + expect(await FlagTrayIcon.pathFor(' '), isNull); + expect(await FlagTrayIcon.pathFor('ZZ'), isNull); + }); +} From 9155072e94b4ee1258cd1c76e096998881ec775e Mon Sep 17 00:00:00 2001 From: Mohammad Hosein Abedini Date: Tue, 4 Aug 2026 20:49:43 +0330 Subject: [PATCH 2/5] chore: build the installer from any checkout path The Inno script pointed at C:\Workspace\Flutter\ir_net for the license file and the build output, so ISCC failed on every machine but the one it was written on. Inno resolves relative paths against the directory holding the .iss, so the repo layout is enough. --- inno/Inno installer script.iss | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/inno/Inno installer script.iss b/inno/Inno installer script.iss index 7db6edb..ae19bfc 100644 --- a/inno/Inno installer script.iss +++ b/inno/Inno installer script.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "IRNet" -#define MyAppVersion "1.4.3" +#define MyAppVersion "1.5.0" #define MyAppPublisher "BuildToApp, Inc." #define MyAppURL "https://www.buildtoapp.com/" #define MyAppExeName "ir_net.exe" @@ -28,10 +28,10 @@ ArchitecturesAllowed=x64compatible ; the 64-bit view of the registry. ArchitecturesInstallIn64BitMode=x64compatible DisableProgramGroupPage=yes -LicenseFile=C:\Workspace\Flutter\ir_net\inno\installer license.txt +LicenseFile=installer license.txt ; Uncomment the following line to run in non administrative install mode (install for current user only.) ;PrivilegesRequired=lowest -OutputBaseFilename=IRNet_windows_setup_1.4.3 +OutputBaseFilename=IRNet_windows_setup_1.5.0 Compression=lzma SolidCompression=yes WizardStyle=modern @@ -43,8 +43,8 @@ Name: "english"; MessagesFile: "compiler:Default.isl" Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked [Files] -Source: "C:\Workspace\Flutter\ir_net\build\windows\x64\runner\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion -Source: "C:\Workspace\Flutter\ir_net\build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "..\build\windows\x64\runner\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs ; NOTE: Don't use "Flags: ignoreversion" on any shared system files [Icons] From c8454627ee7400586676dfe7b2ffd25f557d20c9 Mon Sep 17 00:00:00 2001 From: Mohammad Hosein Abedini Date: Tue, 4 Aug 2026 20:49:51 +0330 Subject: [PATCH 3/5] feat: show the current country flag in the connection views The flag of the detected IP country replaces the globe marker on the "Connected from" card (both layouts) and in the header chip, so the location is recognisable at a glance. CountryFlagTile falls back to the globe whenever the country is still unknown or has no flag of its own, which also covers the first seconds after launch while the IP lookup is running. --- CHANGELOG.md | 1 + lib/ui/components.dart | 52 ++++++++++++++++++++++++++++++++++ lib/ui/connection_widgets.dart | 17 +++++++++-- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3883f1..f188c8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased - [windows][linux] optionally show the flag of the current IP country as the system tray icon +- [all] show the current country flag in the connection card and the header chip # 1.5.0 - [all] redesign ui with a modern dark theme diff --git a/lib/ui/components.dart b/lib/ui/components.dart index 2fbfa92..d310900 100644 --- a/lib/ui/components.dart +++ b/lib/ui/components.dart @@ -1,3 +1,4 @@ +import 'package:country_flags/country_flags.dart'; import 'package:flutter/material.dart'; import 'theme.dart'; @@ -57,6 +58,57 @@ class AppCard extends StatelessWidget { } } +/// Flag of [countryCode], falling back to the globe marker while the country +/// is still unknown (or has no flag of its own). +/// +/// Sized to a rounded square like [IconTile] so it can stand in for the globe +/// wherever the current location is shown. +class CountryFlagTile extends StatelessWidget { + const CountryFlagTile({ + super.key, + required this.countryCode, + this.size = 44, + this.width, + this.iconSize = 22, + this.radius = 13, + }); + + final String? countryCode; + final double size; + + /// Defaults to [size] (a square tile); set it wider for a flag-shaped swatch. + final double? width; + final double iconSize; + final double radius; + + @override + Widget build(BuildContext context) { + final code = countryCode; + if (code == null || FlagCode.fromCountryCode(code.toUpperCase()) == null) { + return IconTile( + icon: Icons.public, + size: size, + iconSize: iconSize, + radius: radius, + ); + } + final tileWidth = width ?? size; + return Container( + width: tileWidth, + height: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: AppColors.line2), + ), + clipBehavior: Clip.antiAlias, + child: CountryFlag.fromCountryCode( + code, + theme: ImageTheme(width: tileWidth, height: size), + ), + ); + } +} + /// Rounded square holding a single icon (e.g. the globe / wallet markers). class IconTile extends StatelessWidget { const IconTile({ diff --git a/lib/ui/connection_widgets.dart b/lib/ui/connection_widgets.dart index 1b3a75c..bfc11fa 100644 --- a/lib/ui/connection_widgets.dart +++ b/lib/ui/connection_widgets.dart @@ -115,7 +115,13 @@ class ConnectionChip extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.public, size: 15, color: AppColors.accent), + CountryFlagTile( + countryCode: info.countryCode, + size: 15, + width: 20, + iconSize: 15, + radius: 3, + ), const SizedBox(width: 9), Text(label, style: AppText.ui(13, FontWeight.w500, AppColors.text2)), const SizedBox(width: 9), @@ -173,7 +179,12 @@ class ConnectionHeroCard extends StatelessWidget { padding: const EdgeInsets.all(22), child: Row( children: [ - const IconTile(icon: Icons.public, size: 56, iconSize: 28, radius: 16), + CountryFlagTile( + countryCode: info.countryCode, + size: 56, + iconSize: 28, + radius: 16, + ), const SizedBox(width: 20), Expanded( child: Column( @@ -209,7 +220,7 @@ class ConnectionHeroCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const IconTile(icon: Icons.public, size: 42, iconSize: 22), + CountryFlagTile(countryCode: info.countryCode, size: 42, iconSize: 22), VpnPill(state), ], ), From 24b18d2c5b524cf9f9478382324761a783861097 Mon Sep 17 00:00:00 2001 From: Mohammad Hosein Abedini Date: Tue, 4 Aug 2026 20:50:02 +0330 Subject: [PATCH 4/5] feat: stamp the IRNet shield on the country flag tray icon Keeps the tray icon recognisable as this app once it shows a flag. The shield is drawn as a path (same silhouette as the launcher icon) in the bottom left, diagonally opposite the leak badge, with a dark rim so it survives on light flags. It reads as a shield on the 32px and 48px frames; at 16px it is only a small brand coloured mark, which is as much as that canvas allows next to a flag. --- lib/utils/flag_tray_icon.dart | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/lib/utils/flag_tray_icon.dart b/lib/utils/flag_tray_icon.dart index 4fee9ae..093f365 100644 --- a/lib/utils/flag_tray_icon.dart +++ b/lib/utils/flag_tray_icon.dart @@ -135,6 +135,7 @@ class FlagTrayIcon { flag.paint(canvas); canvas.restore(); _paintBorder(canvas, bounds, size); + _paintBrandMark(canvas, bounds); if (leaked) { _paintLeakBadge(canvas, size.toDouble()); } @@ -167,6 +168,46 @@ class FlagTrayIcon { ); } + /// Stamps the IRNet shield on the flag so the tray icon still reads as this + /// app. Sits bottom left, diagonally opposite the leak badge. + static void _paintBrandMark(Canvas canvas, Rect bounds) { + final height = bounds.height * 0.42; + final width = height * 0.86; + final left = bounds.left + bounds.width * 0.04; + final top = bounds.bottom - height - bounds.height * 0.06; + + // Same silhouette as the launcher icon: hexagonal shoulders, round point. + final shield = Path() + ..moveTo(left + width / 2, top) + ..lineTo(left + width, top + height * 0.22) + ..lineTo(left + width, top + height * 0.5) + ..quadraticBezierTo( + left + width, + top + height * 0.88, + left + width / 2, + top + height, + ) + ..quadraticBezierTo( + left, + top + height * 0.88, + left, + top + height * 0.5, + ) + ..lineTo(left, top + height * 0.22) + ..close(); + + // Dark rim first so the mark survives on light flags. + canvas.drawPath( + shield, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = math.max(1, bounds.height / 20) + ..strokeJoin = StrokeJoin.round + ..color = const Color(0xFF0B0F14), + ); + canvas.drawPath(shield, Paint()..color = const Color(0xFF33D6C6)); + } + static void _paintLeakBadge(Canvas canvas, double size) { final radius = size * 0.26; final center = Offset(size - radius, size - radius); From ba9fa242753ef7e8762dbe27c47ed2b0f2737440 Mon Sep 17 00:00:00 2001 From: Mohammad Hosein Abedini Date: Wed, 5 Aug 2026 16:46:36 +0330 Subject: [PATCH 5/5] Revert "feat: stamp the IRNet shield on the country flag tray icon" This reverts commit 24b18d2c5b524cf9f9478382324761a783861097. --- lib/utils/flag_tray_icon.dart | 41 ----------------------------------- 1 file changed, 41 deletions(-) diff --git a/lib/utils/flag_tray_icon.dart b/lib/utils/flag_tray_icon.dart index 093f365..4fee9ae 100644 --- a/lib/utils/flag_tray_icon.dart +++ b/lib/utils/flag_tray_icon.dart @@ -135,7 +135,6 @@ class FlagTrayIcon { flag.paint(canvas); canvas.restore(); _paintBorder(canvas, bounds, size); - _paintBrandMark(canvas, bounds); if (leaked) { _paintLeakBadge(canvas, size.toDouble()); } @@ -168,46 +167,6 @@ class FlagTrayIcon { ); } - /// Stamps the IRNet shield on the flag so the tray icon still reads as this - /// app. Sits bottom left, diagonally opposite the leak badge. - static void _paintBrandMark(Canvas canvas, Rect bounds) { - final height = bounds.height * 0.42; - final width = height * 0.86; - final left = bounds.left + bounds.width * 0.04; - final top = bounds.bottom - height - bounds.height * 0.06; - - // Same silhouette as the launcher icon: hexagonal shoulders, round point. - final shield = Path() - ..moveTo(left + width / 2, top) - ..lineTo(left + width, top + height * 0.22) - ..lineTo(left + width, top + height * 0.5) - ..quadraticBezierTo( - left + width, - top + height * 0.88, - left + width / 2, - top + height, - ) - ..quadraticBezierTo( - left, - top + height * 0.88, - left, - top + height * 0.5, - ) - ..lineTo(left, top + height * 0.22) - ..close(); - - // Dark rim first so the mark survives on light flags. - canvas.drawPath( - shield, - Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = math.max(1, bounds.height / 20) - ..strokeJoin = StrokeJoin.round - ..color = const Color(0xFF0B0F14), - ); - canvas.drawPath(shield, Paint()..color = const Color(0xFF33D6C6)); - } - static void _paintLeakBadge(Canvas canvas, double size) { final radius = size * 0.26; final center = Offset(size - radius, size - radius);