diff --git a/docs/pages/docs/avatar.mdx b/docs/pages/docs/avatar.mdx index be032f404..39476361a 100644 --- a/docs/pages/docs/avatar.mdx +++ b/docs/pages/docs/avatar.mdx @@ -6,14 +6,31 @@ An image element with a fallback for representing the user. - + ```dart - FAvatar( - image: const NetworkImage('https://picsum.photos/250?image=9'), - placeholderBuilder: (_) => const Text('MN'), - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // With Valid image. + FAvatar( + image: const NetworkImage('https://raw.githubusercontent.com/forus-labs/forui/main/samples/assets/avatar.png'), + fallback: const Text('MN'), + ), + const SizedBox(width: 10), + + // With Invalid image and fallback. + FAvatar( + image: const NetworkImage(''), + fallback: const Text('MN'), + ), + const SizedBox(width: 10), + + // With Invalid image without fallback. + FAvatar(image: const NetworkImage('')), + ], + ); ``` @@ -24,9 +41,78 @@ An image element with a fallback for representing the user. ```dart FAvatar( - image: const NetworkImage('https://picsum.photos/250?image=9'), - size: 60, - placeholderBuilder: (_) => const Text('MN'), -), + image: const NetworkImage('https://raw.githubusercontent.com/forus-labs/forui/main/samples/assets/avatar.png'), + fallback: const Text('MN'), +); +``` + +### `FAvatar.raw(...)` + +```dart +// Raw constructor - with icon +FAvatar.raw( + child: FAssets.icons.baby( + colorFilter: ColorFilter.mode(theme.colorScheme.mutedForeground, BlendMode.srcIn), + ), +); + +// Raw constructor - with text +FAvatar.raw(child: const Text('MN')); ``` +## Examples + +### Invalid image + + + + + + ```dart + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // With fallback widget. + FAvatar( + image: const AssetImage(''), + fallback: const Text('MN'), + ), + const SizedBox(width: 10), + + // Without fallback widget. + FAvatar(image: const AssetImage('')), + ], + ); + ``` + + + +### Without fallback + + + + + + ```dart + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Raw constructor - without child. + FAvatar.raw(), + const SizedBox(width: 10), + + // Raw constructor - with child. + FAvatar.raw( + child: FAssets.icons.baby( + colorFilter: ColorFilter.mode(theme.colorScheme.mutedForeground, BlendMode.srcIn), + ), + ), + const SizedBox(width: 10), + + // Raw constructor - with text. + FAvatar.raw(child: const Text('MN')), + ], + ); + ``` + + diff --git a/forui/lib/src/widgets/avatar.dart b/forui/lib/src/widgets/avatar/avatar.dart similarity index 55% rename from forui/lib/src/widgets/avatar.dart rename to forui/lib/src/widgets/avatar/avatar.dart index f1a5c0830..2dee94eb2 100644 --- a/forui/lib/src/widgets/avatar.dart +++ b/forui/lib/src/widgets/avatar/avatar.dart @@ -5,47 +5,59 @@ import 'package:meta/meta.dart'; import 'package:forui/forui.dart'; +part 'avatar_content.dart'; + /// An image element with a fallback for representing the user. /// +/// use image property to provide a profile image displayed within the circle. /// Typically used with a user's profile image. If the image fails to load, -/// [placeholderBuilder] is used instead, which usually displays the user's initials. +/// the fallback widget is used instead, which usually displays the user's initials. /// -/// If the user's profile has no image, use [placeholderBuilder] to provide +/// If the user's profile has no image, use the fallback property to provide /// the initials using a [Text] widget styled with [FAvatarStyle.backgroundColor]. class FAvatar extends StatelessWidget { /// The style. Defaults to [FThemeData.avatarStyle]. final FAvatarStyle? style; - /// The profile image displayed within the circle. - /// - /// If the user's initials are used, use [placeholderBuilder] instead. - final ImageProvider image; - /// The circle's size. final double size; - /// The fallback widget displayed if [image] fails to load. + /// The fallback widget displayed if image parameter fails to load. /// /// Typically used to display the user's initials using a [Text] widget /// styled with [FAvatarStyle.backgroundColor]. /// - /// Use [image] to display an image; use [placeholderBuilder] for initials. - final Widget Function(BuildContext)? placeholderBuilder; + /// Use image parameter to display an image; use [fallback] for initials. + final Widget fallback; /// Creates an [FAvatar]. - const FAvatar({ - required this.image, + FAvatar({ + required ImageProvider image, this.style, this.size = 40.0, - this.placeholderBuilder, + Widget? fallback, super.key, - }); + }) : fallback = _AvatarContent( + image: image, + style: style, + size: size, + fallback: fallback, + ); + + /// Creates a [FAvatar] without a fallback. + FAvatar.raw({ + Widget? child, + this.style, + this.size = 40.0, + super.key, + }) : fallback = child ?? _Placeholder(style: style, size: size); @override Widget build(BuildContext context) { final style = this.style ?? context.theme.avatarStyle; return Container( + alignment: Alignment.center, height: size, width: size, decoration: BoxDecoration( @@ -53,39 +65,9 @@ class FAvatar extends StatelessWidget { shape: BoxShape.circle, ), clipBehavior: Clip.hardEdge, - child: Center( - child: Image( - filterQuality: FilterQuality.medium, - image: image, - errorBuilder: (context, exception, stacktrace) => DefaultTextStyle( - style: style.text, - child: placeholderBuilder != null ? placeholderBuilder!(context) : _Placeholder(size: size), - ), - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded) { - return child; - } - return AnimatedSwitcher( - duration: const Duration(milliseconds: 500), - child: frame == null - ? DefaultTextStyle( - style: style.text, - child: placeholderBuilder != null ? placeholderBuilder!(context) : _Placeholder(size: size), - ) - : child, - ); - }, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) { - return child; - } - return DefaultTextStyle( - style: style.text, - child: placeholderBuilder != null ? placeholderBuilder!(context) : _Placeholder(size: size), - ); - }, - fit: BoxFit.cover, - ), + child: DefaultTextStyle( + style: style.text, + child: fallback, ), ); } @@ -94,27 +76,29 @@ class FAvatar extends StatelessWidget { void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties - ..add(DiagnosticsProperty('image', image)) ..add(DoubleProperty('size', size)) - ..add(DiagnosticsProperty('style', style)) - ..add(ObjectFlagProperty.has('placeholderBuilder', placeholderBuilder)); + ..add(DiagnosticsProperty('style', style)); } } /// [FAvatar]'s style. final class FAvatarStyle with Diagnosticable { - /// The placeholder's background color. + /// The fallback's background color. final Color backgroundColor; + /// The fallback's color. + final Color foregroundColor; + /// Duration for the transition animation. final Duration fadeInDuration; - /// The text style for the placeholder text. + /// The text style for the fallback text. final TextStyle text; /// Creates a [FAvatarStyle]. - FAvatarStyle({ + const FAvatarStyle({ required this.backgroundColor, + required this.foregroundColor, required this.fadeInDuration, required this.text, }); @@ -122,6 +106,7 @@ final class FAvatarStyle with Diagnosticable { /// Creates a [FCardStyle] that inherits its properties from [colorScheme] and [typography]. FAvatarStyle.inherit({required FColorScheme colorScheme, required FTypography typography}) : backgroundColor = colorScheme.muted, + foregroundColor = colorScheme.mutedForeground, fadeInDuration = const Duration(milliseconds: 500), text = typography.base.copyWith( color: colorScheme.mutedForeground, @@ -144,11 +129,13 @@ final class FAvatarStyle with Diagnosticable { @useResult FAvatarStyle copyWith({ Color? backgroundColor, + Color? foregroundColor, Duration? fadeInDuration, TextStyle? text, }) => FAvatarStyle( backgroundColor: backgroundColor ?? this.backgroundColor, + foregroundColor: foregroundColor ?? this.foregroundColor, fadeInDuration: fadeInDuration ?? this.fadeInDuration, text: text ?? this.text, ); @@ -158,6 +145,7 @@ final class FAvatarStyle with Diagnosticable { super.debugFillProperties(properties); properties ..add(ColorProperty('backgroundColor', backgroundColor)) + ..add(ColorProperty('foregroundColor', foregroundColor)) ..add(DiagnosticsProperty('fadeInDuration', fadeInDuration)) ..add(DiagnosticsProperty('text', text)); } @@ -168,31 +156,10 @@ final class FAvatarStyle with Diagnosticable { other is FAvatarStyle && runtimeType == other.runtimeType && backgroundColor == other.backgroundColor && + foregroundColor == other.foregroundColor && fadeInDuration == other.fadeInDuration && text == other.text; @override - int get hashCode => backgroundColor.hashCode ^ fadeInDuration.hashCode ^ text.hashCode; -} - -class _Placeholder extends StatelessWidget { - final double size; - - const _Placeholder({required this.size}); - - @override - Widget build(BuildContext context) { - final style = context.theme; - - return FAssets.icons.userRound( - height: size / 2, - colorFilter: ColorFilter.mode(style.colorScheme.mutedForeground, BlendMode.srcIn), - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DoubleProperty('size', size)); - } + int get hashCode => backgroundColor.hashCode ^ foregroundColor.hashCode ^ fadeInDuration.hashCode ^ text.hashCode; } diff --git a/forui/lib/src/widgets/avatar/avatar_content.dart b/forui/lib/src/widgets/avatar/avatar_content.dart new file mode 100644 index 000000000..e9e9096c8 --- /dev/null +++ b/forui/lib/src/widgets/avatar/avatar_content.dart @@ -0,0 +1,79 @@ +part of 'avatar.dart'; + +class _AvatarContent extends StatelessWidget { + final ImageProvider image; + final double size; + final FAvatarStyle? style; + final Widget? fallback; + + const _AvatarContent({ + required this.image, + required this.size, + this.style, + this.fallback, + }); + + @override + Widget build(BuildContext context) { + final style = this.style ?? context.theme.avatarStyle; + + final fallback = this.fallback ?? _Placeholder(style: style, size: size); + + return Image( + height: size, + width: size, + filterQuality: FilterQuality.medium, + image: image, + errorBuilder: (context, exception, stacktrace) => fallback, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded) { + return child; + } + return AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + child: frame == null ? fallback : child, + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + return child; + } + return fallback; + }, + fit: BoxFit.cover, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('image', image)) + ..add(DoubleProperty('size', size)) + ..add(DiagnosticsProperty('style', style)); + } +} + +class _Placeholder extends StatelessWidget { + final double size; + final FAvatarStyle? style; + + const _Placeholder({required this.size, this.style}); + + @override + Widget build(BuildContext context) { + final style = this.style ?? context.theme.avatarStyle; + return FAssets.icons.userRound( + height: size / 2, + colorFilter: ColorFilter.mode(style.foregroundColor, BlendMode.srcIn), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DoubleProperty('size', size)) + ..add(DiagnosticsProperty('style', style)); + } +} diff --git a/forui/lib/widgets.dart b/forui/lib/widgets.dart index 985949896..88561dc9c 100644 --- a/forui/lib/widgets.dart +++ b/forui/lib/widgets.dart @@ -19,7 +19,7 @@ library forui.widgets; import 'package:forui/forui.dart'; export 'src/widgets/alert/alert.dart' hide Variant; -export 'src/widgets/avatar.dart'; +export 'src/widgets/avatar/avatar.dart'; export 'src/widgets/badge/badge.dart' hide Variant; export 'src/widgets/bottom_navigation_bar/bottom_navigation_bar.dart'; export 'src/widgets/button/button.dart' hide Variant; diff --git a/forui/test/golden/avatar/zinc-dark-raw-content.png b/forui/test/golden/avatar/zinc-dark-raw-content.png new file mode 100644 index 000000000..a8fb93399 Binary files /dev/null and b/forui/test/golden/avatar/zinc-dark-raw-content.png differ diff --git a/forui/test/golden/avatar/zinc-dark-with-image.png b/forui/test/golden/avatar/zinc-dark-with-image.png index c9fe7e488..0b7db1fb4 100644 Binary files a/forui/test/golden/avatar/zinc-dark-with-image.png and b/forui/test/golden/avatar/zinc-dark-with-image.png differ diff --git a/forui/test/golden/avatar/zinc-light-raw-content.png b/forui/test/golden/avatar/zinc-light-raw-content.png new file mode 100644 index 000000000..eca73164d Binary files /dev/null and b/forui/test/golden/avatar/zinc-light-raw-content.png differ diff --git a/forui/test/golden/avatar/zinc-light-with-image.png b/forui/test/golden/avatar/zinc-light-with-image.png index 858fcc52e..86cfa19ec 100644 Binary files a/forui/test/golden/avatar/zinc-light-with-image.png and b/forui/test/golden/avatar/zinc-light-with-image.png differ diff --git a/forui/test/src/widgets/avatar_golden_test.dart b/forui/test/src/widgets/avatar_golden_test.dart index d4b4759e6..4c90f00b5 100644 --- a/forui/test/src/widgets/avatar_golden_test.dart +++ b/forui/test/src/widgets/avatar_golden_test.dart @@ -19,12 +19,9 @@ void main() { final testWidget = MaterialApp( home: TestScaffold( data: theme, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: FAvatar( - image: FileImage(File('./test/resources/pante.jpg')), - placeholderBuilder: (_) => const Text('MN'), - ), + child: FAvatar( + image: FileImage(File('./test/resources/pante.jpg')), + fallback: const Text('MN'), ), ), ); @@ -48,6 +45,27 @@ void main() { /// We will not be testing for the fallback behavior due to this issue on flutter /// https://github.com/flutter/flutter/issues/107416 + + testWidgets('$name with raw content', (tester) async { + await tester.pumpWidget( + TestScaffold( + data: theme, + child: FAvatar.raw( + child: Padding( + padding: const EdgeInsets.all(10), + child: FAssets.icons.baby( + colorFilter: ColorFilter.mode(theme.colorScheme.mutedForeground, BlendMode.srcIn), + ), + ), + ), + ), + ); + + await expectLater( + find.byType(TestScaffold), + matchesGoldenFile('avatar/$name-raw-content.png'), + ); + }); } }, ); diff --git a/samples/lib/main.dart b/samples/lib/main.dart index fc7c6e686..70b41ba9e 100644 --- a/samples/lib/main.dart +++ b/samples/lib/main.dart @@ -47,6 +47,14 @@ class _AppRouter extends $_AppRouter { path: '/avatar/default', page: AvatarRoute.page, ), + AutoRoute( + path: '/avatar/raw', + page: AvatarRawRoute.page, + ), + AutoRoute( + path: '/avatar/invalid', + page: AvatarInvalidRoute.page, + ), AutoRoute( path: '/badge/default', page: BadgeRoute.page, diff --git a/samples/lib/widgets/avatar.dart b/samples/lib/widgets/avatar.dart index 5ada65882..30a253906 100644 --- a/samples/lib/widgets/avatar.dart +++ b/samples/lib/widgets/avatar.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:auto_route/auto_route.dart'; @@ -5,6 +6,8 @@ import 'package:forui/forui.dart'; import 'package:forui_samples/sample_scaffold.dart'; +String path(String str) => kIsWeb ? 'assets/$str' : str; + @RoutePage() class AvatarPage extends SampleScaffold { AvatarPage({ @@ -12,13 +15,66 @@ class AvatarPage extends SampleScaffold { }); @override - Widget child(BuildContext context) => Column( + Widget child(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FAvatar( + image: AssetImage(path('avatar.png')), + fallback: const Text('MN'), + ), + const SizedBox(width: 10), + FAvatar( + image: const AssetImage(''), + fallback: const Text('MN'), + ), + const SizedBox(width: 10), + FAvatar( + image: const AssetImage(''), + ), + ], + ); +} + +@RoutePage() +class AvatarRawPage extends SampleScaffold { + AvatarRawPage({ + @queryParam super.theme, + }); + + @override + Widget child(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FAvatar.raw(), + const SizedBox(width: 10), + FAvatar.raw( + child: FAssets.icons.baby( + colorFilter: ColorFilter.mode(theme.colorScheme.mutedForeground, BlendMode.srcIn), + ), + ), + const SizedBox(width: 10), + FAvatar.raw(child: const Text('MN')), + ], + ); +} + +@RoutePage() +class AvatarInvalidPage extends SampleScaffold { + AvatarInvalidPage({ + @queryParam super.theme, + }); + + @override + Widget child(BuildContext context) => Row( mainAxisAlignment: MainAxisAlignment.center, children: [ FAvatar( - size: 70, - image: const AssetImage('avatar.png'), - placeholderBuilder: (_) => const Text('MN'), + image: const AssetImage(''), + fallback: const Text('MN'), + ), + const SizedBox(width: 10), + FAvatar( + image: const AssetImage(''), ), ], );