diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f3bf4e..0e4d1e39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [1.7.0] +* ✨ New + * `MacosImageIcon` widget. Identical to the `ImageIcon` from `flutter/widgets.dart` except it will obey a +`MacosIconThemeData` instead of an `IconThemeData` + * `SidebarItemSize` enum, which determines the height of sidebar items and the maximum size their `leading` widgets. + * `SidebarItem` now accepts an optional `trailing` widget. +* 🔄 Updated + * `SidebarItems` now supports `SidebarItemSize` via the `itemSize` property, which defaults to +`SidebarItemSize.medium`. The widget has been updated to manage the item's height, the maximum size of the item's +leading widget, and the font size of the item's label widget according to the given `SidebarItemSize`. + * The example app has been tweaked to use some icons from the SF Symbols 4 Beta via the new `MacosImageIcon` widget. + ## [1.6.0] * New widgets: `MacosTabView` and `MacosTabView` * BREAKING CHANGE: `Label.yAxis` has been renamed to `Label.crossAxisAlignment` diff --git a/example/assets/sf_symbols/button_programmable_2x.png b/example/assets/sf_symbols/button_programmable_2x.png new file mode 100644 index 00000000..1a4ee84d Binary files /dev/null and b/example/assets/sf_symbols/button_programmable_2x.png differ diff --git a/example/assets/sf_symbols/character_cursor_ibeam_2x.png b/example/assets/sf_symbols/character_cursor_ibeam_2x.png new file mode 100644 index 00000000..f52311f8 Binary files /dev/null and b/example/assets/sf_symbols/character_cursor_ibeam_2x.png differ diff --git a/example/assets/sf_symbols/filemenu_and_selection_2x.png b/example/assets/sf_symbols/filemenu_and_selection_2x.png new file mode 100644 index 00000000..7321b4bd Binary files /dev/null and b/example/assets/sf_symbols/filemenu_and_selection_2x.png differ diff --git a/example/assets/sf_symbols/lines_measurement_horizontal_2x.png b/example/assets/sf_symbols/lines_measurement_horizontal_2x.png new file mode 100644 index 00000000..f20e0054 Binary files /dev/null and b/example/assets/sf_symbols/lines_measurement_horizontal_2x.png differ diff --git a/example/assets/sf_symbols/rectangle_3_group_2x.png b/example/assets/sf_symbols/rectangle_3_group_2x.png new file mode 100644 index 00000000..25e33131 Binary files /dev/null and b/example/assets/sf_symbols/rectangle_3_group_2x.png differ diff --git a/example/lib/main.dart b/example/lib/main.dart index 83964684..5fd0a221 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -176,47 +176,77 @@ class _WidgetGalleryState extends State { currentIndex: pageIndex, onChanged: (i) => setState(() => pageIndex = i), scrollController: controller, - items: const [ - SidebarItem( - leading: MacosIcon(CupertinoIcons.square_on_circle), + itemSize: SidebarItemSize.large, + items: [ + const SidebarItem( + // leading: MacosIcon(CupertinoIcons.square_on_circle), + leading: MacosImageIcon( + AssetImage( + 'assets/sf_symbols/button_programmable_2x.png', + ), + ), label: Text('Buttons'), ), - SidebarItem( - leading: MacosIcon(CupertinoIcons.arrow_2_circlepath), + const SidebarItem( + leading: MacosImageIcon( + AssetImage( + 'assets/sf_symbols/lines_measurement_horizontal_2x.png', + ), + ), label: Text('Indicators'), ), - SidebarItem( - leading: MacosIcon(CupertinoIcons.textbox), + const SidebarItem( + leading: MacosImageIcon( + AssetImage( + 'assets/sf_symbols/character_cursor_ibeam_2x.png', + ), + ), label: Text('Fields'), ), SidebarItem( - leading: Icon(CupertinoIcons.folder), - label: Text('Disclosure'), + leading: const MacosIcon(CupertinoIcons.folder), + label: const Text('Disclosure'), + trailing: Text( + '2', + style: TextStyle( + color: MacosTheme.brightnessOf(context) == Brightness.dark + ? MacosColors.tertiaryLabelColor.darkColor + : MacosColors.tertiaryLabelColor, + ), + ), disclosureItems: [ - SidebarItem( - leading: MacosIcon(CupertinoIcons.infinite), + const SidebarItem( + leading: MacosImageIcon( + AssetImage( + 'assets/sf_symbols/rectangle_3_group_2x.png', + ), + ), label: Text('Colors'), ), - SidebarItem( + const SidebarItem( leading: MacosIcon(CupertinoIcons.infinite), label: Text('Item 3'), ), ], ), - SidebarItem( - leading: MacosIcon(CupertinoIcons.rectangle), + const SidebarItem( + leading: MacosIcon(CupertinoIcons.square_on_square), label: Text('Dialogs & Sheets'), ), - SidebarItem( + const SidebarItem( leading: MacosIcon(CupertinoIcons.macwindow), label: Text('Toolbar'), ), - SidebarItem( - leading: MacosIcon(CupertinoIcons.calendar), + const SidebarItem( + leading: MacosImageIcon( + AssetImage( + 'assets/sf_symbols/filemenu_and_selection_2x.png', + ), + ), label: Text('Selectors'), ), - SidebarItem( - leading: MacosIcon(CupertinoIcons.table_fill), + const SidebarItem( + leading: MacosIcon(CupertinoIcons.uiwindow_split_2x1), label: Text('TabView'), ), ], diff --git a/example/pubspec.lock b/example/pubspec.lock index 779cfa22..a3e1e90b 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -49,7 +49,7 @@ packages: name: cupertino_icons url: "https://pub.dartlang.org" source: hosted - version: "1.0.4" + version: "1.0.5" fake_async: dependency: transitive description: @@ -87,7 +87,7 @@ packages: path: ".." relative: true source: path - version: "1.6.0" + version: "1.7.0" matcher: dependency: transitive description: @@ -129,7 +129,7 @@ packages: name: provider url: "https://pub.dartlang.org" source: hosted - version: "6.0.2" + version: "6.0.3" sky_engine: dependency: transitive description: flutter diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 6b4d9e84..e68c5535 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -10,12 +10,16 @@ dependencies: flutter: sdk: flutter - cupertino_icons: ^1.0.4 + cupertino_icons: ^1.0.5 macos_ui: path: .. - provider: ^6.0.2 + provider: ^6.0.3 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.1 + +flutter: + assets: + - assets/sf_symbols/ diff --git a/lib/macos_ui.dart b/lib/macos_ui.dart index 08a2ba91..b783a572 100644 --- a/lib/macos_ui.dart +++ b/lib/macos_ui.dart @@ -30,6 +30,7 @@ export 'src/buttons/toolbar/toolbar_pulldown_button.dart'; export 'src/dialogs/macos_alert_dialog.dart'; export 'src/fields/search_field.dart'; export 'src/fields/text_field.dart'; +export 'src/icon/image_icon.dart'; export 'src/icon/macos_icon.dart'; export 'src/indicators/capacity_indicators.dart'; export 'src/indicators/progress_indicators.dart'; diff --git a/lib/src/icon/image_icon.dart b/lib/src/icon/image_icon.dart new file mode 100644 index 00000000..55986dd1 --- /dev/null +++ b/lib/src/icon/image_icon.dart @@ -0,0 +1,102 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:macos_ui/macos_ui.dart'; + +/// {@template macosImageIcon} +/// An icon that comes from an [ImageProvider], e.g. an [AssetImage]. +/// +/// The [size] and [color] default to the value given by the current [MacosIconTheme]. +/// +/// See also: +/// * [MacosIconButton], for interactive icons. +/// * [MacosIconTheme], which provides ambient configuration for icons. +/// * [MacosIcon], for icons based on glyphs from fonts instead of images. +/// {@endtemplate} +class MacosImageIcon extends StatelessWidget { + /// {@macro macosImageIcon} + const MacosImageIcon( + this.image, { + super.key, + this.size, + this.color, + this.semanticLabel, + }); + + /// The image to display as the icon. + /// + /// The icon can be null, in which case the widget will render as an empty + /// space of the specified [size]. + final ImageProvider? image; + + /// The size of the icon in logical pixels. + /// + /// Icons occupy a square with width and height equal to size. + /// + /// Defaults to the current [MacosIconTheme] size, if any. If there is no + /// [MacosIconTheme], or it does not specify an explicit size, then it + /// defaults to 24.0. + final double? size; + + /// The color to use when drawing the icon. + /// + /// Defaults to the current [MacosIconTheme] color, if any. If there is + /// no [MacosIconTheme], then it defaults to not recolorizing the image. + /// + /// The image will be additionally adjusted by the opacity of the current + /// [MacosIconTheme], if any. + final Color? color; + + /// Semantic label for the icon. + /// + /// Announced in accessibility modes (e.g TalkBack/VoiceOver). + /// This label does not show in the UI. + /// + /// * [SemanticsProperties.label], which is set to [semanticLabel] in the + /// underlying [Semantics] widget. + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final iconTheme = MacosIconTheme.of(context); + final iconSize = size ?? iconTheme.size; + + if (image == null) { + return Semantics( + label: semanticLabel, + child: SizedBox(width: iconSize, height: iconSize), + ); + } + + final iconOpacity = iconTheme.opacity; + Color iconColor = color ?? iconTheme.color!; + + if (iconOpacity != null && iconOpacity != 1.0) { + iconColor = iconColor.withOpacity(iconColor.opacity * iconOpacity); + } + + return Semantics( + label: semanticLabel, + child: Image( + image: image!, + width: iconSize, + height: iconSize, + color: iconColor, + fit: BoxFit.scaleDown, + excludeFromSemantics: true, + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty( + 'image', + image, + ifNull: '', + showName: false, + )); + properties.add(DoubleProperty('size', size, defaultValue: null)); + properties.add(ColorProperty('color', color, defaultValue: null)); + } +} diff --git a/lib/src/layout/sidebar/sidebar_item.dart b/lib/src/layout/sidebar/sidebar_item.dart index 8a98a930..b7e2d487 100644 --- a/lib/src/layout/sidebar/sidebar_item.dart +++ b/lib/src/layout/sidebar/sidebar_item.dart @@ -19,6 +19,7 @@ class SidebarItem with Diagnosticable { this.focusNode, this.semanticLabel, this.disclosureItems, + this.trailing, }); /// The widget before [label]. @@ -57,6 +58,13 @@ class SidebarItem with Diagnosticable { /// If non-null and [leading] is null, a local animated icon is created final List? disclosureItems; + /// An optional trailing widget. + /// + /// Typically a text indicator of a count of items, like in this + /// screenshots from the Apple Notes app: + /// {@image } + final Widget? trailing; + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -69,5 +77,6 @@ class SidebarItem with Diagnosticable { 'disclosure items', disclosureItems, )); + properties.add(DiagnosticsProperty('trailing', trailing)); } } diff --git a/lib/src/layout/sidebar/sidebar_items.dart b/lib/src/layout/sidebar/sidebar_items.dart index e05ddb07..1f00eeba 100644 --- a/lib/src/layout/sidebar/sidebar_items.dart +++ b/lib/src/layout/sidebar/sidebar_items.dart @@ -3,9 +3,39 @@ import 'package:macos_ui/src/library.dart'; const Duration _kExpand = Duration(milliseconds: 200); const ShapeBorder _defaultShape = RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(7.0)), + //TODO: consider changing to 4.0 or 5.0 - App Store, Notes and Mail seem to use 4.0 or 5.0 + borderRadius: BorderRadius.all(Radius.circular(5.0)), ); +/// {@template sidebarItemSize} +/// Enumerates the size specifications of [SidebarItem]s +/// +/// Values were adapted from https://developer.apple.com/design/human-interface-guidelines/components/navigation-and-search/sidebars/#platform-considerations +/// and were eyeballed against apps like App Store, Notes, and Mail. +/// {@endtemplate} +enum SidebarItemSize { + /// A small [SidebarItem]. Has a [height] of 24 and an [iconSize] of 12. + small(24.0, 12.0), + + /// A medium [SidebarItem]. Has a [height] of 28 and an [iconSize] of 16. + medium(29.0, 16.0), + + /// A large [SidebarItem]. Has a [height] of 32 and an [iconSize] of 20.0. + large(36.0, 18.0); + + /// {@macro sidebarItemSize} + const SidebarItemSize( + this.height, + this.iconSize, + ); + + /// The height of the [SidebarItem]. + final double height; + + /// The maximum size of the [SidebarItem]'s leading icon. + final double iconSize; +} + /// A scrollable widget that renders [SidebarItem]s. /// /// See also: @@ -16,9 +46,10 @@ class SidebarItems extends StatelessWidget { /// Creates a scrollable widget that renders [SidebarItem]s. const SidebarItems({ super.key, + required this.items, required this.currentIndex, required this.onChanged, - required this.items, + this.itemSize = SidebarItemSize.medium, this.scrollController, this.selectedColor, this.unselectedColor, @@ -37,6 +68,11 @@ class SidebarItems extends StatelessWidget { /// Called when the current selected index should be changed. final ValueChanged onChanged; + /// The size specifications for all [items]. + /// + /// Defaults to [SidebarItemSize.medium]. + final SidebarItemSize itemSize; + /// The scroll controller used by this sidebar. If null, a local scroll /// controller is created. final ScrollController? scrollController; @@ -85,6 +121,7 @@ class SidebarItems extends StatelessWidget { selectedColor: selectedColor ?? theme.primaryColor, unselectedColor: unselectedColor ?? MacosColors.transparent, shape: shape ?? _defaultShape, + itemSize: itemSize, child: ListView( controller: scrollController, physics: const ClampingScrollPhysics(), @@ -126,11 +163,13 @@ class _SidebarItemsConfiguration extends InheritedWidget { this.selectedColor = MacosColors.transparent, this.unselectedColor = MacosColors.transparent, this.shape = _defaultShape, + this.itemSize = SidebarItemSize.medium, }) : super(key: key); final Color selectedColor; final Color unselectedColor; final ShapeBorder shape; + final SidebarItemSize itemSize; static _SidebarItemsConfiguration of(BuildContext context) { return context @@ -181,6 +220,7 @@ class _SidebarItem extends StatelessWidget { }; bool get hasLeading => item.leading != null; + bool get hasTrailing => item.trailing != null; @override Widget build(BuildContext context) { @@ -199,6 +239,19 @@ class _SidebarItem extends StatelessWidget { ); final double spacing = 10.0 + theme.visualDensity.horizontal; + final itemSize = _SidebarItemsConfiguration.of(context).itemSize; + TextStyle? labelStyle; + switch (itemSize) { + case SidebarItemSize.small: + labelStyle = theme.typography.subheadline; + break; + case SidebarItemSize.medium: + labelStyle = theme.typography.body; + break; + case SidebarItemSize.large: + labelStyle = theme.typography.title3; + break; + } return Semantics( label: item.semanticLabel, @@ -217,7 +270,7 @@ class _SidebarItem extends StatelessWidget { actions: _actionMap, child: Container( width: 134.0 + theme.visualDensity.horizontal, - height: 38.0 + theme.visualDensity.vertical, + height: itemSize.height + theme.visualDensity.vertical, decoration: ShapeDecoration( color: selected ? selectedColor : unselectedColor, shape: item.shape ?? _SidebarItemsConfiguration.of(context).shape, @@ -236,16 +289,26 @@ class _SidebarItem extends StatelessWidget { color: selected ? MacosColors.white : MacosColors.controlAccentColor, + size: itemSize.iconSize, ), child: item.leading!, ), ), DefaultTextStyle( - style: theme.typography.title3.copyWith( + style: labelStyle.copyWith( color: selected ? textLuminance(selectedColor) : null, ), child: item.label, ), + if (hasTrailing) ...[ + const Spacer(), + DefaultTextStyle( + style: labelStyle.copyWith( + color: selected ? textLuminance(selectedColor) : null, + ), + child: item.trailing!, + ), + ], ], ), ), @@ -291,6 +354,8 @@ class __DisclosureSidebarItemState extends State<_DisclosureSidebarItem> bool _isExpanded = false; + bool get hasLeading => widget.item.leading != null; + @override void initState() { super.initState(); @@ -327,6 +392,20 @@ class __DisclosureSidebarItemState extends State<_DisclosureSidebarItem> final theme = MacosTheme.of(context); final double spacing = 10.0 + theme.visualDensity.horizontal; + final itemSize = _SidebarItemsConfiguration.of(context).itemSize; + TextStyle? labelStyle; + switch (itemSize) { + case SidebarItemSize.small: + labelStyle = theme.typography.subheadline; + break; + case SidebarItemSize.medium: + labelStyle = theme.typography.body; + break; + case SidebarItemSize.large: + labelStyle = theme.typography.title3; + break; + } + return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -348,10 +427,14 @@ class __DisclosureSidebarItemState extends State<_DisclosureSidebarItem> : MacosColors.white, ), ), - if (widget.item.leading != null) + if (hasLeading) Padding( padding: EdgeInsets.only(left: spacing), - child: widget.item.leading!, + //child: widget.item.leading!, + child: MacosIconTheme.merge( + data: MacosIconThemeData(size: itemSize.iconSize), + child: widget.item.leading!, + ), ), ], ), @@ -359,16 +442,20 @@ class __DisclosureSidebarItemState extends State<_DisclosureSidebarItem> focusNode: widget.item.focusNode, semanticLabel: widget.item.semanticLabel, shape: widget.item.shape, + trailing: widget.item.trailing, ), onClick: _handleTap, selected: false, ), ), ClipRect( - child: Align( - alignment: Alignment.centerLeft, - heightFactor: _heightFactor.value, - child: child, + child: DefaultTextStyle( + style: labelStyle, + child: Align( + alignment: Alignment.centerLeft, + heightFactor: _heightFactor.value, + child: child, + ), ), ), ], diff --git a/pubspec.yaml b/pubspec.yaml index a46af1e1..d53c1721 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: macos_ui description: Flutter widgets and themes implementing the current macOS design language. -version: 1.6.0 +version: 1.7.0 homepage: "https://macosui.dev" repository: "https://github.com/GroovinChip/macos_ui" diff --git a/test/selectors/date_picker_test.dart b/test/selectors/date_picker_test.dart index 98086010..18e10768 100644 --- a/test/selectors/date_picker_test.dart +++ b/test/selectors/date_picker_test.dart @@ -180,6 +180,7 @@ void main() { testWidgets( 'The selected calendar day matches the expected value', (tester) async { + final today = DateTime.now(); int selectedDay = 0; await tester.pumpWidget( MacosApp( @@ -203,10 +204,11 @@ void main() { ), ); - final dayToSelect = find.text('10'); + int dayToFind = today.day == 21 ? 22 : 21; + final dayToSelect = find.text(dayToFind.toString()); await tester.tap(dayToSelect); await tester.pumpAndSettle(); - expect(selectedDay, 10); + expect(selectedDay, dayToFind); }, );