diff --git a/dev/tools/gen_defaults/bin/gen_defaults.dart b/dev/tools/gen_defaults/bin/gen_defaults.dart index c527121fc490..57ec52579155 100644 --- a/dev/tools/gen_defaults/bin/gen_defaults.dart +++ b/dev/tools/gen_defaults/bin/gen_defaults.dart @@ -19,6 +19,7 @@ import 'dart:io'; import 'package:gen_defaults/action_chip_template.dart'; import 'package:gen_defaults/app_bar_template.dart'; +import 'package:gen_defaults/badge_template.dart'; import 'package:gen_defaults/banner_template.dart'; import 'package:gen_defaults/bottom_app_bar_template.dart'; import 'package:gen_defaults/bottom_sheet_template.dart'; @@ -54,6 +55,7 @@ Future main(List args) async { const List tokenFiles = [ 'badge.json', 'banner.json', + 'badge.json', 'bottom_app_bar.json', 'button_elevated.json', 'button_filled.json', @@ -125,6 +127,8 @@ Future main(List args) async { ActionChipTemplate('Chip', '$materialLib/chip.dart', tokens).updateFile(); ActionChipTemplate('ActionChip', '$materialLib/action_chip.dart', tokens).updateFile(); AppBarTemplate('AppBar', '$materialLib/app_bar.dart', tokens).updateFile(); + BottomAppBarTemplate('BottomAppBar', '$materialLib/bottom_app_bar.dart', tokens).updateFile(); + BadgeTemplate('Badge', '$materialLib/badge.dart', tokens).updateFile(); BannerTemplate('Banner', '$materialLib/banner.dart', tokens).updateFile(); BottomAppBarTemplate('BottomAppBar', '$materialLib/bottom_app_bar.dart', tokens).updateFile(); BottomSheetTemplate('BottomSheet', '$materialLib/bottom_sheet.dart', tokens).updateFile(); diff --git a/dev/tools/gen_defaults/lib/badge_template.dart b/dev/tools/gen_defaults/lib/badge_template.dart new file mode 100644 index 000000000000..18638c98c545 --- /dev/null +++ b/dev/tools/gen_defaults/lib/badge_template.dart @@ -0,0 +1,36 @@ +// 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. + +import 'template.dart'; + +class BadgeTemplate extends TokenTemplate { + const BadgeTemplate(super.blockName, super.fileName, super.tokens, { + super.colorSchemePrefix = '_colors.', + }); + + @override + String generate() => ''' +class _${blockName}DefaultsM3 extends BadgeThemeData { + _${blockName}DefaultsM3(this.context) : super( + smallSize: ${tokens["md.comp.badge.size"]}, + largeSize: ${tokens["md.comp.badge.large.size"]}, + padding: const EdgeInsets.symmetric(horizontal: 4), + alignment: const AlignmentDirectional(12, -4), + ); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + + @override + Color? get backgroundColor => ${color("md.comp.badge.color")}; + + @override + Color? get foregroundColor => ${color("md.comp.badge.large.label-text.color")}; + + @override + TextStyle? get textStyle => ${textStyle("md.comp.badge.large.label-text")}; +} +'''; +} diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 795bb6412072..cd057df69f46 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -30,6 +30,8 @@ export 'src/material/app_bar_theme.dart'; export 'src/material/arc.dart'; export 'src/material/autocomplete.dart'; export 'src/material/back_button.dart'; +export 'src/material/badge.dart'; +export 'src/material/badge_theme.dart'; export 'src/material/banner.dart'; export 'src/material/banner_theme.dart'; export 'src/material/bottom_app_bar.dart'; diff --git a/packages/flutter/lib/src/material/badge.dart b/packages/flutter/lib/src/material/badge.dart new file mode 100644 index 000000000000..e8b2465c6649 --- /dev/null +++ b/packages/flutter/lib/src/material/badge.dart @@ -0,0 +1,190 @@ +// 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. + +import 'package:flutter/widgets.dart'; + +import 'badge_theme.dart'; +import 'color_scheme.dart'; +import 'theme.dart'; + +/// A Material Design "badge". +/// +/// A badge's [label] conveys a small amount of information about its +/// [child], like a count or status. If the label is null then this is +/// a "small" badge that's displayed as a [smallSize] diameter filled +/// circle. Otherwise this is a [StadiumBorder] shaped "large" badge +/// with height [largeSize]. +/// +/// Badges are typically used to decorate the icon within a +/// BottomNavigationBarItem] or a [NavigationRailDestination] +/// or a button's icon, as in [TextButton.icon]. The badges default +/// configuration is intended to work well with a default sized (24) +/// [Icon]. +class Badge extends StatelessWidget { + /// Create a Badge that stacks [label] on top of [child]. + /// + /// If [label] is null then just a filled circle is displayed. Otherwise + /// the [label] is displayed within a [StadiumBorder] shaped area. + const Badge({ + super.key, + this.backgroundColor, + this.foregroundColor, + this.smallSize, + this.largeSize, + this.textStyle, + this.padding, + this.alignment, + this.label, + this.child, + }); + + /// The badge's fill color. + /// + /// Defaults to the [BadgeTheme]'s background color, or + /// [ColorScheme.errorColor] if the theme value is null. + final Color? backgroundColor; + + /// The color of the badge's [label] text. + /// + /// This color overrides the color of the label's [textStyle]. + /// + /// Defaults to the [BadgeTheme]'s foreground color, or + /// [ColorScheme.onError] if the theme value is null. + final Color? foregroundColor; + + /// The diameter of the badge if [label] is null. + /// + /// Defaults to the [BadgeTheme]'s small size, or 6 if the theme value + /// is null. + final double? smallSize; + + /// The badge's height if [label] is non-null. + /// + /// Defaults to the [BadgeTheme]'s large size, or 16 if the theme value + /// is null. If the default value is overridden then it may be useful to + /// also override [padding] and [alignment]. + final double? largeSize; + + /// The [DefaultTextStyle] for the badge's label. + /// + /// The text style's color is overwritten by the [foregroundColor]. + /// + /// This value is only used if [label] is non-null. + /// + /// Defaults to the [BadgeTheme]'s text style, or the overall theme's + /// [TextTheme.labelSmall] if the badge theme's value is null. If + /// the default text style is overridden then it may be useful to + /// also override [largeSize], [padding], and [alignment]. + final TextStyle? textStyle; + + /// The padding added to the badge's label. + /// + /// This value is only used if [label] is non-null. + /// + /// Defaults to the [BadgeTheme]'s padding, or 4 pixels on the + /// left and right if the theme's value is null. + final EdgeInsetsGeometry? padding; + + /// The location of the [label] relative to the [child]. + /// + /// This value is only used if [label] is non-null. + /// + /// Defaults to the [BadgeTheme]'s alignment, or `start = 12` + /// and `top = -4` if the theme's value is null. + final AlignmentDirectional? alignment; + + /// The badge's content, typically a [Text] widget that contains 1 to 4 + /// characters. + /// + /// If the label is null then this is a "small" badge that's + /// displayed as a [smallSize] diameter filled circle. Otherwise + /// this is a [StadiumBorder] shaped "large" badge with height [largeSize]. + final Widget? label; + + /// The widget that the badge is stacked on top of. + /// + /// Typically this is an default sized [Icon] that's part of a + /// [BottomNavigationBarItem] or a [NavigationRailDestination]. + final Widget? child; + + @override + Widget build(BuildContext context) { + final BadgeThemeData badgeTheme = BadgeTheme.of(context); + final BadgeThemeData defaults = _BadgeDefaultsM3(context); + final double effectiveSmallSize = smallSize ?? badgeTheme.smallSize ?? defaults.smallSize!; + final double effectiveLargeSize = largeSize ?? badgeTheme.largeSize ?? defaults.largeSize!; + + final Widget badge = DefaultTextStyle( + style: (textStyle ?? badgeTheme.textStyle ?? defaults.textStyle!).copyWith( + color: foregroundColor ?? badgeTheme.foregroundColor ?? defaults.foregroundColor!, + ), + child: IntrinsicWidth( + child: Container( + height: label == null ? effectiveSmallSize : effectiveLargeSize, + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + color: backgroundColor ?? badgeTheme.backgroundColor ?? defaults.backgroundColor!, + shape: const StadiumBorder(), + ), + padding: label == null ? null : (padding ?? badgeTheme.padding ?? defaults.padding!), + alignment: label == null ? null : Alignment.center, + child: label ?? SizedBox(width: effectiveSmallSize, height: effectiveSmallSize), + ), + ), + ); + + if (child == null) { + return badge; + } + + final AlignmentDirectional effectiveAlignment = alignment ?? badgeTheme.alignment ?? defaults.alignment!; + return + Stack( + clipBehavior: Clip.none, + children: [ + child!, + Positioned.directional( + textDirection: Directionality.of(context), + start: label == null ? null : effectiveAlignment.start, + end: label == null ? 0 : null, + top: label == null ? 0 : effectiveAlignment.y, + child: badge, + ), + ], + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - Badge + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// Token database version: v0_137 + +class _BadgeDefaultsM3 extends BadgeThemeData { + _BadgeDefaultsM3(this.context) : super( + smallSize: 6.0, + largeSize: 16.0, + padding: const EdgeInsets.symmetric(horizontal: 4), + alignment: const AlignmentDirectional(12, -4), + ); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + + @override + Color? get backgroundColor => _colors.error; + + @override + Color? get foregroundColor => _colors.onError; + + @override + TextStyle? get textStyle => Theme.of(context).textTheme.labelSmall; +} + +// END GENERATED TOKEN PROPERTIES - Badge diff --git a/packages/flutter/lib/src/material/badge_theme.dart b/packages/flutter/lib/src/material/badge_theme.dart new file mode 100644 index 000000000000..97256f8869af --- /dev/null +++ b/packages/flutter/lib/src/material/badge_theme.dart @@ -0,0 +1,183 @@ +// 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. + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + + +// Examples can assume: +// late BuildContext context; + +/// Overrides the default properties values for descendant [Badge] widgets. +/// +/// Descendant widgets obtain the current [BadgeThemeData] object +/// using `BadgeTheme.of(context)`. Instances of [BadgeThemeData] can +/// be customized with [BadgeThemeData.copyWith]. +/// +/// Typically a [BadgeThemeData] is specified as part of the +/// overall [Theme] with [ThemeData.badgeTheme]. +/// +/// All [BadgeThemeData] properties are `null` by default. +/// When null, the [Badge] will use the values from [ThemeData] +/// if they exist, otherwise it will provide its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class BadgeThemeData with Diagnosticable { + /// Creates the set of color, style, and size properties used to configure [Badge]. + const BadgeThemeData({ + this.backgroundColor, + this.foregroundColor, + this.smallSize, + this.largeSize, + this.textStyle, + this.padding, + this.alignment, + }); + + /// Overrides the default value for [Badge.backgroundColor]. + final Color? backgroundColor; + + /// Overrides the default value for [Badge.foregroundColor]. + final Color? foregroundColor; + + /// Overrides the default value for [Badge.smallSize]. + final double? smallSize; + + /// Overrides the default value for [Badge.largeSize]. + final double? largeSize; + + /// Overrides the default value for [Badge.textStyle]. + final TextStyle? textStyle; + + /// Overrides the default value for [Badge.padding]. + final EdgeInsetsGeometry? padding; + + /// Overrides the default value for [Badge.alignment]. + final AlignmentDirectional? alignment; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + BadgeThemeData copyWith({ + Color? backgroundColor, + Color? foregroundColor, + double? smallSize, + double? largeSize, + TextStyle? textStyle, + EdgeInsetsGeometry? padding, + AlignmentDirectional? alignment, + }) { + return BadgeThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + foregroundColor: foregroundColor ?? this.foregroundColor, + smallSize: smallSize ?? this.smallSize, + largeSize: largeSize ?? this.largeSize, + textStyle: textStyle ?? this.textStyle, + padding: padding ?? this.padding, + alignment: alignment ?? this.alignment, + ); + } + + /// Linearly interpolate between two [Badge] themes. + static BadgeThemeData lerp(BadgeThemeData? a, BadgeThemeData? b, double t) { + return BadgeThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + foregroundColor: Color.lerp(a?.foregroundColor, b?.foregroundColor, t), + smallSize: lerpDouble(a?.smallSize, b?.smallSize, t), + largeSize: lerpDouble(a?.largeSize, b?.largeSize, t), + textStyle: TextStyle.lerp(a?.textStyle, b?.textStyle, t), + padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t), + alignment: AlignmentDirectional.lerp(a?.alignment, b?.alignment, t), + ); + } + + @override + int get hashCode => Object.hash( + backgroundColor, + foregroundColor, + smallSize, + largeSize, + textStyle, + padding, + alignment, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is BadgeThemeData + && other.backgroundColor == backgroundColor + && other.foregroundColor == foregroundColor + && other.smallSize == smallSize + && other.largeSize == largeSize + && other.textStyle == textStyle + && other.padding == padding + && other.alignment == alignment; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(ColorProperty('foregroundColor', foregroundColor, defaultValue: null)); + properties.add(DoubleProperty('smallSize', smallSize, defaultValue: null)); + properties.add(DoubleProperty('largeSize', largeSize, defaultValue: null)); + properties.add(DiagnosticsProperty('textStyle', textStyle, defaultValue: null)); + properties.add(DiagnosticsProperty('padding', padding, defaultValue: null)); + properties.add(DiagnosticsProperty('alignment', alignment, defaultValue: null)); + } +} + +/// An inherited widget that overrides the default color style, and size +/// parameters for [Badge]s in this widget's subtree. +/// +/// Values specified here override the defaults for [Badge] properties which +/// are not given an explicit non-null value. +class BadgeTheme extends InheritedTheme { + /// Creates a theme that overrides the default color parameters for [Badge]s + /// in this widget's subtree. + const BadgeTheme({ + super.key, + required this.data, + required super.child, + }) : assert(data != null); + + /// Specifies the default color and size overrides for descendant [Badge] widgets. + final BadgeThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [BadgeTheme] widget, then + /// [ThemeData.badgeTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// BadgeThemeData theme = BadgeTheme.of(context); + /// ``` + static BadgeThemeData of(BuildContext context) { + final BadgeTheme? badgeTheme = context.dependOnInheritedWidgetOfExactType(); + return badgeTheme?.data ?? Theme.of(context).badgeTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return BadgeTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(BadgeTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index e264a7898230..f2d427cf0670 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -8,6 +8,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'app_bar_theme.dart'; +import 'badge_theme.dart'; import 'banner_theme.dart'; import 'bottom_app_bar_theme.dart'; import 'bottom_navigation_bar_theme.dart'; @@ -331,6 +332,7 @@ class ThemeData with Diagnosticable { Typography? typography, // COMPONENT THEMES AppBarTheme? appBarTheme, + BadgeThemeData? badgeTheme, MaterialBannerThemeData? bannerTheme, BottomAppBarTheme? bottomAppBarTheme, BottomNavigationBarThemeData? bottomNavigationBarTheme, @@ -583,6 +585,7 @@ class ThemeData with Diagnosticable { // COMPONENT THEMES appBarTheme ??= const AppBarTheme(); + badgeTheme ??= const BadgeThemeData(); bannerTheme ??= const MaterialBannerThemeData(); bottomAppBarTheme ??= const BottomAppBarTheme(); bottomNavigationBarTheme ??= const BottomNavigationBarThemeData(); @@ -676,6 +679,7 @@ class ThemeData with Diagnosticable { primaryIconTheme: primaryIconTheme, // COMPONENT THEMES appBarTheme: appBarTheme, + badgeTheme: badgeTheme, bannerTheme: bannerTheme, bottomAppBarTheme: bottomAppBarTheme, bottomNavigationBarTheme: bottomNavigationBarTheme, @@ -786,6 +790,7 @@ class ThemeData with Diagnosticable { required this.typography, // COMPONENT THEMES required this.appBarTheme, + required this.badgeTheme, required this.bannerTheme, required this.bottomAppBarTheme, required this.bottomNavigationBarTheme, @@ -954,6 +959,7 @@ class ThemeData with Diagnosticable { assert(typography != null), // COMPONENT THEMES assert(appBarTheme != null), + assert(badgeTheme != null), assert(bannerTheme != null), assert(bottomAppBarTheme != null), assert(bottomNavigationBarTheme != null), @@ -1487,6 +1493,9 @@ class ThemeData with Diagnosticable { /// textTheme of [AppBar]s. final AppBarTheme appBarTheme; + /// A theme for customizing the color of [Badge]s. + final BadgeThemeData badgeTheme; + /// A theme for customizing the color and text style of a [MaterialBanner]. final MaterialBannerThemeData bannerTheme; @@ -1842,6 +1851,7 @@ class ThemeData with Diagnosticable { Typography? typography, // COMPONENT THEMES AppBarTheme? appBarTheme, + BadgeThemeData? badgeTheme, MaterialBannerThemeData? bannerTheme, BottomAppBarTheme? bottomAppBarTheme, BottomNavigationBarThemeData? bottomNavigationBarTheme, @@ -2003,6 +2013,7 @@ class ThemeData with Diagnosticable { typography: typography ?? this.typography, // COMPONENT THEMES appBarTheme: appBarTheme ?? this.appBarTheme, + badgeTheme: badgeTheme ?? this.badgeTheme, bannerTheme: bannerTheme ?? this.bannerTheme, bottomAppBarTheme: bottomAppBarTheme ?? this.bottomAppBarTheme, bottomNavigationBarTheme: bottomNavigationBarTheme ?? this.bottomNavigationBarTheme, @@ -2206,6 +2217,7 @@ class ThemeData with Diagnosticable { typography: Typography.lerp(a.typography, b.typography, t), // COMPONENT THEMES appBarTheme: AppBarTheme.lerp(a.appBarTheme, b.appBarTheme, t), + badgeTheme: BadgeThemeData.lerp(a.badgeTheme, b.badgeTheme, t), bannerTheme: MaterialBannerThemeData.lerp(a.bannerTheme, b.bannerTheme, t), bottomAppBarTheme: BottomAppBarTheme.lerp(a.bottomAppBarTheme, b.bottomAppBarTheme, t), bottomNavigationBarTheme: BottomNavigationBarThemeData.lerp(a.bottomNavigationBarTheme, b.bottomNavigationBarTheme, t), @@ -2311,6 +2323,7 @@ class ThemeData with Diagnosticable { other.typography == typography && // COMPONENT THEMES other.appBarTheme == appBarTheme && + other.badgeTheme == badgeTheme && other.bannerTheme == bannerTheme && other.bottomAppBarTheme == bottomAppBarTheme && other.bottomNavigationBarTheme == bottomNavigationBarTheme && @@ -2413,6 +2426,7 @@ class ThemeData with Diagnosticable { typography, // COMPONENT THEMES appBarTheme, + badgeTheme, bannerTheme, bottomAppBarTheme, bottomNavigationBarTheme, @@ -2517,6 +2531,7 @@ class ThemeData with Diagnosticable { properties.add(DiagnosticsProperty('typography', typography, defaultValue: defaultData.typography, level: DiagnosticLevel.debug)); // COMPONENT THEMES properties.add(DiagnosticsProperty('appBarTheme', appBarTheme, defaultValue: defaultData.appBarTheme, level: DiagnosticLevel.debug)); + properties.add(DiagnosticsProperty('badgeTheme', badgeTheme, defaultValue: defaultData.badgeTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('bannerTheme', bannerTheme, defaultValue: defaultData.bannerTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('bottomAppBarTheme', bottomAppBarTheme, defaultValue: defaultData.bottomAppBarTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('bottomNavigationBarTheme', bottomNavigationBarTheme, defaultValue: defaultData.bottomNavigationBarTheme, level: DiagnosticLevel.debug)); diff --git a/packages/flutter/test/material/badge_test.dart b/packages/flutter/test/material/badge_test.dart new file mode 100644 index 000000000000..755689bed3d9 --- /dev/null +++ b/packages/flutter/test/material/badge_test.dart @@ -0,0 +1,206 @@ +// 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. + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../rendering/mock_canvas.dart'; + + +void main() { + testWidgets('Large Badge defaults', (WidgetTester tester) async { + late final ThemeData theme; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light(useMaterial3: true), + home: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (BuildContext context) { + // theme.textTtheme is updated when the MaterialApp is built. + theme = Theme.of(context); + return const Badge( + label: Text('0'), + child: Icon(Icons.add), + ); + }, + ), + ), + ), + ); + + expect( + tester.renderObject(find.text('0')).text.style, + theme.textTheme.labelSmall!.copyWith(color: theme.colorScheme.onError), + ); + + // default badge alignment = AlignmentDirectional(12, -4) + // default padding = EdgeInsets.symmetric(horizontal: 4) + // default largeSize = 16 + // '0'.width = 12 + // icon.width = 24 + + expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size + expect(tester.getTopLeft(find.byType(Badge)), Offset.zero); + + // x = alignment.start + padding.left + // y = alignment.top + expect(tester.getTopLeft(find.text('0')), const Offset(16, -4)); + + final RenderBox box = tester.renderObject(find.byType(Badge)); + // '0'.width = 12 + // L = alignment.start + // T = alignment.top + // R = L + '0'.width + padding.width + // B = T + largeSize, R = largeSize/2 + expect(box, paints..rrect(rrect: RRect.fromLTRBR(12, -4, 32, 12, const Radius.circular(8)), color: theme.colorScheme.error)); + }); + + testWidgets('Large Badge defaults with RTL', (WidgetTester tester) async { + late final ThemeData theme; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light(useMaterial3: true), + home: Directionality( + textDirection: TextDirection.rtl, + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (BuildContext context) { + // theme.textTtheme is updated when the MaterialApp is built. + theme = Theme.of(context); + return const Badge( + label: Text('0'), + child: Icon(Icons.add), + ); + }, + ), + ), + ), + ), + ); + + expect( + tester.renderObject(find.text('0')).text.style, + theme.textTheme.labelSmall!.copyWith(color: theme.colorScheme.onError), + ); + + // default badge alignment = AlignmentDirectional(12, -4) + // default padding = EdgeInsets.symmetric(horizontal: 4) + // default largeSize = 16 + // '0'.width = 12 + // icon.width = 24 + + expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size + expect(tester.getTopLeft(find.byType(Badge)), Offset.zero); + + // x = icon.width - alignment.start - '0'.width - padding.right + // y = alignment.top + expect(tester.getTopLeft(find.text('0')), const Offset(-4, -4)); + + final RenderBox box = tester.renderObject(find.byType(Badge)); + // L = icon.width - alignment.start - '0.width' - padding.width + // T = alignment.top + // R = L + '0.width' + padding.width + // B = T + largeSize + // R = largeSize/2 + expect(box, paints..rrect(rrect: RRect.fromLTRBR(-8, -4, 12, 12, const Radius.circular(8)), color: theme.colorScheme.error)); + }); + + testWidgets('Small Badge defaults', (WidgetTester tester) async { + final ThemeData theme = ThemeData.light(useMaterial3: true); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Align( + alignment: Alignment.topLeft, + child: Badge( + child: Icon(Icons.add), + ), + ), + ), + ); + + // default badge location is end=0, top=0 + // default padding = EdgeInsets.symmetric(horizontal: 4) + // default smallSize = 6 + // icon.width = 24 + + expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size + expect(tester.getTopLeft(find.byType(Badge)), Offset.zero); + + final RenderBox box = tester.renderObject(find.byType(Badge)); + // L = icon.size.width - smallSize + // T = 0 + // R = icon.size.width + // B = smallSize + expect(box, paints..rrect(rrect: RRect.fromLTRBR(18, 0, 24, 6, const Radius.circular(3)), color: theme.colorScheme.error)); + }); + + testWidgets('Small Badge RTL defaults', (WidgetTester tester) async { + final ThemeData theme = ThemeData.light(useMaterial3: true); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Directionality( + textDirection: TextDirection.rtl, + child: Align( + alignment: Alignment.topLeft, + child: Badge( + child: Icon(Icons.add), + ), + ), + ), + ), + ); + + // default badge location is end=0, top=0 + // default smallSize = 6 + // icon.width = 24 + + expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size + expect(tester.getTopLeft(find.byType(Badge)), Offset.zero); + + final RenderBox box = tester.renderObject(find.byType(Badge)); + // L = 0 + // T = 0 + // R = smallSize + // B = smallSize + expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 0, 6, 6, const Radius.circular(3)), color: theme.colorScheme.error)); + }); + + testWidgets('Large Badge textStyle and colors', (WidgetTester tester) async { + final ThemeData theme = ThemeData.light(useMaterial3: true); + const Color green = Color(0xff00ff00); + const Color black = Color(0xff000000); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Align( + alignment: Alignment.topLeft, + child: Badge( + foregroundColor: green, + backgroundColor: black, + textStyle: TextStyle(fontSize: 10), + label: Text('0'), + child: Icon(Icons.add), + ), + ), + ), + ); + + final TextStyle textStyle = tester.renderObject(find.text('0')).text.style!; + expect(textStyle.fontSize, 10); + expect(textStyle.color, green); + expect(tester.renderObject(find.byType(Badge)), paints..rrect(color: black)); + }); + + +} diff --git a/packages/flutter/test/material/badge_theme_test.dart b/packages/flutter/test/material/badge_theme_test.dart new file mode 100644 index 000000000000..4e0235d5b81c --- /dev/null +++ b/packages/flutter/test/material/badge_theme_test.dart @@ -0,0 +1,155 @@ +// 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. + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../rendering/mock_canvas.dart'; + +void main() { + test('BadgeThemeData copyWith, ==, hashCode basics', () { + expect(const BadgeThemeData(), const BadgeThemeData().copyWith()); + expect(const BadgeThemeData().hashCode, const BadgeThemeData().copyWith().hashCode); + }); + + test('BadgeThemeData defaults', () { + const BadgeThemeData themeData = BadgeThemeData(); + expect(themeData.backgroundColor, null); + expect(themeData.foregroundColor, null); + expect(themeData.smallSize, null); + expect(themeData.largeSize, null); + expect(themeData.textStyle, null); + expect(themeData.padding, null); + expect(themeData.alignment, null); + }); + + testWidgets('Default BadgeThemeData debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const BadgeThemeData().debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, []); + }); + + testWidgets('BadgeThemeData implements debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const BadgeThemeData( + backgroundColor: Color(0xfffffff0), + foregroundColor: Color(0xfffffff1), + smallSize: 1, + largeSize: 2, + textStyle: TextStyle(fontSize: 4), + padding: EdgeInsets.all(5), + alignment: AlignmentDirectional(6, 7), + ).debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, [ + 'backgroundColor: Color(0xfffffff0)', + 'foregroundColor: Color(0xfffffff1)', + 'smallSize: 1.0', + 'largeSize: 2.0', + 'textStyle: TextStyle(inherit: true, size: 4.0)', + 'padding: EdgeInsets.all(5.0)', + 'alignment: AlignmentDirectional(6.0, 7.0)' + ]); + }); + + testWidgets('Badge uses ThemeData badge theme', (WidgetTester tester) async { + const Color green = Color(0xff00ff00); + const Color black = Color(0xff000000); + const BadgeThemeData badgeTheme = BadgeThemeData( + backgroundColor: green, + foregroundColor: black, + smallSize: 5, + largeSize: 20, + textStyle: TextStyle(fontSize: 12), + padding: EdgeInsets.symmetric(horizontal: 5), + alignment: AlignmentDirectional(24, 0), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light(useMaterial3: true).copyWith( + badgeTheme: badgeTheme, + ), + home: const Scaffold( + body: Badge( + label: Text('1234'), + child: Icon(Icons.add), + ), + ), + ), + ); + + // text width = 48 = fontSize * 4, text height = fontSize + expect(tester.getSize(find.text('1234')), const Size(48, 12)); + + // x = 29 = alignment.start + padding.left, y = 4 = (largeSize - fontSize) / 2 + expect(tester.getTopLeft(find.text('1234')), const Offset(29, 4)); + + + expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size + expect(tester.getTopLeft(find.byType(Badge)), Offset.zero); + + final TextStyle textStyle = tester.renderObject(find.text('1234')).text.style!; + expect(textStyle.fontSize, 12); + expect(textStyle.color, black); + + final RenderBox box = tester.renderObject(find.byType(Badge)); + // L = alignment.start, T = alignment.top, R = L + fontSize * 4 + padding.width, B = largeSize R = largeSize/2 + expect(box, paints..rrect(rrect: RRect.fromLTRBR(24, 0, 82, 20, const Radius.circular(10)), color: green)); + }); + + + // This test is essentially the same as 'Badge uses ThemeData badge theme'. In + // this case the theme is introduced with the BadgeTheme widget instead of + // ThemeData.badgeTheme. + testWidgets('Badge uses BadgeTheme', (WidgetTester tester) async { + const Color green = Color(0xff00ff00); + const Color black = Color(0xff000000); + const BadgeThemeData badgeTheme = BadgeThemeData( + backgroundColor: green, + foregroundColor: black, + smallSize: 5, + largeSize: 20, + textStyle: TextStyle(fontSize: 12), + padding: EdgeInsets.symmetric(horizontal: 5), + alignment: AlignmentDirectional(24, 0), + ); + + await tester.pumpWidget( + const MaterialApp( + home: BadgeTheme( + data: badgeTheme, + child: Scaffold( + body: Badge( + label: Text('1234'), + child: Icon(Icons.add), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.text('1234')), const Size(48, 12)); + expect(tester.getTopLeft(find.text('1234')), const Offset(29, 4)); + expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size + expect(tester.getTopLeft(find.byType(Badge)), Offset.zero); + final TextStyle textStyle = tester.renderObject(find.text('1234')).text.style!; + expect(textStyle.fontSize, 12); + expect(textStyle.color, black); + final RenderBox box = tester.renderObject(find.byType(Badge)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(24, 0, 82, 20, const Radius.circular(10)), color: green)); + }); +} diff --git a/packages/flutter/test/material/theme_data_test.dart b/packages/flutter/test/material/theme_data_test.dart index 01fb19472eb3..7e8190d27912 100644 --- a/packages/flutter/test/material/theme_data_test.dart +++ b/packages/flutter/test/material/theme_data_test.dart @@ -770,6 +770,7 @@ void main() { typography: Typography.material2018(), // COMPONENT THEMES appBarTheme: const AppBarTheme(backgroundColor: Colors.black), + badgeTheme: const BadgeThemeData(backgroundColor: Colors.black), bannerTheme: const MaterialBannerThemeData(backgroundColor: Colors.black), bottomAppBarTheme: const BottomAppBarTheme(color: Colors.black), bottomNavigationBarTheme: const BottomNavigationBarThemeData(type: BottomNavigationBarType.fixed), @@ -887,6 +888,7 @@ void main() { // COMPONENT THEMES appBarTheme: const AppBarTheme(backgroundColor: Colors.white), + badgeTheme: const BadgeThemeData(backgroundColor: Colors.black), bannerTheme: const MaterialBannerThemeData(backgroundColor: Colors.white), bottomAppBarTheme: const BottomAppBarTheme(color: Colors.white), bottomNavigationBarTheme: const BottomNavigationBarThemeData(type: BottomNavigationBarType.shifting), @@ -990,6 +992,7 @@ void main() { // COMPONENT THEMES appBarTheme: otherTheme.appBarTheme, + badgeTheme: otherTheme.badgeTheme, bannerTheme: otherTheme.bannerTheme, bottomAppBarTheme: otherTheme.bottomAppBarTheme, bottomNavigationBarTheme: otherTheme.bottomNavigationBarTheme, @@ -1092,6 +1095,7 @@ void main() { // COMPONENT THEMES expect(themeDataCopy.appBarTheme, equals(otherTheme.appBarTheme)); + expect(themeDataCopy.badgeTheme, equals(otherTheme.badgeTheme)); expect(themeDataCopy.bannerTheme, equals(otherTheme.bannerTheme)); expect(themeDataCopy.bottomAppBarTheme, equals(otherTheme.bottomAppBarTheme)); expect(themeDataCopy.bottomNavigationBarTheme, equals(otherTheme.bottomNavigationBarTheme)); @@ -1230,6 +1234,7 @@ void main() { 'primaryIconTheme', // COMPONENT THEMES 'appBarTheme', + 'badgeTheme', 'bannerTheme', 'bottomAppBarTheme', 'bottomNavigationBarTheme',