Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions forui/lib/forui.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
183 changes: 183 additions & 0 deletions forui/lib/src/widgets/rating/rating.dart
Original file line number Diff line number Diff line change
@@ -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<double>? onStateChanged;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be onChange, onStateChange is meant for WidgetState changes.


/// 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<bool>? onFocusChange;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably add a onHoverChange, I think you can use FButton as a reference.


/// The spacing between rating icons.
final double spacing;
Comment on lines +52 to +53
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be in the style.


/// 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
Comment on lines +63 to +64
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should move the color to inside the style. Generally for icons, we wrap them in a IconTheme inside the build method, e.g. IconTheme(style.iconThemeData, child: filledIcon).

this.halfFilledIcon,
this.semanticsLabel,
this.autofocus = false,
this.focusNode,
this.onFocusChange,
this.spacing = 4.0,
});

@override
State<FRating> 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<ValueChanged<double>?>.has('onStateChanged', onStateChanged))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
..add(ObjectFlagProperty<ValueChanged<double>?>.has('onStateChanged', onStateChanged))
..add(ObjectFlagProperty.has('onStateChanged', onStateChanged))

Generally we don't specify the generic type for diagnostic properties.

..add(StringProperty('semanticsLabel', semanticsLabel))
..add(DiagnosticsProperty<bool>('autofocus', autofocus))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
..add(DiagnosticsProperty<bool>('autofocus', autofocus))
..add(FlagProperty('autofocus', value: autofocus, ifTrue: 'autofocus'))

..add(DiagnosticsProperty<FocusNode?>('focusNode', focusNode))
..add(ObjectFlagProperty<ValueChanged<bool>?>.has('onFocusChange', onFocusChange))
..add(DoubleProperty('spacing', spacing));
}
}

class _FRatingState extends State<FRating> {
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);
}
Comment on lines +97 to +132
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally, I think we should try to avoid using retrieving the renderbox like this. Perhaps, you can use the PointerEnterEvent passed into MouseRegion.onEnter to calculate the size?

I think there's something similar for tap gestures too.


@override
Widget build(BuildContext context) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can use FTappable to manage the tapping/hovering logic.

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});
}
Comment on lines +172 to +183
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these properties can be merged into a IconThemeData. IconThemeData can then be wrapped inside a FWidgetStateMap to account for the various states, i.e. selected & unselected.

I think you can reference FTileContentStyle.

82 changes: 82 additions & 0 deletions forui/lib/src/widgets/rating/rating_content.dart
Original file line number Diff line number Diff line change
@@ -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<bool>? 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<FRatingStyle>('style', style))
..add(DiagnosticsProperty<FThemeData>('theme', theme))
..add(StringProperty('semanticsLabel', semanticsLabel))
..add(DiagnosticsProperty<FocusNode?>('focusNode', focusNode))
..add(DiagnosticsProperty<bool>('autofocus', autofocus))
..add(ObjectFlagProperty<ValueChanged<bool>?>.has('onFocusChange', onFocusChange));
}
}
8 changes: 8 additions & 0 deletions forui/lib/widgets/rating.dart
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +3 to +5
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs need to be updated.

library forui.widgets.rating;

export '../src/widgets/rating/rating.dart';
Binary file added forui/test/golden/rating/rating_counts.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added forui/test/golden/rating/rating_dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added forui/test/golden/rating/rating_styles.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added forui/test/golden/rating/rating_values.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading