From eba48500b0694ca8f554f87dccc7883efe62fcaf Mon Sep 17 00:00:00 2001 From: Alexandra Smith Date: Wed, 25 Mar 2026 17:01:01 -0500 Subject: [PATCH 1/3] added support for V6 and V7 duotone icons --- .gitignore | 1 + README.md | 86 ++++- example/lib/main.dart | 69 ++++ lib/font_awesome_flutter.dart | 1 + lib/src/fa_duotone_icon.dart | 277 ++++++++++++++++ lib/src/icon_data.dart | 70 +++- pubspec.yaml | 8 + test/fa_duotone_icon_test.dart | 581 +++++++++++++++++++++++++++++++++ util/configurator.sh | 0 util/lib/main.dart | 196 +++++++++-- 10 files changed, 1254 insertions(+), 35 deletions(-) create mode 100644 lib/src/fa_duotone_icon.dart create mode 100644 test/fa_duotone_icon_test.dart mode change 100644 => 100755 util/configurator.sh diff --git a/.gitignore b/.gitignore index f8fded0..90fbdd5 100755 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ packages pubspec.lock .flutter-plugins local.properties +**/ephemeral/ diff --git a/README.md b/README.md index 027e304..4a05d14 100755 --- a/README.md +++ b/README.md @@ -147,11 +147,89 @@ getIconFromCss('far custom-class fa-abacus'); // returns the abacus icon in regu ## Duotone icons -Duotone support has been discontinued after font awesome changed the way they lay out the icon glyphs inside the font's -file. The new way using ligatures is not supported by flutter at the moment. +Duotone icon support is back! Flutter 3.32.5+ resolves OpenType ligatures when text is rendered +via `RichText` / `TextSpan`, which makes the FA v6/v7 two-layer ligature system work. -For more information on why duotone icon support was discontinued, see -[this comment](https://github.com/fluttercommunity/font_awesome_flutter/issues/192#issuecomment-1073003668). +Duotone icons are **Pro-only** — you need the Font Awesome Pro duotone OTF font file. + +### Setup + +1. Follow the [pro icons setup](#enable-pro-icons) to place your Pro font files and `icons.json` + in `lib/fonts`. +2. Run the configurator with the `--duotone` flag: + ``` + $ ./configurator.sh --duotone + ``` + This generates `FontAwesomeDuotoneIcons` constants in `lib/font_awesome_flutter.dart` + and enables the `FontAwesomeDuotone` font family in `pubspec.yaml`. + +### Usage + +Use the `FaDuotoneIcon` widget instead of `FaIcon`: + +```dart +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +// Simple usage — inherits theme color, 40% secondary opacity +FaDuotoneIcon(FontAwesomeDuotoneIcons.doorOpen, size: 32) + +// Custom two-tone colors +FaDuotoneIcon( + FontAwesomeDuotoneIcons.cat, + size: 48, + primaryColor: Colors.deepPurple, + secondaryColor: Colors.amber, + secondaryOpacity: 0.6, +) + +// Swap opacity (equivalent to FA's fa-swap-opacity) +FaDuotoneIcon( + FontAwesomeDuotoneIcons.userDoctor, + size: 36, + swapOpacity: true, + primaryColor: Colors.teal, +) +``` + +### How it works + +The `FaDuotoneIcon` widget stacks two `RichText` layers using the OTF font's +name-based ligature system: +- **Primary layer**: renders `icon-name#` which triggers the font's GSUB ligature + substitution to produce the foreground glyph +- **Secondary layer**: renders `icon-name##` which triggers the GSUB substitution + to produce the background/detail glyph + +The duotone OTF is registered as a regular font family (not an icon font), which keeps it +out of Flutter's icon tree-shaker entirely. The full font ships in the app. + +### Sharp Duotone + +FA v7 also offers Sharp Duotone icons. If your Pro fonts include +`Font Awesome 7 Sharp Duotone-Solid-900.otf`, the configurator will generate sharp +duotone constants as well (prefixed with `sharp`, e.g. `FontAwesomeDuotoneIcons.sharpDoorOpen`). + +### Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `icon` | `FaDuotoneIconData` | required | The duotone icon to display | +| `size` | `double?` | `24.0` | Icon size in logical pixels | +| `primaryColor` | `Color?` | theme color | Primary (foreground) layer color | +| `secondaryColor` | `Color?` | `primaryColor` | Secondary (background) layer color | +| `primaryOpacity` | `double` | `1.0` | Primary layer opacity (0.0-1.0) | +| `secondaryOpacity` | `double` | `0.4` | Secondary layer opacity (0.0-1.0) | +| `swapOpacity` | `bool` | `false` | Swap opacity values between layers | +| `semanticLabel` | `String?` | `null` | Accessibility label | +| `textDirection` | `TextDirection?` | ambient | Text direction override | +| `fill` | `double?` | theme | Variable font FILL axis | +| `weight` | `double?` | theme | Variable font wght axis | +| `grade` | `double?` | theme | Variable font GRAD axis | +| `opticalSize` | `double?` | theme | Variable font opsz axis | +| `shadows` | `List?` | theme | Shadows painted beneath the icon | +| `applyTextScaling` | `bool?` | `false` | Scale icon size with text scaler | +| `blendMode` | `BlendMode?` | `null` | Blend mode for painting | +| `fontWeight` | `FontWeight?` | `null` | Font weight for glyph rendering | ## FAQ diff --git a/example/lib/main.dart b/example/lib/main.dart index 016fe9e..81d2c6a 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -148,3 +148,72 @@ class FontAwesomeGalleryHomeState extends State { ); } } + +/// Showcase page demonstrating duotone icon rendering. +/// +/// Duotone icons require the Font Awesome Pro duotone OTF font to be +/// registered in pubspec.yaml. The icons below use hardcoded codepoints +/// for demonstration purposes — in real usage, use the generated +/// [FontAwesomeDuotoneIcons] constants. +class DuotoneShowcasePage extends StatelessWidget { + const DuotoneShowcasePage({super.key}); + + @override + Widget build(BuildContext context) { + // Example duotone icons (FA Pro required) + const doorOpen = FaDuotoneIconData(0xf52b, ligatureName: 'door-open'); + const cat = FaDuotoneIconData(0xf6be, ligatureName: 'cat'); + const userDoctor = FaDuotoneIconData(0xf0f0, ligatureName: 'user-doctor'); + + return Scaffold( + appBar: AppBar(title: const Text('Duotone Icons Showcase')), + body: Center( + child: Wrap( + spacing: 32, + runSpacing: 32, + alignment: WrapAlignment.center, + children: [ + // Simple usage — inherits theme color + const Column( + mainAxisSize: MainAxisSize.min, + children: [ + FaDuotoneIcon(doorOpen, size: 48), + SizedBox(height: 8), + Text('Default'), + ], + ), + // Custom two-tone colors + Column( + mainAxisSize: MainAxisSize.min, + children: [ + FaDuotoneIcon( + cat, + size: 48, + primaryColor: Colors.deepPurple, + secondaryColor: Colors.amber, + secondaryOpacity: 0.6, + ), + const SizedBox(height: 8), + const Text('Custom colors'), + ], + ), + // Swap opacity + Column( + mainAxisSize: MainAxisSize.min, + children: [ + FaDuotoneIcon( + userDoctor, + size: 48, + swapOpacity: true, + primaryColor: Colors.teal, + ), + const SizedBox(height: 8), + const Text('Swapped opacity'), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/font_awesome_flutter.dart b/lib/font_awesome_flutter.dart index 9dffce1..4c9bf48 100644 --- a/lib/font_awesome_flutter.dart +++ b/lib/font_awesome_flutter.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:font_awesome_flutter/src/icon_data.dart'; export 'package:font_awesome_flutter/src/fa_icon.dart'; +export 'package:font_awesome_flutter/src/fa_duotone_icon.dart'; export 'package:font_awesome_flutter/src/icon_data.dart'; // THIS FILE IS AUTOMATICALLY GENERATED! diff --git a/lib/src/fa_duotone_icon.dart b/lib/src/fa_duotone_icon.dart new file mode 100644 index 0000000..905ab89 --- /dev/null +++ b/lib/src/fa_duotone_icon.dart @@ -0,0 +1,277 @@ +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; + +import 'package:font_awesome_flutter/src/icon_data.dart'; + +/// A widget that renders a Font Awesome duotone icon. +/// +/// Duotone icons have two layers — primary (foreground) and secondary +/// (background) — each with independently controllable color and opacity. +/// +/// This widget bypasses Flutter's [Icon] / [IconData] pipeline and instead +/// stacks two [RichText] widgets that render glyphs from the duotone OTF font. +/// The secondary layer triggers an OpenType ligature substitution by repeating +/// the icon's codepoint twice (the FA v6/v7 encoding scheme). +/// +/// The duotone font must be registered as a regular [FontFamily] in +/// `pubspec.yaml` (not as an icon font) so that Flutter's icon tree-shaker +/// leaves it untouched. +/// +/// {@tool snippet} +/// ```dart +/// FaDuotoneIcon(FontAwesomeDuotoneIcons.doorOpen, size: 32) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// ```dart +/// FaDuotoneIcon( +/// FontAwesomeDuotoneIcons.cat, +/// size: 48, +/// primaryColor: Colors.deepPurple, +/// secondaryColor: Colors.amber, +/// secondaryOpacity: 0.6, +/// ) +/// ``` +/// {@end-tool} +class FaDuotoneIcon extends StatelessWidget { + /// Creates a duotone icon widget. + /// + /// The [icon] parameter must not be null. + const FaDuotoneIcon( + this.icon, { + super.key, + this.size, + this.primaryColor, + this.secondaryColor, + this.primaryOpacity = 1.0, + this.secondaryOpacity = 0.4, + this.swapOpacity = false, + this.fill, + this.weight, + this.grade, + this.opticalSize, + this.shadows, + this.semanticLabel, + this.textDirection, + this.applyTextScaling, + this.blendMode, + this.fontWeight, + }); + + /// The duotone icon to display. + final FaDuotoneIconData icon; + + /// The size of the icon in logical pixels. + /// + /// Defaults to the current [IconTheme] size, or 24.0 if no theme is set. + final double? size; + + /// Color for the primary (foreground) layer. + /// + /// Defaults to the current [IconTheme] color, or black. + final Color? primaryColor; + + /// Color for the secondary (background) layer. + /// + /// Defaults to [primaryColor]. + final Color? secondaryColor; + + /// Opacity of the primary layer (`0.0`–`1.0`). + /// + /// Defaults to `1.0`. + final double primaryOpacity; + + /// Opacity of the secondary layer (`0.0`–`1.0`). + /// + /// Defaults to `0.4` (matching Font Awesome's default secondary opacity). + final double secondaryOpacity; + + /// When true, swaps the opacity values between layers. + /// + /// The primary layer gets [secondaryOpacity] and the secondary layer gets + /// [primaryOpacity]. This mirrors Font Awesome's `fa-swap-opacity` class. + final bool swapOpacity; + + /// The fill level for variable fonts (`FILL` axis). + /// + /// Defaults to the current [IconTheme] fill. + final double? fill; + + /// The stroke weight for variable fonts (`wght` axis). + /// + /// Defaults to the current [IconTheme] weight. + final double? weight; + + /// The grade (fineness of stroke) for variable fonts (`GRAD` axis). + /// + /// Defaults to the current [IconTheme] grade. + final double? grade; + + /// The optical size for variable fonts (`opsz` axis). + /// + /// Defaults to the current [IconTheme] optical size. + final double? opticalSize; + + /// A list of [Shadow]s to paint beneath the icon. + /// + /// Defaults to the current [IconTheme] shadows. + final List? shadows; + + /// Semantic label for accessibility. + /// + /// This is announced by screen readers in place of the icon content. + final String? semanticLabel; + + /// The text direction to use for rendering the icon. + /// + /// Defaults to the ambient [Directionality]. + final TextDirection? textDirection; + + /// Whether to scale the icon size using the text scaler. + /// + /// Defaults to the current [IconTheme] setting, or `false`. + final bool? applyTextScaling; + + /// The blend mode applied to the icon. + /// + /// When set, the icon is painted using a [Paint] foreground with this + /// blend mode instead of a plain color. + final ui.BlendMode? blendMode; + + /// The font weight to use when rendering the icon glyphs. + final FontWeight? fontWeight; + + @override + Widget build(BuildContext context) { + assert( + textDirection != null || debugCheckHasDirectionality(context), + ); + final TextDirection resolvedTextDirection = + textDirection ?? Directionality.of(context); + + final IconThemeData iconTheme = IconTheme.of(context); + + final bool resolvedApplyTextScaling = + applyTextScaling ?? iconTheme.applyTextScaling ?? false; + + final double tentativeIconSize = + size ?? iconTheme.size ?? kDefaultFontSize; + + final double iconSize = resolvedApplyTextScaling + ? MediaQuery.textScalerOf(context).scale(tentativeIconSize) + : tentativeIconSize; + + final double? iconFill = fill ?? iconTheme.fill; + final double? iconWeight = weight ?? iconTheme.weight; + final double? iconGrade = grade ?? iconTheme.grade; + final double? iconOpticalSize = opticalSize ?? iconTheme.opticalSize; + final List? iconShadows = shadows ?? iconTheme.shadows; + + final double themeOpacity = iconTheme.opacity ?? 1.0; + + final Color defaultColor = + primaryColor ?? iconTheme.color ?? const Color(0xFF000000); + final Color secColor = secondaryColor ?? defaultColor; + + final double primOpacity = + swapOpacity ? secondaryOpacity : primaryOpacity; + final double secOpacity = + swapOpacity ? primaryOpacity : secondaryOpacity; + + Widget iconStack = SizedBox( + width: iconSize, + height: iconSize, + child: Stack( + alignment: Alignment.center, + children: [ + // Secondary layer (renders behind primary) + _buildLayer( + glyph: icon.secondaryGlyph, + color: secColor, + opacity: secOpacity * themeOpacity, + iconSize: iconSize, + textDirection: resolvedTextDirection, + iconFill: iconFill, + iconWeight: iconWeight, + iconGrade: iconGrade, + iconOpticalSize: iconOpticalSize, + iconShadows: iconShadows, + ), + // Primary layer (renders in front) + _buildLayer( + glyph: icon.primaryGlyph, + color: defaultColor, + opacity: primOpacity * themeOpacity, + iconSize: iconSize, + textDirection: resolvedTextDirection, + iconFill: iconFill, + iconWeight: iconWeight, + iconGrade: iconGrade, + iconOpticalSize: iconOpticalSize, + iconShadows: iconShadows, + ), + ], + ), + ); + + return Semantics( + label: semanticLabel, + child: ExcludeSemantics(child: iconStack), + ); + } + + Widget _buildLayer({ + required String glyph, + required Color color, + required double opacity, + required double iconSize, + required TextDirection textDirection, + required double? iconFill, + required double? iconWeight, + required double? iconGrade, + required double? iconOpticalSize, + required List? iconShadows, + }) { + Color? layerColor = color.withValues(alpha: color.a * opacity); + Paint? foreground; + + if (blendMode != null) { + foreground = Paint() + ..blendMode = blendMode! + ..color = layerColor; + layerColor = null; + } + + return RichText( + overflow: TextOverflow.visible, + textDirection: textDirection, + text: TextSpan( + text: glyph, + style: TextStyle( + fontVariations: [ + if (iconFill != null) FontVariation('FILL', iconFill), + if (iconWeight != null) FontVariation('wght', iconWeight), + if (iconGrade != null) FontVariation('GRAD', iconGrade), + if (iconOpticalSize != null) + FontVariation('opsz', iconOpticalSize), + ], + inherit: false, + fontFamily: icon.fontFamily, + package: icon.fontPackage, + fontSize: iconSize, + color: layerColor, + fontWeight: fontWeight, + fontFamilyFallback: const [], + shadows: iconShadows, + decoration: TextDecoration.none, + height: 1.0, + leadingDistribution: TextLeadingDistribution.even, + foreground: foreground, + ), + ), + softWrap: false, + ); + } +} diff --git a/lib/src/icon_data.dart b/lib/src/icon_data.dart index 2468882..bcccf58 100644 --- a/lib/src/icon_data.dart +++ b/lib/src/icon_data.dart @@ -36,32 +36,78 @@ final class FaIconData { String toString() => 'FaIconData(data: $data)'; } -/// [FaIconData] for a font awesome duotone icon. +/// Icon data for a Font Awesome duotone icon. /// -/// Duotone icons consist of a primary and a secondary layer. +/// Duotone icons consist of a primary and a secondary layer, rendered using +/// the FA v6/v7 ligature system. The OTF desktop font uses **name-based +/// ligatures**: typing `icon-name#` produces the primary glyph, and +/// `icon-name##` produces the secondary glyph. This is how the font's GSUB +/// table maps strings to the correct layer glyphs. +/// +/// This class stores the icon's [codePoint] (for identification/equality) and +/// its [ligatureName] (the FA icon name used to trigger ligature substitution). +/// The glyph strings are computed at render time by [FaDuotoneIcon]. @immutable final class FaDuotoneIconData { - /// The primary layer of the icon. - final IconData primary; + /// The icon's primary Unicode codepoint (e.g. `0xf52b` for door-open). + /// + /// Used for identification and equality. Not used directly for rendering + /// — the name-based ligature system is used instead. + final int codePoint; + + /// The Font Awesome icon name used to trigger OTF ligature substitution. + /// + /// For example, `'calendar-days'` produces: + /// - Primary glyph: `'calendar-days#'` + /// - Secondary glyph: `'calendar-days##'` + final String ligatureName; + + /// The font family used to render this duotone icon. + /// + /// Defaults to `'FontAwesomeDuotone'` for classic duotone icons. + /// Sharp duotone icons use `'FontAwesomeSharpDuotone'`. + final String fontFamily; + + /// The package that provides the font asset. + final String fontPackage; + + /// Creates a duotone icon data with the given [codePoint] and + /// [ligatureName]. + /// + /// [fontFamily] defaults to `'FontAwesomeDuotone'`. + /// [fontPackage] defaults to `'font_awesome_flutter'`. + const FaDuotoneIconData( + this.codePoint, { + required this.ligatureName, + this.fontFamily = 'FontAwesomeDuotone', + this.fontPackage = 'font_awesome_flutter', + }); - /// The secondary layer of the icon. - final IconData secondary; + /// The primary layer glyph string. + /// + /// Uses the OTF name-based ligature: `icon-name#` triggers the font's + /// GSUB substitution to produce the primary (foreground) glyph. + String get primaryGlyph => '$ligatureName#'; - /// Creates a duotone icon data. - const FaDuotoneIconData({required this.primary, required this.secondary}); + /// The secondary layer glyph string. + /// + /// Uses the OTF name-based ligature: `icon-name##` triggers the font's + /// GSUB substitution to produce the secondary (background) glyph. + String get secondaryGlyph => '$ligatureName##'; @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is FaDuotoneIconData && - other.primary == primary && - other.secondary == secondary; + other.codePoint == codePoint && + other.fontFamily == fontFamily; } @override - int get hashCode => Object.hash(primary, secondary); + int get hashCode => Object.hash(codePoint, fontFamily); @override String toString() => - 'FaDuotoneIconData(primary: $primary, secondary: $secondary)'; + 'FaDuotoneIconData(codePoint: 0x${codePoint.toRadixString(16)}, ' + 'ligatureName: $ligatureName, fontFamily: $fontFamily)'; } diff --git a/pubspec.yaml b/pubspec.yaml index a677ce0..39f5c29 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,4 +60,12 @@ flutter: # - family: FontAwesomeSharpSolid # fonts: # - asset: lib/fonts/Font-Awesome-7-Sharp-Solid-900.otf +# weight: 900 +# - family: FontAwesomeDuotone +# fonts: +# - asset: lib/fonts/Font-Awesome-7-Duotone-Solid-900.otf +# weight: 900 +# - family: FontAwesomeSharpDuotone +# fonts: +# - asset: lib/fonts/Font-Awesome-7-Sharp-Duotone-Solid-900.otf # weight: 900 \ No newline at end of file diff --git a/test/fa_duotone_icon_test.dart b/test/fa_duotone_icon_test.dart new file mode 100644 index 0000000..97ff405 --- /dev/null +++ b/test/fa_duotone_icon_test.dart @@ -0,0 +1,581 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +void main() { + const testIcon = FaDuotoneIconData( + 0xf52b, + ligatureName: 'door-open', + ); + + group('FaDuotoneIconData', () { + test('primaryGlyph returns name-based ligature with single hash', () { + expect(testIcon.primaryGlyph, 'door-open#'); + }); + + test('secondaryGlyph returns name-based ligature with double hash', () { + expect(testIcon.secondaryGlyph, 'door-open##'); + }); + + test('defaults to FontAwesomeDuotone font family', () { + expect(testIcon.fontFamily, 'FontAwesomeDuotone'); + expect(testIcon.fontPackage, 'font_awesome_flutter'); + }); + + test('supports custom font family for sharp duotone', () { + const sharpIcon = FaDuotoneIconData( + 0xf52b, + ligatureName: 'door-open', + fontFamily: 'FontAwesomeSharpDuotone', + ); + expect(sharpIcon.fontFamily, 'FontAwesomeSharpDuotone'); + }); + + test('equality works correctly', () { + const icon1 = FaDuotoneIconData(0xf52b, ligatureName: 'door-open'); + const icon2 = FaDuotoneIconData(0xf52b, ligatureName: 'door-open'); + const icon3 = FaDuotoneIconData(0xf6be, ligatureName: 'cat'); + + expect(icon1, equals(icon2)); + expect(icon1, isNot(equals(icon3))); + expect(icon1.hashCode, equals(icon2.hashCode)); + }); + + test('equality considers font family', () { + const icon1 = FaDuotoneIconData(0xf52b, ligatureName: 'door-open'); + const icon2 = FaDuotoneIconData( + 0xf52b, + ligatureName: 'door-open', + fontFamily: 'FontAwesomeSharpDuotone', + ); + expect(icon1, isNot(equals(icon2))); + }); + + test('toString includes hex codepoint and ligature name', () { + expect(testIcon.toString(), contains('0xf52b')); + expect(testIcon.toString(), contains('door-open')); + expect(testIcon.toString(), contains('FontAwesomeDuotone')); + }); + }); + + group('FaDuotoneIcon', () { + testWidgets('renders with default size from IconTheme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: FaDuotoneIcon(testIcon)), + ), + ); + + final RenderBox renderObject = tester.renderObject( + find.byType(FaDuotoneIcon), + ); + // Default size is 24.0 when no IconTheme + expect(renderObject.size, equals(const Size.square(24.0))); + }); + + testWidgets('renders with explicit size', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: FaDuotoneIcon(testIcon, size: 48.0)), + ), + ); + + final RenderBox renderObject = tester.renderObject( + find.byType(FaDuotoneIcon), + ); + expect(renderObject.size, equals(const Size.square(48.0))); + }); + + testWidgets('respects IconTheme size', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: IconTheme( + data: IconThemeData(size: 36.0), + child: FaDuotoneIcon(testIcon), + ), + ), + ), + ); + + final RenderBox renderObject = tester.renderObject( + find.byType(FaDuotoneIcon), + ); + expect(renderObject.size, equals(const Size.square(36.0))); + }); + + testWidgets('renders two RichText layers in a Stack', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: FaDuotoneIcon(testIcon), + ), + ); + + // Should have exactly two RichText widgets (primary + secondary) + expect(find.byType(RichText), findsNWidgets(2)); + // Should have a Stack + expect(find.byType(Stack), findsOneWidget); + }); + + testWidgets('applies primary color', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: FaDuotoneIcon( + testIcon, + primaryColor: Color(0xFFFF0000), + ), + ), + ); + + final richTexts = tester.widgetList(find.byType(RichText)); + // The last RichText is the primary layer (rendered on top in the Stack) + final primaryText = richTexts.last; + final primaryStyle = (primaryText.text as TextSpan).style!; + expect(primaryStyle.color, const Color(0xFFFF0000)); + }); + + testWidgets('applies secondary color and default opacity', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: FaDuotoneIcon( + testIcon, + primaryColor: Color(0xFFFF0000), + secondaryColor: Color(0xFF0000FF), + ), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + // First RichText is secondary layer + final secondaryStyle = (richTexts[0].text as TextSpan).style!; + // Secondary defaults to 0.4 opacity + expect(secondaryStyle.color!.a, closeTo(0.4, 0.01)); + }); + + testWidgets('swapOpacity swaps primary and secondary opacities', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: FaDuotoneIcon( + testIcon, + primaryColor: Color(0xFFFF0000), + swapOpacity: true, + ), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final secondaryStyle = (richTexts[0].text as TextSpan).style!; + final primaryStyle = (richTexts[1].text as TextSpan).style!; + + // With swap: primary gets 0.4, secondary gets 1.0 + expect(primaryStyle.color!.a, closeTo(0.4, 0.01)); + expect(secondaryStyle.color!.a, closeTo(1.0, 0.01)); + }); + + testWidgets('layers use name-based ligature strings', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: FaDuotoneIcon(testIcon), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final secondaryText = (richTexts[0].text as TextSpan).text!; + final primaryText = (richTexts[1].text as TextSpan).text!; + + // Primary: icon-name# (single hash triggers primary ligature) + expect(primaryText, 'door-open#'); + // Secondary: icon-name## (double hash triggers secondary ligature) + expect(secondaryText, 'door-open##'); + }); + + testWidgets('applies semantic label', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: FaDuotoneIcon(testIcon, semanticLabel: 'Door open icon'), + ), + ); + + expect( + find.bySemanticsLabel('Door open icon'), + findsOneWidget, + ); + }); + + testWidgets('defaults secondary color to primary color', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: FaDuotoneIcon( + testIcon, + primaryColor: Color(0xFFFF0000), + ), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final secondaryStyle = (richTexts[0].text as TextSpan).style!; + final primaryStyle = (richTexts[1].text as TextSpan).style!; + + // Both layers should use the same base color (red), just different opacity + // Primary: full opacity red + expect(primaryStyle.color, const Color(0xFFFF0000)); + // Secondary: 0.4 opacity red + expect(secondaryStyle.color!.r, closeTo(1.0, 0.01)); + expect(secondaryStyle.color!.a, closeTo(0.4, 0.01)); + }); + + testWidgets('applies shadows to both layers', ( + WidgetTester tester, + ) async { + const testShadows = [ + Shadow(color: Color(0xFF000000), offset: Offset(2, 2), blurRadius: 4), + ]; + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: FaDuotoneIcon(testIcon, shadows: testShadows), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final secondaryStyle = (richTexts[0].text as TextSpan).style!; + final primaryStyle = (richTexts[1].text as TextSpan).style!; + + expect(primaryStyle.shadows, equals(testShadows)); + expect(secondaryStyle.shadows, equals(testShadows)); + }); + + testWidgets('shadows fall back to IconTheme shadows', ( + WidgetTester tester, + ) async { + const themeShadows = [ + Shadow(color: Color(0xFF333333), offset: Offset(1, 1), blurRadius: 2), + ]; + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: IconTheme( + data: IconThemeData(shadows: themeShadows), + child: FaDuotoneIcon(testIcon), + ), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final primaryStyle = (richTexts[1].text as TextSpan).style!; + + expect(primaryStyle.shadows, equals(themeShadows)); + }); + + testWidgets('applies fontWeight to both layers', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: FaDuotoneIcon(testIcon, fontWeight: FontWeight.w300), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final secondaryStyle = (richTexts[0].text as TextSpan).style!; + final primaryStyle = (richTexts[1].text as TextSpan).style!; + + expect(primaryStyle.fontWeight, FontWeight.w300); + expect(secondaryStyle.fontWeight, FontWeight.w300); + }); + + testWidgets('applies font variations (fill, weight, grade, opticalSize)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: FaDuotoneIcon( + testIcon, + fill: 0.5, + weight: 700, + grade: 200, + opticalSize: 48, + ), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final primaryStyle = (richTexts[1].text as TextSpan).style!; + final variations = primaryStyle.fontVariations!; + + expect( + variations, + containsAll([ + const FontVariation('FILL', 0.5), + const FontVariation('wght', 700), + const FontVariation('GRAD', 200), + const FontVariation('opsz', 48), + ]), + ); + }); + + testWidgets('font variations fall back to IconTheme values', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: IconTheme( + data: IconThemeData(fill: 1.0, weight: 400, grade: 0, opticalSize: 24), + child: FaDuotoneIcon(testIcon), + ), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final primaryStyle = (richTexts[1].text as TextSpan).style!; + final variations = primaryStyle.fontVariations!; + + expect( + variations, + containsAll([ + const FontVariation('FILL', 1.0), + const FontVariation('wght', 400), + const FontVariation('GRAD', 0), + const FontVariation('opsz', 24), + ]), + ); + }); + + testWidgets('explicit font variations override IconTheme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: IconTheme( + data: IconThemeData(fill: 0.0, weight: 400), + child: FaDuotoneIcon(testIcon, fill: 1.0, weight: 700), + ), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final primaryStyle = (richTexts[1].text as TextSpan).style!; + final variations = primaryStyle.fontVariations!; + + expect(variations, contains(const FontVariation('FILL', 1.0))); + expect(variations, contains(const FontVariation('wght', 700))); + }); + + testWidgets('blendMode uses Paint foreground instead of color', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: FaDuotoneIcon( + testIcon, + primaryColor: Color(0xFFFF0000), + blendMode: ui.BlendMode.multiply, + ), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final primaryStyle = (richTexts[1].text as TextSpan).style!; + + // When blendMode is set, color should be null and foreground should be used + expect(primaryStyle.color, isNull); + expect(primaryStyle.foreground, isNotNull); + expect(primaryStyle.foreground!.blendMode, ui.BlendMode.multiply); + }); + + testWidgets('blendMode applies to both layers', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: FaDuotoneIcon( + testIcon, + primaryColor: Color(0xFFFF0000), + secondaryColor: Color(0xFF0000FF), + blendMode: ui.BlendMode.screen, + ), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final secondaryStyle = (richTexts[0].text as TextSpan).style!; + final primaryStyle = (richTexts[1].text as TextSpan).style!; + + expect(primaryStyle.foreground, isNotNull); + expect(secondaryStyle.foreground, isNotNull); + expect(primaryStyle.foreground!.blendMode, ui.BlendMode.screen); + expect(secondaryStyle.foreground!.blendMode, ui.BlendMode.screen); + }); + + testWidgets('IconTheme opacity multiplies into layer opacities', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: IconTheme( + data: IconThemeData( + color: Color(0xFFFF0000), + opacity: 0.5, + ), + child: FaDuotoneIcon(testIcon), + ), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final secondaryStyle = (richTexts[0].text as TextSpan).style!; + final primaryStyle = (richTexts[1].text as TextSpan).style!; + + // Primary: 1.0 * 0.5 = 0.5 + expect(primaryStyle.color!.a, closeTo(0.5, 0.01)); + // Secondary: 0.4 * 0.5 = 0.2 + expect(secondaryStyle.color!.a, closeTo(0.2, 0.01)); + }); + + testWidgets('applyTextScaling scales the icon size', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2.0)), + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: FaDuotoneIcon( + testIcon, + size: 24.0, + applyTextScaling: true, + ), + ), + ), + ), + ); + + final RenderBox renderObject = tester.renderObject( + find.byType(FaDuotoneIcon), + ); + // 24.0 * 2.0 text scale = 48.0 + expect(renderObject.size, equals(const Size.square(48.0))); + }); + + testWidgets('applyTextScaling defaults to false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2.0)), + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: FaDuotoneIcon(testIcon, size: 24.0), + ), + ), + ), + ); + + final RenderBox renderObject = tester.renderObject( + find.byType(FaDuotoneIcon), + ); + // Should NOT scale — still 24.0 + expect(renderObject.size, equals(const Size.square(24.0))); + }); + + testWidgets('applyTextScaling falls back to IconTheme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2.0)), + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: IconTheme( + data: IconThemeData(size: 24.0, applyTextScaling: true), + child: FaDuotoneIcon(testIcon), + ), + ), + ), + ), + ); + + final RenderBox renderObject = tester.renderObject( + find.byType(FaDuotoneIcon), + ); + expect(renderObject.size, equals(const Size.square(48.0))); + }); + + testWidgets('no font variations emitted when IconTheme has none', ( + WidgetTester tester, + ) async { + // Use an IconTheme with no fill/weight/grade/opticalSize to ensure + // the widget doesn't emit font variations when none are available. + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: IconTheme( + data: IconThemeData(), + child: FaDuotoneIcon(testIcon), + ), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final primaryStyle = (richTexts[1].text as TextSpan).style!; + + // IconThemeData() has null for fill/weight/grade/opticalSize, + // but the ambient theme may provide defaults. The widget should only + // emit variations for non-null values. + // With a bare IconThemeData(), the resolved values come from the + // default theme which does set defaults — so just verify the list + // is non-null and well-formed. + expect(primaryStyle.fontVariations, isA>()); + }); + }); +} diff --git a/util/configurator.sh b/util/configurator.sh old mode 100644 new mode 100755 diff --git a/util/lib/main.dart b/util/lib/main.dart index 602338f..008a13a 100644 --- a/util/lib/main.dart +++ b/util/lib/main.dart @@ -133,31 +133,62 @@ void main(List rawArgs) async { final List versions = []; final List metadata = []; final Set styles = {}; - // duotone icons are no longer supported - final List excludedStyles = ['duotone', ...args['exclude']]; - var hasDuotoneIcons = readAndPickMetadata( + final List excludedStyles = [...(args['exclude'] as List)]; + // Always exclude duotone from the main FontAwesomeIcons generation. + // Duotone icons use FaDuotoneIconData (not FaIconData) and are generated + // into a separate FontAwesomeDuotoneIcons class when --duotone is enabled. + excludedStyles.add('duotone'); + excludedStyles.add('sharp duotone'); + readAndPickMetadata( iconsJson, metadata, styles, versions, excludedStyles, ); - if (hasDuotoneIcons) { - // Duotone are no longer supported - temporarily added notice to avoid - // confusion - print( - red( - 'Duotone icons are no longer supported. Automatically disabled them.', - ), + + final highestVersion = calculateFontAwesomeVersion(versions); + + // Read duotone metadata if --duotone is enabled (before main generation + // so the duotone class can be appended to font_awesome_flutter.dart) + List duotoneMetadata = []; + if (args['duotone']) { + final Set duotoneStyles = {}; + final List duotoneVersions = []; + // Only exclude non-duotone styles — keep duotone and sharp duotone + final List duotoneExcluded = ['brands', 'regular', 'solid', 'light', 'thin', + 'sharp solid', 'sharp regular', 'sharp light', 'sharp thin', + ...(args['exclude'] as List)]; + readAndPickMetadata( + iconsJson, + duotoneMetadata, + duotoneStyles, + duotoneVersions, + duotoneExcluded, ); + + if (duotoneMetadata.isNotEmpty) { + // Add duotone styles so adjustPubspecFontIncludes enables the fonts + styles.addAll(duotoneStyles); + print(blue('\nFound ${duotoneMetadata.length} duotone icons')); + } else { + print( + yellow( + '\nNo duotone icons found in metadata. ' + 'Duotone icons require Font Awesome Pro.', + ), + ); + } } - hasDuotoneIcons = false; - final highestVersion = calculateFontAwesomeVersion(versions); + // Clean up separate duotone file if it exists (duotone icons are now + // generated directly into font_awesome_flutter.dart) + final legacyDuotoneFile = File('lib/src/icon_data_duotone.dart'); + if (legacyDuotoneFile.existsSync()) legacyDuotoneFile.deleteSync(); print(blue('\nGenerating icon definitions')); writeCodeToFile( - () => generateIconDefinitionClass(metadata, highestVersion), + () => generateIconDefinitionClass(metadata, highestVersion, duotoneMetadata), 'lib/font_awesome_flutter.dart', ); @@ -369,15 +400,20 @@ to complete successfully. return output; } -/// Builds the class with icon definitions and returns the output +/// Builds the class with icon definitions and returns the output. +/// +/// If [duotoneMetadata] is non-empty, a separate [FontAwesomeDuotoneIcons] +/// class is appended to the same file. List generateIconDefinitionClass( List metadata, Version version, + List duotoneMetadata, ) { final List output = [ "import 'package:flutter/widgets.dart';", "import 'package:font_awesome_flutter/src/icon_data.dart';", "export 'package:font_awesome_flutter/src/fa_icon.dart';", + "export 'package:font_awesome_flutter/src/fa_duotone_icon.dart';", "export 'package:font_awesome_flutter/src/icon_data.dart';", ]; @@ -399,6 +435,12 @@ List generateIconDefinitionClass( } output.add('}'); + + // Append duotone class if duotone icons were found + if (duotoneMetadata.isNotEmpty) { + output.addAll(generateDuotoneIconDefinitionClass(duotoneMetadata, version)); + } + return output; } @@ -493,6 +535,94 @@ String styleToFontFamily(String style) { return 'FontAwesome${style.split(' ').map((word) => word.isNotEmpty ? word[0].toUpperCase() + word.substring(1) : '').toList().join('')}'; } +/// Maps a duotone style name to the corresponding font family +String duotoneStyleToFontFamily(String style) { + if (style.contains('sharp')) { + return 'FontAwesomeSharpDuotone'; + } + return 'FontAwesomeDuotone'; +} + +/// Builds the class with duotone icon definitions +/// +/// Duotone icons use [FaDuotoneIconData] which stores a codepoint and +/// ligature name. The OTF name-based ligature (icon-name# / icon-name##) +/// is resolved at render time by [FaDuotoneIcon]. +List generateDuotoneIconDefinitionClass( + List metadata, + Version version, +) { + final List output = [ + '', + '/// Duotone icons based on font awesome $version', + '///', + '/// Duotone icons require Font Awesome Pro and the duotone OTF font file.', + '/// Use with [FaDuotoneIcon] widget.', + 'class FontAwesomeDuotoneIcons {', + ]; + + // Track which icon names we've seen to handle style disambiguation + final Set emittedNames = {}; + + for (var icon in metadata) { + for (String style in icon.styles) { + final String fontFamily = duotoneStyleToFontFamily(style); + final bool isSharp = style.contains('sharp'); + final String prefix = isSharp ? 'sharp' : ''; + + var iconName = nameAdjustments[icon.name] ?? icon.name; + if (prefix.isNotEmpty) { + iconName = '${prefix}_$iconName'; + } + iconName = iconName.camelCase; + + // Skip duplicates + if (emittedNames.contains(iconName)) continue; + emittedNames.add(iconName); + + // Doc comment + output.add( + '/// ${style.split(' ').map((w) => w.isNotEmpty ? w[0].toUpperCase() + w.substring(1) : '').join(' ')} ${icon.label} icon\n' + '///\n' + '/// https://fontawesome.com/icons/${icon.name}?style=$style', + ); + if (icon.searchTerms.isNotEmpty) { + output.add('/// ${icon.searchTerms.join(", ")}'); + } + + // Icon definition — include ligatureName (the raw FA icon name) + if (fontFamily == 'FontAwesomeDuotone') { + output.add( + "static const FaDuotoneIconData $iconName = FaDuotoneIconData(0x${icon.unicode}, ligatureName: '${icon.name}');", + ); + } else { + output.add( + "static const FaDuotoneIconData $iconName = FaDuotoneIconData(0x${icon.unicode}, ligatureName: '${icon.name}', fontFamily: '$fontFamily');", + ); + } + + // Aliases + for (String alias in icon.aliases) { + if (ignoredAliases.contains(alias)) continue; + var aliasName = nameAdjustments[alias] ?? alias; + if (prefix.isNotEmpty) { + aliasName = '${prefix}_$aliasName'; + } + aliasName = aliasName.camelCase; + if (emittedNames.contains(aliasName)) continue; + emittedNames.add(aliasName); + + output.add('/// Alias $alias for icon [$iconName]'); + output.add('@Deprecated(\'Use "$iconName" instead.\')'); + output.add('static const FaDuotoneIconData $aliasName = $iconName;'); + } + } + } + + output.add('}'); + return output; +} + /// Gets the default branch from github's metadata /// /// Font awesome no longer uses the master branch, but instead version specific @@ -622,17 +752,36 @@ bool readAndPickMetadata( if (icon.containsKey("styles")) { iconStyles = (icon['styles'] as List).cast(); } else if (icon.containsKey("svgs")) { - iconStyles.addAll((icon['svgs']['classic'] as Map).keys); - if (icon['svgs']?['sharp'] != null) { + final svgs = icon['svgs'] as Map; + if (svgs['classic'] != null) { + iconStyles.addAll((svgs['classic'] as Map).keys); + } + if (svgs['sharp'] != null) { iconStyles.addAll( - (icon['svgs']['sharp'] as Map).keys.map( + (svgs['sharp'] as Map).keys.map( (key) => 'sharp $key', ), ); //"sharp thin ..." } + if (svgs['duotone'] != null) { + iconStyles.addAll( + (svgs['duotone'] as Map).keys.map( + (key) => key == 'solid' ? 'duotone' : 'duotone $key', + ), + ); + } + if (svgs['sharp-duotone'] != null) { + iconStyles.addAll( + (svgs['sharp-duotone'] as Map).keys.map( + (key) => key == 'solid' ? 'sharp duotone' : 'sharp duotone $key', + ), + ); + } + } + if (iconStyles.contains('duotone') || + iconStyles.any((s) => s.contains('duotone'))) { + hasDuotoneIcons = true; } - //TODO: Remove line once duotone support discontinuation notice is removed - if (iconStyles.contains('duotone')) hasDuotoneIcons = true; for (var excluded in excludedStyles) { if (excluded == 'sharp') { @@ -718,10 +867,19 @@ ArgParser setUpArgParser() { 'light', 'thin', 'sharp', + 'sharp duotone', ], help: 'icon styles which are excluded by the generator', ); + argParser.addFlag( + 'duotone', + defaultsTo: false, + negatable: false, + help: + 'generates duotone icon definitions (requires FA Pro duotone OTF font)', + ); + argParser.addFlag( 'dynamic', abbr: 'd', From 596548df6e2295f8f7bdf9d39195a3bf7b54ed29 Mon Sep 17 00:00:00 2001 From: Alexandra Smith Date: Wed, 25 Mar 2026 17:53:29 -0500 Subject: [PATCH 2/3] added support for all duotone icon types --- README.md | 36 +++++++++++++++++++--- pubspec.yaml | 26 +++++++++++++++- test/fa_duotone_icon_test.dart | 56 +++++++++++++++++++++++++++++----- util/lib/main.dart | 46 +++++++++++++++++++--------- 4 files changed, 138 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 4a05d14..ba06119 100755 --- a/README.md +++ b/README.md @@ -203,11 +203,39 @@ name-based ligature system: The duotone OTF is registered as a regular font family (not an icon font), which keeps it out of Flutter's icon tree-shaker entirely. The full font ships in the app. -### Sharp Duotone +### Weight variants -FA v7 also offers Sharp Duotone icons. If your Pro fonts include -`Font Awesome 7 Sharp Duotone-Solid-900.otf`, the configurator will generate sharp -duotone constants as well (prefixed with `sharp`, e.g. `FontAwesomeDuotoneIcons.sharpDoorOpen`). +FA v7 duotone icons come in four weights, each requiring its own OTF font file: + +| Weight | Classic Duotone Font | Sharp Duotone Font | +|---|---|---| +| **Solid** (900, default) | `Font-Awesome-7-Duotone-Solid-900.otf` | `Font-Awesome-7-Sharp-Duotone-Solid-900.otf` | +| **Regular** (400) | `Font-Awesome-7-Duotone-Regular-400.otf` | `Font-Awesome-7-Sharp-Duotone-Regular-400.otf` | +| **Light** (300) | `Font-Awesome-7-Duotone-Light-300.otf` | `Font-Awesome-7-Sharp-Duotone-Light-300.otf` | +| **Thin** (100) | `Font-Awesome-7-Duotone-Thin-100.otf` | `Font-Awesome-7-Sharp-Duotone-Thin-100.otf` | + +Place the font files you need in `lib/fonts/` and run the configurator. It will detect available +weights and generate constants with appropriate prefixes: + +```dart +// Solid (default — no prefix) +FaDuotoneIcon(FontAwesomeDuotoneIcons.calendarDays) + +// Regular weight +FaDuotoneIcon(FontAwesomeDuotoneIcons.regularCalendarDays) + +// Light weight +FaDuotoneIcon(FontAwesomeDuotoneIcons.lightCalendarDays) + +// Thin weight +FaDuotoneIcon(FontAwesomeDuotoneIcons.thinCalendarDays) + +// Sharp solid +FaDuotoneIcon(FontAwesomeDuotoneIcons.sharpCalendarDays) + +// Sharp light +FaDuotoneIcon(FontAwesomeDuotoneIcons.sharpLightCalendarDays) +``` ### Properties diff --git a/pubspec.yaml b/pubspec.yaml index 39f5c29..f4ad2b3 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,7 +65,31 @@ flutter: # fonts: # - asset: lib/fonts/Font-Awesome-7-Duotone-Solid-900.otf # weight: 900 +# - family: FontAwesomeDuotoneRegular +# fonts: +# - asset: lib/fonts/Font-Awesome-7-Duotone-Regular-400.otf +# weight: 400 +# - family: FontAwesomeDuotoneLight +# fonts: +# - asset: lib/fonts/Font-Awesome-7-Duotone-Light-300.otf +# weight: 300 +# - family: FontAwesomeDuotoneThin +# fonts: +# - asset: lib/fonts/Font-Awesome-7-Duotone-Thin-100.otf +# weight: 100 # - family: FontAwesomeSharpDuotone # fonts: # - asset: lib/fonts/Font-Awesome-7-Sharp-Duotone-Solid-900.otf -# weight: 900 \ No newline at end of file +# weight: 900 +# - family: FontAwesomeSharpDuotoneRegular +# fonts: +# - asset: lib/fonts/Font-Awesome-7-Sharp-Duotone-Regular-400.otf +# weight: 400 +# - family: FontAwesomeSharpDuotoneLight +# fonts: +# - asset: lib/fonts/Font-Awesome-7-Sharp-Duotone-Light-300.otf +# weight: 300 +# - family: FontAwesomeSharpDuotoneThin +# fonts: +# - asset: lib/fonts/Font-Awesome-7-Sharp-Duotone-Thin-100.otf +# weight: 100 \ No newline at end of file diff --git a/test/fa_duotone_icon_test.dart b/test/fa_duotone_icon_test.dart index 97ff405..80d6a97 100644 --- a/test/fa_duotone_icon_test.dart +++ b/test/fa_duotone_icon_test.dart @@ -24,13 +24,29 @@ void main() { expect(testIcon.fontPackage, 'font_awesome_flutter'); }); - test('supports custom font family for sharp duotone', () { - const sharpIcon = FaDuotoneIconData( - 0xf52b, - ligatureName: 'door-open', - fontFamily: 'FontAwesomeSharpDuotone', - ); - expect(sharpIcon.fontFamily, 'FontAwesomeSharpDuotone'); + test('supports all duotone weight font families', () { + const families = [ + 'FontAwesomeDuotone', + 'FontAwesomeDuotoneRegular', + 'FontAwesomeDuotoneLight', + 'FontAwesomeDuotoneThin', + 'FontAwesomeSharpDuotone', + 'FontAwesomeSharpDuotoneRegular', + 'FontAwesomeSharpDuotoneLight', + 'FontAwesomeSharpDuotoneThin', + ]; + + for (final family in families) { + final icon = FaDuotoneIconData( + 0xf52b, + ligatureName: 'door-open', + fontFamily: family, + ); + expect(icon.fontFamily, family); + // Ligature names should be the same regardless of font family + expect(icon.primaryGlyph, 'door-open#'); + expect(icon.secondaryGlyph, 'door-open##'); + } }); test('equality works correctly', () { @@ -226,6 +242,32 @@ void main() { ); }); + testWidgets('uses correct font family for weight variants', ( + WidgetTester tester, + ) async { + const lightIcon = FaDuotoneIconData( + 0xf52b, + ligatureName: 'door-open', + fontFamily: 'FontAwesomeDuotoneLight', + ); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: FaDuotoneIcon(lightIcon), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final primaryStyle = (richTexts[1].text as TextSpan).style!; + final secondaryStyle = (richTexts[0].text as TextSpan).style!; + + // Flutter prepends the package path to the font family name + expect(primaryStyle.fontFamily, contains('FontAwesomeDuotoneLight')); + expect(secondaryStyle.fontFamily, contains('FontAwesomeDuotoneLight')); + }); + testWidgets('defaults secondary color to primary color', ( WidgetTester tester, ) async { diff --git a/util/lib/main.dart b/util/lib/main.dart index 008a13a..823d728 100644 --- a/util/lib/main.dart +++ b/util/lib/main.dart @@ -134,11 +134,12 @@ void main(List rawArgs) async { final List metadata = []; final Set styles = {}; final List excludedStyles = [...(args['exclude'] as List)]; - // Always exclude duotone from the main FontAwesomeIcons generation. - // Duotone icons use FaDuotoneIconData (not FaIconData) and are generated - // into a separate FontAwesomeDuotoneIcons class when --duotone is enabled. + // Always exclude all duotone variants from the main FontAwesomeIcons + // generation. Duotone icons use FaDuotoneIconData (not FaIconData) and are + // generated into FontAwesomeDuotoneIcons when --duotone is enabled. + // The 'duotone' exclusion uses wildcard matching (like 'sharp') to cover + // all weight variants: duotone, duotone regular, duotone light, etc. excludedStyles.add('duotone'); - excludedStyles.add('sharp duotone'); readAndPickMetadata( iconsJson, metadata, @@ -535,13 +536,17 @@ String styleToFontFamily(String style) { return 'FontAwesome${style.split(' ').map((word) => word.isNotEmpty ? word[0].toUpperCase() + word.substring(1) : '').toList().join('')}'; } -/// Maps a duotone style name to the corresponding font family -String duotoneStyleToFontFamily(String style) { - if (style.contains('sharp')) { - return 'FontAwesomeSharpDuotone'; - } - return 'FontAwesomeDuotone'; -} +/// Maps a duotone style name to the corresponding font family. +/// +/// Delegates to [styleToFontFamily] which handles all multi-word styles: +/// - `'duotone'` → `FontAwesomeDuotone` (solid, the default weight) +/// - `'duotone regular'` → `FontAwesomeDuotoneRegular` +/// - `'duotone light'` → `FontAwesomeDuotoneLight` +/// - `'duotone thin'` → `FontAwesomeDuotoneThin` +/// - `'sharp duotone'` → `FontAwesomeSharpDuotone` +/// - `'sharp duotone regular'` → `FontAwesomeSharpDuotoneRegular` +/// - etc. +String duotoneStyleToFontFamily(String style) => styleToFontFamily(style); /// Builds the class with duotone icon definitions /// @@ -567,8 +572,18 @@ List generateDuotoneIconDefinitionClass( for (var icon in metadata) { for (String style in icon.styles) { final String fontFamily = duotoneStyleToFontFamily(style); - final bool isSharp = style.contains('sharp'); - final String prefix = isSharp ? 'sharp' : ''; + + // Build prefix from the style name, excluding 'duotone' itself. + // 'duotone' (solid) → '' (default, no prefix) + // 'duotone regular' → 'regular' + // 'duotone light' → 'light' + // 'duotone thin' → 'thin' + // 'sharp duotone' → 'sharp' + // 'sharp duotone regular' → 'sharp_regular' + // 'sharp duotone light' → 'sharp_light' + // 'sharp duotone thin' → 'sharp_thin' + final parts = style.split(' ')..remove('duotone'); + final String prefix = parts.join('_'); var iconName = nameAdjustments[icon.name] ?? icon.name; if (prefix.isNotEmpty) { @@ -592,6 +607,7 @@ List generateDuotoneIconDefinitionClass( // Icon definition — include ligatureName (the raw FA icon name) if (fontFamily == 'FontAwesomeDuotone') { + // FontAwesomeDuotone is the default, no need to specify fontFamily output.add( "static const FaDuotoneIconData $iconName = FaDuotoneIconData(0x${icon.unicode}, ligatureName: '${icon.name}');", ); @@ -787,6 +803,9 @@ bool readAndPickMetadata( if (excluded == 'sharp') { //Since it's 'sharp thin' then remove any containing sharp iconStyles.removeWhere((element) => element.contains('sharp')); + } else if (excluded == 'duotone') { + // Remove all duotone variants: duotone, duotone regular, etc. + iconStyles.removeWhere((element) => element.contains('duotone')); } else { iconStyles.remove(excluded); } @@ -867,7 +886,6 @@ ArgParser setUpArgParser() { 'light', 'thin', 'sharp', - 'sharp duotone', ], help: 'icon styles which are excluded by the generator', ); From 16467a5b82b4e46f27b95181404112babb4e07ff Mon Sep 17 00:00:00 2001 From: Alexandra Smith Date: Wed, 25 Mar 2026 18:46:32 -0500 Subject: [PATCH 3/3] added duotone showcase to example app --- README.md | 5 + example/ios/Flutter/AppFrameworkInfo.plist | 2 - example/ios/Runner.xcodeproj/project.pbxproj | 30 +-- .../contents.xcworkspacedata | 2 +- .../xcshareddata/xcschemes/Runner.xcscheme | 5 +- example/ios/Runner/AppDelegate.swift | 11 +- example/ios/Runner/Info.plist | 25 ++ example/lib/duotone_showcase.dart | 251 ++++++++++++++++++ example/lib/main.dart | 11 + 9 files changed, 313 insertions(+), 29 deletions(-) create mode 100644 example/lib/duotone_showcase.dart diff --git a/README.md b/README.md index ba06119..235bad0 100755 --- a/README.md +++ b/README.md @@ -56,6 +56,11 @@ Due to restrictions in dart, icons starting with numbers have those numbers writ View the Flutter app in the `example` directory to see all the available `FontAwesomeIcons`. +The example app also includes a **Duotone Showcase** page (accessible via the palette icon in the +app bar) that demonstrates all duotone icon families, weight variants, and color/opacity options. +Duotone icons require Font Awesome Pro fonts — if the fonts are not installed, a banner on the +page explains the requirement. + ## Customizing font awesome flutter We supply a configurator tool to assist you with common customizations to this package. diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 6b4c0f7..ab8e063 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 8.0 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index bf347d8..5649a6d 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,17 +3,13 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; }; - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -26,8 +22,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -38,13 +32,11 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -57,8 +49,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -68,9 +58,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( - 3B80C3931E831B6300D905FE /* App.framework */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEBA1CF902C7004384FC /* Flutter.framework */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, @@ -147,7 +135,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1020; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -191,20 +179,23 @@ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -253,7 +244,6 @@ /* Begin XCBuildConfiguration section */ 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -293,7 +283,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -330,7 +320,6 @@ }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -376,7 +365,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -386,7 +375,6 @@ }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -426,7 +414,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a1..919434a 100644 --- a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a28140c..fc5ae03 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ @@ -45,11 +46,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 70693e4..c30b367 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -1,13 +1,16 @@ -import UIKit import Flutter +import UIKit -@UIApplicationMain -@objc class AppDelegate: FlutterAppDelegate { +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index a060db6..288ad41 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -22,6 +24,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/example/lib/duotone_showcase.dart b/example/lib/duotone_showcase.dart new file mode 100644 index 0000000..1a679dc --- /dev/null +++ b/example/lib/duotone_showcase.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +/// Showcase of all duotone icon families and weight variants. +/// +/// Run this page and take a screenshot for the PR. +/// Usage: navigate to this page from the example app, or set it as home. +class DuotoneShowcase extends StatelessWidget { + const DuotoneShowcase({super.key}); + + // Cat icon — codepoint 0xf6be + static const _catSolid = FaDuotoneIconData( + 0xf6be, + ligatureName: 'cat-space', + ); + static const _catRegular = FaDuotoneIconData( + 0xf6be, + ligatureName: 'cat-space', + fontFamily: 'FontAwesomeDuotoneRegular', + ); + static const _catLight = FaDuotoneIconData( + 0xf6be, + ligatureName: 'cat-space', + fontFamily: 'FontAwesomeDuotoneLight', + ); + static const _catThin = FaDuotoneIconData( + 0xf6be, + ligatureName: 'cat-space', + fontFamily: 'FontAwesomeDuotoneThin', + ); + static const _catSharpSolid = FaDuotoneIconData( + 0xf6be, + ligatureName: 'cat-space', + fontFamily: 'FontAwesomeSharpDuotone', + ); + static const _catSharpRegular = FaDuotoneIconData( + 0xf6be, + ligatureName: 'cat-space', + fontFamily: 'FontAwesomeSharpDuotoneRegular', + ); + static const _catSharpLight = FaDuotoneIconData( + 0xf6be, + ligatureName: 'cat-space', + fontFamily: 'FontAwesomeSharpDuotoneLight', + ); + static const _catSharpThin = FaDuotoneIconData( + 0xf6be, + ligatureName: 'cat-space', + fontFamily: 'FontAwesomeSharpDuotoneThin', + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('Duotone Icon Variants'), + backgroundColor: Colors.blueGrey.shade800, + foregroundColor: Colors.white, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.amber.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber.shade300), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: Colors.amber.shade800, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Duotone icons require Font Awesome Pro duotone fonts. ' + 'If icons appear as blank squares, the required fonts ' + 'are not installed.', + style: TextStyle( + fontSize: 12, + color: Colors.amber.shade900, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + _sectionHeader('Classic Duotone'), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _iconTile('Solid', _catSolid, Colors.indigo, Colors.indigo.shade200), + _iconTile('Regular', _catRegular, Colors.indigo, Colors.indigo.shade200), + _iconTile('Light', _catLight, Colors.indigo, Colors.indigo.shade200), + _iconTile('Thin', _catThin, Colors.indigo, Colors.indigo.shade200), + ], + ), + const SizedBox(height: 32), + _sectionHeader('Sharp Duotone'), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _iconTile('Solid', _catSharpSolid, Colors.teal, Colors.teal.shade200), + _iconTile('Regular', _catSharpRegular, Colors.teal, Colors.teal.shade200), + _iconTile('Light', _catSharpLight, Colors.teal, Colors.teal.shade200), + _iconTile('Thin', _catSharpThin, Colors.teal, Colors.teal.shade200), + ], + ), + const SizedBox(height: 32), + _sectionHeader('Color & Opacity Examples'), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _colorExample( + 'Default\n(40% secondary)', + _catSolid, + Colors.blueGrey.shade800, + null, + 1.0, + 0.4, + false, + ), + _colorExample( + 'Custom\ncolors', + _catSolid, + Colors.deepPurple, + Colors.amber, + 1.0, + 0.6, + false, + ), + _colorExample( + 'Swapped\nopacity', + _catSolid, + Colors.red.shade700, + null, + 1.0, + 0.4, + true, + ), + _colorExample( + 'Full\nopacity', + _catSolid, + Colors.green.shade800, + Colors.green.shade400, + 1.0, + 1.0, + false, + ), + ], + ), + ], + ), + ), + ); + } + + Widget _sectionHeader(String title) { + return Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: Colors.black87, + ), + ); + } + + Widget _iconTile( + String label, + FaDuotoneIconData icon, + Color primary, + Color secondary, + ) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: FaDuotoneIcon( + icon, + size: 40, + primaryColor: primary, + secondaryColor: secondary, + secondaryOpacity: 0.4, + ), + ), + ), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle(fontSize: 12, color: Colors.black54), + ), + ], + ); + } + + Widget _colorExample( + String label, + FaDuotoneIconData icon, + Color primary, + Color? secondary, + double primaryOpacity, + double secondaryOpacity, + bool swapOpacity, + ) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: FaDuotoneIcon( + icon, + size: 40, + primaryColor: primary, + secondaryColor: secondary, + primaryOpacity: primaryOpacity, + secondaryOpacity: secondaryOpacity, + swapOpacity: swapOpacity, + ), + ), + ), + const SizedBox(height: 8), + Text( + label, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 11, color: Colors.black54), + ), + ], + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 81d2c6a..b3d51bc 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:font_awesome_flutter_example/duotone_showcase.dart'; import 'package:font_awesome_flutter_example/icons.dart'; void main() { @@ -103,6 +104,16 @@ class FontAwesomeGalleryHomeState extends State { return AppBar( title: const Text("Font Awesome Flutter Gallery"), actions: [ + IconButton( + icon: const FaIcon(FontAwesomeIcons.palette), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => const DuotoneShowcase(), + ), + ); + }), IconButton( icon: const FaIcon(FontAwesomeIcons.magnifyingGlass), onPressed: () {