diff --git a/forui/lib/forui.dart b/forui/lib/forui.dart index 91bf608f1..17828c3d5 100644 --- a/forui/lib/forui.dart +++ b/forui/lib/forui.dart @@ -28,6 +28,7 @@ export 'widgets/popover.dart'; export 'widgets/popover_menu.dart'; export 'widgets/progress.dart'; export 'widgets/radio.dart'; +export 'widgets/rating.dart'; export 'widgets/resizable.dart'; export 'widgets/scaffold.dart'; export 'widgets/select.dart'; diff --git a/forui/lib/src/widgets/rating/rating.dart b/forui/lib/src/widgets/rating/rating.dart new file mode 100644 index 000000000..155aa8bd1 --- /dev/null +++ b/forui/lib/src/widgets/rating/rating.dart @@ -0,0 +1,183 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:forui/forui.dart'; +import 'package:meta/meta.dart'; + +part 'rating.style.dart'; +part 'rating_content.dart'; + +/// A customizable rating widget that allows users to select a rating by tapping on icons. +/// +/// See: +/// * https://forui.dev/docs/rating for working examples. +/// * [FRatingStyle] for customizing a rating's appearance. +class FRating extends StatefulWidget { + /// The current rating value. + final double value; + + /// The maximum rating value. + final int count; + + /// Called when the rating value changes. + final ValueChanged? onStateChanged; + + /// Whether this rating widget is enabled. Defaults to true. + final bool enabled; + + /// The style of the rating widget. + final FRatingStyle? style; + + /// The icon to display for a filled (rated) state. + final Widget filledIcon; + + /// The icon to display for an empty (unrated) state. + final Widget emptyIcon; + + /// The icon to display for a half-filled state (when allowHalfRating is true). + final Widget? halfFilledIcon; + + /// The semantic label for accessibility. + final String? semanticsLabel; + + /// {@macro forui.foundation.doc_templates.autofocus} + final bool autofocus; + + /// {@macro forui.foundation.doc_templates.focusNode} + final FocusNode? focusNode; + + /// {@macro forui.foundation.doc_templates.onFocusChange} + final ValueChanged? onFocusChange; + + /// The spacing between rating icons. + final double spacing; + + /// Creates a [FRating] widget. + const FRating({ + super.key, + this.value = 0.0, + this.count = 5, + this.onStateChanged, + this.enabled = true, + this.style, + this.filledIcon = const Icon(FIcons.star, color: Color(0xFFFFD700)), // Gold + this.emptyIcon = const Icon(FIcons.starOff, color: Color(0xFFBDBDBD)), // Gray + this.halfFilledIcon, + this.semanticsLabel, + this.autofocus = false, + this.focusNode, + this.onFocusChange, + this.spacing = 4.0, + }); + + @override + State createState() => _FRatingState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DoubleProperty('value', value)) + ..add(IntProperty('count', count)) + ..add(FlagProperty('enabled', value: enabled, ifTrue: 'enabled')) + ..add(DiagnosticsProperty('style', style)) + ..add(ObjectFlagProperty?>.has('onStateChanged', onStateChanged)) + ..add(StringProperty('semanticsLabel', semanticsLabel)) + ..add(DiagnosticsProperty('autofocus', autofocus)) + ..add(DiagnosticsProperty('focusNode', focusNode)) + ..add(ObjectFlagProperty?>.has('onFocusChange', onFocusChange)) + ..add(DoubleProperty('spacing', spacing)); + } +} + +class _FRatingState extends State { + double _hoverValue = 0.0; + bool _isHovering = false; + + double _calculateRating(double dx, double totalWidth) { + if (totalWidth <= 0) { + return 0; + } + + final itemWidth = totalWidth / widget.count; + final x = dx.clamp(0.0, totalWidth); + final index = x ~/ itemWidth; + final localPosition = x - (index * itemWidth); + + double rating = index + 1.0; + if (widget.halfFilledIcon != null && localPosition < itemWidth / 2) { + rating -= 0.5; + } + + return rating.clamp(0.0, widget.count.toDouble()); + } + + void _handleHover(PointerHoverEvent event) { + final box = context.findRenderObject() as RenderBox?; + if (box == null) { + return; + } + + final localPosition = box.globalToLocal(event.position); + setState(() => _hoverValue = _calculateRating(localPosition.dx, box.size.width)); + } + + void _handleTap(TapUpDetails details) { + final box = context.findRenderObject()! as RenderBox; + final localPosition = box.globalToLocal(details.globalPosition); + final rating = _calculateRating(localPosition.dx, box.size.width); + + widget.onStateChanged?.call(rating); + setState(() => _isHovering = false); + } + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final style = widget.style ?? const FRatingStyle(); + final effectiveEnabled = widget.enabled && widget.onStateChanged != null; + final effectiveValue = _isHovering ? _hoverValue : widget.value; + + return MouseRegion( + onEnter: effectiveEnabled ? (_) => setState(() => _isHovering = true) : null, + onExit: effectiveEnabled ? (_) => setState(() => _isHovering = false) : null, + onHover: effectiveEnabled ? _handleHover : null, + child: GestureDetector( + onTapUp: effectiveEnabled ? _handleTap : null, + child: _RatingContent( + value: effectiveValue, + count: widget.count, + spacing: widget.spacing, + filledIcon: widget.filledIcon, + emptyIcon: widget.emptyIcon, + halfFilledIcon: widget.halfFilledIcon, + style: style, + theme: theme, + semanticsLabel: widget.semanticsLabel, + focusNode: widget.focusNode, + autofocus: widget.autofocus, + onFocusChange: widget.onFocusChange, + ), + ), + ); + } +} + +/// Internal widget to display the rating content + +/// Defines the visual properties for [FRating]. +/// +/// See also: +/// * [FRating], which uses this class for its visual styling. +final class FRatingStyle with Diagnosticable, _$FRatingStyleFunctions { + /// The color of the rating icons. + @override + final Color? color; + + /// The size of the rating icons. + @override + final double? size; + + /// Creates a [FRatingStyle]. + const FRatingStyle({this.color, this.size}); +} diff --git a/forui/lib/src/widgets/rating/rating_content.dart b/forui/lib/src/widgets/rating/rating_content.dart new file mode 100644 index 000000000..8c7a36501 --- /dev/null +++ b/forui/lib/src/widgets/rating/rating_content.dart @@ -0,0 +1,82 @@ +part of 'rating.dart'; + +class _RatingContent extends StatelessWidget { + final double value; + final int count; + final double spacing; + final Widget filledIcon; + final Widget emptyIcon; + final Widget? halfFilledIcon; + final FRatingStyle style; + final FThemeData theme; + final String? semanticsLabel; + final FocusNode? focusNode; + final bool autofocus; + final ValueChanged? onFocusChange; + + const _RatingContent({ + required this.value, + required this.count, + required this.spacing, + required this.filledIcon, + required this.emptyIcon, + required this.style, + required this.theme, + required this.autofocus, + this.halfFilledIcon, + this.semanticsLabel, + this.focusNode, + this.onFocusChange, + }); + + @override + Widget build(BuildContext context) => Semantics( + label: semanticsLabel ?? 'Rating: ${value.toStringAsFixed(1)} of $count', + child: Focus( + focusNode: focusNode, + autofocus: autofocus, + onFocusChange: onFocusChange, + child: LayoutBuilder( + builder: + (context, constraints) => + Row(mainAxisSize: MainAxisSize.min, children: List.generate(count, _buildRatingItem)), + ), + ), + ); + + Widget _buildRatingItem(int index) { + final itemValue = index + 1.0; + final Widget icon; + + if (itemValue <= value) { + icon = filledIcon; + } else if (halfFilledIcon != null && (itemValue - 0.5) <= value && value < itemValue) { + icon = halfFilledIcon ?? filledIcon; + } else { + icon = emptyIcon; + } + + return Padding( + padding: EdgeInsets.only(right: index < count - 1 ? spacing : 0), + child: IconTheme( + data: IconThemeData(color: style.color ?? theme.colors.primary, size: style.size ?? 24.0), + child: icon, + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DoubleProperty('value', value)) + ..add(IntProperty('count', count)) + ..add(DoubleProperty('spacing', spacing)) + ..add(DiagnosticsProperty('style', style)) + ..add(DiagnosticsProperty('theme', theme)) + ..add(StringProperty('semanticsLabel', semanticsLabel)) + ..add(DiagnosticsProperty('focusNode', focusNode)) + ..add(DiagnosticsProperty('autofocus', autofocus)) + ..add(ObjectFlagProperty?>.has('onFocusChange', onFocusChange)); + } +} diff --git a/forui/lib/widgets/rating.dart b/forui/lib/widgets/rating.dart new file mode 100644 index 000000000..77ef09b3c --- /dev/null +++ b/forui/lib/widgets/rating.dart @@ -0,0 +1,8 @@ +/// {@category Widgets} +/// +/// A line calendar displays dates in a single horizontal, scrollable line. +/// +/// See https://forui.dev/docs/data/line-calendar for working examples. +library forui.widgets.rating; + +export '../src/widgets/rating/rating.dart'; diff --git a/forui/test/golden/rating/rating_counts.png b/forui/test/golden/rating/rating_counts.png new file mode 100644 index 000000000..065914af3 Binary files /dev/null and b/forui/test/golden/rating/rating_counts.png differ diff --git a/forui/test/golden/rating/rating_dark.png b/forui/test/golden/rating/rating_dark.png new file mode 100644 index 000000000..8880f2e62 Binary files /dev/null and b/forui/test/golden/rating/rating_dark.png differ diff --git a/forui/test/golden/rating/rating_styles.png b/forui/test/golden/rating/rating_styles.png new file mode 100644 index 000000000..a3041f3bf Binary files /dev/null and b/forui/test/golden/rating/rating_styles.png differ diff --git a/forui/test/golden/rating/rating_values.png b/forui/test/golden/rating/rating_values.png new file mode 100644 index 000000000..65f113844 Binary files /dev/null and b/forui/test/golden/rating/rating_values.png differ diff --git a/forui/test/src/widgets/rating/rating_golden_test.dart b/forui/test/src/widgets/rating/rating_golden_test.dart new file mode 100644 index 000000000..d8c7d8758 --- /dev/null +++ b/forui/test/src/widgets/rating/rating_golden_test.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; + +import '../../test_scaffold.dart'; + +void main() { + group('FRating', () { + testWidgets('renders different values', (tester) async { + await tester.pumpWidget( + TestScaffold.app( + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 20), + Text('Empty Rating'), + SizedBox(height: 8), + FRating( + ), + SizedBox(height: 20), + Text('Partial Rating'), + SizedBox(height: 8), + FRating( + value: 3, + ), + SizedBox(height: 20), + Text('Full Rating'), + SizedBox(height: 8), + FRating( + value: 5, + ), + SizedBox(height: 20), + Text('Half Rating'), + SizedBox(height: 8), + FRating( + value: 2.5, + halfFilledIcon: Icon(Icons.star_half), + ), + ], + ), + ), + ); + + await expectLater( + find.byType(TestScaffold), + matchesGoldenFile('rating/rating_values.png'), + ); + }); + + testWidgets('renders with custom styling', (tester) async { + await tester.pumpWidget( + TestScaffold.app( + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 20), + Text('Default Icons'), + SizedBox(height: 8), + FRating( + value: 3, + ), + SizedBox(height: 20), + Text('Custom Color'), + SizedBox(height: 8), + FRating( + value: 3, + style: FRatingStyle( + color: Colors.red, + ), + ), + SizedBox(height: 20), + Text('Custom Size'), + SizedBox(height: 8), + FRating( + value: 3, + style: FRatingStyle( + size: 36.0, + ), + ), + SizedBox(height: 20), + Text('Custom Icons'), + SizedBox(height: 8), + FRating( + value: 3, + filledIcon: Icon(Icons.favorite, color: Colors.red), + emptyIcon: Icon(Icons.favorite_border, color: Colors.red), + ), + ], + ), + ), + ); + + await expectLater( + find.byType(TestScaffold), + matchesGoldenFile('rating/rating_styles.png'), + ); + }); + + testWidgets('renders with different counts', (tester) async { + await tester.pumpWidget( + TestScaffold.app( + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 20), + Text('3 Icons'), + SizedBox(height: 8), + FRating( + count: 3, + value: 2, + ), + SizedBox(height: 20), + Text('5 Icons (Default)'), + SizedBox(height: 8), + FRating( + value: 3, + ), + SizedBox(height: 20), + Text('10 Icons'), + SizedBox(height: 8), + FRating( + count: 10, + value: 7, + ), + ], + ), + ), + ); + + await expectLater( + find.byType(TestScaffold), + matchesGoldenFile('rating/rating_counts.png'), + ); + }); + + testWidgets('renders in dark theme', (tester) async { + await tester.pumpWidget( + TestScaffold.app( + theme: FThemes.zinc.dark, + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 20), + Text('Default Rating'), + SizedBox(height: 8), + FRating( + value: 3, + ), + SizedBox(height: 20), + Text('Custom Rating'), + SizedBox(height: 8), + FRating( + value: 3, + filledIcon: Icon(Icons.favorite), + emptyIcon: Icon(Icons.favorite_border), + style: FRatingStyle( + color: Colors.pink, + ), + ), + ], + ), + ), + ); + + await expectLater( + find.byType(TestScaffold), + matchesGoldenFile('rating/rating_dark.png'), + ); + }); + }); +} diff --git a/samples/lib/main.dart b/samples/lib/main.dart index 5df7f71f8..6331e73eb 100644 --- a/samples/lib/main.dart +++ b/samples/lib/main.dart @@ -26,16 +26,90 @@ class ForuiSamples extends StatelessWidget { } @RoutePage() -class EmptyPage extends Sample { +class InitialPage extends Sample { + InitialPage({super.key, super.maxWidth = double.infinity}); + @override - Widget sample(BuildContext context) => const Placeholder(); + Widget sample(BuildContext context) { + final router = AutoRouter.of(context); + final routeCollection = router.routeCollection; + + // Group routes by component name + final Map> groupedRoutes = {}; + + // Skip the initial route + for (final route in routeCollection.routes) { + final path = route.path; + if (path.isEmpty || path == '/') { + continue; + } + + final pathParts = path.split('/'); + if (pathParts.length >= 2) { + final componentName = pathParts[1].replaceAll('-', ' '); + + if (!groupedRoutes.containsKey(componentName)) { + groupedRoutes[componentName] = []; + } + + groupedRoutes[componentName]!.add(path); + } + } + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(2.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Forui Component Samples', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + const SizedBox(height: 10), + ...groupedRoutes.entries.map((entry) { + final componentName = entry.key; + final routes = entry.value; + + return FAccordion( + children: [ + FAccordionItem( + title: Text( + componentName.toUpperCase(), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...routes.map((route) { + final variantName = route.split('/').last; + return MouseRegion( + cursor: SystemMouseCursors.click, // This changes the cursor to a hand/pointer + child: GestureDetector( + onTap: () => context.router.navigatePath(route), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 3.0), + child: Text(variantName, style: const TextStyle(fontSize: 12)), + ), + ), + ); + }), + ], + ), + ), + ], + ); + }), + ], + ), + ), + ); + } } +// ...existing code... @AutoRouterConfig() class _AppRouter extends RootStackRouter { @override List get routes => [ - AutoRoute(page: EmptyRoute.page, initial: true), + AutoRoute(page: InitialRoute.page, initial: true), AutoRoute(path: '/accordion/default', page: AccordionRoute.page), AutoRoute(path: '/alert/default', page: AlertRoute.page), AutoRoute(path: '/avatar/default', page: AvatarRoute.page), @@ -87,6 +161,8 @@ class _AppRouter extends RootStackRouter { AutoRoute(path: '/progress/linear', page: DeterminateLinearProgressRoute.page), AutoRoute(path: '/progress/circular', page: CircularProgressRoute.page), AutoRoute(path: '/radio/default', page: RadioRoute.page), + AutoRoute(path: '/rating/default', page: RatingBasicRoute.page), + AutoRoute(path: '/rating/interactive', page: RatingInteractiveRoute.page), AutoRoute(path: '/resizable/default', page: ResizableRoute.page), AutoRoute(path: '/resizable/no-cascading', page: NoCascadingResizableRoute.page), AutoRoute(path: '/resizable/horizontal', page: HorizontalResizableRoute.page), diff --git a/samples/lib/widgets/rating.dart b/samples/lib/widgets/rating.dart new file mode 100644 index 000000000..c07773448 --- /dev/null +++ b/samples/lib/widgets/rating.dart @@ -0,0 +1,46 @@ +import 'package:flutter/widgets.dart'; + +import 'package:auto_route/auto_route.dart'; +import 'package:forui/forui.dart'; + +import 'package:forui_samples/sample.dart'; + +@RoutePage() +class RatingBasicPage extends Sample { + RatingBasicPage({@queryParam super.theme}); + + @override + Widget sample(BuildContext context) => const FRating(value: 3); +} + +@RoutePage() +class RatingInteractivePage extends StatefulSample { + RatingInteractivePage({@queryParam super.theme}); + + @override + RatingInteractivePageState createState() => RatingInteractivePageState(); +} + +class RatingInteractivePageState extends StatefulSampleState { + double interactiveRatingValue = 2.5; + + @override + Widget sample(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FRating( + value: interactiveRatingValue, + filledIcon: Icon(FIcons.star, color: context.theme.colors.primary), + emptyIcon: Icon(FIcons.starOff, color: context.theme.colors.primary), + halfFilledIcon: Icon(FIcons.starHalf, color: context.theme.colors.primary), + onStateChanged: (value) { + setState(() { + interactiveRatingValue = value; + }); + }, + ), + const SizedBox(height: 8), + Text('Current rating: ${interactiveRatingValue.toStringAsFixed(1)}'), + ], + ); +}