From 93a697fd50a9b857179ee1e712d0e4b347ed47d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20H=C3=A4ndel?= Date: Sat, 21 Sep 2024 17:20:49 +0200 Subject: [PATCH] Feature: Configure dark and tinted mode icons for iOS 18+ (#569) * Add support for iOS dark and tinted icons * Fix location of Contents.json file * Fix location of Contents.json file * Fix generation of Contents.json file * Prevent ios-marketing idiom from getting dark and tinted variants * Split Contents.json types in legacy and new variants * Add tests * Add ability to desaturate tinted iOS images * Add documentation and template file for iOS dark and tinted icons --- README.md | 3 + bin/generate.dart | 3 + lib/config/config.dart | 15 + lib/config/config.g.dart | 20 +- lib/config/windows_config.g.dart | 2 +- lib/ios.dart | 456 +++++++++++------- test/abs/icon_generator_test.mocks.dart | 36 +- .../macos_icon_generator_test.mocks.dart | 48 +- test/main_test.dart | 22 +- .../windows_icon_generator_test.mocks.dart | 42 +- 10 files changed, 460 insertions(+), 187 deletions(-) diff --git a/README.md b/README.md index 0650989eab..9b54cb987d 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,9 @@ foreground of the Android 13+ themed icon. For more information see [Android Ada - `icon/path/here.png`: This will generate a new launcher icons for the platform with the name you specify, without removing the old default existing Flutter launcher icon. - `image_path_ios`: The location of the icon image file specific for iOS platform (optional - if not defined then the image_path is used) - `remove_alpha_ios`: Removes alpha channel for IOS icons +- `image_path_ios_dark_transparent`: The location of the dark mode icon image file specific for iOS 18+ platform. *Note: Apple recommends this icon to be transparent. For more information see [Apple Human Interface Guidelines for App Icons](https://developer.apple.com/design/human-interface-guidelines/app-icons#iOS-iPadOS)* +- `image_path_ios_tinted_grayscale`: The location of the tinted mode icon image file specific for iOS 18+ platform. *Note: This icon should be an grayscale image. Use `desaturate_tinted_to_grayscale_ios: true` to automatically desaturate the image provided here.* +- `desaturate_tinted_to_grayscale_ios`: Automatically desaturates tinted mode icon image to grayscale, *defaults to false* - `background_color_ios`: The color (in the format "#RRGGBB") to be used as the background when removing the alpha channel. It is used only when the `remove_alpha_ios` property is set to true. (optional - if not defined then `#ffffff` is used) ### Web diff --git a/bin/generate.dart b/bin/generate.dart index b7772d7827..54ae786276 100644 --- a/bin/generate.dart +++ b/bin/generate.dart @@ -96,6 +96,9 @@ flutter_launcher_icons: ios: true # image_path_ios: "assets/icon/icon.png" remove_alpha_channel_ios: true + # image_path_ios_dark_transparent: "assets/icon/icon_dark.png" + # image_path_ios_tinted_grayscale: "assets/icon/icon_tinted.png" + # desaturate_tinted_to_grayscale_ios: true web: generate: true diff --git a/lib/config/config.dart b/lib/config/config.dart index ba03809b68..df650dac98 100644 --- a/lib/config/config.dart +++ b/lib/config/config.dart @@ -25,11 +25,14 @@ class Config { this.ios = false, this.imagePathAndroid, this.imagePathIOS, + this.imagePathIOSDarkTransparent, + this.imagePathIOSTintedGrayscale, this.adaptiveIconForeground, this.adaptiveIconBackground, this.adaptiveIconMonochrome, this.minSdkAndroid = constants.androidDefaultAndroidMinSDK, this.removeAlphaIOS = false, + this.desaturateTintedToGrayscaleIOS = false, this.backgroundColorIOS = '#ffffff', this.webConfig, this.windowsConfig, @@ -117,6 +120,14 @@ class Config { @JsonKey(name: 'image_path_ios') final String? imagePathIOS; + /// IOS image_path_ios_dark_transparent + @JsonKey(name: 'image_path_ios_dark_transparent') + final String? imagePathIOSDarkTransparent; + + /// IOS image_path_ios_tinted_grayscale + @JsonKey(name: 'image_path_ios_tinted_grayscale') + final String? imagePathIOSTintedGrayscale; + /// android adaptive_icon_foreground image @JsonKey(name: 'adaptive_icon_foreground') final String? adaptiveIconForeground; @@ -137,6 +148,10 @@ class Config { @JsonKey(name: 'remove_alpha_ios') final bool removeAlphaIOS; + /// IOS desaturate_tinted_to_grayscale + @JsonKey(name: 'desaturate_tinted_to_grayscale_ios') + final bool desaturateTintedToGrayscaleIOS; + /// IOS background_color_ios @JsonKey(name: 'background_color_ios') final String backgroundColorIOS; diff --git a/lib/config/config.g.dart b/lib/config/config.g.dart index 2be66ae374..ed9100de04 100644 --- a/lib/config/config.g.dart +++ b/lib/config/config.g.dart @@ -17,16 +17,25 @@ Config _$ConfigFromJson(Map json) => $checkedCreate( imagePathAndroid: $checkedConvert('image_path_android', (v) => v as String?), imagePathIOS: $checkedConvert('image_path_ios', (v) => v as String?), + imagePathIOSDarkTransparent: $checkedConvert( + 'image_path_ios_dark_transparent', (v) => v as String?), + imagePathIOSTintedGrayscale: $checkedConvert( + 'image_path_ios_tinted_grayscale', (v) => v as String?), adaptiveIconForeground: $checkedConvert('adaptive_icon_foreground', (v) => v as String?), adaptiveIconBackground: $checkedConvert('adaptive_icon_background', (v) => v as String?), adaptiveIconMonochrome: $checkedConvert('adaptive_icon_monochrome', (v) => v as String?), - minSdkAndroid: $checkedConvert('min_sdk_android', - (v) => v as int? ?? constants.androidDefaultAndroidMinSDK), + minSdkAndroid: $checkedConvert( + 'min_sdk_android', + (v) => + (v as num?)?.toInt() ?? + constants.androidDefaultAndroidMinSDK), removeAlphaIOS: $checkedConvert('remove_alpha_ios', (v) => v as bool? ?? false), + desaturateTintedToGrayscaleIOS: $checkedConvert( + 'desaturate_tinted_to_grayscale_ios', (v) => v as bool? ?? false), backgroundColorIOS: $checkedConvert( 'background_color_ios', (v) => v as String? ?? '#ffffff'), webConfig: $checkedConvert( @@ -42,11 +51,14 @@ Config _$ConfigFromJson(Map json) => $checkedCreate( 'imagePath': 'image_path', 'imagePathAndroid': 'image_path_android', 'imagePathIOS': 'image_path_ios', + 'imagePathIOSDarkTransparent': 'image_path_ios_dark_transparent', + 'imagePathIOSTintedGrayscale': 'image_path_ios_tinted_grayscale', 'adaptiveIconForeground': 'adaptive_icon_foreground', 'adaptiveIconBackground': 'adaptive_icon_background', 'adaptiveIconMonochrome': 'adaptive_icon_monochrome', 'minSdkAndroid': 'min_sdk_android', 'removeAlphaIOS': 'remove_alpha_ios', + 'desaturateTintedToGrayscaleIOS': 'desaturate_tinted_to_grayscale_ios', 'backgroundColorIOS': 'background_color_ios', 'webConfig': 'web', 'windowsConfig': 'windows', @@ -60,11 +72,15 @@ Map _$ConfigToJson(Config instance) => { 'ios': instance.ios, 'image_path_android': instance.imagePathAndroid, 'image_path_ios': instance.imagePathIOS, + 'image_path_ios_dark_transparent': instance.imagePathIOSDarkTransparent, + 'image_path_ios_tinted_grayscale': instance.imagePathIOSTintedGrayscale, 'adaptive_icon_foreground': instance.adaptiveIconForeground, 'adaptive_icon_background': instance.adaptiveIconBackground, 'adaptive_icon_monochrome': instance.adaptiveIconMonochrome, 'min_sdk_android': instance.minSdkAndroid, 'remove_alpha_ios': instance.removeAlphaIOS, + 'desaturate_tinted_to_grayscale_ios': + instance.desaturateTintedToGrayscaleIOS, 'background_color_ios': instance.backgroundColorIOS, 'web': instance.webConfig, 'windows': instance.windowsConfig, diff --git a/lib/config/windows_config.g.dart b/lib/config/windows_config.g.dart index ef7a20e1ec..553ddb1edd 100644 --- a/lib/config/windows_config.g.dart +++ b/lib/config/windows_config.g.dart @@ -13,7 +13,7 @@ WindowsConfig _$WindowsConfigFromJson(Map json) => $checkedCreate( final val = WindowsConfig( generate: $checkedConvert('generate', (v) => v as bool? ?? false), imagePath: $checkedConvert('image_path', (v) => v as String?), - iconSize: $checkedConvert('icon_size', (v) => v as int?), + iconSize: $checkedConvert('icon_size', (v) => (v as num?)?.toInt()), ); return val; }, diff --git a/lib/ios.dart b/lib/ios.dart index 5fad0e7da2..907e056b31 100644 --- a/lib/ios.dart +++ b/lib/ios.dart @@ -22,7 +22,7 @@ class IosIconTemplate { } /// details of the ios icons which need to be generated -List iosIcons = [ +List legacyIosIcons = [ IosIconTemplate(name: '-20x20@1x', size: 20), IosIconTemplate(name: '-20x20@2x', size: 40), IosIconTemplate(name: '-20x20@3x', size: 60), @@ -46,19 +46,75 @@ List iosIcons = [ IosIconTemplate(name: '-1024x1024@1x', size: 1024), ]; +List iosIcons = [ + IosIconTemplate(name: '-20x20@2x', size: 40), + IosIconTemplate(name: '-20x20@3x', size: 60), + IosIconTemplate(name: '-29x29@2x', size: 58), + IosIconTemplate(name: '-29x29@3x', size: 87), + IosIconTemplate(name: '-38x38@2x', size: 76), + IosIconTemplate(name: '-38x38@3x', size: 114), + IosIconTemplate(name: '-40x40@2x', size: 80), + IosIconTemplate(name: '-40x40@3x', size: 120), + IosIconTemplate(name: '-60x60@2x', size: 120), + IosIconTemplate(name: '-60x60@3x', size: 180), + IosIconTemplate(name: '-64x64@2x', size: 128), + IosIconTemplate(name: '-64x64@3x', size: 192), + IosIconTemplate(name: '-68x68@2x', size: 136), + IosIconTemplate(name: '-76x76@2x', size: 152), + IosIconTemplate(name: '-83.5x83.5@2x', size: 167), + IosIconTemplate(name: '-1024x1024@1x', size: 1024), +]; + /// create the ios icons void createIcons(Config config, String? flavor) { // TODO(p-mazhnik): support prefixPath final String? filePath = config.getImagePathIOS(); + final String? darkFilePath = config.imagePathIOSDarkTransparent; + final String? tintedFilePath = config.imagePathIOSTintedGrayscale; + if (filePath == null) { throw const InvalidConfigException(errorMissingImagePath); } + // decodeImageFile shows error message if null // so can return here if image is null Image? image = decodeImage(File(filePath).readAsBytesSync()); if (image == null) { return; } + + // For dark and tinted images, return here if path was specified but image is null + Image? darkImage; + if (darkFilePath != null) { + darkImage = decodeImage(File(darkFilePath).readAsBytesSync()); + if (darkImage == null) { + return; + } + } + + Image? tintedImage; + if (tintedFilePath != null) { + tintedImage = decodeImage(File(tintedFilePath).readAsBytesSync()); + if (tintedImage == null) { + return; + } + if (config.desaturateTintedToGrayscaleIOS) { + printStatus('Desaturating iOS tinted image to grayscale'); + tintedImage = grayscale(tintedImage); + } else { + // Check if the image is already grayscale + final pixel = tintedImage.getPixel(0, 0); + do { + if (pixel.r != pixel.g || pixel.g != pixel.b) { + print( + '\nWARNING: Tinted iOS image is not grayscale.\nSet "desaturate_tinted_to_grayscale_ios: true" to desaturate it.\n', + ); + break; + } + } while (pixel.moveNext()); + } + } + if (config.removeAlphaIOS && image.hasAlpha) { final backgroundColor = _getBackgroundColor(config); final pixel = image.getPixel(0, 0); @@ -74,46 +130,96 @@ void createIcons(Config config, String? flavor) { ); } String iconName; + String? darkIconName; + String? tintedIconName; + final List generateIosIcons = (darkImage == null && tintedImage == null) ? legacyIosIcons : iosIcons; final dynamic iosConfig = config.ios; if (flavor != null) { final String catalogName = 'AppIcon-$flavor'; printStatus('Building iOS launcher icon for $flavor'); - for (IosIconTemplate template in iosIcons) { + for (IosIconTemplate template in generateIosIcons) { saveNewIcons(template, image, catalogName); } + if (darkImage != null) { + final String darkCatalogName = 'AppIcon-$flavor-Dark'; + printStatus('Building iOS dark launcher icon for $flavor'); + for (IosIconTemplate template in generateIosIcons) { + saveNewIcons(template, darkImage, darkCatalogName); + } + darkIconName = darkCatalogName; + } + if (tintedImage != null) { + final String tintedCatalogName = 'AppIcon-$flavor-Tinted'; + printStatus('Building iOS tinted launcher icon for $flavor'); + for (IosIconTemplate template in generateIosIcons) { + saveNewIcons(template, tintedImage, tintedCatalogName); + } + tintedIconName = tintedCatalogName; + } iconName = iosDefaultIconName; changeIosLauncherIcon(catalogName, flavor); - modifyContentsFile(catalogName); + modifyContentsFile(catalogName, darkIconName, tintedIconName); } else if (iosConfig is String) { // If the IOS configuration is a string then the user has specified a new icon to be created // and for the old icon file to be kept final String newIconName = iosConfig; printStatus('Adding new iOS launcher icon'); - for (IosIconTemplate template in iosIcons) { + for (IosIconTemplate template in generateIosIcons) { saveNewIcons(template, image, newIconName); } + if (darkImage != null) { + darkIconName = newIconName + '-Dark'; + printStatus('Adding new iOS dark launcher icon'); + for (IosIconTemplate template in generateIosIcons) { + saveNewIcons(template, darkImage, darkIconName); + } + } + if (tintedImage != null) { + tintedIconName = newIconName + '-Tinted'; + printStatus('Adding new iOS tinted launcher icon'); + for (IosIconTemplate template in generateIosIcons) { + saveNewIcons(template, tintedImage, tintedIconName); + } + } iconName = newIconName; changeIosLauncherIcon(iconName, flavor); - modifyContentsFile(iconName); + modifyContentsFile(iconName, darkIconName, tintedIconName); } // Otherwise the user wants the new icon to use the default icons name and // update config file to use it else { printStatus('Overwriting default iOS launcher icon with new icon'); - for (IosIconTemplate template in iosIcons) { + for (IosIconTemplate template in generateIosIcons) { overwriteDefaultIcons(template, image); } + if (darkImage != null) { + printStatus('Overwriting default iOS dark launcher icon with new icon'); + for (IosIconTemplate template in generateIosIcons) { + overwriteDefaultIcons(template, darkImage, '-Dark'); + } + darkIconName = iosDefaultIconName + '-Dark'; + } + if (tintedImage != null) { + printStatus('Overwriting default iOS tinted launcher icon with new icon'); + for (IosIconTemplate template in generateIosIcons) { + overwriteDefaultIcons(template, tintedImage, '-Tinted'); + } + tintedIconName = iosDefaultIconName + '-Tinted'; + } iconName = iosDefaultIconName; changeIosLauncherIcon('AppIcon', flavor); + // Still need to modify the Contents.json file + // since the user could have added dark and tinted icons + modifyDefaultContentsFile(iconName, darkIconName, tintedIconName); } } /// Note: Do not change interpolation unless you end up with better results (see issue for result when using cubic /// interpolation) /// https://github.com/fluttercommunity/flutter_launcher_icons/issues/101#issuecomment-495528733 -void overwriteDefaultIcons(IosIconTemplate template, Image image) { +void overwriteDefaultIcons(IosIconTemplate template, Image image, [String iconNameSuffix = '']) { final Image newFile = createResizedImage(template, image); - File(iosDefaultIconFolder + iosDefaultIconName + template.name + '.png') + File(iosDefaultIconFolder + iosDefaultIconName + iconNameSuffix + template.name + '.png') ..writeAsBytesSync(encodePng(newFile)); } @@ -184,43 +290,83 @@ Future changeIosLauncherIcon(String iconName, String? flavor) async { } /// Create the Contents.json file -void modifyContentsFile(String newIconName) { +void modifyContentsFile(String newIconName, String? darkIconName, String? tintedIconName) { final String newIconFolder = iosAssetFolder + newIconName + '.appiconset/Contents.json'; File(newIconFolder).create(recursive: true).then((File contentsJsonFile) { final String contentsFileContent = - generateContentsFileAsString(newIconName); + generateContentsFileAsString(newIconName, darkIconName, tintedIconName); + contentsJsonFile.writeAsString(contentsFileContent); + }); +} + +/// Modify default Contents.json file +void modifyDefaultContentsFile(String newIconName, String? darkIconName, String? tintedIconName) { + const String newIconFolder = + iosAssetFolder + 'AppIcon.appiconset/Contents.json'; + File(newIconFolder).create(recursive: true).then((File contentsJsonFile) { + final String contentsFileContent = + generateContentsFileAsString(newIconName, darkIconName, tintedIconName); contentsJsonFile.writeAsString(contentsFileContent); }); } -String generateContentsFileAsString(String newIconName) { +String generateContentsFileAsString(String newIconName, String? darkIconName, String? tintedIconName) { + final List> imageList; + if (darkIconName == null && tintedIconName == null) { + imageList = createLegacyImageList(newIconName); + } else { + imageList = createImageList(newIconName, darkIconName, tintedIconName); + } final Map contentJson = { - 'images': createImageList(newIconName), + 'images': imageList, 'info': ContentsInfoObject(version: 1, author: 'xcode').toJson(), }; return json.encode(contentJson); } +class ContentsImageAppearanceObject { + ContentsImageAppearanceObject({ + required this.appearance, + required this.value, + }); + + final String appearance; + final String value; + + Map toJson() { + return { + 'appearance': appearance, + 'value': value, + }; + } +} + class ContentsImageObject { ContentsImageObject({ required this.size, required this.idiom, required this.filename, required this.scale, + this.platform, + this.appearances, }); final String size; final String idiom; final String filename; final String scale; + final String? platform; + final List? appearances; - Map toJson() { - return { + Map toJson() { + return { 'size': size, 'idiom': idiom, 'filename': filename, 'scale': scale, + if (platform != null) 'platform': platform, + if (appearances != null) 'appearances': appearances!.map((e) => e.toJson()).toList(), }; } } @@ -239,159 +385,137 @@ class ContentsInfoObject { } } -List> createImageList(String fileNamePrefix) { - final List> imageList = >[ - ContentsImageObject( - size: '20x20', - idiom: 'iphone', - filename: '$fileNamePrefix-20x20@2x.png', - scale: '2x', - ).toJson(), - ContentsImageObject( - size: '20x20', - idiom: 'iphone', - filename: '$fileNamePrefix-20x20@3x.png', - scale: '3x', - ).toJson(), - ContentsImageObject( - size: '29x29', - idiom: 'iphone', - filename: '$fileNamePrefix-29x29@1x.png', - scale: '1x', - ).toJson(), - ContentsImageObject( - size: '29x29', - idiom: 'iphone', - filename: '$fileNamePrefix-29x29@2x.png', - scale: '2x', - ).toJson(), - ContentsImageObject( - size: '29x29', - idiom: 'iphone', - filename: '$fileNamePrefix-29x29@3x.png', - scale: '3x', - ).toJson(), - ContentsImageObject( - size: '40x40', - idiom: 'iphone', - filename: '$fileNamePrefix-40x40@2x.png', - scale: '2x', - ).toJson(), - ContentsImageObject( - size: '40x40', - idiom: 'iphone', - filename: '$fileNamePrefix-40x40@3x.png', - scale: '3x', - ).toJson(), - ContentsImageObject( - size: '50x50', - idiom: 'ipad', - filename: '$fileNamePrefix-50x50@1x.png', - scale: '1x', - ).toJson(), - ContentsImageObject( - size: '50x50', - idiom: 'ipad', - filename: '$fileNamePrefix-50x50@2x.png', - scale: '2x', - ).toJson(), - ContentsImageObject( - size: '57x57', - idiom: 'iphone', - filename: '$fileNamePrefix-57x57@1x.png', - scale: '1x', - ).toJson(), - ContentsImageObject( - size: '57x57', - idiom: 'iphone', - filename: '$fileNamePrefix-57x57@2x.png', - scale: '2x', - ).toJson(), - ContentsImageObject( - size: '60x60', - idiom: 'iphone', - filename: '$fileNamePrefix-60x60@2x.png', - scale: '2x', - ).toJson(), - ContentsImageObject( - size: '60x60', - idiom: 'iphone', - filename: '$fileNamePrefix-60x60@3x.png', - scale: '3x', - ).toJson(), - ContentsImageObject( - size: '20x20', - idiom: 'ipad', - filename: '$fileNamePrefix-20x20@1x.png', - scale: '1x', - ).toJson(), - ContentsImageObject( - size: '20x20', - idiom: 'ipad', - filename: '$fileNamePrefix-20x20@2x.png', - scale: '2x', - ).toJson(), - ContentsImageObject( - size: '29x29', - idiom: 'ipad', - filename: '$fileNamePrefix-29x29@1x.png', - scale: '1x', - ).toJson(), - ContentsImageObject( - size: '29x29', - idiom: 'ipad', - filename: '$fileNamePrefix-29x29@2x.png', - scale: '2x', - ).toJson(), - ContentsImageObject( - size: '40x40', - idiom: 'ipad', - filename: '$fileNamePrefix-40x40@1x.png', - scale: '1x', - ).toJson(), - ContentsImageObject( - size: '40x40', - idiom: 'ipad', - filename: '$fileNamePrefix-40x40@2x.png', - scale: '2x', - ).toJson(), - ContentsImageObject( - size: '72x72', - idiom: 'ipad', - filename: '$fileNamePrefix-72x72@1x.png', - scale: '1x', - ).toJson(), - ContentsImageObject( - size: '72x72', - idiom: 'ipad', - filename: '$fileNamePrefix-72x72@2x.png', - scale: '2x', - ).toJson(), - ContentsImageObject( - size: '76x76', - idiom: 'ipad', - filename: '$fileNamePrefix-76x76@1x.png', - scale: '1x', - ).toJson(), - ContentsImageObject( - size: '76x76', - idiom: 'ipad', - filename: '$fileNamePrefix-76x76@2x.png', - scale: '2x', - ).toJson(), - ContentsImageObject( - size: '83.5x83.5', - idiom: 'ipad', - filename: '$fileNamePrefix-83.5x83.5@2x.png', - scale: '2x', - ).toJson(), - ContentsImageObject( - size: '1024x1024', - idiom: 'ios-marketing', - filename: '$fileNamePrefix-1024x1024@1x.png', - scale: '1x', - ).toJson(), +/// Create the image list for the Contents.json file for Xcode versions below Xcode 14 +List> createLegacyImageList(String fileNamePrefix) { + const List> imageConfigurations = [ + {'size': '20x20', 'idiom': 'iphone', 'scales': ['2x', '3x']}, + {'size': '29x29', 'idiom': 'iphone', 'scales': ['1x', '2x', '3x']}, + {'size': '40x40', 'idiom': 'iphone', 'scales': ['2x', '3x']}, + {'size': '57x57', 'idiom': 'iphone', 'scales': ['1x', '2x']}, + {'size': '60x60', 'idiom': 'iphone', 'scales': ['2x', '3x']}, + {'size': '20x20', 'idiom': 'ipad', 'scales': ['1x', '2x']}, + {'size': '29x29', 'idiom': 'ipad', 'scales': ['1x', '2x']}, + {'size': '40x40', 'idiom': 'ipad', 'scales': ['1x', '2x']}, + {'size': '50x50', 'idiom': 'ipad', 'scales': ['1x', '2x']}, + {'size': '72x72', 'idiom': 'ipad', 'scales': ['1x', '2x']}, + {'size': '76x76', 'idiom': 'ipad', 'scales': ['1x', '2x']}, + {'size': '83.5x83.5', 'idiom': 'ipad', 'scales': ['2x']}, + {'size': '1024x1024', 'idiom': 'ios-marketing', 'scales': ['1x']}, + ]; + + final List> imageList = >[]; + + for (final config in imageConfigurations) { + final size = config['size']!; + final idiom = config['idiom']!; + final List scales = config['scales']; + + for (final scale in scales) { + final filename = '$fileNamePrefix-$size@$scale.png'; + imageList.add( + ContentsImageObject( + size: size, + idiom: idiom, + filename: filename, + scale: scale, + ).toJson(), + ); + } + } + + return imageList; +} + +/// Create the image list for the Contents.json file for Xcode versions Xcode 14 and above +List> createImageList(String fileNamePrefix, String? darkFileNamePrefix, String? tintedFileNamePrefix) { + const List> imageConfigurations = [ + {'size': '20x20', 'idiom': 'universal', 'platform': 'ios', 'scales': ['2x', '3x']}, + {'size': '29x29', 'idiom': 'universal', 'platform': 'ios', 'scales': ['2x', '3x']}, + {'size': '38x38', 'idiom': 'universal', 'platform': 'ios', 'scales': ['2x', '3x']}, + {'size': '40x40', 'idiom': 'universal', 'platform': 'ios', 'scales': ['2x', '3x']}, + {'size': '60x60', 'idiom': 'universal', 'platform': 'ios', 'scales': ['2x', '3x']}, + {'size': '64x64', 'idiom': 'universal', 'platform': 'ios', 'scales': ['2x', '3x']}, + {'size': '68x68', 'idiom': 'universal', 'platform': 'ios', 'scales': ['2x']}, + {'size': '76x76', 'idiom': 'universal', 'platform': 'ios', 'scales': ['2x']}, + {'size': '83.5x83.5', 'idiom': 'universal', 'platform': 'ios', 'scales': ['2x']}, + {'size': '1024x1024', 'idiom': 'universal', 'platform': 'ios', 'scales': ['1x']}, + {'size': '1024x1024', 'idiom': 'ios-marketing', 'scales': ['1x']}, ]; + + final List> imageList = >[]; + + for (final config in imageConfigurations) { + final size = config['size']!; + final idiom = config['idiom']!; + final platform = config['platform']; + final List scales = config['scales']; + + for (final scale in scales) { + final filename = '$fileNamePrefix-$size@$scale.png'; + imageList.add( + ContentsImageObject( + size: size, + idiom: idiom, + filename: filename, + platform: platform, + scale: scale, + ).toJson(), + ); + } + } + + // Prevent ios-marketing icon from being tinted or dark + + if (darkFileNamePrefix != null) { + for (final config in imageConfigurations.where((e) => e['idiom'] == 'universal')) { + final size = config['size']!; + final idiom = config['idiom']!; + final platform = config['platform']; + final List scales = config['scales']; + + for (final scale in scales) { + final filename = '$darkFileNamePrefix-$size@$scale.png'; + imageList.add( + ContentsImageObject( + size: size, + idiom: idiom, + filename: filename, + platform: platform, + scale: scale, + appearances: [ + ContentsImageAppearanceObject(appearance: 'luminosity', value: 'dark'), + ], + ).toJson(), + ); + } + } + } + + if (tintedFileNamePrefix != null) { + for (final config in imageConfigurations.where((e) => e['idiom'] == 'universal')) { + final size = config['size']!; + final idiom = config['idiom']!; + final platform = config['platform']; + final List scales = config['scales']; + + for (final scale in scales) { + final filename = '$tintedFileNamePrefix-$size@$scale.png'; + imageList.add( + ContentsImageObject( + size: size, + idiom: idiom, + filename: filename, + platform: platform, + scale: scale, + appearances: [ + ContentsImageAppearanceObject(appearance: 'luminosity', value: 'tinted'), + ], + ).toJson(), + ); + } + } + } + return imageList; } diff --git a/test/abs/icon_generator_test.mocks.dart b/test/abs/icon_generator_test.mocks.dart index 0b320f4f56..8d60d4b125 100644 --- a/test/abs/icon_generator_test.mocks.dart +++ b/test/abs/icon_generator_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.2 from annotations +// Mocks generated by Mockito 5.4.4 from annotations // in flutter_launcher_icons/test/abs/icon_generator_test.dart. // Do not manually edit this file. @@ -6,11 +6,14 @@ import 'package:flutter_launcher_icons/abs/icon_generator.dart' as _i2; import 'package:flutter_launcher_icons/config/config.dart' as _i3; import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i4; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values // ignore_for_file: avoid_setters_without_getters // ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member // ignore_for_file: prefer_const_constructors @@ -42,61 +45,82 @@ class MockConfig extends _i1.Mock implements _i3.Config { Invocation.getter(#minSdkAndroid), returnValue: 0, ) as int); + @override bool get removeAlphaIOS => (super.noSuchMethod( Invocation.getter(#removeAlphaIOS), returnValue: false, ) as bool); + + @override + bool get desaturateTintedToGrayscaleIOS => (super.noSuchMethod( + Invocation.getter(#desaturateTintedToGrayscaleIOS), + returnValue: false, + ) as bool); + @override String get backgroundColorIOS => (super.noSuchMethod( Invocation.getter(#backgroundColorIOS), - returnValue: '', + returnValue: _i4.dummyValue( + this, + Invocation.getter(#backgroundColorIOS), + ), ) as String); + @override bool get hasAndroidAdaptiveConfig => (super.noSuchMethod( Invocation.getter(#hasAndroidAdaptiveConfig), returnValue: false, ) as bool); + @override bool get hasAndroidAdaptiveMonochromeConfig => (super.noSuchMethod( Invocation.getter(#hasAndroidAdaptiveMonochromeConfig), returnValue: false, ) as bool); + @override bool get hasPlatformConfig => (super.noSuchMethod( Invocation.getter(#hasPlatformConfig), returnValue: false, ) as bool); + @override bool get hasWebConfig => (super.noSuchMethod( Invocation.getter(#hasWebConfig), returnValue: false, ) as bool); + @override bool get hasWindowsConfig => (super.noSuchMethod( Invocation.getter(#hasWindowsConfig), returnValue: false, ) as bool); + @override bool get hasMacOSConfig => (super.noSuchMethod( Invocation.getter(#hasMacOSConfig), returnValue: false, ) as bool); + @override bool get isCustomAndroidFile => (super.noSuchMethod( Invocation.getter(#isCustomAndroidFile), returnValue: false, ) as bool); + @override bool get isNeedingNewAndroidIcon => (super.noSuchMethod( Invocation.getter(#isNeedingNewAndroidIcon), returnValue: false, ) as bool); + @override bool get isNeedingNewIOSIcon => (super.noSuchMethod( Invocation.getter(#isNeedingNewIOSIcon), returnValue: false, ) as bool); + @override Map toJson() => (super.noSuchMethod( Invocation.method( @@ -123,11 +147,16 @@ class MockIconGenerator extends _i1.Mock implements _i2.IconGenerator { Invocation.getter(#context), ), ) as _i2.IconGeneratorContext); + @override String get platformName => (super.noSuchMethod( Invocation.getter(#platformName), - returnValue: '', + returnValue: _i4.dummyValue( + this, + Invocation.getter(#platformName), + ), ) as String); + @override void createIcons() => super.noSuchMethod( Invocation.method( @@ -136,6 +165,7 @@ class MockIconGenerator extends _i1.Mock implements _i2.IconGenerator { ), returnValueForMissingStub: null, ); + @override bool validateRequirements() => (super.noSuchMethod( Invocation.method( diff --git a/test/macos/macos_icon_generator_test.mocks.dart b/test/macos/macos_icon_generator_test.mocks.dart index 6d6509666e..47b5dc9128 100644 --- a/test/macos/macos_icon_generator_test.mocks.dart +++ b/test/macos/macos_icon_generator_test.mocks.dart @@ -1,18 +1,21 @@ -// Mocks generated by Mockito 5.4.2 from annotations +// Mocks generated by Mockito 5.4.4 from annotations // in flutter_launcher_icons/test/macos/macos_icon_generator_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes import 'package:cli_util/cli_logging.dart' as _i2; import 'package:flutter_launcher_icons/config/config.dart' as _i3; -import 'package:flutter_launcher_icons/config/macos_config.dart' as _i4; -import 'package:flutter_launcher_icons/logger.dart' as _i5; +import 'package:flutter_launcher_icons/config/macos_config.dart' as _i5; +import 'package:flutter_launcher_icons/logger.dart' as _i6; import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i4; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values // ignore_for_file: avoid_setters_without_getters // ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member // ignore_for_file: prefer_const_constructors @@ -50,72 +53,97 @@ class MockConfig extends _i1.Mock implements _i3.Config { returnValue: 0, returnValueForMissingStub: 0, ) as int); + @override bool get removeAlphaIOS => (super.noSuchMethod( Invocation.getter(#removeAlphaIOS), returnValue: false, returnValueForMissingStub: false, ) as bool); + + @override + bool get desaturateTintedToGrayscaleIOS => (super.noSuchMethod( + Invocation.getter(#desaturateTintedToGrayscaleIOS), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + @override String get backgroundColorIOS => (super.noSuchMethod( Invocation.getter(#backgroundColorIOS), - returnValue: '', - returnValueForMissingStub: '', + returnValue: _i4.dummyValue( + this, + Invocation.getter(#backgroundColorIOS), + ), + returnValueForMissingStub: _i4.dummyValue( + this, + Invocation.getter(#backgroundColorIOS), + ), ) as String); + @override bool get hasAndroidAdaptiveConfig => (super.noSuchMethod( Invocation.getter(#hasAndroidAdaptiveConfig), returnValue: false, returnValueForMissingStub: false, ) as bool); + @override bool get hasAndroidAdaptiveMonochromeConfig => (super.noSuchMethod( Invocation.getter(#hasAndroidAdaptiveMonochromeConfig), returnValue: false, returnValueForMissingStub: false, ) as bool); + @override bool get hasPlatformConfig => (super.noSuchMethod( Invocation.getter(#hasPlatformConfig), returnValue: false, returnValueForMissingStub: false, ) as bool); + @override bool get hasWebConfig => (super.noSuchMethod( Invocation.getter(#hasWebConfig), returnValue: false, returnValueForMissingStub: false, ) as bool); + @override bool get hasWindowsConfig => (super.noSuchMethod( Invocation.getter(#hasWindowsConfig), returnValue: false, returnValueForMissingStub: false, ) as bool); + @override bool get hasMacOSConfig => (super.noSuchMethod( Invocation.getter(#hasMacOSConfig), returnValue: false, returnValueForMissingStub: false, ) as bool); + @override bool get isCustomAndroidFile => (super.noSuchMethod( Invocation.getter(#isCustomAndroidFile), returnValue: false, returnValueForMissingStub: false, ) as bool); + @override bool get isNeedingNewAndroidIcon => (super.noSuchMethod( Invocation.getter(#isNeedingNewAndroidIcon), returnValue: false, returnValueForMissingStub: false, ) as bool); + @override bool get isNeedingNewIOSIcon => (super.noSuchMethod( Invocation.getter(#isNeedingNewIOSIcon), returnValue: false, returnValueForMissingStub: false, ) as bool); + @override Map toJson() => (super.noSuchMethod( Invocation.method( @@ -130,13 +158,14 @@ class MockConfig extends _i1.Mock implements _i3.Config { /// A class which mocks [MacOSConfig]. /// /// See the documentation for Mockito's code generation for more information. -class MockMacOSConfig extends _i1.Mock implements _i4.MacOSConfig { +class MockMacOSConfig extends _i1.Mock implements _i5.MacOSConfig { @override bool get generate => (super.noSuchMethod( Invocation.getter(#generate), returnValue: false, returnValueForMissingStub: false, ) as bool); + @override Map toJson() => (super.noSuchMethod( Invocation.method( @@ -151,13 +180,14 @@ class MockMacOSConfig extends _i1.Mock implements _i4.MacOSConfig { /// A class which mocks [FLILogger]. /// /// See the documentation for Mockito's code generation for more information. -class MockFLILogger extends _i1.Mock implements _i5.FLILogger { +class MockFLILogger extends _i1.Mock implements _i6.FLILogger { @override bool get isVerbose => (super.noSuchMethod( Invocation.getter(#isVerbose), returnValue: false, returnValueForMissingStub: false, ) as bool); + @override _i2.Logger get rawLogger => (super.noSuchMethod( Invocation.getter(#rawLogger), @@ -170,6 +200,7 @@ class MockFLILogger extends _i1.Mock implements _i5.FLILogger { Invocation.getter(#rawLogger), ), ) as _i2.Logger); + @override void error(Object? message) => super.noSuchMethod( Invocation.method( @@ -178,6 +209,7 @@ class MockFLILogger extends _i1.Mock implements _i5.FLILogger { ), returnValueForMissingStub: null, ); + @override void verbose(Object? message) => super.noSuchMethod( Invocation.method( @@ -186,6 +218,7 @@ class MockFLILogger extends _i1.Mock implements _i5.FLILogger { ), returnValueForMissingStub: null, ); + @override void info(Object? message) => super.noSuchMethod( Invocation.method( @@ -194,6 +227,7 @@ class MockFLILogger extends _i1.Mock implements _i5.FLILogger { ), returnValueForMissingStub: null, ); + @override _i2.Progress progress(String? message) => (super.noSuchMethod( Invocation.method( diff --git a/test/main_test.dart b/test/main_test.dart index 17bb7a041b..c86f1c8234 100644 --- a/test/main_test.dart +++ b/test/main_test.dart @@ -20,9 +20,27 @@ void main() { }); test( - 'iOS image list used to generate Contents.json for icon directory is correct size', + 'iOS image list used to generate legacy Contents.json for icon directory is correct size (no dark or tinted icons)', () { - expect(ios.createImageList('blah').length, 25); + expect(ios.createLegacyImageList('blah').length, 25); + }); + + test( + 'iOS image list used to generate Contents.json for icon directory is correct size (with dark icon)', + () { + expect(ios.createImageList('blah', 'dark-blah', null).length, 16 * 2 + 1); // 16 normal, 16 dark icons + 1 marketing icon + }); + + test( + 'iOS image list used to generate Contents.json for icon directory is correct size (with tinted icon)', + () { + expect(ios.createImageList('blah', null, 'tinted-blah').length, 16 * 2 + 1); // 16 normal, 16 tinted icons + 1 marketing icon + }); + + test( + 'iOS image list used to generate Contents.json for icon directory is correct size (with dark and tinted icon)', + () { + expect(ios.createImageList('blah', 'dark-blah', 'tinted-blah').length, 16 * 3 + 1); // 16 normal, 16 dark, 16 tinted icons + 1 marketing icon }); group('config file from args', () { diff --git a/test/windows/windows_icon_generator_test.mocks.dart b/test/windows/windows_icon_generator_test.mocks.dart index 1a6463b7c8..9cd13510eb 100644 --- a/test/windows/windows_icon_generator_test.mocks.dart +++ b/test/windows/windows_icon_generator_test.mocks.dart @@ -1,18 +1,21 @@ -// Mocks generated by Mockito 5.4.2 from annotations +// Mocks generated by Mockito 5.4.4 from annotations // in flutter_launcher_icons/test/windows/windows_icon_generator_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes import 'package:cli_util/cli_logging.dart' as _i2; import 'package:flutter_launcher_icons/config/config.dart' as _i3; -import 'package:flutter_launcher_icons/config/windows_config.dart' as _i4; -import 'package:flutter_launcher_icons/logger.dart' as _i5; +import 'package:flutter_launcher_icons/config/windows_config.dart' as _i5; +import 'package:flutter_launcher_icons/logger.dart' as _i6; import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i4; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values // ignore_for_file: avoid_setters_without_getters // ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member // ignore_for_file: prefer_const_constructors @@ -53,61 +56,82 @@ class MockConfig extends _i1.Mock implements _i3.Config { Invocation.getter(#minSdkAndroid), returnValue: 0, ) as int); + @override bool get removeAlphaIOS => (super.noSuchMethod( Invocation.getter(#removeAlphaIOS), returnValue: false, ) as bool); + + @override + bool get desaturateTintedToGrayscaleIOS => (super.noSuchMethod( + Invocation.getter(#desaturateTintedToGrayscaleIOS), + returnValue: false, + ) as bool); + @override String get backgroundColorIOS => (super.noSuchMethod( Invocation.getter(#backgroundColorIOS), - returnValue: '', + returnValue: _i4.dummyValue( + this, + Invocation.getter(#backgroundColorIOS), + ), ) as String); + @override bool get hasAndroidAdaptiveConfig => (super.noSuchMethod( Invocation.getter(#hasAndroidAdaptiveConfig), returnValue: false, ) as bool); + @override bool get hasAndroidAdaptiveMonochromeConfig => (super.noSuchMethod( Invocation.getter(#hasAndroidAdaptiveMonochromeConfig), returnValue: false, ) as bool); + @override bool get hasPlatformConfig => (super.noSuchMethod( Invocation.getter(#hasPlatformConfig), returnValue: false, ) as bool); + @override bool get hasWebConfig => (super.noSuchMethod( Invocation.getter(#hasWebConfig), returnValue: false, ) as bool); + @override bool get hasWindowsConfig => (super.noSuchMethod( Invocation.getter(#hasWindowsConfig), returnValue: false, ) as bool); + @override bool get hasMacOSConfig => (super.noSuchMethod( Invocation.getter(#hasMacOSConfig), returnValue: false, ) as bool); + @override bool get isCustomAndroidFile => (super.noSuchMethod( Invocation.getter(#isCustomAndroidFile), returnValue: false, ) as bool); + @override bool get isNeedingNewAndroidIcon => (super.noSuchMethod( Invocation.getter(#isNeedingNewAndroidIcon), returnValue: false, ) as bool); + @override bool get isNeedingNewIOSIcon => (super.noSuchMethod( Invocation.getter(#isNeedingNewIOSIcon), returnValue: false, ) as bool); + @override Map toJson() => (super.noSuchMethod( Invocation.method( @@ -121,7 +145,7 @@ class MockConfig extends _i1.Mock implements _i3.Config { /// A class which mocks [WindowsConfig]. /// /// See the documentation for Mockito's code generation for more information. -class MockWindowsConfig extends _i1.Mock implements _i4.WindowsConfig { +class MockWindowsConfig extends _i1.Mock implements _i5.WindowsConfig { MockWindowsConfig() { _i1.throwOnMissingStub(this); } @@ -131,6 +155,7 @@ class MockWindowsConfig extends _i1.Mock implements _i4.WindowsConfig { Invocation.getter(#generate), returnValue: false, ) as bool); + @override Map toJson() => (super.noSuchMethod( Invocation.method( @@ -144,7 +169,7 @@ class MockWindowsConfig extends _i1.Mock implements _i4.WindowsConfig { /// A class which mocks [FLILogger]. /// /// See the documentation for Mockito's code generation for more information. -class MockFLILogger extends _i1.Mock implements _i5.FLILogger { +class MockFLILogger extends _i1.Mock implements _i6.FLILogger { MockFLILogger() { _i1.throwOnMissingStub(this); } @@ -154,6 +179,7 @@ class MockFLILogger extends _i1.Mock implements _i5.FLILogger { Invocation.getter(#isVerbose), returnValue: false, ) as bool); + @override _i2.Logger get rawLogger => (super.noSuchMethod( Invocation.getter(#rawLogger), @@ -162,6 +188,7 @@ class MockFLILogger extends _i1.Mock implements _i5.FLILogger { Invocation.getter(#rawLogger), ), ) as _i2.Logger); + @override void error(Object? message) => super.noSuchMethod( Invocation.method( @@ -170,6 +197,7 @@ class MockFLILogger extends _i1.Mock implements _i5.FLILogger { ), returnValueForMissingStub: null, ); + @override void verbose(Object? message) => super.noSuchMethod( Invocation.method( @@ -178,6 +206,7 @@ class MockFLILogger extends _i1.Mock implements _i5.FLILogger { ), returnValueForMissingStub: null, ); + @override void info(Object? message) => super.noSuchMethod( Invocation.method( @@ -186,6 +215,7 @@ class MockFLILogger extends _i1.Mock implements _i5.FLILogger { ), returnValueForMissingStub: null, ); + @override _i2.Progress progress(String? message) => (super.noSuchMethod( Invocation.method(