flutter_flutter/dev/tools/update_icons.dart
Kate Lovett 9d96df2364
Modernize framework lints (#179089)
WIP

Commits separated as follows:
- Update lints in analysis_options files
- Run `dart fix --apply`
- Clean up leftover analysis issues 
- Run `dart format .` in the right places.

Local analysis and testing passes. Checking CI now.

Part of https://github.com/flutter/flutter/issues/178827
- Adoption of flutter_lints in examples/api coming in a separate change
(cc @loic-sharma)

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
2025-11-26 01:10:39 +00:00

620 lines
19 KiB
Dart

// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Regenerates a Dart file with a class containing IconData constants.
// See https://github.com/flutter/flutter/blob/main/docs/libraries/material/Updating-Material-Design-Fonts-%26-Icons.md
// Should be idempotent with:
// dart dev/tools/update_icons.dart --new-codepoints bin/cache/artifacts/material_fonts/codepoints
import 'dart:collection';
import 'dart:convert' show LineSplitter;
import 'dart:io';
import 'package:args/args.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
const String _iconsPathOption = 'icons';
const String _iconsTemplatePathOption = 'icons-template';
const String _newCodepointsPathOption = 'new-codepoints';
const String _oldCodepointsPathOption = 'old-codepoints';
const String _fontFamilyOption = 'font-family';
const String _possibleStyleSuffixesOption = 'style-suffixes';
const String _classNameOption = 'class-name';
const String _enforceSafetyChecks = 'enforce-safety-checks';
const String _dryRunOption = 'dry-run';
const String _defaultIconsPath = 'packages/flutter/lib/src/material/icons.dart';
const String _defaultNewCodepointsPath = 'codepoints';
const String _defaultOldCodepointsPath = 'bin/cache/artifacts/material_fonts/codepoints';
const String _defaultFontFamily = 'MaterialIcons';
const List<String> _defaultPossibleStyleSuffixes = <String>['_outlined', '_rounded', '_sharp'];
const String _defaultClassName = 'Icons';
const String _defaultDemoFilePath = '/tmp/new_icons_demo.dart';
const String _beginGeneratedMark = '// BEGIN GENERATED ICONS';
const String _endGeneratedMark = '// END GENERATED ICONS';
const String _beginPlatformAdaptiveGeneratedMark = '// BEGIN GENERATED PLATFORM ADAPTIVE ICONS';
const String _endPlatformAdaptiveGeneratedMark = '// END GENERATED PLATFORM ADAPTIVE ICONS';
const Map<String, List<String>> _platformAdaptiveIdentifiers = <String, List<String>>{
// Mapping of Flutter IDs to an Android/agnostic ID and an iOS ID.
// Flutter IDs can be anything, but should be chosen to be agnostic.
'arrow_back': <String>['arrow_back', 'arrow_back_ios'],
'arrow_forward': <String>['arrow_forward', 'arrow_forward_ios'],
'flip_camera': <String>['flip_camera_android', 'flip_camera_ios'],
'more': <String>['more_vert', 'more_horiz'],
'share': <String>['share', 'ios_share'],
};
// Rewrite certain Flutter IDs (numbers) using prefix matching.
const Map<String, String> _identifierPrefixRewrites = <String, String>{
'1': 'one_',
'2': 'two_',
'3': 'three_',
'4': 'four_',
'5': 'five_',
'6': 'six_',
'7': 'seven_',
'8': 'eight_',
'9': 'nine_',
'10': 'ten_',
'11': 'eleven_',
'12': 'twelve_',
'13': 'thirteen_',
'14': 'fourteen_',
'15': 'fifteen_',
'16': 'sixteen_',
'17': 'seventeen_',
'18': 'eighteen_',
'19': 'nineteen_',
'20': 'twenty_',
'21': 'twenty_one_',
'22': 'twenty_two_',
'23': 'twenty_three_',
'24': 'twenty_four_',
'30': 'thirty_',
'60': 'sixty_',
'123': 'onetwothree',
'360': 'threesixty',
'2d': 'twod',
'3d': 'threed',
'3d_rotation': 'threed_rotation',
};
// Rewrite certain Flutter IDs (reserved keywords) using exact matching.
const Map<String, String> _identifierExactRewrites = <String, String>{
'class': 'class_',
'new': 'new_',
'switch': 'switch_',
'try': 'try_sms_star',
'door_back': 'door_back_door',
'door_front': 'door_front_door',
};
const Set<String> _iconsMirroredWhenRTL = <String>{
// This list is obtained from:
// https://developers.google.com/fonts/docs/material_icons#which_icons_should_be_mirrored_for_rtl
'arrow_back',
'arrow_back_ios',
'arrow_forward',
'arrow_forward_ios',
'arrow_left',
'arrow_right',
'assignment',
'assignment_return',
'backspace',
'battery_unknown',
'call_made',
'call_merge',
'call_missed',
'call_missed_outgoing',
'call_received',
'call_split',
'chevron_left',
'chevron_right',
'chrome_reader_mode',
'device_unknown',
'dvr',
'event_note',
'featured_play_list',
'featured_video',
'first_page',
'flight_land',
'flight_takeoff',
'format_indent_decrease',
'format_indent_increase',
'format_list_bulleted',
'forward',
'functions',
'help',
'help_outline',
'input',
'keyboard_backspace',
'keyboard_tab',
'label',
'label_important',
'label_outline',
'last_page',
'launch',
'list',
'live_help',
'mobile_screen_share',
'multiline_chart',
'navigate_before',
'navigate_next',
'next_week',
'note',
'open_in',
'playlist_add',
'queue_music',
'redo',
'reply',
'reply_all',
'screen_share',
'send',
'short_text',
'show_chart',
'sort',
'star_half',
'subject',
'trending_flat',
'toc',
'trending_down',
'trending_up',
'undo',
'view_list',
'view_quilt',
'wrap_text',
};
void main(List<String> args) {
// If we're run from the `tools` dir, set the cwd to the repo root.
if (path.basename(Directory.current.path) == 'tools') {
Directory.current = Directory.current.parent.parent;
}
final ArgResults argResults = _handleArguments(args);
final iconsFile = File(path.normalize(path.absolute(argResults[_iconsPathOption] as String)));
if (!iconsFile.existsSync()) {
stderr.writeln('Error: Icons file not found: ${iconsFile.path}');
exit(1);
}
final iconsTemplateFile = File(
path.normalize(path.absolute(argResults[_iconsTemplatePathOption] as String)),
);
if (!iconsTemplateFile.existsSync()) {
stderr.writeln('Error: Icons template file not found: ${iconsTemplateFile.path}');
exit(1);
}
final newCodepointsFile = File(argResults[_newCodepointsPathOption] as String);
if (!newCodepointsFile.existsSync()) {
stderr.writeln('Error: New codepoints file not found: ${newCodepointsFile.path}');
exit(1);
}
final oldCodepointsFile = File(argResults[_oldCodepointsPathOption] as String);
if (!oldCodepointsFile.existsSync()) {
stderr.writeln('Error: Old codepoints file not found: ${oldCodepointsFile.path}');
exit(1);
}
final String newCodepointsString = newCodepointsFile.readAsStringSync();
final Map<String, String> newTokenPairMap = stringToTokenPairMap(newCodepointsString);
final String oldCodepointsString = oldCodepointsFile.readAsStringSync();
final Map<String, String> oldTokenPairMap = stringToTokenPairMap(oldCodepointsString);
stderr.writeln('Performing safety checks');
final bool isSuperset = testIsSuperset(newTokenPairMap, oldTokenPairMap);
final bool isStable = testIsStable(newTokenPairMap, oldTokenPairMap);
if ((!isSuperset || !isStable) && argResults[_enforceSafetyChecks] as bool) {
exit(1);
}
final String iconsTemplateContents = iconsTemplateFile.readAsStringSync();
stderr.writeln(
"Generating icons ${argResults[_dryRunOption] as bool ? '' : 'to ${iconsFile.path}'}",
);
final String newIconsContents = _regenerateIconsFile(
iconsTemplateContents,
newTokenPairMap,
argResults[_fontFamilyOption] as String,
argResults[_classNameOption] as String,
argResults[_enforceSafetyChecks] as bool,
);
if (argResults[_dryRunOption] as bool) {
stdout.write(newIconsContents);
} else {
iconsFile.writeAsStringSync(newIconsContents);
final sortedNewTokenPairMap = SplayTreeMap<String, String>.of(newTokenPairMap);
_regenerateCodepointsFile(oldCodepointsFile, sortedNewTokenPairMap);
sortedNewTokenPairMap.removeWhere(
(String key, String value) => oldTokenPairMap.containsKey(key),
);
_generateIconDemo(File(_defaultDemoFilePath), sortedNewTokenPairMap);
}
}
ArgResults _handleArguments(List<String> args) {
final argParser = ArgParser()
..addOption(
_iconsPathOption,
defaultsTo: _defaultIconsPath,
help: 'Location of the material icons file',
)
..addOption(
_iconsTemplatePathOption,
defaultsTo: _defaultIconsPath,
help: 'Location of the material icons file template. Usually the same as --$_iconsPathOption',
)
..addOption(
_newCodepointsPathOption,
defaultsTo: _defaultNewCodepointsPath,
help: 'Location of the new codepoints directory',
)
..addOption(
_oldCodepointsPathOption,
defaultsTo: _defaultOldCodepointsPath,
help: 'Location of the existing codepoints directory',
)
..addOption(
_fontFamilyOption,
defaultsTo: _defaultFontFamily,
help: 'The font family to use for the IconData constants',
)
..addMultiOption(
_possibleStyleSuffixesOption,
defaultsTo: _defaultPossibleStyleSuffixes,
help:
'A comma-separated list of suffixes (typically an optional '
'family + a style) e.g. _outlined, _monoline_filled',
)
..addOption(
_classNameOption,
defaultsTo: _defaultClassName,
help: 'The containing class for all icons',
)
..addFlag(
_enforceSafetyChecks,
defaultsTo: true,
help: 'Whether to exit if safety checks fail (e.g. codepoints are missing or unstable',
)
..addFlag(_dryRunOption);
argParser.addFlag(
'help',
abbr: 'h',
negatable: false,
callback: (bool help) {
if (help) {
print(argParser.usage);
exit(1);
}
},
);
return argParser.parse(args);
}
Map<String, String> stringToTokenPairMap(String codepointData) {
final Iterable<String> cleanData = LineSplitter.split(
codepointData,
).map((String line) => line.trim()).where((String line) => line.isNotEmpty);
final pairs = <String, String>{};
for (final line in cleanData) {
final List<String> tokens = line.split(' ');
if (tokens.length != 2) {
throw FormatException('Unexpected codepoint data: $line');
}
pairs.putIfAbsent(tokens[0], () => tokens[1]);
}
return pairs;
}
String _regenerateIconsFile(
String templateFileContents,
Map<String, String> tokenPairMap,
String fontFamily,
String className,
bool enforceSafetyChecks,
) {
final List<Icon> newIcons = tokenPairMap.entries
.map(
(MapEntry<String, String> entry) =>
Icon(entry, fontFamily: fontFamily, className: className),
)
.toList();
newIcons.sort((Icon a, Icon b) => a._compareTo(b));
final buf = StringBuffer();
var generating = false;
for (final String line in LineSplitter.split(templateFileContents)) {
if (!generating) {
buf.writeln(line);
}
// Generate for PlatformAdaptiveIcons
if (line.contains(_beginPlatformAdaptiveGeneratedMark)) {
generating = true;
final platformAdaptiveDeclarations = <String>[];
_platformAdaptiveIdentifiers.forEach((String flutterId, List<String> ids) {
// Automatically finds and generates all icon declarations.
for (final style in <String>['', '_outlined', '_rounded', '_sharp']) {
try {
final Icon agnosticIcon = newIcons.firstWhere(
(Icon icon) => icon.id == '${ids[0]}$style',
orElse: () => throw ids[0],
);
final Icon iOSIcon = newIcons.firstWhere(
(Icon icon) => icon.id == '${ids[1]}$style',
orElse: () => throw ids[1],
);
platformAdaptiveDeclarations.add(
agnosticIcon.platformAdaptiveDeclaration('$flutterId$style', iOSIcon),
);
} catch (e) {
if (style == '') {
// Throw an error for baseline icons.
stderr.writeln("❌ Platform adaptive icon '$e' not found.");
if (enforceSafetyChecks) {
stderr.writeln('Safety checks failed');
exit(1);
}
} else {
// Ignore errors for styled icons since some don't exist.
}
}
}
});
buf.write(platformAdaptiveDeclarations.join());
} else if (line.contains(_endPlatformAdaptiveGeneratedMark)) {
generating = false;
buf.writeln(line);
}
// Generate for Icons
if (line.contains(_beginGeneratedMark)) {
generating = true;
final String iconDeclarationsString = newIcons
.map((Icon icon) => icon.fullDeclaration)
.join();
buf.write(iconDeclarationsString);
} else if (line.contains(_endGeneratedMark)) {
generating = false;
buf.writeln(line);
}
}
return buf.toString();
}
@visibleForTesting
bool testIsSuperset(Map<String, String> newCodepoints, Map<String, String> oldCodepoints) {
final Set<String> newCodepointsSet = newCodepoints.keys.toSet();
final Set<String> oldCodepointsSet = oldCodepoints.keys.toSet();
final int diff = newCodepointsSet.length - oldCodepointsSet.length;
if (diff > 0) {
stderr.writeln('🆕 $diff new codepoints: ${newCodepointsSet.difference(oldCodepointsSet)}');
}
if (!newCodepointsSet.containsAll(oldCodepointsSet)) {
stderr.writeln(
'❌ new codepoints file does not contain all ${oldCodepointsSet.length} '
'existing codepoints. Missing: ${oldCodepointsSet.difference(newCodepointsSet)}',
);
return false;
} else {
stderr.writeln(
'✅ new codepoints file contains all ${oldCodepointsSet.length} existing codepoints',
);
}
return true;
}
@visibleForTesting
bool testIsStable(Map<String, String> newCodepoints, Map<String, String> oldCodepoints) {
final int oldCodepointsCount = oldCodepoints.length;
final unstable = <String>[
for (final MapEntry<String, String>(:String key, :String value) in oldCodepoints.entries)
if (newCodepoints.containsKey(key) && value != newCodepoints[key]) key,
];
if (unstable.isNotEmpty) {
stderr.writeln(
'❌ out of $oldCodepointsCount existing codepoints, ${unstable.length} were unstable: $unstable',
);
return false;
} else {
stderr.writeln('✅ all existing $oldCodepointsCount codepoints are stable');
return true;
}
}
void _regenerateCodepointsFile(File oldCodepointsFile, Map<String, String> tokenPairMap) {
stderr.writeln('Regenerating old codepoints file ${oldCodepointsFile.path}');
final buf = StringBuffer();
tokenPairMap.forEach((String key, String value) => buf.writeln('$key $value'));
oldCodepointsFile.writeAsStringSync(buf.toString());
}
void _generateIconDemo(File demoFilePath, Map<String, String> tokenPairMap) {
if (tokenPairMap.isEmpty) {
stderr.writeln('No new icons, skipping generating icon demo');
return;
}
stderr.writeln('Generating icon demo at $_defaultDemoFilePath');
final newIconUsages = StringBuffer();
for (final MapEntry<String, String> entry in tokenPairMap.entries) {
newIconUsages.writeln(Icon(entry).usage);
}
final demoFileContents =
'''
import 'package:flutter/material.dart';
void main() => runApp(const IconDemo());
class IconDemo extends StatelessWidget {
const IconDemo({ Key? key }) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Wrap(
children: const [
$newIconUsages
],
),
),
);
}
}
''';
demoFilePath.writeAsStringSync(demoFileContents);
}
class Icon {
// Parse tokenPair (e.g. {"6_ft_apart_outlined": "e004"}).
Icon(
MapEntry<String, String> tokenPair, {
this.fontFamily = _defaultFontFamily,
this.possibleStyleSuffixes = _defaultPossibleStyleSuffixes,
this.className = _defaultClassName,
}) {
id = tokenPair.key;
hexCodepoint = tokenPair.value;
// Determine family and HTML class suffix for Dartdoc.
if (id.endsWith('_gm_outlined')) {
dartdocFamily = 'GM';
dartdocHtmlSuffix = '-outlined';
} else if (id.endsWith('_gm_filled')) {
dartdocFamily = 'GM';
dartdocHtmlSuffix = '-filled';
} else if (id.endsWith('_monoline_outlined')) {
dartdocFamily = 'Monoline';
dartdocHtmlSuffix = '-outlined';
} else if (id.endsWith('_monoline_filled')) {
dartdocFamily = 'Monoline';
dartdocHtmlSuffix = '-filled';
} else {
dartdocFamily = 'material';
if (id.endsWith('_baseline')) {
id = _removeLast(id, '_baseline');
dartdocHtmlSuffix = '';
} else if (id.endsWith('_outlined')) {
dartdocHtmlSuffix = '-outlined';
} else if (id.endsWith('_rounded')) {
dartdocHtmlSuffix = '-round';
} else if (id.endsWith('_sharp')) {
dartdocHtmlSuffix = '-sharp';
}
}
_generateShortId();
_generateFlutterId();
}
late String id; // e.g. 5g, 5g_outlined, 5g_rounded, 5g_sharp
late String shortId; // e.g. 5g
late String flutterId; // e.g. five_g, five_g_outlined, five_g_rounded, five_g_sharp
late String hexCodepoint; // e.g. e547
late String dartdocFamily; // e.g. material
late String dartdocHtmlSuffix = ''; // The suffix for the 'material-icons' HTML class.
String fontFamily; // The IconData font family.
List<String>
possibleStyleSuffixes; // A list of possible suffixes e.g. _outlined, _monoline_filled.
String className; // The containing class.
String get name => shortId.replaceAll('_', ' ').trim();
String get style =>
dartdocHtmlSuffix == '' ? '' : ' (${dartdocHtmlSuffix.replaceFirst('-', '')})';
String get dartDoc =>
'<i class="material-icons$dartdocHtmlSuffix md-36">$shortId</i> &#x2014; $dartdocFamily icon named "$name"$style';
String get usage => 'Icon($className.$flutterId),';
bool get isMirroredInRTL {
// Remove common suffixes (e.g. "_new" or "_alt") from the shortId.
final String normalizedShortId = shortId.replaceAll(RegExp(r'_(new|alt|off|on)$'), '');
return _iconsMirroredWhenRTL.any(
(String shortIdMirroredWhenRTL) => normalizedShortId == shortIdMirroredWhenRTL,
);
}
String get declaration =>
"static const IconData $flutterId = IconData(0x$hexCodepoint, fontFamily: '$fontFamily'${isMirroredInRTL ? ', matchTextDirection: true' : ''});";
String get fullDeclaration =>
'''
/// $dartDoc.
$declaration
''';
String platformAdaptiveDeclaration(String fullFlutterId, Icon iOSIcon) =>
'''
/// Platform-adaptive icon for $dartDoc and ${iOSIcon.dartDoc}.;
IconData get $fullFlutterId => !_isCupertino() ? $className.$flutterId : $className.${iOSIcon.flutterId};
''';
@override
String toString() => id;
/// Analogous to [String.compareTo]
int _compareTo(Icon b) {
if (shortId == b.shortId) {
// Sort a regular icon before its variants.
return id.length - b.id.length;
}
return shortId.compareTo(b.shortId);
}
static String _removeLast(String string, String toReplace) {
return string.replaceAll(RegExp('$toReplace\$'), '');
}
/// See [shortId].
void _generateShortId() {
shortId = id;
for (final String styleSuffix in possibleStyleSuffixes) {
shortId = _removeLast(shortId, styleSuffix);
if (shortId != id) {
break;
}
}
}
/// See [flutterId].
void _generateFlutterId() {
flutterId = id;
// Exact identifier rewrites.
for (final MapEntry<String, String> rewritePair in _identifierExactRewrites.entries) {
if (shortId == rewritePair.key) {
flutterId = id.replaceFirst(rewritePair.key, _identifierExactRewrites[rewritePair.key]!);
}
}
// Prefix identifier rewrites.
for (final MapEntry<String, String> rewritePair in _identifierPrefixRewrites.entries) {
if (id.startsWith(rewritePair.key)) {
flutterId = id.replaceFirst(rewritePair.key, _identifierPrefixRewrites[rewritePair.key]!);
}
}
// Prevent double underscores.
flutterId = flutterId.replaceAll('__', '_');
}
}