diff --git a/analysis_options.yaml b/analysis_options.yaml index 98c6b5e3f6..6df18fa29e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -13,12 +13,13 @@ analyzer: # Please see https://github.com/flutter/flutter/pull/24528 for details. sdk_version_async_exported_from_core: ignore exclude: - - 'bin/cache/**' + - "bin/cache/**" # the following two are relative to the stocks example and the flutter package respectively # see https://github.com/dart-lang/sdk/issues/28463 - - 'lib/i18n/stock_messages_*.dart' - - 'lib/src/http/**' - - 'example/**' + - "lib/i18n/stock_messages_*.dart" + - "lib/src/http/**" + - "example/**" + - "**/*.g.dart" linter: rules: @@ -128,7 +129,7 @@ linter: # - public_member_api_docs # enabled on a case-by-case basis; see e.g. packages/analysis_options.yaml - recursive_getters - slash_for_doc_comments - - sort_constructors_first + # - sort_constructors_first: vscode's command "dart:sort members" does not follow this - sort_pub_dependencies - sort_unnamed_constructors_first # - super_goes_last # no longer needed w/ Dart 2 @@ -157,3 +158,5 @@ linter: # - use_to_and_as_if_applicable # has false positives, so we prefer to catch this by code-review - valid_regexps # - void_checks # not yet tested + - public_member_api_docs + - require_trailing_commas diff --git a/flutter_launcher_icons.code-workspace b/flutter_launcher_icons.code-workspace new file mode 100644 index 0000000000..b743fb6b85 --- /dev/null +++ b/flutter_launcher_icons.code-workspace @@ -0,0 +1,24 @@ +{ + "folders": [ + { + "name": "Flutter Launcher Icons", + "path": "." + } + ], + "settings": { + "[dart]": { + "editor.formatOnSave": true, + // "editor.formatOnType": true, + "editor.rulers": [ + 120 + ], + "editor.selectionHighlight": false, + "editor.suggest.snippetsPreventQuickSuggestions": false, + "editor.suggestSelection": "first", + "editor.tabCompletion": "onlySnippets", + "editor.wordBasedSuggestions": false, + "files.insertFinalNewline": true + }, + "dart.lineLength": 120, + } +} \ No newline at end of file diff --git a/lib/abs/icon_generator.dart b/lib/abs/icon_generator.dart new file mode 100644 index 0000000000..1576ddb939 --- /dev/null +++ b/lib/abs/icon_generator.dart @@ -0,0 +1,109 @@ +import 'dart:io'; + +import 'package:flutter_launcher_icons/flutter_launcher_icons_config.dart'; +import 'package:flutter_launcher_icons/logger.dart'; + +/// A base class to generate icons +abstract class IconGenerator { + /// Contains config + final IconGeneratorContext context; + + /// Name of the platform this [IconGenerator] is created for. + final String platformName; + + /// Creates a instance of [IconGenerator]. + /// + /// A [context] is created and provided by [generateIconsFor], + /// [platformName] takes the name of the platform that this [IconGenerator] + /// is implemented for + /// + /// Also Refer + /// - [WebIconGenerator] generate icons for web + /// - [generateIconFor] generates icons for given platform + IconGenerator(this.context, this.platformName); + + /// Creates icons for this platform. + void createIcons(); + + /// Should return `true` if this platform + /// has all the requirments to create icons. + /// This runs before to [createIcons] + bool validateRequirements(); +} + +/// Provides easy access to user arguments and configuration +class IconGeneratorContext { + /// Contains configuration from configuration file + final FlutterLauncherIconsConfig config; + + /// A logger + final FLILogger logger; + + /// Value of `--prefix` flag + final String prefixPath; + + /// Value of `--flavor` flag + final String? flavor; + + /// Creates an instance of [IconGeneratorContext] + IconGeneratorContext({ + required this.config, + required this.logger, + required this.prefixPath, + this.flavor, + }); + + /// Shortcut for `config.webConfig` + WebConfig? get webConfig => config.webConfig; +} + +/// Generates Icon for given platforms +void generateIconsFor({ + required FlutterLauncherIconsConfig config, + required String? flavor, + required String prefixPath, + required FLILogger logger, + required List Function(IconGeneratorContext context) platforms, +}) { + try { + final platformList = platforms(IconGeneratorContext( + config: config, + logger: logger, + prefixPath: prefixPath, + flavor: flavor, + )); + if (platformList.isEmpty) { + // ? maybe we can print help + logger.info('No platform provided'); + } + + for (final platform in platformList) { + final progress = logger.progress('Creating Icons for ${platform.platformName}'); + logger.verbose('Validating platform requirments for ${platform.platformName}'); + // in case a platform throws an exception it should not effect other platforms + try { + if (!platform.validateRequirements()) { + logger.error('Requirments failed for platform ${platform.platformName}. Skipped'); + progress.cancel(); + continue; + } + platform.createIcons(); + progress.finish(message: 'done', showTiming: true); + } catch (e, st) { + progress.cancel(); + logger + ..error(e.toString()) + ..verbose(st); + continue; + } + } + } catch (e, st) { + // todo: better error handling + // stacktrace should only print when verbose is turned on + // else a normal help line + logger + ..error(e.toString()) + ..verbose(st); + exit(1); + } +} diff --git a/lib/constants.dart b/lib/constants.dart index cb22b10227..638249f4cf 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,35 +1,55 @@ -String androidResFolder(String? flavor) => - "android/app/src/${flavor ?? 'main'}/res/"; -String androidColorsFile(String? flavor) => - "android/app/src/${flavor ?? 'main'}/res/values/colors.xml"; +import 'package:path/path.dart' as path; + +/// Relative path to android resource folder +String androidResFolder(String? flavor) => "android/app/src/${flavor ?? 'main'}/res/"; + +/// Relative path to android colors.xml file +String androidColorsFile(String? flavor) => "android/app/src/${flavor ?? 'main'}/res/values/colors.xml"; + const String androidManifestFile = 'android/app/src/main/AndroidManifest.xml'; const String androidGradleFile = 'android/app/build.gradle'; const String androidLocalPropertiesFile = 'android/local.properties'; const String androidFileName = 'ic_launcher.png'; const String androidAdaptiveForegroundFileName = 'ic_launcher_foreground.png'; const String androidAdaptiveBackgroundFileName = 'ic_launcher_background.png'; -String androidAdaptiveXmlFolder(String? flavor) => - androidResFolder(flavor) + 'mipmap-anydpi-v26/'; +String androidAdaptiveXmlFolder(String? flavor) => androidResFolder(flavor) + 'mipmap-anydpi-v26/'; const String androidDefaultIconName = 'ic_launcher'; -const String iosDefaultIconFolder = - 'ios/Runner/Assets.xcassets/AppIcon.appiconset/'; +const String iosDefaultIconFolder = 'ios/Runner/Assets.xcassets/AppIcon.appiconset/'; const String iosAssetFolder = 'ios/Runner/Assets.xcassets/'; const String iosConfigFile = 'ios/Runner.xcodeproj/project.pbxproj'; const String iosDefaultIconName = 'Icon-App'; +// web +/// favicon.ico size +const int kFaviconSize = 16; + +/// Relative web direcotry path +String webDirPath = path.join('web'); + +/// Relative web icons directory path +String webIconsDirPath = path.join(webDirPath, 'icons'); + +/// Relative web manifest.json file path +String webManifestFilePath = path.join(webDirPath, 'manifest.json'); +// todo: support for other images formats +/// Relative favicon.png path +String webFaviconFilePath = path.join(webDirPath, 'favicon.png'); + +/// Relative index.html file path +String webIndexFilePath = path.join(webDirPath, 'index.html'); + +/// Relative pubspec.yaml path +String pubspecFilePath = path.join('pubspec.yaml'); + const String errorMissingImagePath = 'Missing "image_path" or "image_path_android" + "image_path_ios" within configuration'; -const String errorMissingPlatform = - 'No platform specified within config to generate icons for.'; -const String errorMissingRegularAndroid = - 'Adaptive icon config found but no regular Android config. ' +const String errorMissingPlatform = 'No platform specified within config to generate icons for.'; +const String errorMissingRegularAndroid = 'Adaptive icon config found but no regular Android config. ' 'Below API 26 the regular Android config is required'; -const String errorMissingMinSdk = - 'Cannot not find minSdk from android/app/build.gradle or android/local.properties' +const String errorMissingMinSdk = 'Cannot not find minSdk from android/app/build.gradle or android/local.properties' 'Specify minSdk in either android/app/build.gradle or android/local.properties'; -const String errorIncorrectIconName = - 'The icon name must contain only lowercase a-z, 0-9, or underscore: ' +const String errorIncorrectIconName = 'The icon name must contain only lowercase a-z, 0-9, or underscore: ' 'E.g. "ic_my_new_icon"'; String introMessage(String currentVersion) => ''' diff --git a/lib/custom_exceptions.dart b/lib/custom_exceptions.dart index 3a297b0cd2..82cbe2b746 100644 --- a/lib/custom_exceptions.dart +++ b/lib/custom_exceptions.dart @@ -39,3 +39,17 @@ class NoDecoderForImageFormatException implements Exception { return generateError(this, message); } } + +/// A exception to throw when given [fileName] is not found +class FileNotFoundException implements Exception { + /// Creates a instance of [FileNotFoundException]. + const FileNotFoundException(this.fileName); + + /// Name of the file + final String fileName; + + @override + String toString() { + return generateError(this, '$fileName file not found'); + } +} diff --git a/lib/flutter_launcher_icons_config.dart b/lib/flutter_launcher_icons_config.dart new file mode 100644 index 0000000000..1f5d0bc895 --- /dev/null +++ b/lib/flutter_launcher_icons_config.dart @@ -0,0 +1,163 @@ +import 'dart:io'; + +import 'package:checked_yaml/checked_yaml.dart' as yaml; +import 'package:json_annotation/json_annotation.dart'; +import 'package:path/path.dart' as path; + +import 'constants.dart' as constants; +import 'custom_exceptions.dart'; +import 'utils.dart' as utils; + +part 'flutter_launcher_icons_config.g.dart'; + +/// A Config parsed from flutter_launcher_config.yaml +@JsonSerializable( + anyMap: true, + checked: true, +) +class FlutterLauncherIconsConfig { + /// Generic imagepath + @JsonKey(name: 'image_path') + final String? imagePath; + + /// Returns true or path if android config is enabled + final dynamic android; // path or bool + + /// Returns true or path if ios config is enabled + final dynamic ios; // path or bool + + /// Image path specific to android + @JsonKey(name: 'image_path_android') + final String? imagePathAndroid; + + /// Image path specific to ios + @JsonKey(name: 'image_path_ios') + final String? imagePathIOS; + + /// android adaptive icon foreground image + @JsonKey(name: 'adaptive_icon_foreground') + final String? adaptiveIconForeground; + + /// android adaptive_icon_background image + @JsonKey(name: 'adaptive_icon_background') + final String? adaptiveIconBackground; + + /// Web platform config + @JsonKey(name: 'web') + final WebConfig? webConfig; + + /// Creates an instance of [FlutterLauncherIconsConfig] + const FlutterLauncherIconsConfig({ + this.imagePath, + this.android = false, + this.ios = false, + this.imagePathAndroid, + this.imagePathIOS, + this.adaptiveIconForeground, + this.adaptiveIconBackground, + this.webConfig, + }); + + /// Creates [FlutterLauncherIconsConfig] icons from [json] + factory FlutterLauncherIconsConfig.fromJson(Map json) => _$FlutterLauncherIconsConfigFromJson(json); + + /// Loads flutter launcher icons configs from given [filePath] + static FlutterLauncherIconsConfig? loadConfigFromPath(String filePath, String prefixPath) { + final configFile = File(path.join(prefixPath, filePath)); + if (!configFile.existsSync()) { + return null; + } + final configContent = configFile.readAsStringSync(); + try { + return yaml.checkedYamlDecode( + configContent, + (json) { + // todo: add support for new scheme https://github.com/fluttercommunity/flutter_launcher_icons/issues/373 + return json == null || json['flutter_icons'] == null + ? null + : FlutterLauncherIconsConfig.fromJson(json['flutter_icons']); + }, + allowNull: true, + ); + } on yaml.ParsedYamlException catch (e) { + throw InvalidConfigException(e.formattedMessage); + } catch (e) { + rethrow; + } + } + + /// Loads flutter launcher icons config from `pubspec.yaml` file + static FlutterLauncherIconsConfig? loadConfigFromPubSpec(String prefix) { + try { + final pubspecFile = File(path.join(prefix, constants.pubspecFilePath)); + if (!pubspecFile.existsSync()) { + return null; + } + final pubspecContent = pubspecFile.readAsStringSync(); + return yaml.checkedYamlDecode( + pubspecContent, + (json) { + // todo: add support for new scheme https://github.com/fluttercommunity/flutter_launcher_icons/issues/373 + return json == null || json['flutter_icons'] == null + ? null + : FlutterLauncherIconsConfig.fromJson(json['flutter_icons']); + }, + allowNull: true, + ); + } on yaml.ParsedYamlException catch (e) { + throw InvalidConfigException(e.formattedMessage); + } catch (e) { + rethrow; + } + } + + /// Creates [FlutterLauncherIconsConfig] for given [flavor] and [prefixPath] + static FlutterLauncherIconsConfig? loadConfigFromFlavor(String flavor, String prefixPath) { + return FlutterLauncherIconsConfig.loadConfigFromPath(utils.flavorConfigFile(flavor), prefixPath); + } + + /// Converts config to [Map] + Map toJson() => _$FlutterLauncherIconsConfigToJson(this); + + @override + String toString() => 'FlutterLauncherIconsConfig: ${toJson()}'; +} + +/// Parse `web` config from `flutter_launcher_icons.yaml` +@JsonSerializable( + anyMap: true, + checked: true, +) +class WebConfig { + /// Specifies weather to generate icons for web + final bool generate; + + /// Image path for web + @JsonKey(name: 'image_path') + final String? imagePath; + + /// manifest.json's background_color + @JsonKey(name: 'background_color') + final String? backgroundColor; + + /// manifest.json's theme_color + @JsonKey(name: 'theme_color') + final String? themeColor; + + /// Creates an instance of [WebConfig] + const WebConfig({ + this.generate = false, + this.imagePath, + this.backgroundColor, + this.themeColor, + }); + + /// Creates [WebConfig] from [json] + factory WebConfig.fromJson(Map json) => _$WebConfigFromJson(json); + + /// Creates [Map] from [WebConfig] + Map toJson() => _$WebConfigToJson(this); + + @override + String toString() => 'WebConfig: ${toJson()}'; +} diff --git a/lib/flutter_launcher_icons_config.g.dart b/lib/flutter_launcher_icons_config.g.dart new file mode 100644 index 0000000000..c3d85f33a5 --- /dev/null +++ b/lib/flutter_launcher_icons_config.g.dart @@ -0,0 +1,78 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'flutter_launcher_icons_config.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FlutterLauncherIconsConfig _$FlutterLauncherIconsConfigFromJson(Map json) => + $checkedCreate( + 'FlutterLauncherIconsConfig', + json, + ($checkedConvert) { + final val = FlutterLauncherIconsConfig( + imagePath: $checkedConvert('image_path', (v) => v as String?), + android: $checkedConvert('android', (v) => v ?? false), + ios: $checkedConvert('ios', (v) => v ?? false), + imagePathAndroid: + $checkedConvert('image_path_android', (v) => v as String?), + imagePathIOS: $checkedConvert('image_path_ios', (v) => v as String?), + adaptiveIconForeground: + $checkedConvert('adaptive_icon_foreground', (v) => v as String?), + adaptiveIconBackground: + $checkedConvert('adaptive_icon_background', (v) => v as String?), + webConfig: $checkedConvert( + 'web', (v) => v == null ? null : WebConfig.fromJson(v as Map)), + ); + return val; + }, + fieldKeyMap: const { + 'imagePath': 'image_path', + 'imagePathAndroid': 'image_path_android', + 'imagePathIOS': 'image_path_ios', + 'adaptiveIconForeground': 'adaptive_icon_foreground', + 'adaptiveIconBackground': 'adaptive_icon_background', + 'webConfig': 'web' + }, + ); + +Map _$FlutterLauncherIconsConfigToJson( + FlutterLauncherIconsConfig instance) => + { + 'image_path': instance.imagePath, + 'android': instance.android, + 'ios': instance.ios, + 'image_path_android': instance.imagePathAndroid, + 'image_path_ios': instance.imagePathIOS, + 'adaptive_icon_foreground': instance.adaptiveIconForeground, + 'adaptive_icon_background': instance.adaptiveIconBackground, + 'web': instance.webConfig, + }; + +WebConfig _$WebConfigFromJson(Map json) => $checkedCreate( + 'WebConfig', + json, + ($checkedConvert) { + final val = WebConfig( + generate: $checkedConvert('generate', (v) => v as bool? ?? false), + imagePath: $checkedConvert('image_path', (v) => v as String?), + backgroundColor: + $checkedConvert('background_color', (v) => v as String?), + themeColor: $checkedConvert('theme_color', (v) => v as String?), + ); + return val; + }, + fieldKeyMap: const { + 'imagePath': 'image_path', + 'backgroundColor': 'background_color', + 'themeColor': 'theme_color' + }, + ); + +Map _$WebConfigToJson(WebConfig instance) => { + 'generate': instance.generate, + 'image_path': instance.imagePath, + 'background_color': instance.backgroundColor, + 'theme_color': instance.themeColor, + }; diff --git a/lib/logger.dart b/lib/logger.dart new file mode 100644 index 0000000000..22cf2f0334 --- /dev/null +++ b/lib/logger.dart @@ -0,0 +1,34 @@ +import 'package:cli_util/cli_logging.dart'; + +export 'package:cli_util/cli_logging.dart' show Progress; + +/// Flutter Launcher Icons Logger +class FLILogger { + late Logger _logger; + + /// Returns true if this is a verbose logger + final bool isVerbose; + + /// Gives access to internal logger + Logger get rawLogger => _logger; + + /// Creates a instance of [FLILogger]. + /// In case [isVerbose] is `true`, + /// it logs all the [verbose] logs to console + FLILogger(this.isVerbose) { + final ansi = Ansi(Ansi.terminalSupportsAnsi); + _logger = isVerbose ? Logger.verbose(ansi: ansi) : Logger.standard(ansi: ansi); + } + + /// Logs error messages + void error(Object? message) => _logger.stderr('⚠️' + message.toString()); + + /// Prints to console if [isVerbose] is true + void verbose(Object? message) => _logger.trace(message.toString()); + + /// Prints to console + void info(Object? message) => _logger.stdout(message.toString()); + + /// Shows progress in console + Progress progress(String message) => _logger.progress(message); +} diff --git a/lib/main.dart b/lib/main.dart index d7176af4c6..5cffcc4bfb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,17 +1,26 @@ import 'dart:io'; import 'package:args/args.dart'; -import 'package:path/path.dart' as path; -import 'package:yaml/yaml.dart'; +import 'package:flutter_launcher_icons/abs/icon_generator.dart'; import 'package:flutter_launcher_icons/android.dart' as android_launcher_icons; -import 'package:flutter_launcher_icons/ios.dart' as ios_launcher_icons; +import 'package:flutter_launcher_icons/constants.dart' as constants; import 'package:flutter_launcher_icons/constants.dart'; import 'package:flutter_launcher_icons/custom_exceptions.dart'; +import 'package:flutter_launcher_icons/flutter_launcher_icons_config.dart'; +import 'package:flutter_launcher_icons/ios.dart' as ios_launcher_icons; +import 'package:flutter_launcher_icons/logger.dart'; +import 'package:flutter_launcher_icons/web/web_icon_generator.dart'; +import 'package:path/path.dart' as path; +import 'package:yaml/yaml.dart'; const String fileOption = 'file'; const String helpFlag = 'help'; +const String verboseFlag = 'verbose'; +const String prefixOption = 'prefix'; const String defaultConfigFile = 'flutter_launcher_icons.yaml'; const String flavorConfigFilePattern = r'^flutter_launcher_icons-(.*).yaml$'; + +/// todo: remove this as it is moved to utils.dart String flavorConfigFile(String flavor) => 'flutter_launcher_icons-$flavor.yaml'; List getFlavors() { @@ -30,11 +39,23 @@ List getFlavors() { Future createIconsFromArguments(List arguments) async { final ArgParser parser = ArgParser(allowTrailingOptions: true); - parser.addFlag(helpFlag, abbr: 'h', help: 'Usage help', negatable: false); - // Make default null to differentiate when it is explicitly set - parser.addOption(fileOption, - abbr: 'f', help: 'Config file (default: $defaultConfigFile)'); + parser + ..addFlag(helpFlag, abbr: 'h', help: 'Usage help', negatable: false) + // Make default null to differentiate when it is explicitly set + ..addOption(fileOption, abbr: 'f', help: 'Path to config file', defaultsTo: defaultConfigFile) + ..addFlag(verboseFlag, abbr: 'v', help: 'Verbose output', defaultsTo: false) + ..addOption( + prefixOption, + abbr: 'p', + help: 'Generates config in the given path. Only Supports web platform', + defaultsTo: '.', + ); + final ArgResults argResults = parser.parse(arguments); + // creating logger based on -v flag + final logger = FLILogger(argResults[verboseFlag]); + + logger.verbose('Recieved args ${argResults.arguments}'); if (argResults[helpFlag]) { stdout.writeln('Generates icons for iOS and Android'); @@ -47,17 +68,25 @@ Future createIconsFromArguments(List arguments) async { final hasFlavors = flavors.isNotEmpty; // Load the config file - final Map? yamlConfig = - loadConfigFileFromArgResults(argResults, verbose: true); + final Map? yamlConfig = loadConfigFileFromArgResults(argResults, verbose: true); + final String prefixPath = argResults[prefixOption]; + + // Load configs from given file(defaults to ./flutter_launcher_icons.yaml) or from ./pubspec.yaml - if (yamlConfig == null) { - throw const NoConfigFoundException(); + final flutterLauncherIconsConfigs = + FlutterLauncherIconsConfig.loadConfigFromPath(argResults[fileOption], prefixPath) ?? + FlutterLauncherIconsConfig.loadConfigFromPubSpec(prefixPath); + if (yamlConfig == null || flutterLauncherIconsConfigs == null) { + throw NoConfigFoundException( + 'No configuration found in $defaultConfigFile or in ${constants.pubspecFilePath}. ' + 'In case file exists in different directory use --file option', + ); } // Create icons if (!hasFlavors) { try { - createIconsFromConfig(yamlConfig); + await createIconsFromConfig(yamlConfig, flutterLauncherIconsConfigs, logger, prefixPath); print('\n✓ Successfully generated launcher icons'); } catch (e) { stderr.writeln('\n✕ Could not generate launcher icons'); @@ -68,9 +97,8 @@ Future createIconsFromArguments(List arguments) async { try { for (String flavor in flavors) { print('\nFlavor: $flavor'); - final Map yamlConfig = - loadConfigFile(flavorConfigFile(flavor), flavorConfigFile(flavor)); - await createIconsFromConfig(yamlConfig, flavor); + final Map yamlConfig = loadConfigFile(flavorConfigFile(flavor), flavorConfigFile(flavor)); + await createIconsFromConfig(yamlConfig, flutterLauncherIconsConfigs, logger, prefixPath, flavor); } print('\n✓ Successfully generated launcher icons for flavors'); } catch (e) { @@ -81,8 +109,13 @@ Future createIconsFromArguments(List arguments) async { } } -Future createIconsFromConfig(Map config, - [String? flavor]) async { +Future createIconsFromConfig( + Map config, + FlutterLauncherIconsConfig flutterConfigs, + FLILogger logger, + String prefixPath, [ + String? flavor, +]) async { if (!isImagePathInConfig(config)) { throw const InvalidConfigException(errorMissingImagePath); } @@ -95,9 +128,7 @@ Future createIconsFromConfig(Map config, if (minSdk == 0) { throw const InvalidConfigException(errorMissingMinSdk); } - if (minSdk < 26 && - hasAndroidAdaptiveConfig(config) && - !hasAndroidConfig(config)) { + if (minSdk < 26 && hasAndroidAdaptiveConfig(config) && !hasAndroidConfig(config)) { throw const InvalidConfigException(errorMissingRegularAndroid); } } @@ -111,10 +142,21 @@ Future createIconsFromConfig(Map config, if (isNeedingNewIOSIcon(config)) { ios_launcher_icons.createIcons(config, flavor); } + + // Generates Icons for given platform + generateIconsFor( + config: flutterConfigs, + logger: logger, + prefixPath: prefixPath, + flavor: flavor, + platforms: (context) => [ + WebIconGenerator(context), + // todo: add other platforms + ], + ); } -Map? loadConfigFileFromArgResults(ArgResults argResults, - {bool verbose = false}) { +Map? loadConfigFileFromArgResults(ArgResults argResults, {bool verbose = false}) { final String? configFile = argResults[fileOption]; final String? fileOptionResult = argResults[fileOption]; @@ -176,13 +218,12 @@ Map loadConfigFile(String path, String? fileOptionResult) { bool isImagePathInConfig(Map flutterIconsConfig) { return flutterIconsConfig.containsKey('image_path') || - (flutterIconsConfig.containsKey('image_path_android') && - flutterIconsConfig.containsKey('image_path_ios')); + (flutterIconsConfig.containsKey('image_path_android') && flutterIconsConfig.containsKey('image_path_ios')) || + flutterIconsConfig.containsKey('web'); } bool hasPlatformConfig(Map flutterIconsConfig) { - return hasAndroidConfig(flutterIconsConfig) || - hasIOSConfig(flutterIconsConfig); + return hasAndroidConfig(flutterIconsConfig) || hasIOSConfig(flutterIconsConfig) || hasWebConfig(flutterIconsConfig); } bool hasAndroidConfig(Map flutterLauncherIcons) { @@ -190,8 +231,7 @@ bool hasAndroidConfig(Map flutterLauncherIcons) { } bool isNeedingNewAndroidIcon(Map flutterLauncherIconsConfig) { - return hasAndroidConfig(flutterLauncherIconsConfig) && - flutterLauncherIconsConfig['android'] != false; + return hasAndroidConfig(flutterLauncherIconsConfig) && flutterLauncherIconsConfig['android'] != false; } bool hasAndroidAdaptiveConfig(Map flutterLauncherIconsConfig) { @@ -205,6 +245,15 @@ bool hasIOSConfig(Map flutterLauncherIconsConfig) { } bool isNeedingNewIOSIcon(Map flutterLauncherIconsConfig) { - return hasIOSConfig(flutterLauncherIconsConfig) && - flutterLauncherIconsConfig['ios'] != false; + return hasIOSConfig(flutterLauncherIconsConfig) && flutterLauncherIconsConfig['ios'] != false; +} + +/// Checks if the [flutterLauncherIconsConfig] contains web configs +bool hasWebConfig(Map flutterLauncherIconsConfig) { + return flutterLauncherIconsConfig.containsKey('web'); +} + +/// Checks if we should generate icons for web platform +bool isNeddingNewWebIcons(Map flutterLauncherIconsConfig) { + return hasWebConfig(flutterLauncherIconsConfig) && flutterLauncherIconsConfig['web'] != false; } diff --git a/lib/utils.dart b/lib/utils.dart index 71491bfad5..d87e9d0601 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -1,6 +1,8 @@ +import 'dart:convert'; import 'dart:io'; import 'package:image/image.dart'; +import 'package:path/path.dart' as path; import 'custom_exceptions.dart'; @@ -38,3 +40,38 @@ Image? decodeImageFile(String filePath) { } return image; } + +/// Creates [File] in the given [filePath] if not exists +File createFileIfNotExist(String filePath) { + final file = File(path.joinAll(path.split(filePath))); + if (!file.existsSync()) { + file.createSync(recursive: true); + } + return file; +} + +/// Creates [Directory] in the given [dirPath] if not exists +Directory createDirIfNotExist(String dirPath) { + final dir = Directory(path.joinAll(path.split(dirPath))); + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + return dir; +} + +/// Returns a prettified json string +String prettifyJsonEncode(Object? map) => JsonEncoder.withIndent(' ' * 2).convert(map); + +/// Check if give [File] or [Directory] exists at the give [paths], +/// if not returns the failed [FileSystemEntity] path +String? areFSEntiesExist(List paths) { + for (final path in paths) { + final fsType = FileSystemEntity.typeSync(path); + if (![FileSystemEntityType.directory, FileSystemEntityType.file].contains(fsType)) { + return path; + } + } + return null; +} + +String flavorConfigFile(String flavor) => 'flutter_launcher_icons-$flavor.yaml'; diff --git a/lib/web/web_icon_generator.dart b/lib/web/web_icon_generator.dart new file mode 100644 index 0000000000..abb5f05ccb --- /dev/null +++ b/lib/web/web_icon_generator.dart @@ -0,0 +1,153 @@ +import 'dart:convert'; + +import 'package:image/image.dart'; +import 'package:path/path.dart' as path; + +import '../abs/icon_generator.dart'; +import '../constants.dart' as constants; +import '../custom_exceptions.dart'; +import '../utils.dart' as utils; +import 'web_template.dart'; + +// This is not yet implemented +// ignore: public_member_api_docs +final metaTagsTemplate = ( + String appleMobileWebAppTitle, + String appleMobileWebAppStatusBarStyle, { + bool shouldInsertFLIString = false, +}) => + ''' + + + + + + + + + + + + + +'''; + +/// Generates Web icons for flutter +class WebIconGenerator extends IconGenerator { + static const _webIconSizeTemplates = [ + WebIconTemplate(size: 192), + WebIconTemplate(size: 512), + WebIconTemplate(size: 192, maskable: true), + WebIconTemplate(size: 512, maskable: true), + ]; + + /// Creates an instance of [WebIconGenerator]. + /// + /// + WebIconGenerator(IconGeneratorContext context) : super(context, 'Web'); + + @override + void createIcons() { + final imgFilePath = path.join(context.prefixPath, context.webConfig!.imagePath ?? context.config.imagePath!); + + context.logger.verbose('Decoding and loading image file at $imgFilePath...'); + final imgFile = utils.decodeImageFile(imgFilePath); + if (imgFile == null) { + context.logger.error('Image File not found at give path $imgFilePath...'); + throw FileNotFoundException(imgFilePath); + } + + // generate favicon in web/favicon.png + context.logger.verbose('Generating favicon from $imgFilePath...'); + _generateFavicon(imgFile); + + // generate icons in web/icons/ + context.logger.verbose('Generating icons from $imgFilePath...'); + _generateIcons(imgFile); + + // update manifest.json in web/mainfest.json + context.logger.verbose('Updating ${path.join(context.prefixPath, constants.webManifestFilePath)}...'); + _updateManifestFile(); + + // todo: update index.html in web/index.html + // as we are using flutter default config we no need + // to update index.html for now + // _updateIndexFile(); + } + + @override + bool validateRequirements() { + // check if web config exists + context.logger.verbose('Checking webconfig...'); + final webConfig = context.webConfig; + if (webConfig == null || !webConfig.generate) { + context.logger.verbose('Web config is not provided or generate is false. Skipped...'); + return false; + } + if (webConfig.imagePath == null && context.config.imagePath == null) { + context.logger.verbose('Invalid config. Either provide web.imagePath or imagePath'); + return false; + } + + // verify web platform related files and directories exists + final entitesToCheck = [ + path.join(context.prefixPath, constants.webDirPath), + path.join(context.prefixPath, constants.webManifestFilePath), + path.join(context.prefixPath, constants.webIndexFilePath), + ]; + + // web platform related files must exist to continue + final failedEntityPath = utils.areFSEntiesExist(entitesToCheck); + if (failedEntityPath != null) { + context.logger.error('$failedEntityPath this file or folder is required to generate web icons'); + } + + return true; + } + + void _generateFavicon(Image image) { + final favIcon = utils.createResizedImage(constants.kFaviconSize, image); + final favIconFile = utils.createFileIfNotExist(path.join(context.prefixPath, constants.webFaviconFilePath)); + favIconFile.writeAsBytesSync(encodePng(favIcon)); + } + + void _generateIcons(Image image) { + final iconsDir = utils.createDirIfNotExist(path.join(context.prefixPath, constants.webIconsDirPath)); + // generate icons + for (final template in _webIconSizeTemplates) { + final resizedImg = utils.createResizedImage(template.size, image); + final iconFile = utils.createFileIfNotExist(path.join(context.prefixPath, iconsDir.path, template.iconFile)); + iconFile.writeAsBytesSync(encodePng(resizedImg)); + } + } + + // void _updateIndexFile() { + // todo + // final indexFile = File(constants.webIndexFilePath); + // if (!indexFile.existsSync()) { + // throw FileNotFoundException(constants.webFaviconFilePath); + // } + // } + + void _updateManifestFile() { + final manifestFile = utils.createFileIfNotExist(path.join(context.prefixPath, constants.webManifestFilePath)); + final manifestConfig = jsonDecode(manifestFile.readAsStringSync()) as Map; + + // update background_color + if (context.webConfig?.backgroundColor != null) { + manifestConfig['background_color'] = context.webConfig?.backgroundColor; + } + + // update theme_color + if (context.webConfig?.themeColor != null) { + manifestConfig['theme_color'] = context.webConfig?.themeColor; + } + + // replace existing icons to eliminate conflicts + manifestConfig + ..remove('icons') + ..['icons'] = _webIconSizeTemplates.map>((e) => e.iconManifest).toList(); + + manifestFile.writeAsStringSync(utils.prettifyJsonEncode(manifestConfig)); + } +} diff --git a/lib/web/web_template.dart b/lib/web/web_template.dart new file mode 100644 index 0000000000..485578043f --- /dev/null +++ b/lib/web/web_template.dart @@ -0,0 +1,38 @@ +/// A Icon Template for Web +class WebIconTemplate { + /// Size of the web icon + final int size; + + /// Support for maskable icon + /// + /// Refer to https://web.dev/maskable-icon/ + final bool maskable; + + /// Creates an instance of [WebIconTemplate]. + const WebIconTemplate({ + required this.size, + this.maskable = false, + }); + + /// Icon file name + String get iconFile => 'Icon${maskable ? '-maskable' : ''}-$size.png'; + + /// Icon config for manifest.json + /// + /// ```json + /// { + /// "src": "icons/Icon-maskable-192.png", + /// "sizes": "192x192", + /// "type": "image/png", + /// "purpose": "maskable" + /// }, + /// ``` + Map get iconManifest { + return { + 'src': 'icons/$iconFile', + 'sizes': '${size}x$size', + 'type': 'image/png', + if (maskable) 'purpose': 'maskable', + }; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 349a811dac..dee2aaf911 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,10 @@ homepage: https://github.com/fluttercommunity/flutter_launcher_icons dependencies: args: ^2.2.0 + checked_yaml: ^2.0.1 + cli_util: ^0.3.5 image: ^3.0.2 + json_annotation: ^4.5.0 path: ^1.8.0 yaml: ^3.1.0 @@ -19,4 +22,7 @@ dev_dependencies: # allows us to get version number from pubspec yaml which we can pass to Sentry # https://pub.dev/packages/build_version build_version: ^2.1.1 + json_serializable: ^6.2.0 + mockito: ^5.2.0 test: ^1.21.1 + test_descriptor: ^2.0.0 diff --git a/test/abs/icon_generator_test.dart b/test/abs/icon_generator_test.dart new file mode 100644 index 0000000000..6edc3f4f44 --- /dev/null +++ b/test/abs/icon_generator_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_launcher_icons/abs/icon_generator.dart'; +import 'package:flutter_launcher_icons/flutter_launcher_icons_config.dart'; +import 'package:flutter_launcher_icons/logger.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +import 'icon_generator_test.mocks.dart'; + +@GenerateMocks([FlutterLauncherIconsConfig, IconGenerator]) +void main() { + group('#generateIconsFor', () { + late String prefixPath; + late FLILogger logger; + late IconGenerator mockGenerator; + late FlutterLauncherIconsConfig mockFLIConfig; + setUp(() async { + prefixPath = path.join(d.sandbox, 'fli_test'); + mockFLIConfig = MockFlutterLauncherIconsConfig(); + logger = FLILogger(false); + mockGenerator = MockIconGenerator(); + when(mockGenerator.platformName).thenReturn('Mock'); + when(mockGenerator.context).thenReturn( + IconGeneratorContext( + config: mockFLIConfig, + prefixPath: prefixPath, + logger: logger, + ), + ); + }); + test('should execute createIcons() when validateRequiremnts() returns true', () { + when(mockGenerator.validateRequirements()).thenReturn(true); + generateIconsFor( + config: mockFLIConfig, + flavor: null, + prefixPath: prefixPath, + logger: logger, + platforms: (context) => [mockGenerator], + ); + verify(mockGenerator.validateRequirements()).called(equals(1)); + verify(mockGenerator.createIcons()).called(equals(1)); + }); + + test('should not execute createIcons() when validateRequiremnts() returns false', () { + when(mockGenerator.validateRequirements()).thenReturn(false); + generateIconsFor( + config: mockFLIConfig, + flavor: null, + prefixPath: prefixPath, + logger: logger, + platforms: (context) => [mockGenerator], + ); + verify(mockGenerator.validateRequirements()).called(equals(1)); + verifyNever(mockGenerator.createIcons()); + }); + + test('should skip platform if any exception occured', () { + when(mockGenerator.validateRequirements()).thenReturn(true); + when(mockGenerator.createIcons()).thenThrow(Exception('should-skip-platform')); + generateIconsFor( + config: mockFLIConfig, + flavor: null, + prefixPath: prefixPath, + logger: logger, + platforms: (context) => [mockGenerator], + ); + verify(mockGenerator.validateRequirements()).called(equals(1)); + verify(mockGenerator.createIcons()).called(equals(1)); + expect(() => mockGenerator.createIcons(), throwsException); + }); + }); +} diff --git a/test/abs/icon_generator_test.mocks.dart b/test/abs/icon_generator_test.mocks.dart new file mode 100644 index 0000000000..e75828592b --- /dev/null +++ b/test/abs/icon_generator_test.mocks.dart @@ -0,0 +1,53 @@ +// Mocks generated by Mockito 5.2.0 from annotations +// in flutter_launcher_icons/test/abs/icon_generator_test.dart. +// Do not manually edit this file. + +import 'package:flutter_launcher_icons/abs/icon_generator.dart' as _i2; +import 'package:flutter_launcher_icons/flutter_launcher_icons_config.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; + +// 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: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types + +class _FakeIconGeneratorContext_0 extends _i1.Fake implements _i2.IconGeneratorContext {} + +/// A class which mocks [FlutterLauncherIconsConfig]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFlutterLauncherIconsConfig extends _i1.Mock implements _i3.FlutterLauncherIconsConfig { + MockFlutterLauncherIconsConfig() { + _i1.throwOnMissingStub(this); + } + + @override + Map toJson() => + (super.noSuchMethod(Invocation.method(#toJson, []), returnValue: {}) as Map); +} + +/// A class which mocks [IconGenerator]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockIconGenerator extends _i1.Mock implements _i2.IconGenerator { + MockIconGenerator() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.IconGeneratorContext get context => + (super.noSuchMethod(Invocation.getter(#context), returnValue: _FakeIconGeneratorContext_0()) + as _i2.IconGeneratorContext); + @override + String get platformName => (super.noSuchMethod(Invocation.getter(#platformName), returnValue: '') as String); + @override + void createIcons() => super.noSuchMethod(Invocation.method(#createIcons, []), returnValueForMissingStub: null); + @override + bool validateRequirements() => + (super.noSuchMethod(Invocation.method(#validateRequirments, []), returnValue: false) as bool); +} diff --git a/test/all_tests.dart b/test/all_tests.dart new file mode 100644 index 0000000000..2ae13005cf --- /dev/null +++ b/test/all_tests.dart @@ -0,0 +1,25 @@ +import 'package:test/test.dart'; + +import 'abs/icon_generator_test.dart' as icon_generator_test; +import 'android_test.dart' as android_test; +import 'flutter_launcher_icons_config_test.dart' as fli_config; +import 'main_test.dart' as main_test; +import 'utils_test.dart' as utils_test; +import 'web/web_template_test.dart' as web_template_test; +import 'web/web_icon_generator_test.dart' as web_icon_gen_test; + +void main() { + group('Flutter launcher icons', () { + // others + utils_test.main(); + fli_config.main(); + icon_generator_test.main(); + + main_test.main(); + // android + android_test.main(); + // web + web_template_test.main(); + web_icon_gen_test.main(); + }); +} diff --git a/test/assets/app_icon.png b/test/assets/app_icon.png new file mode 100644 index 0000000000..c61b320e33 Binary files /dev/null and b/test/assets/app_icon.png differ diff --git a/test/flutter_launcher_icons_config_test.dart b/test/flutter_launcher_icons_config_test.dart new file mode 100644 index 0000000000..a73e414410 --- /dev/null +++ b/test/flutter_launcher_icons_config_test.dart @@ -0,0 +1,152 @@ +import 'package:flutter_launcher_icons/custom_exceptions.dart'; +import 'package:flutter_launcher_icons/flutter_launcher_icons_config.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +import './templates.dart' as templates; + +void main() { + group('FlutterLauncherIconsConfig', () { + late String prefixPath; + setUpAll(() { + prefixPath = path.join(d.sandbox, 'fli_test'); + }); + group('#loadConfigFromPath', () { + setUpAll(() async { + await d.dir('fli_test', [ + d.file('flutter_launcher_icons.yaml', templates.fliConfigTemplate), + d.file('invalid_fli_config.yaml', templates.invlaidfliConfigTemplate), + ]).create(); + }); + test('should return valid configs', () { + final configs = FlutterLauncherIconsConfig.loadConfigFromPath('flutter_launcher_icons.yaml', prefixPath); + expect(configs, isNotNull); + // android configs + expect(configs!.android, isTrue); + expect(configs.imagePath, isNotNull); + expect(configs.imagePathAndroid, isNotNull); + expect(configs.adaptiveIconBackground, isNotNull); + expect(configs.adaptiveIconForeground, isNotNull); + // ios configs + expect(configs.ios, isTrue); + expect(configs.imagePathIOS, isNotNull); + // web configs + expect(configs.webConfig, isNotNull); + expect(configs.webConfig!.generate, isTrue); + expect(configs.webConfig!.backgroundColor, isNotNull); + expect(configs.webConfig!.imagePath, isNotNull); + expect(configs.webConfig!.themeColor, isNotNull); + expect( + configs.webConfig!.toJson(), + equals({ + 'generate': true, + 'image_path': 'app_icon.png', + 'background_color': '#0175C2', + 'theme_color': '#0175C2', + }), + ); + }); + + test('should return null when invalid filePath is given', () { + final configs = FlutterLauncherIconsConfig.loadConfigFromPath('file_that_dont_exist.yaml', prefixPath); + expect(configs, isNull); + }); + + test('should throw InvalidConfigException when config is invalid', () { + expect( + () => FlutterLauncherIconsConfig.loadConfigFromPath('invalid_fli_config.yaml', prefixPath), + throwsA(isA()), + ); + }); + }); + + group('#loadConfigFromPubSpec', () { + setUpAll(() async { + await d.dir('fli_test', [ + d.file('pubspec.yaml', templates.pubspecTemplate), + d.file('flutter_launcher_icons.yaml', templates.fliConfigTemplate), + d.file('invalid_fli_config.yaml', templates.invlaidfliConfigTemplate), + ]).create(); + }); + test('should return valid configs', () { + final configs = FlutterLauncherIconsConfig.loadConfigFromPubSpec(prefixPath); + expect(configs, isNotNull); + // android configs + expect(configs!.android, isTrue); + expect(configs.imagePath, isNotNull); + expect(configs.imagePathAndroid, isNotNull); + expect(configs.adaptiveIconBackground, isNotNull); + expect(configs.adaptiveIconForeground, isNotNull); + // ios configs + expect(configs.ios, isTrue); + expect(configs.imagePathIOS, isNotNull); + // web configs + expect(configs.webConfig, isNotNull); + expect(configs.webConfig!.generate, isTrue); + expect(configs.webConfig!.backgroundColor, isNotNull); + expect(configs.webConfig!.imagePath, isNotNull); + expect(configs.webConfig!.themeColor, isNotNull); + expect( + configs.webConfig!.toJson(), + equals({ + 'generate': true, + 'image_path': 'app_icon.png', + 'background_color': '#0175C2', + 'theme_color': '#0175C2', + }), + ); + }); + + group('should throw', () { + setUp(() async { + await d.dir('fli_test', [ + d.file('pubspec.yaml', templates.invalidPubspecTemplate), + d.file('flutter_launcher_icons.yaml', templates.fliConfigTemplate), + d.file('invalid_fli_config.yaml', templates.invlaidfliConfigTemplate), + ]).create(); + }); + test('InvalidConfigException when config is invalid', () { + expect( + () => FlutterLauncherIconsConfig.loadConfigFromPubSpec(prefixPath), + throwsA(isA()), + ); + }); + }); + }); + group('#loadConfigFromFlavor', () { + setUpAll(() async { + await d.dir('fli_test', [ + d.file('flutter_launcher_icons-development.yaml', templates.flavorFLIConfigTemplate), + ]).create(); + }); + test('should return valid config', () { + final configs = FlutterLauncherIconsConfig.loadConfigFromFlavor('development', prefixPath); + expect(configs, isNotNull); + expect(configs!.android, isTrue); + expect(configs.imagePath, isNotNull); + expect(configs.imagePathAndroid, isNotNull); + expect(configs.adaptiveIconBackground, isNotNull); + expect(configs.adaptiveIconForeground, isNotNull); + // ios configs + expect(configs.ios, isTrue); + expect(configs.imagePathIOS, isNotNull); + // web configs + expect(configs.webConfig, isNotNull); + expect(configs.webConfig!.generate, isTrue); + expect(configs.webConfig!.backgroundColor, isNotNull); + expect(configs.webConfig!.imagePath, isNotNull); + expect(configs.webConfig!.themeColor, isNotNull); + expect( + configs.webConfig!.toJson(), + equals({ + 'generate': true, + 'image_path': 'app_icon.png', + 'background_color': '#0175C2', + 'theme_color': '#0175C2', + }), + ); + }); + }); + }); +} diff --git a/test/templates.dart b/test/templates.dart new file mode 100644 index 0000000000..013e48db0e --- /dev/null +++ b/test/templates.dart @@ -0,0 +1,260 @@ +const fliConfigTemplate = r''' +flutter_icons: + android: true + ios: true + image_path: "assets/images/icon-128x128.png" + image_path_android: "assets/images/icon-710x599-android.png" + image_path_ios: "assets/images/icon-1024x1024.png" + adaptive_icon_background: "assets/images/christmas-background.png" + adaptive_icon_foreground: "assets/images/icon-foreground-432x432.png" + web: + generate: true + image_path: "app_icon.png" # filepath + background_color: "#0175C2" # hex_color + theme_color: "#0175C2" # hex_color + apple_mobile_web_app_title: "demo" + apple_mobile_web_app_status_bar_style: "hex_color" +'''; + +const flavorFLIConfigTemplate = fliConfigTemplate; + +const fliWebConfig = r''' +flutter_icons: + web: + generate: true + image_path: "app_icon.png" # filepath + background_color: "#0175C2" # hex_color + theme_color: "#0175C2" # hex_color + apple_mobile_web_app_title: "demo" + apple_mobile_web_app_status_bar_style: "hex_color" +'''; + +const invlaidfliConfigTemplate = r''' +# flutter_icons +android: true +ios: true +image_path: "assets/images/icon-128x128.png" + ad +image_path_android: "assets/images/icon-710x599-android.png" +image_path_ios: "assets/images/icon-1024x1024.png" +adaptive_icon_background: "assets/images/christmas-background.png" +adaptive_icon_foreground: "assets/images/icon-foreground-432x432.png" +web: + generate: true + image_path: "app_icon.png" # filepath + background_color: "#0175C2" # hex_color + theme_color: "#0175C2" # hex_color + apple_mobile_web_app_title: "demo" + apple_mobile_web_app_status_bar_style: "hex_color" +'''; + +const pubspecTemplate = r''' +name: demo +description: A new Flutter project. +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=2.18.0-44.1.beta <3.0.0' + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + flutter_launcher_icons: + path: C:/Users/asus/projects/flutter_launcher_icons + +flutter: + uses-material-design: true + assets: + - images/a_dot_burr.jpeg + - images/a_dot_ham.jpeg + fonts: + - family: Schyler + fonts: + - asset: fonts/Schyler-Regular.ttf + - asset: fonts/Schyler-Italic.ttf + style: italic + - family: Trajan Pro + fonts: + - asset: fonts/TrajanPro.ttf + - asset: fonts/TrajanPro_Bold.ttf + weight: 700 + +flutter_icons: + android: true + ios: true + image_path: "assets/images/icon-128x128.png" + image_path_android: "assets/images/icon-710x599-android.png" + image_path_ios: "assets/images/icon-1024x1024.png" + adaptive_icon_background: "assets/images/christmas-background.png" + adaptive_icon_foreground: "assets/images/icon-foreground-432x432.png" + web: + generate: true + image_path: "app_icon.png" # filepath + background_color: "#0175C2" # hex_color + theme_color: "#0175C2" # hex_color + apple_mobile_web_app_title: "demo" + apple_mobile_web_app_status_bar_style: "hex_color" +'''; + +const invalidPubspecTemplate = r''' +name: demo +description: A new Flutter project. +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=2.18.0-44.1.beta <3.0.0' + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + flutter_launcher_icons: + path: C:/Users/asus/projects/flutter_launcher_icons + +flutter: + uses-material-design: true + assets: + - images/a_dot_burr.jpeg + - images/a_dot_ham.jpeg + fonts: + - family: Schyler + fonts: + - asset: fonts/Schyler-Regular.ttf + - asset: fonts/Schyler-Italic.ttf + style: italic + - family: Trajan Pro + fonts: + - asset: fonts/TrajanPro.ttf + - asset: fonts/TrajanPro_Bold.ttf + weight: 700 + +flutter_icons: + android: true + invalid_indented_key_key + ios: true + image_path: "assets/images/icon-128x128.png" + image_path_android: "assets/images/icon-710x599-android.png" + image_path_ios: "assets/images/icon-1024x1024.png" + adaptive_icon_background: "assets/images/christmas-background.png" + adaptive_icon_foreground: "assets/images/icon-foreground-432x432.png" + web: + generate: true + image_path: "app_icon.png" # filepath + background_color: "#0175C2" # hex_color + theme_color: "#0175C2" # hex_color + apple_mobile_web_app_title: "demo" + apple_mobile_web_app_status_bar_style: "hex_color" +'''; + +const webManifestTemplate = r''' +{ + "name": "demo", + "short_name": "demo", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} +'''; + +const webIndexTemplate = r''' + + + + + + + + + + + + + + + + + + + + demo + + + + + + + + + + + +'''; diff --git a/test/utils_test.dart b/test/utils_test.dart new file mode 100644 index 0000000000..1ff60f5e17 --- /dev/null +++ b/test/utils_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter_launcher_icons/utils.dart' as utils; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +void main() { + group('#areFSEntitesExist', () { + late String prefixPath; + setUp(() async { + prefixPath = path.join(d.sandbox, 'fli_test'); + await d.dir('fli_test', [ + d.file('file1.txt', 'contents1'), + d.dir('dir1'), + ]).create(); + }); + + test('should return null when entites exists', () async { + expect( + utils.areFSEntiesExist([ + path.join(prefixPath, 'file1.txt'), + path.join(prefixPath, 'dir1'), + ]), + isNull, + ); + }); + + test('should return the file path that does not exist', () { + final result = utils.areFSEntiesExist([ + path.join(prefixPath, 'dir1'), + path.join(prefixPath, 'file_that_does_not_exist.txt'), + ]); + expect(result, isNotNull); + expect(result, equals(path.join(prefixPath, 'file_that_does_not_exist.txt'))); + }); + + test('should return the dir path that does not exist', () { + final result = utils.areFSEntiesExist([ + path.join(prefixPath, 'dir_that_does_not_exist'), + path.join(prefixPath, 'file.txt'), + ]); + expect(result, isNotNull); + expect(result, equals(path.join(prefixPath, 'dir_that_does_not_exist'))); + }); + + test('should return the first entity path that does not exist', () { + final result = utils.areFSEntiesExist([ + path.join(prefixPath, 'dir_that_does_not_exist'), + path.join(prefixPath, 'file_that_dodes_not_exist.txt'), + ]); + expect(result, isNotNull); + expect(result, equals(path.join(prefixPath, 'dir_that_does_not_exist'))); + }); + }); + + group('#createDirIfNotExist', () { + setUpAll(() async { + await d.dir('fli_test', [ + d.dir('dir_exists'), + ]).create(); + }); + test('should create directory if it does not exist', () async { + await expectLater( + d.dir('fli_test', [d.dir('dir_that_does_not_exist')]).validate(), + throwsException, + ); + final result = utils.createDirIfNotExist(path.join(d.sandbox, 'fli_test', 'dir_that_does_not_exist')); + expect(result.existsSync(), isTrue); + await expectLater( + d.dir('fli_test', [d.dir('dir_that_does_not_exist')]).validate(), + completes, + ); + }); + test('should return dir if it exist', () async { + await expectLater( + d.dir('fli_test', [d.dir('dir_exists')]).validate(), + completes, + ); + final result = utils.createDirIfNotExist(path.join(d.sandbox, 'fli_test', 'dir_exists')); + expect(result.existsSync(), isTrue); + await expectLater( + d.dir('fli_test', [d.dir('dir_exists')]).validate(), + completes, + ); + }); + }); + + group('#createFileIfNotExist', () { + setUpAll(() async { + await d.dir('fli_test', [ + d.file('file_exists.txt'), + ]).create(); + }); + test('should create file if it does not exist', () async { + await expectLater( + d.dir('fli_test', [d.file('file_that_does_not_exist.txt')]).validate(), + throwsException, + ); + final result = utils.createFileIfNotExist(path.join(d.sandbox, 'fli_test', 'file_that_does_not_exist.txt')); + expect(result.existsSync(), isTrue); + await expectLater( + d.dir('fli_test', [d.file('file_that_does_not_exist.txt')]).validate(), + completes, + ); + }); + test('should return file if it exist', () async { + await expectLater( + d.dir('fli_test', [d.file('file_exists.txt')]).validate(), + completes, + ); + final result = utils.createFileIfNotExist(path.join(d.sandbox, 'fli_test', 'file_exists.txt')); + expect(result.existsSync(), isTrue); + await expectLater( + d.dir('fli_test', [d.file('file_exists.txt')]).validate(), + completes, + ); + }); + }); + + group('#prettifyJsonEncode', () { + test('should return prettiffed json string 2 indents', () { + const expectedValue = r''' +{ + "key1": "value1", + "key2": "value2" +}'''; + final result = utils.prettifyJsonEncode({ + 'key1': 'value1', + 'key2': 'value2', + }); + expect(result, equals(expectedValue)); + }); + }); +} diff --git a/test/web/web_icon_generator_test.dart b/test/web/web_icon_generator_test.dart new file mode 100644 index 0000000000..644e2e170a --- /dev/null +++ b/test/web/web_icon_generator_test.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:flutter_launcher_icons/abs/icon_generator.dart'; +import 'package:flutter_launcher_icons/flutter_launcher_icons_config.dart'; +import 'package:flutter_launcher_icons/logger.dart'; +import 'package:flutter_launcher_icons/web/web_icon_generator.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +import '../templates.dart' as templates; + +void main() { + group('WebIconGenerator', () { + late IconGeneratorContext context; + late IconGenerator generator; + late FlutterLauncherIconsConfig config; + late String prefixPath; + final assetPath = path.join(Directory.current.path, 'test', 'assets'); + + setUp(() async { + final imageFile = File(path.join(assetPath, 'app_icon.png')); + expect(imageFile.existsSync(), isTrue); + await d.dir('fli_test', [ + d.dir('web', [ + d.dir('icons'), + d.file('index.html', templates.webIndexTemplate), + d.file('manifest.json', templates.webManifestTemplate), + ]), + d.file('flutter_launcher_icons.yaml', templates.fliWebConfig), + d.file('pubspec.yaml', templates.pubspecTemplate), + d.file('app_icon.png', imageFile.readAsBytesSync()), + ]).create(); + prefixPath = path.join(d.sandbox, 'fli_test'); + config = FlutterLauncherIconsConfig.loadConfigFromPath('flutter_launcher_icons.yaml', prefixPath)!; + context = IconGeneratorContext(config: config, prefixPath: prefixPath, logger: FLILogger(false)); + generator = WebIconGenerator(context); + }); + + // end to end test + test('should generate valid icons', () async { + expect(generator.validateRequirements(), isTrue); + generator.createIcons(); + await expectLater( + d.dir('fli_test', [ + d.dir('web', [ + d.dir('icons', [ + // this icons get created in fs + d.file('Icon-192.png', anything), + d.file('Icon-512.png', anything), + d.file('Icon-maskable-192.png', anything), + d.file('Icon-maskable-512.png', anything), + ]), + // this favicon get created in fs + d.file('favicon.png', anything), + d.file('index.html', anything), + // this manifest.json get updated in fs + d.file('manifest.json', anything), + ]), + d.file('flutter_launcher_icons.yaml', anything), + d.file('pubspec.yaml', templates.pubspecTemplate) + ]).validate(), + completes, + ); + }); + }); +} diff --git a/test/web/web_template_test.dart b/test/web/web_template_test.dart new file mode 100644 index 0000000000..84351893ea --- /dev/null +++ b/test/web/web_template_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_launcher_icons/web/web_template.dart'; +import 'package:test/test.dart'; + +void main() { + group('WebTemplate', () { + late WebIconTemplate icTemplate; + late WebIconTemplate icMaskableTemplate; + + setUp(() { + icTemplate = const WebIconTemplate(size: 512); + icMaskableTemplate = const WebIconTemplate(size: 512, maskable: true); + }); + + test('.iconFile should return valid file name', () async { + expect(icTemplate.iconFile, equals('Icon-512.png')); + expect(icMaskableTemplate.iconFile, equals('Icon-maskable-512.png')); + }); + + test('.iconManifest should return valid manifest config', () { + expect( + icTemplate.iconManifest, + equals({'src': 'icons/Icon-512.png', 'sizes': '512x512', 'type': 'image/png'}), + ); + expect( + icMaskableTemplate.iconManifest, + equals({'src': 'icons/Icon-maskable-512.png', 'sizes': '512x512', 'type': 'image/png', 'purpose': 'maskable'}), + ); + }); + }); +}