diff --git a/CHANGELOG.md b/CHANGELOG.md index 4883b39..f188c8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 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/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/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] 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/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), ], ), 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); + }); +}