diff --git a/docs/pages/docs/avatar.mdx b/docs/pages/docs/avatar.mdx new file mode 100644 index 000000000..be032f404 --- /dev/null +++ b/docs/pages/docs/avatar.mdx @@ -0,0 +1,32 @@ +import { Tabs } from 'nextra/components'; +import { Widget } from "../../components/widget"; + +# Avatar +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'), + ), + ``` + + + +## Usage + +### `FAvatar(...)` + +```dart +FAvatar( + image: const NetworkImage('https://picsum.photos/250?image=9'), + size: 60, + placeholderBuilder: (_) => const Text('MN'), +), +``` + diff --git a/forui/CHANGELOG.md b/forui/CHANGELOG.md index 19dd1a078..e62e58a6c 100644 --- a/forui/CHANGELOG.md +++ b/forui/CHANGELOG.md @@ -1,6 +1,7 @@ ## Next ### Additions +* Add `FAvatar` * Add `FResizable` diff --git a/forui/lib/src/theme/theme_data.dart b/forui/lib/src/theme/theme_data.dart index 7528de02f..25c25c1d9 100644 --- a/forui/lib/src/theme/theme_data.dart +++ b/forui/lib/src/theme/theme_data.dart @@ -28,6 +28,9 @@ final class FThemeData with Diagnosticable { /// The alert styles. final FAlertStyles alertStyles; + /// The avatar style. + final FAvatarStyle avatarStyle; + /// The badge styles. final FBadgeStyles badgeStyles; @@ -78,12 +81,13 @@ final class FThemeData with Diagnosticable { /// widget styles. FThemeData({ required this.colorScheme, + required this.alertStyles, + required this.avatarStyle, required this.badgeStyles, required this.bottomNavigationBarStyle, required this.buttonStyles, required this.calendarStyle, required this.cardStyle, - required this.alertStyles, required this.checkboxStyle, required this.dialogStyle, required this.headerStyle, @@ -110,6 +114,7 @@ final class FThemeData with Diagnosticable { typography: typography, style: style, alertStyles: FAlertStyles.inherit(colorScheme: colorScheme, typography: typography, style: style), + avatarStyle: FAvatarStyle.inherit(colorScheme: colorScheme, typography: typography), badgeStyles: FBadgeStyles.inherit(colorScheme: colorScheme, typography: typography, style: style), bottomNavigationBarStyle: FBottomNavigationBarStyle.inherit(colorScheme: colorScheme, typography: typography), buttonStyles: FButtonStyles.inherit(colorScheme: colorScheme, typography: typography, style: style), @@ -149,6 +154,7 @@ final class FThemeData with Diagnosticable { FTypography? typography, FStyle? style, FAlertStyles? alertStyles, + FAvatarStyle? avatarStyle, FBadgeStyles? badgeStyles, FBottomNavigationBarStyle? bottomNavigationBarStyle, FButtonStyles? buttonStyles, @@ -169,6 +175,7 @@ final class FThemeData with Diagnosticable { typography: typography ?? this.typography, style: style ?? this.style, alertStyles: alertStyles ?? this.alertStyles, + avatarStyle: avatarStyle ?? this.avatarStyle, badgeStyles: badgeStyles ?? this.badgeStyles, bottomNavigationBarStyle: bottomNavigationBarStyle ?? this.bottomNavigationBarStyle, buttonStyles: buttonStyles ?? this.buttonStyles, @@ -193,6 +200,7 @@ final class FThemeData with Diagnosticable { ..add(DiagnosticsProperty('typography', typography, level: DiagnosticLevel.debug)) ..add(DiagnosticsProperty('style', style, level: DiagnosticLevel.debug)) ..add(DiagnosticsProperty('alertStyles', alertStyles, level: DiagnosticLevel.debug)) + ..add(DiagnosticsProperty('avatarStyle', avatarStyle)) ..add(DiagnosticsProperty('badgeStyles', badgeStyles, level: DiagnosticLevel.debug)) ..add(DiagnosticsProperty('bottomNavigationBarStyle', bottomNavigationBarStyle, level: DiagnosticLevel.debug)) ..add(DiagnosticsProperty('buttonStyles', buttonStyles, level: DiagnosticLevel.debug)) @@ -218,6 +226,7 @@ final class FThemeData with Diagnosticable { typography == other.typography && style == other.style && alertStyles == other.alertStyles && + avatarStyle == other.avatarStyle && badgeStyles == other.badgeStyles && bottomNavigationBarStyle == other.bottomNavigationBarStyle && buttonStyles == other.buttonStyles && @@ -239,6 +248,7 @@ final class FThemeData with Diagnosticable { typography.hashCode ^ style.hashCode ^ alertStyles.hashCode ^ + avatarStyle.hashCode ^ badgeStyles.hashCode ^ bottomNavigationBarStyle.hashCode ^ buttonStyles.hashCode ^ diff --git a/forui/lib/src/widgets/avatar.dart b/forui/lib/src/widgets/avatar.dart new file mode 100644 index 000000000..f1a5c0830 --- /dev/null +++ b/forui/lib/src/widgets/avatar.dart @@ -0,0 +1,198 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:meta/meta.dart'; + +import 'package:forui/forui.dart'; + +/// An image element with a fallback for representing the user. +/// +/// 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. +/// +/// If the user's profile has no image, use [placeholderBuilder] 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. + /// + /// 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; + + /// Creates an [FAvatar]. + const FAvatar({ + required this.image, + this.style, + this.size = 40.0, + this.placeholderBuilder, + super.key, + }); + + @override + Widget build(BuildContext context) { + final style = this.style ?? context.theme.avatarStyle; + + return Container( + height: size, + width: size, + decoration: BoxDecoration( + color: style.backgroundColor, + 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, + ), + ), + ); + } + + @override + 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)); + } +} + +/// [FAvatar]'s style. +final class FAvatarStyle with Diagnosticable { + /// The placeholder's background color. + final Color backgroundColor; + + /// Duration for the transition animation. + final Duration fadeInDuration; + + /// The text style for the placeholder text. + final TextStyle text; + + /// Creates a [FAvatarStyle]. + FAvatarStyle({ + required this.backgroundColor, + required this.fadeInDuration, + required this.text, + }); + + /// Creates a [FCardStyle] that inherits its properties from [colorScheme] and [typography]. + FAvatarStyle.inherit({required FColorScheme colorScheme, required FTypography typography}) + : backgroundColor = colorScheme.muted, + fadeInDuration = const Duration(milliseconds: 500), + text = typography.base.copyWith( + color: colorScheme.mutedForeground, + height: 0, + ); + + /// Returns a copy of this [FAvatarStyle] with the given properties replaced. + /// + /// ```dart + /// final style = FAvatarStyle( + /// backgroundColor: ..., + /// fadeInDuration: ..., + /// ); + /// + /// final copy = style.copyWith(fadeInDuration: ...); + /// + /// print(style.backgroundColor == copy.backgroundColor); // true + /// print(style.fadeInDuration == copy.fadeInDuration); // false + /// ``` + @useResult + FAvatarStyle copyWith({ + Color? backgroundColor, + Duration? fadeInDuration, + TextStyle? text, + }) => + FAvatarStyle( + backgroundColor: backgroundColor ?? this.backgroundColor, + fadeInDuration: fadeInDuration ?? this.fadeInDuration, + text: text ?? this.text, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('backgroundColor', backgroundColor)) + ..add(DiagnosticsProperty('fadeInDuration', fadeInDuration)) + ..add(DiagnosticsProperty('text', text)); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FAvatarStyle && + runtimeType == other.runtimeType && + backgroundColor == other.backgroundColor && + 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)); + } +} diff --git a/forui/lib/widgets.dart b/forui/lib/widgets.dart index 5d4c2c44f..985949896 100644 --- a/forui/lib/widgets.dart +++ b/forui/lib/widgets.dart @@ -19,6 +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/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-with-image.png b/forui/test/golden/avatar/zinc-dark-with-image.png new file mode 100644 index 000000000..c9fe7e488 Binary files /dev/null and b/forui/test/golden/avatar/zinc-dark-with-image.png differ diff --git a/forui/test/golden/avatar/zinc-light-with-image.png b/forui/test/golden/avatar/zinc-light-with-image.png new file mode 100644 index 000000000..858fcc52e Binary files /dev/null and b/forui/test/golden/avatar/zinc-light-with-image.png differ diff --git a/forui/test/resources/pante.jpg b/forui/test/resources/pante.jpg new file mode 100644 index 000000000..e3964d479 Binary files /dev/null and b/forui/test/resources/pante.jpg differ diff --git a/forui/test/src/widgets/avatar_golden_test.dart b/forui/test/src/widgets/avatar_golden_test.dart new file mode 100644 index 000000000..d4b4759e6 --- /dev/null +++ b/forui/test/src/widgets/avatar_golden_test.dart @@ -0,0 +1,54 @@ +@Tags(['golden']) +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:forui/forui.dart'; +import '../test_scaffold.dart'; + +void main() { + group( + 'FAvatar', + () { + for (final (name, theme, _) in TestScaffold.themes) { + testWidgets('$name with image', (tester) async { + 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'), + ), + ), + ), + ); + + /// current workaround for flaky image asset testing. + /// https://github.com/flutter/flutter/issues/38997 + await tester.runAsync(() async { + await tester.pumpWidget(testWidget); + for (final element in find.byType(Image).evaluate()) { + final Image widget = element.widget as Image; + final ImageProvider image = widget.image; + await precacheImage(image, element); + await tester.pumpAndSettle(); + } + }); + await expectLater( + find.byType(TestScaffold), + matchesGoldenFile('avatar/$name-with-image.png'), + ); + }); + + /// We will not be testing for the fallback behavior due to this issue on flutter + /// https://github.com/flutter/flutter/issues/107416 + } + }, + ); +} diff --git a/samples/assets/avatar.png b/samples/assets/avatar.png new file mode 100644 index 000000000..274b3f45a Binary files /dev/null and b/samples/assets/avatar.png differ diff --git a/samples/lib/main.dart b/samples/lib/main.dart index a5604278c..fc7c6e686 100644 --- a/samples/lib/main.dart +++ b/samples/lib/main.dart @@ -43,6 +43,10 @@ class _AppRouter extends $_AppRouter { path: '/alert/default', page: AlertRoute.page, ), + AutoRoute( + path: '/avatar/default', + page: AvatarRoute.page, + ), AutoRoute( path: '/badge/default', page: BadgeRoute.page, diff --git a/samples/lib/widgets/avatar.dart b/samples/lib/widgets/avatar.dart new file mode 100644 index 000000000..5ada65882 --- /dev/null +++ b/samples/lib/widgets/avatar.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +import 'package:auto_route/auto_route.dart'; +import 'package:forui/forui.dart'; + +import 'package:forui_samples/sample_scaffold.dart'; + +@RoutePage() +class AvatarPage extends SampleScaffold { + AvatarPage({ + @queryParam super.theme, + }); + + @override + Widget child(BuildContext context) => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FAvatar( + size: 70, + image: const AssetImage('avatar.png'), + placeholderBuilder: (_) => const Text('MN'), + ), + ], + ); +} diff --git a/samples/pubspec.lock b/samples/pubspec.lock index 4f8bd5ab7..20441f796 100644 --- a/samples/pubspec.lock +++ b/samples/pubspec.lock @@ -37,18 +37,18 @@ packages: dependency: "direct main" description: name: auto_route - sha256: "878186aae276296bf1cfc0a02cd2788cfb473eb622e0f5e4293f40ecdf86d80d" + sha256: a9001a90539ca3effc168f7e1029a5885c7326b9032c09ac895e303c1d137704 url: "https://pub.dev" source: hosted - version: "8.2.0" + version: "8.3.0" auto_route_generator: dependency: "direct dev" description: name: auto_route_generator - sha256: ba28133d3a3bf0a66772bcc98dade5843753cd9f1a8fb4802b842895515b67d3 + sha256: a21d7a936c917488653c972f62d884d8adcf8c5d37acc7cd24da33cf784546c0 url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "8.1.0" boolean_selector: dependency: transitive description: @@ -303,18 +303,18 @@ packages: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" http: dependency: transitive description: name: http - sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" http_multi_server: dependency: transitive description: @@ -732,18 +732,18 @@ packages: dependency: transitive description: name: web_socket - sha256: "24301d8c293ce6fe327ffe6f59d8fd8834735f0ec36e4fd383ec7ff8a64aa078" + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" url: "https://pub.dev" source: hosted - version: "0.1.5" + version: "0.1.6" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: a2d56211ee4d35d9b344d9d4ce60f362e4f5d1aafb988302906bd732bc731276 + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.1" xdg_directories: dependency: transitive description: diff --git a/samples/pubspec.yaml b/samples/pubspec.yaml index fc338bb43..d8e52649d 100644 --- a/samples/pubspec.yaml +++ b/samples/pubspec.yaml @@ -72,6 +72,9 @@ flutter: # For details regarding adding assets from package dependencies, see # https://flutter.dev/assets-and-images/#from-packages + assets: + - assets/avatar.png + # To add custom fonts to your application, add a fonts section here, # in this "flutter" section. Each entry in this list should have a # "family" key with the font family name, and a "fonts" key with a