diff --git a/Directory.Build.props b/Directory.Build.props index 1685894f51..9388161523 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -48,7 +48,8 @@ CS1712: Type parameter has no matching typeparam tag in the XML comment CS1723: XML comment has cref attribute that refers to a type parameter CS1734: XML comment has a paramref tag, but there is no parameter by that name - MVMTK0042: The field [ObservableProperty] can be converted to a partial property + MVVMTK0042: The field [ObservableProperty] can be converted to a partial property + MVVMTK0056: The semi-auto property can be converted to a partial property using [ObservableProperty] CsWinRT1028: Class implements WinRT interfaces but isn't marked partial CsWinRT1030: Class implements WinRT interfaces that require unsafe code NU1900 Error communicating with package source, while getting vulnerability information. @@ -150,7 +151,7 @@ nullable, CS0419,CS1570,CS1571,CS1572,CS1573,CS1574,CS1580,CS1581,CS1584,CS1587,CS1589,CS1590,CS1591,CS1592,CS1598,CS1658,CS1710,CS1711,CS1712,CS1723,CS1734, CsWinRT1028,CsWinRT1030, - MVMTK0042, + MVVMTK0042,MVVMTK0056, NU1900,NU1901,NU1902,NU1903,NU1904,NU1905, xUnit1000,xUnit1001,xUnit1002,xUnit1003,xUnit1004,xUnit1005,xUnit1006,xUnit1007,xUnit1008,xUnit1009,xUnit1010,xUnit1011,xUnit1012,xUnit1013,xUnit1014,xUnit1015,xUnit1016,xUnit1017,xUnit1018,xUnit1019,xUnit1020,xUnit1021,xUnit1022,xUnit1023,xUnit1024,xUnit1025,xUnit1026,xUnit1027,xUnit1028,xUnit1029,xUnit1030,xUnit1031,xUnit1032,xUnit1033,xUnit1034,xUnit1035,xUnit1036,xUnit1037,xUnit1038,xUnit1039,xUnit1040,xUnit1041,xUnit1042,xUnit1043,xUnit1048,xUnit1049,xUnit1050,xUnit1051, xUnit2000,xUnit2001,xUnit2002,xUnit2003,xUnit2004,xUnit2005,xUnit2006,xUnit2007,xUnit2008,xUnit2009,xUnit2010,xUnit2011,xUnit2012,xUnit2013,xUnit2014,xUnit2015,xUnit2016,xUnit2017,xUnit2018,xUnit2019,xUnit2020,xUnit2021,xUnit2022,xUnit2023,xUnit2024,xUnit2025,xUnit2026,xUnit2027,xUnit2028,xUnit2029,xUnit2030,xUnit2031,xUnit2032, diff --git a/samples/CommunityToolkit.Maui.Sample/AppShell.xaml.cs b/samples/CommunityToolkit.Maui.Sample/AppShell.xaml.cs index 5010fa1b96..9088e4679e 100644 --- a/samples/CommunityToolkit.Maui.Sample/AppShell.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/AppShell.xaml.cs @@ -135,6 +135,9 @@ public partial class AppShell : Shell CreateViewModelMapping(), CreateViewModelMapping(), CreateViewModelMapping(), + CreateViewModelMapping(), + CreateViewModelMapping(), + CreateViewModelMapping(), CreateViewModelMapping(), CreateViewModelMapping(), CreateViewModelMapping(), diff --git a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs index 1dba8b3525..1ff40e68a2 100644 --- a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs +++ b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs @@ -89,32 +89,31 @@ public static MauiApp CreateMauiApp() fonts.AddFont("Font Awesome 6 Brands-Regular-400.otf", FontFamilies.FontAwesomeBrands); }); - builder.ConfigureLifecycleEvents(events => { #if WINDOWS10_0_17763_0_OR_GREATER - events.AddWindows(static windowLifeCycleBuilder => - { - windowLifeCycleBuilder.OnWindowCreated(window => - { - window.SystemBackdrop = new MicaBackdrop { Kind = MicaKind.Base }; + events.AddWindows(static windowLifeCycleBuilder => + { + windowLifeCycleBuilder.OnWindowCreated(window => + { + window.SystemBackdrop = new MicaBackdrop { Kind = MicaKind.Base }; - var titleBar = window.GetAppWindow()?.TitleBar ?? throw new InvalidOperationException("App Window Cannot be Null"); + var titleBar = window.GetAppWindow()?.TitleBar ?? throw new InvalidOperationException("App Window Cannot be Null"); - titleBar.PreferredHeightOption = TitleBarHeightOption.Tall; + titleBar.PreferredHeightOption = TitleBarHeightOption.Tall; - window.ExtendsContentIntoTitleBar = false; + window.ExtendsContentIntoTitleBar = false; - IntPtr nativeWindowHandle = WinRT.Interop.WindowNative.GetWindowHandle(window); - WindowId win32WindowsId = Win32Interop.GetWindowIdFromWindow(nativeWindowHandle); - AppWindow winuiAppWindow = AppWindow.GetFromWindowId(win32WindowsId); + IntPtr nativeWindowHandle = WinRT.Interop.WindowNative.GetWindowHandle(window); + WindowId win32WindowsId = Win32Interop.GetWindowIdFromWindow(nativeWindowHandle); + AppWindow winuiAppWindow = AppWindow.GetFromWindowId(win32WindowsId); - if (winuiAppWindow.Presenter is OverlappedPresenter p) - { - p.SetBorderAndTitleBar(true, true); - } - }); - }); + if (winuiAppWindow.Presenter is OverlappedPresenter p) + { + p.SetBorderAndTitleBar(true, true); + } + }); + }); #endif }); @@ -148,7 +147,6 @@ static void RegisterViewsAndViewModels(in IServiceCollection services) services.AddTransient(); services.AddTransient(); - // Add Alerts Pages + ViewModels services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); @@ -261,6 +259,9 @@ static void RegisterViewsAndViewModels(in IServiceCollection services) services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); + services.AddTransientWithShellRoute(); + services.AddTransientWithShellRoute(); + services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewCsharpPage.cs b/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewCsharpPage.cs new file mode 100644 index 0000000000..10cbe59207 --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewCsharpPage.cs @@ -0,0 +1,842 @@ +// Ignore Spelling: csharp, color, colors + +using CommunityToolkit.Maui.Alerts; +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Markup; +using CommunityToolkit.Maui.Sample.ViewModels.Views; +using CommunityToolkit.Maui.Views; +using Microsoft.Maui.Controls.Shapes; +using static CommunityToolkit.Maui.Markup.GridRowsColumns; + +namespace CommunityToolkit.Maui.Sample.Pages.Views; + +public class RatingViewCsharpPage : BasePage +{ + public RatingViewCsharpPage(RatingViewCsharpViewModel viewModel) : base(viewModel) + { + const int ratingViewTitleRowHeight = 18; + const int stepperRowHeight = 38; + const int sliderRowHeight = 24; + const int pickerRowHeight = 38; + const int smallestSizeRatingViewHeight = 32; + const int smallerSizeRatingViewHeight = 40; + const int largerSizeRatingViewHeight = 50; + const int largestSizeRatingViewHeight = 60; + const int sampleRatingViewHeight = smallerSizeRatingViewHeight; + + Title = "RatingView C# Syntax"; + + Content = new ScrollView + { + Content = new Grid + { + RowSpacing = 12, + ColumnSpacing = 8, + + RowDefinitions = Rows.Define( + (Row.DefaultsHeader, SectionHeader.RequestedHeight), + (Row.DefaultsRatingView, sampleRatingViewHeight), + (Row.DefaultsRatingViewUsingProperties, sampleRatingViewHeight), + (Row.DefaultsRatingViewUsingStyles, sampleRatingViewHeight), + (Row.ShapesHeader, SectionHeader.RequestedHeight), + (Row.ShapesStar, sampleRatingViewHeight), + (Row.ShapesCircle, sampleRatingViewHeight), + (Row.ShapesHeart, sampleRatingViewHeight), + (Row.ShapesLike, sampleRatingViewHeight), + (Row.ShapesDislike, sampleRatingViewHeight), + (Row.ShapesCustomAnimal, sampleRatingViewHeight), + (Row.ShapesCustomLogo, sampleRatingViewHeight), + (Row.MaximumRatingsHeader, SectionHeader.RequestedHeight), + (Row.MaximumRatingsStepper, stepperRowHeight), + (Row.MaximumRatingsRatingView, sampleRatingViewHeight), + (Row.ColorsHeader, SectionHeader.RequestedHeight), + (Row.ColorsEmptyRatingViewPicker, pickerRowHeight), + (Row.ColorsFilledRatingViewPicker, pickerRowHeight), + (Row.ColorsBorderRatingViewPicker, pickerRowHeight), + (Row.ColorsShapeFillTitle, ratingViewTitleRowHeight), + (Row.ColorsShapeFillRatingView, largerSizeRatingViewHeight), + (Row.ColorsItemFillTitle, ratingViewTitleRowHeight), + (Row.ColorsItemFillRatingView, largerSizeRatingViewHeight), + (Row.BorderThicknessHeader, SectionHeader.RequestedHeight), + (Row.BorderThicknessStepper, stepperRowHeight), + (Row.BorderThicknessRatingView, sampleRatingViewHeight), + (Row.ReadOnlyHeader, SectionHeader.RequestedHeight), + (Row.ReadOnlyCheckBox, ratingViewTitleRowHeight), + (Row.ReadOnlyRatingView, sampleRatingViewHeight), + (Row.PaddingHeader, SectionHeader.RequestedHeight), + (Row.PaddingLeftStepper, stepperRowHeight), + (Row.PaddingTopStepper, stepperRowHeight), + (Row.PaddingRightStepper, stepperRowHeight), + (Row.PaddingBottomStepper, stepperRowHeight), + (Row.PaddingRatingView, sampleRatingViewHeight), + (Row.RatingHeader, SectionHeader.RequestedHeight), + (Row.RatingSlider, sliderRowHeight), + (Row.RatingShapeFillTitle, ratingViewTitleRowHeight), + (Row.RatingShapeFillRatingView, sampleRatingViewHeight), + (Row.RatingItemFillTitle, ratingViewTitleRowHeight), + (Row.RatingItemFillRatingView, sampleRatingViewHeight), + (Row.SizingHeader, SectionHeader.RequestedHeight), + (Row.SizingRatingViewSmallest, smallestSizeRatingViewHeight), + (Row.SizingRatingViewSmaller, smallerSizeRatingViewHeight), + (Row.SizingRatingViewLarger, largerSizeRatingViewHeight), + (Row.SizingRatingViewLargest, largestSizeRatingViewHeight), + (Row.SpacingHeader, SectionHeader.RequestedHeight), + (Row.SpacingStepper, stepperRowHeight), + (Row.SpacingRatingView, sampleRatingViewHeight)), + + ColumnDefinitions = Columns.Define( + (Column.Input, 120), + (Column.Result, Star)), + + Children = + { + new SectionHeader("Defaults") + .Row(Row.DefaultsHeader).ColumnSpan(All()), + + new Label() + .Row(Row.DefaultsRatingView).Column(Column.Input) + .Text("Default") + .CenterVertical(), + + new RatingView() + .Row(Row.DefaultsRatingView).Column(Column.Result) + .SemanticDescription("A RatingView showing the defaults."), + + new Label() + .Row(Row.DefaultsRatingViewUsingProperties).Column(Column.Input) + .Text("Using Properties") + .CenterVertical(), + + new RatingView + { + BackgroundColor = Colors.Red, + EmptyColor = Colors.Green, + FilledColor = Colors.Blue, + MaximumRating = 5, + Rating = 2.5 + } + .Start() + .Row(Row.DefaultsRatingViewUsingProperties).Column(Column.Result) + .SemanticDescription("A RatingView customised by setting properties."), + + new Label() + .Row(Row.DefaultsRatingViewUsingStyles).Column(Column.Input) + .Text("Using Styles") + .CenterVertical(), + + new RatingView + { + MaximumRating = 5, + Rating = 2.5, + Style = new Style() + .Add(RatingView.EmptyColorProperty, Colors.Green) + .Add(RatingView.FilledColorProperty, Colors.Blue) + .Add(BackgroundColorProperty, Colors.Red) + } + .Start() + .Row(Row.DefaultsRatingViewUsingStyles).Column(Column.Result) + .SemanticDescription("A RatingView customised by setting a style."), + + new SectionHeader("Shapes") + .Row(Row.ShapesHeader).ColumnSpan(All()), + + new Label() + .Row(Row.ShapesStar).Column(Column.Input) + .Text("Star") + .Font(size: 16) + .CenterVertical(), + + new RatingView + { + MaximumRating = 5, + EmptyColor = Colors.White, + FilledColor = Colors.Blue, + Rating = 2, + ItemShape = RatingViewShape.Star, + ShapeBorderThickness = 1 + } + .Start() + .Row(Row.ShapesStar).Column(Column.Result) + .SemanticDescription("A RatingView showing the 'Star' shape."), + + new Label() + .Row(Row.ShapesCircle).Column(Column.Input) + .Text("Circle") + .Font(size: 16) + .CenterVertical(), + + new RatingView + { + MaximumRating = 5, + EmptyColor = Colors.Red, + FilledColor = Colors.Blue, + Rating = 2, + ItemShape = RatingViewShape.Circle, + ShapeBorderThickness = 1 + } + .Start() + .Row(Row.ShapesCircle).Column(Column.Result) + .SemanticDescription("A RatingView showing the 'Circle' shape."), + + new Label() + .Row(Row.ShapesHeart).Column(Column.Input) + .Text("Heart") + .Font(size: 16) + .CenterVertical(), + + new RatingView + { + MaximumRating = 5, + FilledColor = Colors.White, + Rating = 5, + ItemShape = RatingViewShape.Heart, + ShapeBorderThickness = 1 + } + .Start() + .Row(Row.ShapesHeart).Column(Column.Result) + .SemanticDescription("A RatingView showing the 'Heart' shape."), + + new Label() + .Row(Row.ShapesLike).Column(Column.Input) + .Text("Like") + .Font(size: 16) + .CenterVertical(), + + new RatingView + { + MaximumRating = 5, + Rating = 5, + FilledColor = Colors.Red, + ItemShape = RatingViewShape.Like, + ShapeBorderThickness = 1 + } + .Start() + .Row(Row.ShapesLike).Column(Column.Result) + .SemanticDescription("A RatingView showing the 'Like' shape."), + + new Label() + .Row(Row.ShapesDislike).Column(Column.Input) + .Text("Dislike") + .Font(size: 16) + .CenterVertical(), + + new RatingView + { + MaximumRating = 5, + Rating = 5, + FilledColor = Colors.White, + ItemShape = RatingViewShape.Dislike, + ShapeBorderThickness = 1 + } + .Start() + .Row(Row.ShapesDislike).Column(Column.Result) + .SemanticDescription("A RatingView showing the 'Dislike' shape."), + + new Label() + .Row(Row.ShapesCustomAnimal).Column(Column.Input) + .Text("Custom") + .Font(size: 16) + .CenterVertical(), + + new RatingView + { + CustomItemShape = + "M3.15452 1.01195C5.11987 1.32041 7.17569 2.2474 8.72607 3.49603C9.75381 3.17407 10.8558 2.99995 12 2.99995C13.1519 2.99995 14.261 3.17641 15.2946 3.5025C16.882 2.27488 18.8427 1.31337 20.8354 1.01339C21.2596 0.95092 21.7008 1.16534 21.8945 1.55273C22.6719 3.38958 22.6983 5.57987 22.2202 7.49248L22.2128 7.52213C22.0847 8.03536 21.9191 8.69868 21.3876 8.92182C21.7827 9.89315 22 10.9466 22 12.0526C22 14.825 20.8618 17.6774 19.8412 20.2348L19.8412 20.2348L19.7379 20.4936C19.1182 22.0486 17.7316 23.1196 16.125 23.418L13.8549 23.8397C13.1549 23.9697 12.4562 23.7172 12 23.2082C11.5438 23.7172 10.8452 23.9697 10.1452 23.8397L7.87506 23.418C6.26852 23.1196 4.88189 22.0486 4.26214 20.4936L4.15891 20.2348C3.13833 17.6774 2.00004 14.825 2.00004 12.0526C2.00004 10.9466 2.21737 9.89315 2.6125 8.92182C2.08046 8.69845 1.91916 8.05124 1.7909 7.53658L1.7799 7.49248C1.32311 5.66527 1.23531 3.2968 2.10561 1.55273C2.29827 1.16741 2.72906 0.945855 3.15452 1.01195ZM6.58478 4.44052C5.45516 5.10067 4.47474 5.9652 3.71373 6.98132C3.41572 5.76461 3.41236 4.41153 3.67496 3.18754C4.68842 3.48029 5.68018 3.89536 6.58478 4.44052ZM20.2863 6.98133C19.5303 5.97184 18.5577 5.11195 17.4374 4.45347C18.3364 3.9005 19.3043 3.45749 20.3223 3.17455C20.5884 4.40199 20.5853 5.76068 20.2863 6.98133ZM8.85364 5.56694C9.81678 5.20285 10.8797 4.99995 12 4.99995C13.1204 4.99995 14.1833 5.20285 15.1464 5.56694C18.0554 6.66661 20 9.1982 20 12.0526C20 14.4676 18.9891 16.9876 18.0863 19.238L18.0862 19.2382C18.0167 19.4115 17.9478 19.5832 17.8801 19.7531C17.5291 20.6338 16.731 21.2712 15.7597 21.4516L13.4896 21.8733L12.912 20.5896C12.7505 20.2307 12.3935 19.9999 12 19.9999C11.6065 19.9999 11.2496 20.2307 11.0881 20.5896L10.5104 21.8733L8.24033 21.4516C7.26908 21.2712 6.471 20.6338 6.12001 19.7531C6.05237 19.5834 5.98357 19.4119 5.91414 19.2388L5.91395 19.2384L5.91381 19.238C5.01102 16.9876 4.00004 14.4676 4.00004 12.0526C4.00004 9.1982 5.94472 6.66661 8.85364 5.56694ZM10.5 15.9999C10.1212 15.9999 9.77497 16.2139 9.60557 16.5527C9.43618 16.8915 9.47274 17.2969 9.7 17.5999L11.2 19.5999C11.3889 19.8517 11.6852 19.9999 12 19.9999C12.3148 19.9999 12.6111 19.8517 12.8 19.5999L14.3 17.5999C14.5273 17.2969 14.5638 16.8915 14.3944 16.5527C14.225 16.2139 13.8788 15.9999 13.5 15.9999H10.5ZM9.62134 11.1212C9.62134 11.9497 8.94977 12.6212 8.12134 12.6212C7.29291 12.6212 6.62134 11.9497 6.62134 11.1212C6.62134 10.2928 7.29291 9.62125 8.12134 9.62125C8.94977 9.62125 9.62134 10.2928 9.62134 11.1212ZM16 12.4999C16.8284 12.4999 17.5 11.8284 17.5 10.9999C17.5 10.1715 16.8284 9.49994 16 9.49994C15.1716 9.49994 14.5 10.1715 14.5 10.9999C14.5 11.8284 15.1716 12.4999 16 12.4999Z", + MaximumRating = 5, + FilledColor = Colors.Red, + Rating = 5, + ItemShape = RatingViewShape.Custom, + ShapeBorderThickness = 1, + } + .Start() + .Row(Row.ShapesCustomAnimal).Column(Column.Result) + .SemanticDescription("A RatingView showing the 'Custom' shape and passing in the required custom shape path."), + + new Label() + .Row(Row.ShapesCustomLogo).Column(Column.Input) + .Text("Custom") + .Font(size: 16) + .CenterVertical(), + + new RatingView + { + CustomItemShape = "M23.07 8h2.89l-6.015 5.957a5.621 5.621 0 01-7.89 0L6.035 8H8.93l4.57 4.523a3.556 3.556 0 004.996 0L23.07 8zM8.895 24.563H6l6.055-5.993a5.621 5.621 0 017.89 0L26 24.562h-2.895L18.5 20a3.556 3.556 0 00-4.996 0l-4.61 4.563z", + EmptyColor = Colors.Red, + FilledColor = Colors.White, + MaximumRating = 5, + Rating = 5, + ItemShape = RatingViewShape.Custom, + ShapeBorderColor = Colors.Grey, + ShapeBorderThickness = 1, + } + .Start() + .Row(Row.ShapesCustomLogo).Column(Column.Result) + .SemanticDescription("A RatingView showing the 'Custom' shape and passing in the required custom shape path."), + + new SectionHeader("Maximum Ratings") + .Row(Row.MaximumRatingsHeader).ColumnSpan(All()), + + new Stepper + { + Increment = 1, + Minimum = 1, + Maximum = 25, + Value = 1 + } + .End() + .Row(Row.MaximumRatingsStepper).Column(Column.Input) + .Assign(out Stepper stepperMaximumRating) + .SemanticHint("Change the maximum number of ratings."), + + new Label() + .Row(Row.MaximumRatingsStepper).Column(Column.Result) + .Start() + .CenterVertical() + .Bind(Label.TextProperty, + getter: static stepper => stepper.Value, + mode: BindingMode.OneWay, + convert: static stepperValue => $": {stepperValue}", + source: stepperMaximumRating), + + new RatingView() + .Row(Row.MaximumRatingsRatingView).ColumnSpan(All()) + .Center() + .Invoke(static ratingView => ratingView.RatingChanged += HandleRatingChanged) + .Bind(RatingView.MaximumRatingProperty, + getter: static stepper => (int)stepper.Value, + mode: BindingMode.OneWay, + source: stepperMaximumRating) + .SemanticDescription("A RatingView showing changes to the 'MaximumRating' property and with an event handler when the 'RatingChanged' event is triggered."), + + new SectionHeader("Colors") + .Row(Row.ColorsHeader).ColumnSpan(All()), + + new Label() + .Row(Row.ColorsEmptyRatingViewPicker).Column(Column.Input) + .Text("Empty Color: ") + .CenterVertical(), + + new Picker() + .Row(Row.ColorsEmptyRatingViewPicker).Column(Column.Result) + .Start() + .Bind(Picker.ItemsSourceProperty, + getter: static (RatingViewCsharpViewModel vm) => vm.ColorsForPickers, + mode: BindingMode.OneTime) + .Bind(Picker.SelectedIndexProperty, + getter: static (RatingViewCsharpViewModel vm) => vm.ColorPickerEmptyBackgroundSelectedIndex, + setter: static (RatingViewCsharpViewModel vm, int index) => vm.ColorPickerEmptyBackgroundSelectedIndex = index, + mode: BindingMode.TwoWay) + .SemanticHint("Pick to change the empty rating background color."), + + new Label() + .Row(Row.ColorsFilledRatingViewPicker).Column(Column.Input) + .Text("Filled Color: ") + .CenterVertical(), + + new Picker() + .Row(Row.ColorsFilledRatingViewPicker).Column(Column.Result) + .Start() + .Bind(Picker.ItemsSourceProperty, + getter: static (RatingViewCsharpViewModel vm) => vm.ColorsForPickers, + mode: BindingMode.OneTime) + .Bind(Picker.SelectedIndexProperty, + getter: static (RatingViewCsharpViewModel vm) => vm.ColorPickerFilledBackgroundSelectedIndex, + setter: static (RatingViewCsharpViewModel vm, int value) => vm.ColorPickerFilledBackgroundSelectedIndex = value, + mode: BindingMode.TwoWay) + .SemanticHint("Pick to change the filled rating background color."), + + new Label() + .Row(Row.ColorsBorderRatingViewPicker).Column(Column.Input) + .Text("Border Color: ") + .CenterVertical(), + + new Picker() + .Row(Row.ColorsBorderRatingViewPicker).Column(Column.Result) + .Start() + .Bind(Picker.ItemsSourceProperty, + getter: static (RatingViewCsharpViewModel vm) => vm.ColorsForPickers, + mode: BindingMode.OneTime) + .Bind(Picker.SelectedIndexProperty, + getter: static (RatingViewCsharpViewModel vm) => vm.ColorPickerRatingShapeBorderColorSelectedIndex, + setter: static (RatingViewCsharpViewModel vm, int index) => vm.ColorPickerRatingShapeBorderColorSelectedIndex = index, + mode: BindingMode.TwoWay) + .SemanticHint("Pick to change the rating shape border color."), + + new Label() + .Row(Row.ColorsShapeFillTitle).ColumnSpan(All()) + .Start() + .Bottom() + .Text("ItemShape Fill"), + + new RatingView + { + ItemShapeSize = largerSizeRatingViewHeight, + MaximumRating = 5, + Rating = 2.7, + ShapeBorderThickness = 1 + } + .Row(Row.ColorsShapeFillRatingView).ColumnSpan(All()) + .Start() + .Top() + .Bind(RatingView.EmptyColorProperty, + static (RatingViewCsharpViewModel vm) => vm.ColorPickerEmptyBackgroundTarget, + mode: BindingMode.OneWay) + .Bind(RatingView.FilledColorProperty, + static (RatingViewCsharpViewModel vm) => vm.ColorPickerFilledBackgroundTarget, + mode: BindingMode.OneWay) + .Bind(RatingView.ShapeBorderColorProperty, + static (RatingViewCsharpViewModel vm) => vm.ColorPickerRatingShapeBorderColorTarget, + mode: BindingMode.OneWay) + .SemanticDescription("A RatingView showing the fill, empty and border color changes, shown using the fill type of 'ItemShape'."), + + new Label() + .Row(Row.ColorsItemFillTitle).ColumnSpan(All()) + .Start() + .Bottom() + .Text("Item Fill"), + + new RatingView + { + ItemShapeSize = largerSizeRatingViewHeight, + MaximumRating = 5, + Rating = 2.7, + RatingFill = RatingFillElement.Item, + ShapeBorderThickness = 1 + } + .Row(Row.ColorsItemFillRatingView).ColumnSpan(All()) + .Top() + .Bind(RatingView.EmptyColorProperty, + getter: static (RatingViewCsharpViewModel vm) => vm.ColorPickerEmptyBackgroundTarget, + mode: BindingMode.OneWay) + .Bind(RatingView.FilledColorProperty, + getter: static (RatingViewCsharpViewModel vm) => vm.ColorPickerFilledBackgroundTarget, + mode: BindingMode.OneWay) + .Bind(RatingView.ShapeBorderColorProperty, + getter: static (RatingViewCsharpViewModel vm) => vm.ColorPickerRatingShapeBorderColorTarget, + mode: BindingMode.OneWay) + .SemanticDescription("A RatingView showing the fill, empty and border color changes, shown using the fill type of 'Item'."), + + new SectionHeader("ItemShape Border Thickness") + .Row(Row.BorderThicknessHeader).ColumnSpan(All()), + + new Stepper + { + Increment = 1, + Minimum = 0, + Maximum = 10, + Value = 1 + } + .Row(Row.BorderThicknessStepper).Column(Column.Input) + .End() + .Assign(out Stepper stepperShapeBorderThickness) + .SemanticHint("Change the rating shape border thickness."), + + new Label() + .Row(Row.BorderThicknessStepper).Column(Column.Result) + .Start() + .CenterVertical() + .Bind(Label.TextProperty, + getter: static stepper => stepper.Value, + mode: BindingMode.OneWay, + convert: static stepperValue => $": {stepperValue}", + source: stepperShapeBorderThickness), + + new RatingView + { + MaximumRating = 5, + Rating = 2.5 + } + .Row(Row.BorderThicknessRatingView).ColumnSpan(All()) + .Center() + .Bind(RatingView.ShapeBorderThicknessProperty, + getter: static stepper => (int)stepper.Value, + mode: BindingMode.OneWay, + convert: static stepperValue => stepperValue, + source: stepperShapeBorderThickness + ).SemanticDescription("A RatingView showing the shape border thickness changes."), + + new SectionHeader("ReadOnly") + .Row(Row.ReadOnlyHeader).ColumnSpan(All()), + + new CheckBox + { + IsChecked = true + } + .Row(Row.ReadOnlyCheckBox).Column(Column.Input) + .End() + .AppThemeColorBinding(CheckBox.BackgroundColorProperty, Colors.Black, Colors.White) + .AppThemeColorBinding(CheckBox.ColorProperty, Colors.White, Colors.Black) + .Assign(out CheckBox checkBox) + .SemanticHint("Check to make read only."), + + new Label() + .Row(Row.ReadOnlyCheckBox).Column(Column.Result) + .Start() + .Text(": IsReadOnly") + .CenterVertical(), + + new RatingView + { + MaximumRating = 5, + Rating = 2.5 + } + .Row(Row.ReadOnlyRatingView).ColumnSpan(All()) + .Center() + .Bind(RatingView.IsReadOnlyProperty, + getter: static checkBox => checkBox.IsChecked, + mode: BindingMode.OneWay, + source: checkBox) + .SemanticDescription("A RatingView showing the IsReadOnly changes."), + + new SectionHeader("ItemShape Padding") + .Row(Row.PaddingHeader).ColumnSpan(All()), + + new Stepper + { + Increment = 1, + Minimum = 0, + Maximum = 10, + Value = 0 + } + .Row(Row.PaddingLeftStepper).Column(Column.Input) + .End() + .CenterVertical() + .Bind(Stepper.ValueProperty, + getter: static (RatingViewCsharpViewModel vm) => vm.RatingViewShapePaddingLeft, + setter: static (RatingViewCsharpViewModel vm, double value) => vm.RatingViewShapePaddingLeft = value) + .Assign(out Stepper stepperPaddingLeft).SemanticHint("Change the rating view padding left."), + + new Label() + .Row(Row.PaddingLeftStepper).Column(Column.Result) + .Start() + .CenterVertical() + .Bind(Label.TextProperty, + static stepper => stepper.Value, + mode: BindingMode.OneWay, + convert: static stepperValue => $": Left: {stepperValue}", + source: stepperPaddingLeft) + .SemanticDescription("ItemShape left padding applied to the RatingView sample."), + + new Stepper + { + Increment = 1, + Minimum = 0, + Maximum = 10, + Value = 0 + } + .Row(Row.PaddingTopStepper).Column(Column.Input) + .End() + .CenterVertical() + .Bind(Stepper.ValueProperty, + getter: static (RatingViewCsharpViewModel vm) => vm.RatingViewShapePaddingTop, + setter: static (RatingViewCsharpViewModel vm, double value) => vm.RatingViewShapePaddingTop = value) + .Assign(out Stepper stepperPaddingTop) + .SemanticHint("Change the rating view padding top."), + + new Label() + .Row(Row.PaddingTopStepper).Column(Column.Result) + .Start() + .CenterVertical() + .Bind(Label.TextProperty, + getter: static stepper => stepper.Value, + mode: BindingMode.OneWay, + convert: static stepperValue => $": Top: {stepperValue}", + source: stepperPaddingTop) + .SemanticDescription("ItemShape top padding applied to the RatingView sample."), + + new Stepper + { + Increment = 1, + Minimum = 0, + Maximum = 10, + Value = 0 + } + .Row(Row.PaddingRightStepper).Column(Column.Input) + .End() + .CenterVertical() + .Bind(Stepper.ValueProperty, + getter: static (RatingViewCsharpViewModel vm) => vm.RatingViewShapePaddingRight, + setter: static (RatingViewCsharpViewModel vm, double value) => vm.RatingViewShapePaddingRight = value) + .Assign(out Stepper stepperPaddingRight) + .SemanticHint("Change the rating view padding right."), + + new Label() + .Row(Row.PaddingRightStepper).Column(Column.Result) + .Start() + .CenterVertical() + .Bind(Label.TextProperty, + getter: static stepper => stepper.Value, + mode: BindingMode.OneWay, + convert: static stepperValue => $": Right: {stepperValue}", + source: stepperPaddingRight) + .SemanticDescription("ItemShape right padding applied to the RatingView sample."), + + new Stepper + { + Increment = 1, + Minimum = 0, + Maximum = 10, + Value = 0, + } + .Row(Row.PaddingBottomStepper).Column(Column.Input) + .End() + .CenterVertical() + .Bind(Stepper.ValueProperty, + getter: static (RatingViewCsharpViewModel vm) => vm.RatingViewShapePaddingBottom, + setter: static (RatingViewCsharpViewModel vm, double value) => vm.RatingViewShapePaddingBottom = value) + .Assign(out Stepper stepperPaddingBottom) + .SemanticHint("Change the rating view padding bottom."), + + new Label() + .Row(Row.PaddingBottomStepper).Column(Column.Result) + .Start() + .CenterVertical() + .Bind(Label.TextProperty, + getter: static stepper => stepper.Value, + mode: BindingMode.OneWay, + convert: static stepperValue => $": Bottom: {stepperValue}", + source: stepperPaddingBottom) + .SemanticDescription("ItemShape bottom padding applied to the RatingView sample."), + + new RatingView + { + MaximumRating = 5, + Rating = 4.5, + } + .Row(Row.PaddingRatingView).ColumnSpan(All()) + .BackgroundColor(Colors.Purple) + .Center() + .Bind(RatingView.ItemPaddingProperty, + getter: static (RatingViewCsharpViewModel vm) => vm.RatingViewShapePaddingValue) + .SemanticDescription("A RatingView sample showing the padding changes."), + + new SectionHeader("Rating") + .Row(Row.RatingHeader).ColumnSpan(All()), + + new Slider + { + Maximum = 7, + Minimum = 0, + Value = 0 + } + .Row(Row.RatingSlider).Column(Column.Input) + .Assign(out Slider ratingViewSlider).SemanticHint("Slide to change the rating."), + + new Label() + .Row(Row.RatingSlider).Column(Column.Result) + .Start() + .CenterVertical() + .Bind(Label.TextProperty, + getter: static slider => slider.Value, + mode: BindingMode.OneWay, + convert: static sliderValue => $": {sliderValue:F2}", + source: ratingViewSlider) + .SemanticDescription("RatingView rating value."), + + new Label() + .Row(Row.RatingShapeFillTitle).ColumnSpan(All()) + .Text("ItemShape Fill") + .Bottom(), + + new RatingView + { + BackgroundColor = Colors.Purple, + EmptyColor = Colors.Blue, + FilledColor = Colors.Green, + HorizontalOptions = LayoutOptions.Start, + IsReadOnly = false, + ItemShapeSize = 30, + MaximumRating = 7, + RatingFill = RatingFillElement.Shape, + ShapeBorderColor = Colors.Grey, + ShapeBorderThickness = 1, + Spacing = 3, + } + .Row(Row.RatingShapeFillRatingView).ColumnSpan(All()) + .Top() + .Bind(RatingView.RatingProperty, + getter: static slider => slider.Value, + mode: BindingMode.OneWay, + convert: static sliderValue => sliderValue, + source: ratingViewSlider) + .SemanticDescription("A RatingView sample showing the rating changes and the fill type of 'ItemShape'."), + + new Label() + .Row(Row.RatingItemFillTitle).ColumnSpan(All()) + .Text("Item Fill") + .Bottom(), + + new RatingView + { + BackgroundColor = Colors.Purple, + EmptyColor = Colors.Blue, + FilledColor = Colors.Green, + HorizontalOptions = LayoutOptions.Start, + IsReadOnly = false, + ItemShapeSize = 30, + MaximumRating = 7, + RatingFill = RatingFillElement.Item, + ShapeBorderColor = Colors.Grey, + ShapeBorderThickness = 1, + Spacing = 3, + } + .Row(Row.RatingItemFillRatingView).ColumnSpan(All()) + .Top() + .Bind(RatingView.RatingProperty, + getter: static slider => slider.Value, + mode: BindingMode.OneWay, + convert: static sliderValue => sliderValue, + source: ratingViewSlider) + .SemanticDescription("A RatingView sample showing the rating changes and the fill type of 'Item'."), + + new SectionHeader("Sizing") + .Row(Row.SizingHeader).ColumnSpan(All()), + + new RatingView + { + ItemShapeSize = smallestSizeRatingViewHeight, + MaximumRating = 5, + Rating = 5, + } + .Row(Row.SizingRatingViewSmallest).ColumnSpan(All()) + .SemanticDescription("A RatingView sample showing the size of 30"), + + new RatingView + { + ItemShapeSize = smallerSizeRatingViewHeight, + MaximumRating = 5, + Rating = 5, + } + .Row(Row.SizingRatingViewSmaller).ColumnSpan(All()) + .SemanticDescription("A RatingView sample showing the size of 40"), + + new RatingView + { + ItemShapeSize = largerSizeRatingViewHeight, + MaximumRating = 5, + Rating = 5, + } + .Row(Row.SizingRatingViewLarger).ColumnSpan(All()) + .SemanticDescription("A RatingView sample showing the size of 50"), + + new RatingView + { + ItemShapeSize = largestSizeRatingViewHeight, + MaximumRating = 5, + Rating = 5, + } + .Row(Row.SizingRatingViewLargest).ColumnSpan(All()) + .SemanticDescription("A RatingView sample showing the size of 60"), + + new SectionHeader("Spacing") + .Row(Row.SpacingHeader).ColumnSpan(All()), + + new Stepper + { + Increment = 1, + Minimum = 0, + Maximum = 10, + Value = 0 + } + .Row(Row.SpacingStepper).Column(Column.Input) + .CenterVertical() + .End() + .Assign(out Stepper stepperSpacing) + .SemanticHint("Change the spacing between rating items."), + + new Label() + .Row(Row.SpacingStepper).Column(Column.Result) + .CenterVertical() + .Start() + .Bind(Label.TextProperty, + getter: static stepper => stepper.Value, + mode: BindingMode.OneWay, + convert: static stepperValue => $": {stepperValue}", + source: stepperSpacing), + + new RatingView + { + MaximumRating = 5, + Rating = 2.5 + } + .Bind(RatingView.SpacingProperty, + getter: static stepper => (int)stepper.Value, + mode: BindingMode.OneWay, + convert: static stepperValue => stepperValue, + source: stepperSpacing + ) + .Row(Row.SpacingRatingView).ColumnSpan(All()) + .Start() + .SemanticDescription("A RatingView sample showing the spacing changes."), + } + } + }; + } + + enum Column { Input, Result } + + enum Row + { + DefaultsHeader, DefaultsRatingView, DefaultsRatingViewUsingProperties, DefaultsRatingViewUsingStyles, + ShapesHeader, ShapesStar, ShapesCircle, ShapesHeart, ShapesLike, ShapesDislike, ShapesCustomAnimal, ShapesCustomLogo, + MaximumRatingsHeader, MaximumRatingsStepper, MaximumRatingsRatingView, + ColorsHeader, ColorsEmptyRatingViewPicker, ColorsFilledRatingViewPicker, ColorsBorderRatingViewPicker, ColorsShapeFillTitle, ColorsShapeFillRatingView, ColorsItemFillTitle, ColorsItemFillRatingView, + BorderThicknessHeader, BorderThicknessStepper, BorderThicknessRatingView, + ReadOnlyHeader, ReadOnlyCheckBox, ReadOnlyRatingView, + PaddingHeader, PaddingLeftStepper, PaddingTopStepper, PaddingRightStepper, PaddingBottomStepper, PaddingRatingView, + RatingHeader, RatingSlider, RatingShapeFillTitle, RatingShapeFillRatingView, RatingItemFillTitle, RatingItemFillRatingView, + SizingHeader, SizingRatingViewSmallest, SizingRatingViewSmaller, SizingRatingViewLarger, SizingRatingViewLargest, + SpacingHeader, SpacingStepper, SpacingRatingView + } + + static async void HandleRatingChanged(object? sender, RatingChangedEventArgs e) + { + ArgumentNullException.ThrowIfNull(sender); + var ratingView = (RatingView)sender; + + // This is the weak event raised when the rating is changed. The developer can then perform further actions (such as save to DB). + await Toast.Make($"New Rating: {ratingView.Rating:F2}").Show(CancellationToken.None); + } + + sealed class SectionHeader : Grid + { + public const int RequestedHeight = (separatorRowHeight * 2) + titleHeight; + const int separatorRowHeight = 8; + const int titleHeight = 32; + + public SectionHeader(in string titleText) + { + RowDefinitions = Rows.Define( + (SectionHeaderRow.TopSeparator, separatorRowHeight), + (SectionHeaderRow.Title, titleHeight), + (SectionHeaderRow.BottomSeparator, separatorRowHeight)); + + Children.Add(GetSeparator().Row(SectionHeaderRow.TopSeparator)); + + Children.Add(new TitleLabel(titleText).Row(SectionHeaderRow.Title)); + + Children.Add(GetSeparator().Row(SectionHeaderRow.BottomSeparator)); + } + + enum SectionHeaderRow { TopSeparator, Title, BottomSeparator } + + static Line GetSeparator() => new Line + { + StrokeThickness = 2, + X2 = 300 + }.Center().AppThemeBinding(Line.StrokeProperty, Colors.Black, Colors.White); + } + + sealed class TitleLabel : Label + { + public TitleLabel(in string text) + { + LineBreakMode = LineBreakMode.WordWrap; + + this.Center() + .TextCenter() + .Text(text) + .Font(size: 24, bold: true); + } + } +} \ No newline at end of file diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewShowcasePage.xaml b/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewShowcasePage.xaml new file mode 100644 index 0000000000..f04a383c37 --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewShowcasePage.xaml @@ -0,0 +1,286 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewShowcasePage.xaml.cs b/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewShowcasePage.xaml.cs new file mode 100644 index 0000000000..942fbe5e79 --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewShowcasePage.xaml.cs @@ -0,0 +1,21 @@ +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Sample.ViewModels.Views; + +namespace CommunityToolkit.Maui.Sample.Pages.Views; + +public partial class RatingViewShowcasePage : BasePage +{ + readonly List ratings = []; + + public RatingViewShowcasePage(RatingViewShowcaseViewModel viewModel) : base(viewModel) + { + InitializeComponent(); + } + + void ReviewSummaryRatingChanged(object sender, RatingChangedEventArgs e) + { + ratings.Add(e.Rating); + BindingContext.ReviewSummaryCount = ratings.Count; + BindingContext.ReviewSummaryAverage = ratings.Average(x => x); + } +} \ No newline at end of file diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewXamlPage.xaml b/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewXamlPage.xaml new file mode 100644 index 0000000000..25efeb25a1 --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewXamlPage.xaml @@ -0,0 +1,723 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewXamlPage.xaml.cs b/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewXamlPage.xaml.cs new file mode 100644 index 0000000000..ec7d47565d --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/RatingView/RatingViewXamlPage.xaml.cs @@ -0,0 +1,23 @@ +using CommunityToolkit.Maui.Alerts; +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Sample.ViewModels.Views; +using CommunityToolkit.Maui.Views; + +namespace CommunityToolkit.Maui.Sample.Pages.Views; + +public partial class RatingViewXamlPage : BasePage +{ + public RatingViewXamlPage(RatingViewXamlViewModel viewModel) : base(viewModel) + { + InitializeComponent(); + } + + static async void StepperMaximumRating_RatingChanged(object? sender, RatingChangedEventArgs e) + { + if (sender is RatingView ratingView) + { + // This is the weak event raised when the rating is changed. The developer can then perform further actions (such as save to DB). + await Toast.Make($"New Rating: {ratingView.Rating:F2}").Show(CancellationToken.None); + } + } +} \ No newline at end of file diff --git a/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/RatingView/RatingViewShowcaseViewModel.cs b/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/RatingView/RatingViewShowcaseViewModel.cs new file mode 100644 index 0000000000..1dbe31ebc2 --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/RatingView/RatingViewShowcaseViewModel.cs @@ -0,0 +1,18 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CommunityToolkit.Maui.Sample.ViewModels.Views; + +public partial class RatingViewShowcaseViewModel : BaseViewModel +{ + [ObservableProperty] + public partial double StepperValueMaximumRatings { get; set; } = 1; + + [ObservableProperty] + public partial double ReviewSummaryAverage { get; set; } = 0; + + [ObservableProperty] + public partial Thickness RatingViewShapePadding { get; set; } = new(0); + + [ObservableProperty] + public partial double ReviewSummaryCount { get; set; } = 0; +} \ No newline at end of file diff --git a/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/RatingView/RatingViewViewModel.cs b/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/RatingView/RatingViewViewModel.cs new file mode 100644 index 0000000000..fc2a029b1f --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/RatingView/RatingViewViewModel.cs @@ -0,0 +1,58 @@ +// Ignore Spelling: csharp, color, colors + +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.Reflection; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CommunityToolkit.Maui.Sample.ViewModels.Views; + +public partial class RatingViewXamlViewModel : BaseRatingViewViewModel; +public partial class RatingViewCsharpViewModel : BaseRatingViewViewModel; + +public abstract partial class BaseRatingViewViewModel : BaseViewModel +{ + static readonly ReadOnlyDictionary colorList = typeof(Colors) + .GetFields(BindingFlags.Static | BindingFlags.Public) + .ToDictionary(static c => c.Name, c => (Color)(c.GetValue(null) ?? throw new InvalidOperationException())) + .AsReadOnly(); + + static readonly ImmutableList colorsForPickers = [.. colorList.Keys]; + + public IReadOnlyList ColorsForPickers => [.. colorsForPickers]; + + public Thickness RatingViewShapePaddingValue => new(RatingViewShapePaddingLeft, RatingViewShapePaddingTop, RatingViewShapePaddingRight, RatingViewShapePaddingBottom); + + public Color ColorPickerEmptyBackgroundTarget => colorList.ElementAtOrDefault(ColorPickerEmptyBackgroundSelectedIndex).Value; + + public Color ColorPickerRatingShapeBorderColorTarget => colorList.ElementAtOrDefault(ColorPickerRatingShapeBorderColorSelectedIndex).Value; + + public Color ColorPickerFilledBackgroundTarget => colorList.ElementAtOrDefault(ColorPickerFilledBackgroundSelectedIndex).Value; + + [ObservableProperty] + public partial double StepperValueMaximumRatings { get; set; } = 1; + + [ObservableProperty] + public partial Thickness RatingViewShapePadding { get; set; } = new(0); + + [ObservableProperty, NotifyPropertyChangedFor(nameof(ColorPickerFilledBackgroundTarget))] + public partial int ColorPickerFilledBackgroundSelectedIndex { get; set; } = colorsForPickers.IndexOf(nameof(Colors.Red)); + + [ObservableProperty, NotifyPropertyChangedFor(nameof(ColorPickerEmptyBackgroundTarget))] + public partial int ColorPickerEmptyBackgroundSelectedIndex { get; set; } = colorsForPickers.IndexOf(nameof(Colors.Green)); + + [ObservableProperty, NotifyPropertyChangedFor(nameof(ColorPickerRatingShapeBorderColorTarget))] + public partial int ColorPickerRatingShapeBorderColorSelectedIndex { get; set; } = colorsForPickers.IndexOf(nameof(Colors.Blue)); + + [ObservableProperty, NotifyPropertyChangedFor(nameof(RatingViewShapePaddingValue))] + public partial double RatingViewShapePaddingLeft { get; set; } + + [ObservableProperty, NotifyPropertyChangedFor(nameof(RatingViewShapePaddingValue))] + public partial double RatingViewShapePaddingTop { get; set; } + + [ObservableProperty, NotifyPropertyChangedFor(nameof(RatingViewShapePaddingValue))] + public partial double RatingViewShapePaddingRight { get; set; } + + [ObservableProperty, NotifyPropertyChangedFor(nameof(RatingViewShapePaddingValue))] + public partial double RatingViewShapePaddingBottom { get; set; } +} \ No newline at end of file diff --git a/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/ViewsGalleryViewModel.cs b/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/ViewsGalleryViewModel.cs index 88044b4adb..107f00d3f5 100644 --- a/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/ViewsGalleryViewModel.cs +++ b/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/ViewsGalleryViewModel.cs @@ -32,6 +32,9 @@ public sealed partial class ViewsGalleryViewModel() : BaseGalleryViewModel( SectionModel.Create("Anchor Popup", Colors.Red, "Popups can be anchored to other view's on the screen"), SectionModel.Create("Popup Layout Page", Colors.Red, "Popup.Content demonstrated using different layouts"), SectionModel.Create("Popup Sizing Issues Page", Colors.Red, "A page demonstrating how Popups can be styled in a .NET MAUI application."), + SectionModel.Create("RatingView Showcase Page", Colors.Red, "A page with showcase examples for the RatingView control."), + SectionModel.Create("RatingView XAML Page", Colors.Red, "A page demonstrating the RatingView control and possible uses using XAML"), + SectionModel.Create("RatingView C# Page", Colors.Red, "A page demonstrating the RatingView control and possible uses using C#"), SectionModel.Create("Show Popup in OnAppearing", Colors.Red, "Proves that we now support showing a popup before the platform is even ready."), SectionModel.Create("Semantic Order View", Colors.Red, "SemanticOrderView allows developers to indicate the focus order of visible controls when a user is navigating via TalkBack (Android), VoiceOver (iOS) or Narrator (Windows)."), SectionModel.Create("Popup Style Page", Colors.Red, "A page demonstrating how Popups can be styled in a .NET MAUI application.") diff --git a/src/CommunityToolkit.Maui.Analyzers.Benchmarks/MaximumRatingRangeAnalyzerBenchmarks.cs b/src/CommunityToolkit.Maui.Analyzers.Benchmarks/MaximumRatingRangeAnalyzerBenchmarks.cs new file mode 100644 index 0000000000..5d08168421 --- /dev/null +++ b/src/CommunityToolkit.Maui.Analyzers.Benchmarks/MaximumRatingRangeAnalyzerBenchmarks.cs @@ -0,0 +1,22 @@ +using BenchmarkDotNet.Attributes; +using CommunityToolkit.Maui.Analyzers.UnitTests; + +namespace CommunityToolkit.Maui.Analyzers.Benchmarks; + +[MemoryDiagnoser] +public class MaximumRatingRangeAnalyzerBenchmarks +{ + static readonly MaximumRatingRangeAnalyzerTests maximumRatingRangeAnalyzerTests = new(); + + [Benchmark] + public Task DiagnosticForInvalidMaximumRating() + { + return maximumRatingRangeAnalyzerTests.DiagnosticForInvalidMaximumRatings(); + } + + [Benchmark] + public Task NoDiagnosticForValidMaximumRating() + { + return maximumRatingRangeAnalyzerTests.NoDiagnosticForValidMaximumRating(); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Analyzers.Benchmarks/Program.cs b/src/CommunityToolkit.Maui.Analyzers.Benchmarks/Program.cs index bb49aaade9..06ff08da28 100644 --- a/src/CommunityToolkit.Maui.Analyzers.Benchmarks/Program.cs +++ b/src/CommunityToolkit.Maui.Analyzers.Benchmarks/Program.cs @@ -11,5 +11,6 @@ public static void Main(string[] args) BenchmarkRunner.Run(config, args); BenchmarkRunner.Run(config, args); BenchmarkRunner.Run(config, args); + BenchmarkRunner.Run(config, args); } } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Analyzers.CodeFixes/MaximumRatingAnalyzerCodeFixProvider.cs b/src/CommunityToolkit.Maui.Analyzers.CodeFixes/MaximumRatingAnalyzerCodeFixProvider.cs new file mode 100644 index 0000000000..fd7c81f4e5 --- /dev/null +++ b/src/CommunityToolkit.Maui.Analyzers.CodeFixes/MaximumRatingAnalyzerCodeFixProvider.cs @@ -0,0 +1,91 @@ +// Ignore Spelling: analyzer + +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace CommunityToolkit.Maui.Analyzers; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MaximumRatingAnalyzerCodeFixProvider)), Shared] +public class MaximumRatingAnalyzerCodeFixProvider : CodeFixProvider +{ + public sealed override ImmutableArray FixableDiagnosticIds => [MaximumRatingRangeAnalyzer.DiagnosticId]; + + public sealed override FixAllProvider GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + Diagnostic? diagnostic = context.Diagnostics.FirstOrDefault(); + if (diagnostic is null) + { + return; + } + + TextSpan diagnosticSpan = diagnostic.Location.SourceSpan; + + // Find the literal expression identified by the diagnostic. + if (root.FindToken(diagnosticSpan.Start).Parent is not LiteralExpressionSyntax literalExpression) + { + return; + } + + // Register a code action that will invoke the fix. + context.RegisterCodeFix( + CodeAction.Create( + title: "Set MaximumRating to valid value, between 1 and 10", + createChangedDocument: c => MaximumRatingToValidValueAsync(context.Document, literalExpression, c), + equivalenceKey: nameof(MaximumRatingAnalyzerCodeFixProvider)), + diagnostic); + } + + static async Task MaximumRatingToValidValueAsync(Document document, LiteralExpressionSyntax literalExpression, CancellationToken cancellationToken) + { + // Get the original value from the literal expression. + if (!int.TryParse(literalExpression.Token.ValueText, out int originalValue)) + { + originalValue = 1; // Fallback value if parsing fails. + } + + int newValue = GetValidRatingValue(originalValue); + // Create a new literal expression with the valid rating value. + LiteralExpressionSyntax newLiteralExpression = LiteralExpression( + SyntaxKind.NumericLiteralExpression, + Literal(newValue)); + + SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + { + return document; + } + + // Replace the old literal with the new one. + SyntaxNode newRoot = root.ReplaceNode(literalExpression, newLiteralExpression); + + return document.WithSyntaxRoot(newRoot); + } + + static int GetValidRatingValue(int value) + { + return value switch + { + < 1 => 1, + > 10 => 10, + _ => value + }; + } +} diff --git a/src/CommunityToolkit.Maui.Analyzers.UnitTests/MaximumRatingRangeAnalyzerTests.cs b/src/CommunityToolkit.Maui.Analyzers.UnitTests/MaximumRatingRangeAnalyzerTests.cs new file mode 100644 index 0000000000..b7257dd26f --- /dev/null +++ b/src/CommunityToolkit.Maui.Analyzers.UnitTests/MaximumRatingRangeAnalyzerTests.cs @@ -0,0 +1,100 @@ +namespace CommunityToolkit.Maui.Analyzers.UnitTests; + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using static CommunityToolkit.Maui.Analyzers.UnitTests.CSharpCodeFixVerifier; + +public class MaximumRatingRangeAnalyzerTests +{ + [Fact] + public void UseCommunityToolkitInitializationAnalyzerId() + { + Assert.Equal("MCT002", MaximumRatingRangeAnalyzer.DiagnosticId); + } + + [Fact] + public async Task NoDiagnosticForValidMaximumRating() + { + const string validCode = + /* language=C#-test */ + //lang=csharp + """ + using System; + + namespace TestApp + { + public class RatingView + { + public int MaximumRating { get; set; } + + public void TestMethod() + { + MaximumRating = 5; // Valid value + } + } + } + """; + + await VerifyMauiToolkitAnalyzer(validCode); + } + + [Fact] + public async Task DiagnosticForInvalidMaximumRatings() + { + const string invalidUpperBoundsCode = + /* language=C#-test */ + //lang=csharp + """ + using System; + + namespace TestApp + { + public class RatingView + { + public int MaximumRating { get; set; } + + public void TestMethod() + { + MaximumRating = 11; // Invalid value + } + } + } + """; + + const string invalidLowerBoundsCode = + /* language=C#-test */ + //lang=csharp + """ + using System; + + namespace TestApp + { + public class RatingView + { + public int MaximumRating { get; set; } + + public void TestMethod() + { + MaximumRating = 0; // Invalid value + } + } + } + """; + + await VerifyMauiToolkitAnalyzer(invalidUpperBoundsCode, Diagnostic().WithSpan(11, 4, 11, 22).WithSeverity(DiagnosticSeverity.Error).WithArguments(1, 10)); + await VerifyMauiToolkitAnalyzer(invalidLowerBoundsCode, Diagnostic().WithSpan(11, 4, 11, 21).WithSeverity(DiagnosticSeverity.Error).WithArguments(1, 10)); + } + + static Task VerifyMauiToolkitAnalyzer(string source, params DiagnosticResult[] expected) + { + return VerifyAnalyzerAsync( + source, + [ + typeof(Options), // CommunityToolkit.Maui + typeof(Core.Options), + ], + expected); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Analyzers/AnalyzerReleases.Shipped.md b/src/CommunityToolkit.Maui.Analyzers/AnalyzerReleases.Shipped.md index 2a5fac1f80..c96143bb0d 100644 --- a/src/CommunityToolkit.Maui.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/CommunityToolkit.Maui.Analyzers/AnalyzerReleases.Shipped.md @@ -4,4 +4,12 @@ Rule ID | Category | Severity | Notes --------|----------|----------|----------------------------------------------------- -MCT001 | Usage | Error | `.UseMauiCommunityToolkit()` Not Found on MauiAppBuilder \ No newline at end of file +MCT001 | Usage | Error | `.UseMauiCommunityToolkit()` Not Found on MauiAppBuilder + +## Release 11.1.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|----------------------------------------------------- +MCT002 | Usage | Error | The value of MaximumRating must be between 1 and 10 \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Analyzers/MaximumRatingRangeAnalyzer.cs b/src/CommunityToolkit.Maui.Analyzers/MaximumRatingRangeAnalyzer.cs new file mode 100644 index 0000000000..ca31234cc7 --- /dev/null +++ b/src/CommunityToolkit.Maui.Analyzers/MaximumRatingRangeAnalyzer.cs @@ -0,0 +1,55 @@ +// Ignore Spelling: Analyzer + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CommunityToolkit.Maui.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class MaximumRatingRangeAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "MCT002"; + const string title = "Invalid MaximumRating value"; + const string messageFormat = "The value of MaximumRating must be between {0} and {1}"; + const string description = "Ensures that the MaximumRating property of RatingView is within a valid range."; + const string category = "Usage"; + const int minValue = 1; + const int maxValue = 10; + + static readonly DiagnosticDescriptor rule = new( + DiagnosticId, + title, + messageFormat, + category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: description + ); + + public override ImmutableArray SupportedDiagnostics => [rule]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzePropertyAssignment, SyntaxKind.SimpleAssignmentExpression); + } + + static void AnalyzePropertyAssignment(SyntaxNodeAnalysisContext context) + { + if (context.Node is AssignmentExpressionSyntax { Left: IdentifierNameSyntax { Identifier.Text: "MaximumRating" } leftIdentifier, Right: LiteralExpressionSyntax { Token.Value: int value } } assignmentExpression) + { + var semanticModel = context.SemanticModel; + var propertySymbol = semanticModel.GetSymbolInfo(leftIdentifier).Symbol as IPropertySymbol; + + if (propertySymbol?.ContainingType.Name == "RatingView" && (value < minValue || value > maxValue)) + { + var diagnostic = Diagnostic.Create(rule, assignmentExpression.GetLocation(), minValue, maxValue); + context.ReportDiagnostic(diagnostic); + } + } + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Analyzers/UseCommunityToolkitInitializationAnalyzer.cs b/src/CommunityToolkit.Maui.Analyzers/UseCommunityToolkitInitializationAnalyzer.cs index f58d7fe30a..334bdc3dd1 100644 --- a/src/CommunityToolkit.Maui.Analyzers/UseCommunityToolkitInitializationAnalyzer.cs +++ b/src/CommunityToolkit.Maui.Analyzers/UseCommunityToolkitInitializationAnalyzer.cs @@ -32,9 +32,7 @@ public override void Initialize(AnalysisContext context) static void AnalyzeNode(SyntaxNodeAnalysisContext context) { - if (context.Node is InvocationExpressionSyntax invocationExpression - && invocationExpression.Expression is MemberAccessExpressionSyntax memberAccessExpression - && memberAccessExpression.Name.Identifier.ValueText == useMauiAppMethodName) + if (context.Node is InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax { Name.Identifier.ValueText: useMauiAppMethodName } } invocationExpression) { var root = invocationExpression.SyntaxTree.GetRoot(); var methodDeclaration = root.FindNode(invocationExpression.FullSpan) @@ -44,8 +42,7 @@ static void AnalyzeNode(SyntaxNodeAnalysisContext context) if (methodDeclaration is not null && !methodDeclaration.DescendantNodes().OfType().Any(static n => - n.Expression is MemberAccessExpressionSyntax m && - m.Name.Identifier.ValueText == useMauiCommunityToolkitMethodName)) + n.Expression is MemberAccessExpressionSyntax { Name.Identifier.ValueText: useMauiCommunityToolkitMethodName })) { var diagnostic = Diagnostic.Create(rule, invocationExpression.GetLocation()); context.ReportDiagnostic(diagnostic); diff --git a/src/CommunityToolkit.Maui.Core/Interfaces/RatingView/IRatingView.shared.cs b/src/CommunityToolkit.Maui.Core/Interfaces/RatingView/IRatingView.shared.cs new file mode 100644 index 0000000000..70af47086b --- /dev/null +++ b/src/CommunityToolkit.Maui.Core/Interfaces/RatingView/IRatingView.shared.cs @@ -0,0 +1,6 @@ +namespace CommunityToolkit.Maui.Core; + +/// Provides functionality to device a rating view. +public interface IRatingView : IContentView, IRatingViewShape +{ +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Core/Interfaces/RatingView/IRatingViewShape.shared.cs b/src/CommunityToolkit.Maui.Core/Interfaces/RatingView/IRatingViewShape.shared.cs new file mode 100644 index 0000000000..7752200739 --- /dev/null +++ b/src/CommunityToolkit.Maui.Core/Interfaces/RatingView/IRatingViewShape.shared.cs @@ -0,0 +1,55 @@ +// Ignore Spelling: color +using System.ComponentModel; + +namespace CommunityToolkit.Maui.Core; + +/// RatingView interface. +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IRatingViewShape +{ + /// Gets a value indicating the rating item shape size. + double ItemShapeSize { get; } + + /// Gets a value indicating the custom rating view shape path. + string? CustomItemShape { get; } + + /// Gets a value indicating the Rating View shape. + RatingViewShape ItemShape { get; } + + /// Gets a value indicating the Rating View item padding. + Thickness ItemPadding { get; } + + /// Gets a value indicating the rating item shape border thickness + double ShapeBorderThickness { get; } + + /// Get a value indicating the rating item shape border color. + Color ShapeBorderColor { get; } + + /// Get a value indicating the rating item background empty color. + Color EmptyColor { get; } + + /// Get a value indicating the rating item background filled color. + Color FilledColor { get; } +} + +/// Rating view shape enumerator. +public enum RatingViewShape +{ + /// A star rating shape. + Star, + + /// A heart rating shape. + Heart, + + /// A circle rating shape. + Circle, + + /// A like/thumbs up rating shape. + Like, + + /// A dislike/thumbs down rating shape. + Dislike, + + /// A custom rating shape. + Custom +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Core/Primitives/Defaults/RatingViewDefaults.shared.cs b/src/CommunityToolkit.Maui.Core/Primitives/Defaults/RatingViewDefaults.shared.cs new file mode 100644 index 0000000000..f1e16630b5 --- /dev/null +++ b/src/CommunityToolkit.Maui.Core/Primitives/Defaults/RatingViewDefaults.shared.cs @@ -0,0 +1,58 @@ +// Ignore Spelling: color + +using System.ComponentModel; + +namespace CommunityToolkit.Maui.Core; + +/// Default Values for RatingView +[EditorBrowsable(EditorBrowsableState.Never)] +public static class RatingViewDefaults +{ + /// Default rating value. + [EditorBrowsable(EditorBrowsableState.Never)] + public const double DefaultRating = 0.0; + + /// Default view element read only. + [EditorBrowsable(EditorBrowsableState.Never)] + public const bool IsReadOnly = false; + + /// Default size of a rating item shape. + [EditorBrowsable(EditorBrowsableState.Never)] + public const double ItemShapeSize = 20.0; + + /// Default maximum value for the rating. + [EditorBrowsable(EditorBrowsableState.Never)] + public const int MaximumRating = 5; + + /// Maximum number of ratings. + [EditorBrowsable(EditorBrowsableState.Never)] + public const int MaximumRatingLimit = 10; + + /// Default border thickness for a rating shape. + [EditorBrowsable(EditorBrowsableState.Never)] + public const double ShapeBorderThickness = 1.0; + + /// Default spacing between ratings. + [EditorBrowsable(EditorBrowsableState.Never)] + public const double Spacing = 10.0; + + /// Default color for an empty rating. + [EditorBrowsable(EditorBrowsableState.Never)] + public static Color EmptyColor { get; } = Colors.Transparent; + + /// Default filled color for a rating. + [EditorBrowsable(EditorBrowsableState.Never)] + public static Color FilledColor { get; } = Colors.Yellow; + + /// Default rating item padding. + [EditorBrowsable(EditorBrowsableState.Never)] + public static Thickness ItemPadding { get; } = new(0); + + /// Default rating shape. + [EditorBrowsable(EditorBrowsableState.Never)] + public static RatingViewShape ItemShape { get; } = RatingViewShape.Star; + + /// Default border color for a rating shape. + [EditorBrowsable(EditorBrowsableState.Never)] + public static Color ShapeBorderColor { get; } = Colors.Grey; +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Core/Primitives/RatingChangedEventArgs.shared.cs b/src/CommunityToolkit.Maui.Core/Primitives/RatingChangedEventArgs.shared.cs new file mode 100644 index 0000000000..f9259fb728 --- /dev/null +++ b/src/CommunityToolkit.Maui.Core/Primitives/RatingChangedEventArgs.shared.cs @@ -0,0 +1,9 @@ +namespace CommunityToolkit.Maui.Core; + +/// Event args containing all contextual information related to rating changed event. +/// The new rating value. +public class RatingChangedEventArgs(double rating) : EventArgs +{ + /// Gets the rating for the new rating changed event + public double Rating { get; } = rating; +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Core/Primitives/RatingFillElement.cs b/src/CommunityToolkit.Maui.Core/Primitives/RatingFillElement.cs new file mode 100644 index 0000000000..c74ef8a195 --- /dev/null +++ b/src/CommunityToolkit.Maui.Core/Primitives/RatingFillElement.cs @@ -0,0 +1,11 @@ +namespace CommunityToolkit.Maui.Core; + +/// Rating view fill element. +public enum RatingFillElement +{ + /// Fill the rating shape. + Shape, + + /// Fill the rating item. + Item +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Core/Primitives/RatingViewShape.cs b/src/CommunityToolkit.Maui.Core/Primitives/RatingViewShape.cs new file mode 100644 index 0000000000..2f1448ba05 --- /dev/null +++ b/src/CommunityToolkit.Maui.Core/Primitives/RatingViewShape.cs @@ -0,0 +1,41 @@ +namespace CommunityToolkit.Maui.Core.Primitives; + +/// Shapes available for the rating view. +public sealed class RatingViewShape +{ + /// Custom data path for the shape of the rating. + public string PathData { get; } + + /// Private constructor to initialize a new shape. + /// Path shape data from . + RatingViewShape(string pathData) + { + PathData = pathData; + } + + /// Star shape. + /// Default shape. + public static readonly RatingViewShape Star = new(PathShapes.Star); + + /// Heart shape. + public static readonly RatingViewShape Heart = new(PathShapes.Heart); + + /// Circle shape. + public static readonly RatingViewShape Circle = new(PathShapes.Circle); + + /// Thumb like shape. + public static readonly RatingViewShape Like = new(PathShapes.Like); + + /// Thumb dislike shape. + public static readonly RatingViewShape Dislike = new(PathShapes.Dislike); + + /// SVG defined path shapes. + static class PathShapes + { + public const string Star = "M9 11.3l3.71 2.7-1.42-4.36L15 7h-4.55L9 2.5 7.55 7H3l3.71 2.64L5.29 14z"; + public const string Heart = "M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"; + public const string Circle = "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"; + public const string Like = "M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-1.91l-.01-.01L23 10z"; + public const string Dislike = "M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v1.91l.01.01L1 14c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"; + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/RatingView/RatingViewTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/RatingView/RatingViewTests.cs new file mode 100644 index 0000000000..5827cde975 --- /dev/null +++ b/src/CommunityToolkit.Maui.UnitTests/Views/RatingView/RatingViewTests.cs @@ -0,0 +1,863 @@ +// Ignore Spelling: color, colors + +using System.ComponentModel; +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Views; +using FluentAssertions; +using Microsoft.Maui.Controls.Shapes; +using Xunit; + +namespace CommunityToolkit.Maui.UnitTests.Views; + +public class RatingViewTests : BaseHandlerTest +{ + [Fact] + public void Defaults_BindingContext() + { + MockRatingViewViewModel vm = new(); + RatingView ratingViewWithBinding = new(); + + ratingViewWithBinding.BindingContext.Should().BeNull(); + ratingViewWithBinding.RatingLayout.BindingContext.Should().BeNull(); + ratingViewWithBinding.RatingLayout.BindingContext.Should().BeEquivalentTo(ratingViewWithBinding.BindingContext); + + ratingViewWithBinding.BindingContext = vm; + + ratingViewWithBinding.BindingContext.Should().Be(vm); + ratingViewWithBinding.RatingLayout.BindingContext.Should().Be(vm); + ratingViewWithBinding.RatingLayout.BindingContext.Should().BeEquivalentTo(ratingViewWithBinding.BindingContext); + } + + [Fact] + public void Defaults_ItemDefaultsApplied() + { + RatingView ratingView = new(); + var firstItem = (Border)ratingView.RatingLayout.Children[0]; + + firstItem.Should().BeOfType(); + firstItem.BackgroundColor.Should().BeNull(); + firstItem.Margin.Should().Be(Thickness.Zero); + firstItem.Padding.Should().Be(RatingViewDefaults.ItemPadding); + firstItem.Stroke.Should().Be(new SolidColorBrush(Colors.Transparent)); + firstItem.StrokeThickness.Should().Be(0); + firstItem.Style.Should().BeNull(); + } + + [Fact] + public void Defaults_ShapeDefaultsApplied() + { + RatingView ratingView = new(); + var firstItemShape = GetItemShape(ratingView, 0); + + firstItemShape.Should().NotBeNull(); + firstItemShape.Should().BeOfType(); + firstItemShape.Aspect.Should().Be(Stretch.Uniform); + firstItemShape.HeightRequest.Should().Be(RatingViewDefaults.ItemShapeSize); + firstItemShape.Stroke.Should().BeOfType().And.Be(new SolidColorBrush(RatingViewDefaults.ShapeBorderColor)); + firstItemShape.StrokeLineCap.Should().Be(PenLineCap.Round); + firstItemShape.StrokeLineJoin.Should().Be(PenLineJoin.Round); + firstItemShape.StrokeThickness.Should().Be(RatingViewDefaults.ShapeBorderThickness); + firstItemShape.WidthRequest.Should().Be(RatingViewDefaults.ItemShapeSize); + } + + [Fact] + public void Defaults_ShouldHaveCorrectDefaultProperties() + { + RatingView ratingView = new(); + ratingView.Rating.Should().Be(RatingViewDefaults.DefaultRating); + ratingView.EmptyColor.Should().BeOfType().And.Be(RatingViewDefaults.EmptyColor); + ratingView.FilledColor.Should().BeOfType().And.Be(RatingViewDefaults.FilledColor); + ratingView.IsReadOnly.Should().BeFalse().And.Be(RatingViewDefaults.IsReadOnly); + ratingView.ItemPadding.Should().BeOfType().And.Be(RatingViewDefaults.ItemPadding); + ratingView.ItemShapeSize.Should().Be(RatingViewDefaults.ItemShapeSize); + ratingView.MaximumRating.Should().Be(RatingViewDefaults.MaximumRating); + ratingView.ItemShape.Should().BeOneOf(RatingViewDefaults.ItemShape).And.Be(RatingViewDefaults.ItemShape); + ratingView.ShapeBorderColor.Should().BeOfType().And.Be(RatingViewDefaults.ShapeBorderColor); + ratingView.ShapeBorderThickness.Should().Be(RatingViewDefaults.ShapeBorderThickness); + ratingView.Spacing.Should().Be(RatingViewDefaults.Spacing); + ratingView.RatingFill.Should().BeOneOf(RatingFillElement.Shape).And.Be(RatingFillElement.Shape); + ratingView.CustomItemShape.Should().BeNullOrEmpty(); + } + + [Fact] + public void Events_Border_TapGestureRecognizer_SingleRating_Toggled() + { + RatingView ratingView = new() + { + MaximumRating = 1 + }; + var child = (Border)ratingView.RatingLayout.Children[0]; + var tapGestureRecognizer = (TapGestureRecognizer)child.GestureRecognizers[0]; + tapGestureRecognizer.SendTapped(child); + ratingView.Rating.Should().Be(1); + + tapGestureRecognizer.SendTapped(child); + ratingView.Rating.Should().Be(0); + } + + [Fact] + public void Events_Border_TapGestureRecognizer_Tapped() + { + var handlerTappedCount = 0; + RatingView ratingView = new(); + ratingView.Rating.Should().Be(handlerTappedCount); + var child = (Border)ratingView.RatingLayout.Children[0]; + var tgr = (TapGestureRecognizer)child.GestureRecognizers[0]; + tgr.SendTapped(child); + handlerTappedCount++; + ratingView.Rating.Should().Be(handlerTappedCount); + } + + [Fact] + public void Events_RatingChanged_AddRemove() + { + List receivedEvents = []; + const double expectedRating = 2; + RatingView ratingView = new() + { + MaximumRating = 3, + Rating = 3 + }; + ratingView.RatingChanged += OnRatingChanged; + ratingView.Rating = expectedRating; + receivedEvents.Should().HaveCount(1); + receivedEvents[0].Rating.Should().Be(expectedRating); + + void OnRatingChanged(object? sender, RatingChangedEventArgs e) + { + ratingView.RatingChanged -= OnRatingChanged; + receivedEvents.Add(e); + } + } + + [Fact] + public void Events_RatingChanged_ShouldBeRaised_MaximumRatingPropertyChanged_LNotReadOnly() + { + const double currentRating = 5; + const double maximumRating = 4; + RatingView ratingView = new() + { + Rating = currentRating + }; + + var signaled = false; + ratingView.RatingChanged += (sender, e) => signaled = true; + ratingView.MaximumRating = 4; + ratingView.Rating.Should().Be(maximumRating); + signaled.Should().BeTrue(); + } + + [Fact] + public void Events_RatingChanged_ShouldBeRaised_RatingPropertyChanged_NotReadOnly() + { + const double currentRating = 3.5; + RatingView ratingView = new(); + ratingView.Rating.Should().Be(RatingViewDefaults.DefaultRating); + var signaled = false; + ratingView.RatingChanged += (sender, e) => signaled = true; + ratingView.Rating = currentRating; + ratingView.Rating.Should().Be(currentRating); + signaled.Should().BeTrue(); + } + + [Fact] + public void Events_RatingChanged_ShouldNotBeRaised_MaximumRatingPropertyChanged_HNotReadOnly() + { + const double currentRating = 5; + const int maximumRating = 7; + RatingView ratingView = new() + { + Rating = currentRating + }; + + var signaled = false; + ratingView.RatingChanged += (sender, e) => signaled = true; + ratingView.MaximumRating = maximumRating; + ratingView.Rating.Should().Be(currentRating); + signaled.Should().BeFalse(); + } + + [Fact] + public void Events_RatingChanged_ShouldNotBeRaised_MaximumRatingPropertyChanged_HReadOnly() + { + const double currentRating = 5; + const int maximumRating = 7; + RatingView ratingView = new() + { + Rating = currentRating, + IsReadOnly = true, + }; + + var signaled = false; + ratingView.RatingChanged += (sender, e) => signaled = true; + ratingView.MaximumRating = maximumRating; + ratingView.Rating.Should().Be(currentRating); + signaled.Should().BeFalse(); + } + + [Fact] + public void Events_RatingChanged_ShouldBeRaised_MaximumRatingPropertyChanged_LReadOnly() + { + const double currentRating = 5; + const int maximumRating = 4; + RatingView ratingView = new() + { + Rating = currentRating, + IsReadOnly = true, + }; + + var signaled = false; + ratingView.RatingChanged += (sender, e) => signaled = true; + ratingView.MaximumRating = maximumRating; + ratingView.Rating.Should().Be(maximumRating); + signaled.Should().BeTrue(); + } + + [Fact] + public void Events_RatingChanged_ShouldBeRaised_RatingPropertyChanged_ReadOnly() + { + const double currentRating = 3.5; + RatingView ratingView = new() + { + IsReadOnly = true + }; + + var signaled = false; + ratingView.RatingChanged += (sender, e) => signaled = true; + ratingView.Rating = currentRating; + ratingView.Rating.Should().Be(currentRating); + signaled.Should().BeTrue(); + } + + [Fact] + public void Events_ShouldBeRaised_MaximumRatingChanged_BelowRating() + { + List receivedEvents = []; + const double expectedRating = 2; + RatingView ratingView = new() + { + MaximumRating = 3, + Rating = 3 + }; + ratingView.RatingChanged += (sender, e) => receivedEvents.Add(e); + ratingView.MaximumRating = (byte)expectedRating; + receivedEvents.Should().HaveCount(1); + receivedEvents[0].Rating.Should().Be(expectedRating); + } + + [Fact] + public void Events_ShouldBeRaised_RatingChangedEvent() + { + List receivedEvents = []; + const double expectedRating = 2.0; + RatingView ratingView = new(); + ratingView.RatingChanged += (sender, e) => receivedEvents.Add(e); + ratingView.Rating = expectedRating; + receivedEvents.Should().ContainSingle(); + receivedEvents[0].Rating.Should().Be(expectedRating); + } + + [Fact] + public void Events_ShouldNotBeRaised_MaximumRatingChanged_AboveRating() + { + List receivedEvents = []; + RatingView ratingView = new() + { + MaximumRating = 3, + Rating = 3 + }; + ratingView.RatingChanged += (sender, e) => receivedEvents.Add(e); + ratingView.MaximumRating = 4; + receivedEvents.Should().HaveCount(0); + } + + [Fact] + public void MaximumRatingViewThrowsArgumentOutOfRangeExceptionWhenOutsideLowerBounds() + { + Assert.Throws(() => new RatingView().MaximumRating = 0); + } + + [Fact] + public void MaximumRatingViewThrowsArgumentOutOfRangeExceptionWhenOutsideUpperBounds() + { + Assert.Throws(() => new RatingView().MaximumRating = RatingViewDefaults.MaximumRatingLimit + 1); + } + + [Fact] + public void Null_EmptyColor() + { + RatingView ratingView = new(); + ratingView.EmptyColor.Should().NotBeNull(); + ratingView.EmptyColor = null; + ratingView.EmptyColor.Should().BeOfType().And.Be(Colors.Transparent); + } + + [Fact] + public void Null_FilledColor() + { + RatingView ratingView = new(); + ratingView.FilledColor.Should().NotBeNull(); + ratingView.FilledColor = null; + ratingView.FilledColor.Should().BeOfType().And.Be(Colors.Transparent); + } + + [Fact] + public void Null_ShapeBorderColor() + { + RatingView ratingView = new(); + ratingView.ShapeBorderColor.Should().NotBeNull(); + ratingView.ShapeBorderColor = null; + ratingView.ShapeBorderColor.Should().BeOfType().And.Be(Colors.Transparent); + } + + [Fact] + public void Properties_Change_CustomShape() + { + const string customShape = "M 12 0C5.388 0 0 5.388 0 12s5.388 12 12 12 12-5.38 12-12c0-6.612-5.38-12-12-12z"; + RatingView ratingView = new(); + ratingView.CustomItemShape.Should().BeNullOrEmpty(); + ratingView.CustomItemShape = customShape; + ratingView.CustomItemShape.Should().Be(customShape); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Properties_Change_CustomShape_Null(string? customShapes) + { + const string customShape = "M 12 0C5.388 0 0 5.388 0 12s5.388 12 12 12 12-5.38 12-12c0-6.612-5.38-12-12-12z"; + RatingView ratingView = new() + { + ItemShape = RatingViewShape.Custom, + CustomItemShape = customShape, + }; + ratingView.ItemShape.Should().Be(RatingViewShape.Custom); + ratingView.CustomItemShape.Should().Be(customShape); + ratingView.CustomItemShape = customShapes; + ratingView.ItemShape.Should().Be(RatingViewShape.Star); + } + + [Fact] + public void Properties_Change_CustomShape_ShapeCustom() + { + const string customShape = "M 12 0C5.388 0 0 5.388 0 12s5.388 12 12 12 12-5.38 12-12c0-6.612-5.38-12-12-12z"; + RatingView ratingView = new() + { + ItemShape = RatingViewShape.Custom + }; + ratingView.ItemShape.Should().Be(RatingViewShape.Custom); + ratingView.CustomItemShape = customShape; + ratingView.CustomItemShape.Should().Be(customShape); + ratingView.ItemShape.Should().Be(RatingViewShape.Custom); + } + + [Fact] + public void Properties_Change_CustomShape_ShapeNotCustom() + { + const string customShape = "M 12 0C5.388 0 0 5.388 0 12s5.388 12 12 12 12-5.38 12-12c0-6.612-5.38-12-12-12z"; + RatingView ratingView = new() + { + ItemShape = RatingViewShape.Heart + }; + ratingView.ItemShape.Should().Be(RatingViewShape.Heart); + ratingView.CustomItemShape = customShape; + ratingView.CustomItemShape.Should().Be(customShape); + ratingView.ItemShape.Should().Be(RatingViewShape.Heart); + } + + [Fact] + public void Properties_Change_EmptyColor_Item() + { + const double rating = 1.5; + const byte maximumRating = 7; + var emptyColor = Colors.Snow; + RatingView ratingView = new() + { + MaximumRating = maximumRating, + Rating = rating, + RatingFill = RatingFillElement.Item + }; + ratingView.EmptyColor.Should().NotBe(emptyColor); + ratingView.EmptyColor = emptyColor; + ratingView.EmptyColor.Should().Be(emptyColor); + + var emptyRatingItem = (Microsoft.Maui.Controls.Shapes.Path) GetItemShape(ratingView, maximumRating - 1).GetVisualTreeDescendants()[0]; + emptyRatingItem.Fill.Should().Be(new SolidColorBrush(emptyColor)); + } + + [Fact] + public void Properties_Change_EmptyColor_Shape() + { + const double rating = 1.5; + const byte maximumRating = 7; + var emptyColor = Colors.Snow; + RatingView ratingView = new() + { + MaximumRating = maximumRating, + Rating = rating + }; + ratingView.EmptyColor.Should().NotBe(emptyColor); + ratingView.EmptyColor = emptyColor; + ratingView.EmptyColor.Should().Be(emptyColor); + var emptyRatingItem = GetItemShape(ratingView, maximumRating - 1); + emptyRatingItem.Fill.Should().Be(new SolidColorBrush(emptyColor)); + } + + [Fact] + public void Properties_Change_FilledColor_Item() + { + const double rating = 1.5; + const byte maximumRating = 7; + var filledColor = Colors.Snow; + var emptyColor = Colors.Firebrick; + var backgroundColor = Colors.DarkGreen; + RatingView ratingView = new() + { + MaximumRating = maximumRating, + Rating = rating, + RatingFill = RatingFillElement.Item + }; + ratingView.FilledColor.Should().NotBe(filledColor); + ratingView.BackgroundColor.Should().BeNull(); + ratingView.BackgroundColor = backgroundColor; + ratingView.EmptyColor = emptyColor; + ratingView.FilledColor = filledColor; + ratingView.FilledColor.Should().Be(filledColor); + var filledRatingShape = GetItemShape(ratingView, (int)Math.Floor(rating)); + filledRatingShape.Fill.Should().BeOfType().And.Be(new SolidColorBrush(emptyColor)); + var filledRatingItem = (Border)ratingView.RatingLayout.Children[0]; + filledRatingItem.Background.Should().BeOfType().And.Be(new SolidColorBrush(filledColor)); + var partialFilledRatingItem = (Border)ratingView.RatingLayout.Children[(int)Math.Floor(rating)]; + partialFilledRatingItem.Background.Should().BeOfType(); + var emptyFilledRatingItem = (Border)ratingView.RatingLayout.Children[maximumRating - 1]; // Check the last one, as this is where we expect the background colour to be set + emptyFilledRatingItem.Background.Should().BeOfType().And.Be(new SolidColorBrush(backgroundColor)); + } + + [Fact] + public void Properties_Change_FilledColor_Shape() + { + const double rating = 1.5; + const byte maximumRating = 7; + var filledColor = Colors.Snow; + RatingView ratingView = new() + { + MaximumRating = maximumRating, + Rating = rating + }; + ratingView.FilledColor.Should().NotBe(filledColor); + ratingView.FilledColor = filledColor; + ratingView.FilledColor.Should().Be(filledColor); + var filledRatingItem = GetItemShape(ratingView, 0); + filledRatingItem.Fill.Should().Be(new SolidColorBrush(filledColor)); + } + + [Fact] + public void Properties_Change_IsReadOnly() + { + RatingView ratingView = new(); + ratingView.IsReadOnly.Should().BeFalse(); + ratingView.IsReadOnly = true; + ratingView.IsReadOnly.Should().BeTrue(); + ratingView.IsReadOnly = false; + ratingView.IsReadOnly.Should().BeFalse(); + } + + [Fact] + public void Properties_Change_IsReadOnly_GestureRecognizers() + { + RatingView ratingView = new(); + ratingView.IsReadOnly.Should().BeFalse(); + foreach (var t in ratingView.RatingLayout.Children) + { + var child = (Border)t; + child.GestureRecognizers.Should().ContainSingle(); + } + + ratingView.IsReadOnly = true; + ratingView.IsReadOnly.Should().BeTrue(); + foreach (var t in ratingView.RatingLayout.Children) + { + var child = (Border)t; + child.GestureRecognizers.Should().BeEmpty(); + } + } + + [Fact] + public void Properties_Change_ItemPadding() + { + Thickness itemPadding = new(1, 2, 3, 4); + RatingView ratingView = new(); + ratingView.ItemPadding.Should().NotBe(itemPadding); + ratingView.ItemPadding = itemPadding; + ratingView.ItemPadding.Should().Be(itemPadding); + var firstItem = (Border)ratingView.RatingLayout.Children[0]; + firstItem.Padding.Should().Be(itemPadding); + } + + [Fact] + public void Properties_Change_MaximumRating() + { + const byte maximumRating = 7; + RatingView ratingView = new(); + ratingView.MaximumRating.Should().NotBe(maximumRating); + ratingView.MaximumRating = maximumRating; + ratingView.MaximumRating.Should().Be(maximumRating); + ratingView.RatingLayout.Children.Should().HaveCount(maximumRating); + } + + [Fact] + public void Properties_Change_Rating() + { + const double rating = 2.3; + RatingView ratingView = new(); + ratingView.Rating.Should().NotBe(rating); + ratingView.Rating = rating; + ratingView.Rating.Should().Be(rating); + } + + [Fact] + public void Properties_Change_RatingFill() + { + const RatingFillElement ratingFill = RatingFillElement.Item; + RatingView ratingView = new(); + ratingView.RatingFill.Should().NotBe(ratingFill); + ratingView.RatingFill = ratingFill; + ratingView.RatingFill.Should().Be(ratingFill); + } + + [Theory] + [InlineData(RatingViewShape.Heart)] + [InlineData(RatingViewShape.Circle)] + [InlineData(RatingViewShape.Like)] + [InlineData(RatingViewShape.Dislike)] + [InlineData(RatingViewShape.Custom)] + public void Properties_Change_Shape(RatingViewShape expectedShape) + { + RatingView ratingView = new(); + ratingView.ItemShape.Should().NotBe(expectedShape); + ratingView.ItemShape = expectedShape; + ratingView.ItemShape.Should().Be(expectedShape); + } + + [Fact] + public void Properties_Change_ShapeBorderColor() + { + var shapeBorderColor = Colors.Snow; + Brush brush = new SolidColorBrush(shapeBorderColor); + RatingView ratingView = new(); + + ratingView.ShapeBorderColor.Should().NotBe(shapeBorderColor); + ratingView.ShapeBorderColor = shapeBorderColor; + ratingView.ShapeBorderColor.Should().Be(shapeBorderColor); + + var firstRatingItem = GetItemShape(ratingView, 0); + firstRatingItem.Stroke.Should().BeOfType().And.Be(brush); + } + + [Fact] + public void Properties_Change_ShapeBorderThickness() + { + const double shapeBorderThickness = 7.3; + RatingView ratingView = new(); + ratingView.ShapeBorderThickness.Should().NotBe(shapeBorderThickness); + ratingView.ShapeBorderThickness = shapeBorderThickness; + ratingView.ShapeBorderThickness.Should().Be(shapeBorderThickness); + + var firstRatingItem = GetItemShape(ratingView, 0); + firstRatingItem.StrokeThickness.Should().Be(shapeBorderThickness); + } + + [Fact] + public void Properties_Change_Size() + { + const int itemShapeSize = 73; + RatingView ratingView = new(); + ratingView.ItemShapeSize.Should().NotBe(itemShapeSize); + ratingView.ItemShapeSize = itemShapeSize; + ratingView.ItemShapeSize.Should().Be(itemShapeSize); + + var firstRatingItem = GetItemShape(ratingView, 0); + firstRatingItem.WidthRequest.Should().Be(itemShapeSize); + firstRatingItem.HeightRequest.Should().Be(itemShapeSize); + } + + [Fact] + public void Properties_Change_Spacing() + { + const int spacing = 73; + RatingView ratingView = new(); + ratingView.Spacing.Should().NotBe(spacing); + ratingView.Spacing = spacing; + ratingView.Spacing.Should().Be(spacing); + var control = ratingView.RatingLayout; + control.Should().NotBeNull(); + control.Spacing.Should().Be(spacing); + } + + [Fact] + public void Properties_MaximumRating_KeptInRange() + { + RatingView ratingView = new(); + const byte minMaximumRating = 1; + const byte maxMaximumRating = RatingViewDefaults.MaximumRatingLimit; + ratingView.MaximumRating = minMaximumRating; + ratingView.MaximumRating.Should().Be(1); + ratingView.MaximumRating = maxMaximumRating; + ratingView.MaximumRating.Should().Be(RatingViewDefaults.MaximumRatingLimit); + } + + [Fact] + public void Properties_MaximumRating_NumberOfChildrenHigher() + { + const byte minMaximumRating = 7; + RatingView ratingView = new(); + ratingView.RatingLayout.Count.Should().Be(RatingViewDefaults.MaximumRating); + ratingView.MaximumRating = minMaximumRating; + ratingView.RatingLayout.Count.Should().Be(minMaximumRating); + } + + [Fact] + public void Properties_MaximumRating_NumberOfChildrenLower() + { + const byte minMaximumRating = 3; + RatingView ratingView = new(); + ratingView.RatingLayout.Count.Should().Be(RatingViewDefaults.MaximumRating); + ratingView.MaximumRating = minMaximumRating; + ratingView.RatingLayout.Count.Should().Be(minMaximumRating); + } + + [Fact] + public void Properties_MaximumRating_Validator() + { + RatingView ratingView = new(); + RatingView.MaximumRatingProperty.ValidateValue(ratingView, 0).Should().BeFalse(); + RatingView.MaximumRatingProperty.ValidateValue(ratingView, RatingViewDefaults.MaximumRatingLimit + 1).Should().BeFalse(); + RatingView.MaximumRatingProperty.ValidateValue(ratingView, 1).Should().BeTrue(); + } + + [Fact] + public void Properties_Rating_Validator() + { + RatingView ratingView = new(); + RatingView.RatingProperty.ValidateValue(ratingView, -1.0).Should().BeFalse(); + RatingView.RatingProperty.ValidateValue(ratingView, (double)(RatingViewDefaults.MaximumRatingLimit + 1)).Should().BeFalse(); + RatingView.RatingProperty.ValidateValue(ratingView, 0.1).Should().BeTrue(); + } + + [Fact] + public void RatingViewDoesNotThrowsArgumentOutOfRangeExceptionWhenRatingSetBeforeMaximumRating() + { + const int maximumRating = RatingViewDefaults.MaximumRatingLimit - 1; + const int rating = RatingViewDefaults.MaximumRatingLimit - 3; + + RatingView ratingView = new() + { + MaximumRating = maximumRating, + Rating = rating, + }; + + Assert.Equal(rating, ratingView.Rating); + Assert.Equal(maximumRating, ratingView.MaximumRating); + } + + [Fact] + public void RatingViewThrowsInvalidOperationExceptionWhenBorderChildIsNotShape() + { + RatingView ratingView = new(); + ((Border)ratingView.RatingLayout.Children[0]).Content = new Button(); + Assert.Throws(() => ratingView.Rating = 1); + } + + [Fact] + public void RatingViewThrowsInvalidOperationExceptionWhenChildIsNotBorder() + { + RatingView ratingView = new(); + ratingView.RatingLayout.Children.Add(new Button()); + Assert.Throws(() => ratingView.Rating = 1); + } + + [Fact] + public void RatingViewThrowsArgumentOutOfRangeExceptionWhenOutsideLowerBounds() + { + RatingView ratingView = new(); + Assert.Throws(() => ratingView.Rating = 0 - double.Epsilon); + } + + [Fact] + public void RatingViewThrowsArgumentOutOfRangeExceptionWhenOutsideUpperBounds() + { + RatingView ratingView = new(); + Assert.Throws(() => ratingView.Rating = ratingView.MaximumRating + 1); + } + + [Fact] + public void RatingViewThrowsArgumentOutOfRangeExceptionWhenRatingSetBeforeMaximumRating() + { + Assert.Throws(() => new RatingView + { + Rating = RatingViewDefaults.MaximumRatingLimit - 1, + MaximumRating = RatingViewDefaults.MaximumRatingLimit + }); + } + + [Fact] + public void ShapeBorderThicknessShouldThrowArgumentOutOfRangeExceptionForNegativeNumbers() + { + Assert.Throws(() => new RatingView + { + ShapeBorderThickness = -1 + }); + } + + [Fact] + public void ViewStructure_Control_IsHorizontalStackLayout() + { + RatingView ratingView = new(); + ratingView.RatingLayout.Should().BeOfType(); + } + + [Fact] + public void ViewStructure_CorrectNumberOfChildren() + { + const int maximumRating = 3; + RatingView ratingView = new() + { + MaximumRating = maximumRating + }; + + Assert.NotNull(ratingView.ControlTemplate); + ratingView.RatingLayout.GetVisualTreeDescendants().Should().HaveCount((maximumRating * 2) + 1); + ratingView.RatingLayout.Children.Should().HaveCount(maximumRating); + } + + [Fact] + public void ViewStructure_Item_IsBorder() + { + RatingView ratingView = new(); + ratingView.RatingLayout.Should().NotBeNull(); + ratingView.RatingLayout.Children[0].Should().NotBeNull(); + ratingView.RatingLayout.Children[0].Should().BeOfType(); + } + + [Fact] + public void ViewStructure_ItemChild_IsPath() + { + RatingView ratingView = new(); + ratingView.RatingLayout.Children[0].Should().BeOfType(); + var child = (Border)ratingView.RatingLayout.Children[0]; + + Assert.NotNull(child.Content); + child.Content.GetVisualTreeDescendants()[0].Should().BeOfType(); + } + + [Fact] + public void ViewStructure_ItemChild_Path_Star() + { + RatingView ratingView = new(); + ratingView.RatingLayout.Children[0].Should().BeOfType(); + var child = (Border)ratingView.RatingLayout.Children[0]; + + Assert.NotNull(child.Content); + + var shape = (Microsoft.Maui.Controls.Shapes.Path)child.Content.GetVisualTreeDescendants()[0]; + shape.GetPath().Should().Be(Core.Primitives.RatingViewShape.Star.PathData); + } + + [Fact] + public void ViewStructure_ItemFill_Colors() + { + var filledColor = Colors.Red; + var emptyColor = Colors.Grey; + var backgroundColor = Colors.CornflowerBlue; + RatingView ratingView = new() + { + Rating = 0, + MaximumRating = 3, + RatingFill = RatingFillElement.Item, + FilledColor = filledColor, + EmptyColor = emptyColor, + BackgroundColor = backgroundColor + }; + ratingView.Rating = 1.5; + var filledRatingItem = (Border)ratingView.RatingLayout.Children[0]; + var partialFilledRatingItem = (Border)ratingView.RatingLayout.Children[1]; + var emptyFilledRatingItem = (Border)ratingView.RatingLayout.Children[2]; + filledRatingItem.Background.Should().BeOfType().And.Be(new SolidColorBrush(filledColor)); + ((Shape)filledRatingItem.Content!).Fill.Should().BeOfType().And.Be(new SolidColorBrush(emptyColor)); + partialFilledRatingItem.Background.Should().BeOfType(); + ((Shape)partialFilledRatingItem.Content!).Fill.Should().BeOfType().And.Be(new SolidColorBrush(emptyColor)); + emptyFilledRatingItem.Background.Should().BeOfType().And.Be(new SolidColorBrush(backgroundColor)); + ((Shape)emptyFilledRatingItem.Content!).Fill.Should().BeOfType().And.Be(new SolidColorBrush(emptyColor)); + } + + [Fact] + public void ViewStructure_ItemPadding() + { + const double expectedLeftPadding = 7; + const double expectedTopPadding = 3; + const double expectedRightPadding = 3; + const double expectedBottomPadding = 7; + Thickness expectedItemPadding = new(expectedLeftPadding, expectedTopPadding, expectedRightPadding, expectedBottomPadding); + RatingView ratingView = new() + { + ItemPadding = expectedItemPadding, + }; + var firstItem = (Border)ratingView.RatingLayout.Children[0]; + firstItem.Padding.Should().Be(expectedItemPadding); + } + + [Fact] + public void ViewStructure_ShapeFill_Colors() + { + var filledColor = Colors.Red; + var emptyColor = Colors.Grey; + RatingView ratingView = new() + { + Rating = 1.5, + MaximumRating = 3, + RatingFill = RatingFillElement.Shape, + FilledColor = filledColor, + EmptyColor = emptyColor + }; + var filledRatingItem = GetItemShape(ratingView, 0); + var partialFilledRatingItem = GetItemShape(ratingView, 1); + var emptyFilledRatingItem = GetItemShape(ratingView, 2); + filledRatingItem.Fill.Should().BeOfType().And.Be(new SolidColorBrush(filledColor)); + emptyFilledRatingItem.Fill.Should().BeOfType().And.Be(new SolidColorBrush(emptyColor)); + partialFilledRatingItem.Fill.Should().BeOfType(); + } + + [Fact] + public void ViewStructure_Spacing() + { + RatingView ratingView = new(); + var rvControl = ratingView.RatingLayout; + rvControl.Spacing.Should().Be(RatingViewDefaults.Spacing); + } + + static Microsoft.Maui.Controls.Shapes.Path GetItemShape(in RatingView ratingView, int itemIndex) + { + var border = (Border)ratingView.RatingLayout.Children[itemIndex]; + Assert.NotNull(border.Content); + + return (Microsoft.Maui.Controls.Shapes.Path)border.Content.GetVisualTreeDescendants()[0]; + } + + sealed class MockRatingViewViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public int MaxRating + { + get; + set + { + if (!Equals(value, field)) + { + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(MaxRating))); + } + } + } = 0; + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj b/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj index 4ad219cbda..c5e09dab36 100644 --- a/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj +++ b/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj @@ -53,7 +53,7 @@ - + diff --git a/src/CommunityToolkit.Maui/Views/RatingView/RatingView.shared.cs b/src/CommunityToolkit.Maui/Views/RatingView/RatingView.shared.cs new file mode 100644 index 0000000000..d31954cb36 --- /dev/null +++ b/src/CommunityToolkit.Maui/Views/RatingView/RatingView.shared.cs @@ -0,0 +1,558 @@ +// Ignore Spelling: color, bindable, colors + +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using CommunityToolkit.Maui.Core; +using Microsoft.Maui.Controls.Shapes; +using Path = Microsoft.Maui.Controls.Shapes.Path; + +namespace CommunityToolkit.Maui.Views; + +/// Rating view control. +public partial class RatingView : TemplatedView, IRatingView +{ + /// Bindable property for attached property . + public static readonly BindableProperty CustomItemShapeProperty = BindableProperty.Create(nameof(CustomItemShape), typeof(string), typeof(RatingView), defaultValue: null, propertyChanged: OnCustomShapePropertyChanged); + + /// Bindable property for . + public static readonly BindableProperty ItemPaddingProperty = BindableProperty.Create(nameof(ItemPadding), typeof(Thickness), typeof(RatingView), default(Thickness), propertyChanged: OnItemPaddingPropertyChanged, defaultValueCreator: static _ => RatingViewDefaults.ItemPadding); + + /// Bindable property for attached property . + public static readonly BindableProperty ItemShapeProperty = BindableProperty.Create(nameof(ItemShape), typeof(RatingViewShape), typeof(RatingView), defaultValue: RatingViewDefaults.ItemShape, propertyChanged: OnItemShapePropertyChanged, defaultValueCreator: static _ => RatingViewDefaults.ItemShape); + + /// Bindable property for attached property . + public static readonly BindableProperty ShapeBorderColorProperty = BindableProperty.Create(nameof(ShapeBorderColor), typeof(Color), typeof(RatingView), defaultValue: RatingViewDefaults.ShapeBorderColor, propertyChanged: OnItemShapeBorderColorChanged, defaultValueCreator: static _ => RatingViewDefaults.ShapeBorderColor); + + /// Bindable property for attached property . + public static readonly BindableProperty ShapeBorderThicknessProperty = BindableProperty.Create(nameof(ShapeBorderThickness), typeof(double), typeof(RatingView), defaultValue: RatingViewDefaults.ShapeBorderThickness, propertyChanged: OnItemShapeBorderThicknessChanged, defaultValueCreator: static _ => RatingViewDefaults.ShapeBorderThickness); + + /// Bindable property for attached property . + public static readonly BindableProperty ItemShapeSizeProperty = BindableProperty.Create(nameof(ItemShapeSize), typeof(double), typeof(RatingView), defaultValue: RatingViewDefaults.ItemShapeSize, propertyChanged: OnItemShapeSizeChanged, defaultValueCreator: static _ => RatingViewDefaults.ItemShapeSize); + + /// Bindable property for attached property . + public static readonly BindableProperty EmptyColorProperty = BindableProperty.Create(nameof(EmptyColor), typeof(Color), typeof(RatingView), defaultValue: RatingViewDefaults.EmptyColor, propertyChanged: OnRatingColorChanged, defaultValueCreator: static _ => RatingViewDefaults.EmptyColor); + + /// Bindable property for attached property . + public static readonly BindableProperty FilledColorProperty = BindableProperty.Create(nameof(FilledColor), typeof(Color), typeof(RatingView), defaultValue: RatingViewDefaults.FilledColor, propertyChanged: OnRatingColorChanged, defaultValueCreator: static _ => RatingViewDefaults.FilledColor); + + /// The backing store for the bindable property. + public static readonly BindableProperty IsReadOnlyProperty = BindableProperty.Create(nameof(IsReadOnly), typeof(bool), typeof(RatingView), defaultValue: RatingViewDefaults.IsReadOnly, propertyChanged: OnIsReadOnlyChanged); + + /// The backing store for the bindable property. + public static readonly BindableProperty MaximumRatingProperty = BindableProperty.Create(nameof(MaximumRating), typeof(int), typeof(RatingView), defaultValue: RatingViewDefaults.MaximumRating, validateValue: IsMaximumRatingValid, propertyChanged: OnMaximumRatingChange); + + /// The backing store for the bindable property. + public static readonly BindableProperty RatingFillProperty = BindableProperty.Create(nameof(RatingFill), typeof(RatingFillElement), typeof(RatingView), defaultValue: RatingFillElement.Shape, propertyChanged: OnRatingColorChanged); + + /// The backing store for the bindable property. + public static readonly BindableProperty RatingProperty = BindableProperty.Create(nameof(Rating), typeof(double), typeof(RatingView), defaultValue: RatingViewDefaults.DefaultRating, validateValue: IsRatingValid, propertyChanged: OnRatingChanged); + + /// The backing store for the bindable property. + public static readonly BindableProperty SpacingProperty = BindableProperty.Create(nameof(Spacing), typeof(double), typeof(RatingView), defaultValue: RatingViewDefaults.Spacing, propertyChanged: OnSpacingChanged); + + readonly WeakEventManager weakEventManager = new(); + + ///The default constructor of the control. + public RatingView() + { + RatingLayout.SetBinding(BindingContextProperty, static ratingView => ratingView.BindingContext, source: this); + base.ControlTemplate = new ControlTemplate(() => RatingLayout); + + AddChildrenToLayout(0, MaximumRating); + } + + /// Occurs when is changed. + public event EventHandler RatingChanged + { + add => weakEventManager.AddEventHandler(value); + remove => weakEventManager.RemoveEventHandler(value); + } + + /// + public new ControlTemplate ControlTemplate => base.ControlTemplate; // Ensures the ControlTemplate is readonly, preventing users from breaking the HorizontalStackLayout + + ///Defines the shape to be drawn. + public string? CustomItemShape + { + get => (string?)GetValue(CustomItemShapeProperty); + set => SetValue(CustomItemShapeProperty, value); + } + + /// Gets or sets a value of the empty rating color property. + [AllowNull] + public Color EmptyColor + { + get => (Color)GetValue(EmptyColorProperty); + set => SetValue(EmptyColorProperty, value ?? Colors.Transparent); + } + + /// Gets or sets a value of the filled rating color property. + [AllowNull] + public Color FilledColor + { + get => (Color)GetValue(FilledColorProperty); + set => SetValue(FilledColorProperty, value ?? Colors.Transparent); + } + + ///Gets or sets a value indicating if the controls is read-only. + public bool IsReadOnly + { + get => (bool)GetValue(IsReadOnlyProperty); + set => SetValue(IsReadOnlyProperty, value); + } + + /// Gets or sets a value indicating the padding between the rating item and the rating shape. + public Thickness ItemPadding + { + get => (Thickness)GetValue(ItemPaddingProperty); + set => SetValue(ItemPaddingProperty, value); + } + + /// Gets or sets a value indicating the shape size. + public double ItemShapeSize + { + get => (double)GetValue(ItemShapeSizeProperty); + set => SetValue(ItemShapeSizeProperty, value); + } + + /// Gets or sets a value indicating the maximum rating. + public int MaximumRating + { + get => (int)GetValue(MaximumRatingProperty); + set + { + switch (value) + { + case <= 0: + throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(MaximumRating)} must be greater than 0"); + case > RatingViewDefaults.MaximumRatingLimit: + throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(MaximumRating)} cannot be greater than {nameof(RatingViewDefaults.MaximumRatingLimit)}"); + default: + SetValue(MaximumRatingProperty, value); + break; + } + } + } + + /// Gets or sets a value indicating the rating. + public double Rating + { + get => (double)GetValue(RatingProperty); + set + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(Rating)} cannot be less than 0"); + } + + if (value > MaximumRating) + { + throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(Rating)} cannot be greater than {nameof(MaximumRating)}"); + } + + SetValue(RatingProperty, value); + } + } + + /// Gets or sets a value indicating which element of the rating to fill. + public RatingFillElement RatingFill + { + get => (RatingFillElement)GetValue(RatingFillProperty); + set => SetValue(RatingFillProperty, value); + } + + ///Gets or sets a value indicating the rating shape. + public RatingViewShape ItemShape + { + get => (RatingViewShape)GetValue(ItemShapeProperty); + set => SetValue(ItemShapeProperty, value); + } + + /// Gets or sets a value indicating the rating shape border color. + [AllowNull] + public Color ShapeBorderColor + { + get => (Color)GetValue(ShapeBorderColorProperty); + set => SetValue(ShapeBorderColorProperty, value ?? Colors.Transparent); + } + + ///Gets or sets a value indicating the rating shape border thickness. + public double ShapeBorderThickness + { + get => (double)GetValue(ShapeBorderThicknessProperty); + set + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(ShapeBorderThickness), $"{nameof(ShapeBorderThickness)} must be greater than 0"); + } + + SetValue(ShapeBorderThicknessProperty, value); + } + } + + ///Gets or sets a value indicating the space between rating items. + public double Spacing + { + get => (double)GetValue(SpacingProperty); + set => SetValue(SpacingProperty, value); + } + + internal HorizontalStackLayout RatingLayout { get; } = new(); + + static int GetRatingWhenMaximumRatingEqualsOne(double rating) => rating.Equals(0.0) ? 1 : 0; + + static Border CreateChild(string shape, Thickness itemPadding, double shapeBorderThickness, double itemShapeSize, Brush shapeBorderColor, Color itemColor) => new() + { + BackgroundColor = itemColor, + Margin = 0, + Padding = itemPadding, + Stroke = new SolidColorBrush(Colors.Transparent), + StrokeThickness = 0, + Style = null!, + + Content = new Path + { + Aspect = Stretch.Uniform, + Data = (Geometry?)new PathGeometryConverter().ConvertFromInvariantString(shape), + HeightRequest = itemShapeSize, + Stroke = shapeBorderColor, + StrokeLineCap = PenLineCap.Round, + StrokeLineJoin = PenLineJoin.Round, + StrokeThickness = shapeBorderThickness, + WidthRequest = itemShapeSize, + } + }; + + static ReadOnlyCollection GetVisualTreeDescendantsWithBorderAndShape(VisualElement root, bool isShapeFill) + { + List result = []; + var stackLayout = (HorizontalStackLayout)root.GetVisualTreeDescendants().OfType().First(); + foreach (var child in stackLayout.Children) + { + if (isShapeFill) + { + if (child is not Border border) + { + throw new InvalidOperationException($"Children must be of type {nameof(Border)}"); + } + + if (border.Content is not Shape borderShape) + { + throw new InvalidOperationException($"Border Content must be of type {nameof(ItemShape)}"); + } + + result.Add(borderShape); + } + else + { + result.Add((Border)child); + } + } + + return result.AsReadOnly(); + } + + static bool IsMaximumRatingValid(BindableObject bindable, object value) + { + return (int)value is >= 1 and <= RatingViewDefaults.MaximumRatingLimit; + } + + static void OnIsReadOnlyChanged(BindableObject bindable, object oldValue, object newValue) + { + var ratingView = (RatingView)bindable; + + foreach (var child in ratingView.RatingLayout.Children.Cast()) + { + if (!ratingView.IsReadOnly) + { + TapGestureRecognizer tapGestureRecognizer = new(); + tapGestureRecognizer.Tapped += ratingView.OnItemTapped; + child.GestureRecognizers.Add(tapGestureRecognizer); + continue; + } + + child.GestureRecognizers.Clear(); + } + } + + static void OnMaximumRatingChange(BindableObject bindable, object oldValue, object newValue) + { + var ratingView = (RatingView)bindable; + + var layout = ratingView.RatingLayout; + var newMaximumRatingValue = (int)newValue; + var oldMaximumRatingValue = (int)oldValue; + + if (newMaximumRatingValue < oldMaximumRatingValue) + { + for (var lastElement = layout.Count - 1; lastElement >= newMaximumRatingValue; lastElement--) + { + layout.RemoveAt(lastElement); + } + + ratingView.UpdateAllRatingsFills(ratingView.RatingFill); + } + else if (newMaximumRatingValue > oldMaximumRatingValue) + { + ratingView.AddChildrenToLayout(oldMaximumRatingValue - 1, newMaximumRatingValue - 1); + } + + if (newMaximumRatingValue < ratingView.Rating) // Ensure Rating is never greater than MaximumRating + { + ratingView.Rating = newMaximumRatingValue; + } + } + + static void OnRatingChanged(BindableObject bindable, object oldValue, object newValue) + { + var ratingView = (RatingView)bindable; + var newRating = (double)newValue; + + ratingView.UpdateAllRatingsFills(ratingView.RatingFill); + ratingView.OnRatingChangedEvent(new RatingChangedEventArgs(newRating)); + } + + static void OnSpacingChanged(BindableObject bindable, object oldValue, object newValue) + { + var ratingView = (RatingView)bindable; + ratingView.RatingLayout.Spacing = (double)newValue; + } + + static void OnRatingColorChanged(BindableObject bindable, object oldValue, object newValue) + { + var ratingView = (RatingView)bindable; + ratingView.UpdateAllRatingsFills(ratingView.RatingFill); + } + + static LinearGradientBrush GetPartialFillBrush(Color filledColor, double partialFill, Color emptyColor) + { + return new( + [ + new GradientStop(filledColor, 0), + new GradientStop(filledColor, (float)partialFill), + new GradientStop(emptyColor, (float)partialFill) + ], + new Point(0, 0), new Point(1, 0)); + } + + static bool IsRatingValid(BindableObject bindable, object value) + { + return (double)value is >= 0.0 and <= RatingViewDefaults.MaximumRatingLimit; + } + + static void OnCustomShapePropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + var ratingView = (RatingView)bindable; + var newShape = (string)newValue; + + if (ratingView.ItemShape is not RatingViewShape.Custom) + { + return; + } + + string newShapePathData; + if (string.IsNullOrEmpty(newShape)) + { + ratingView.ItemShape = RatingViewDefaults.ItemShape; + newShapePathData = Core.Primitives.RatingViewShape.Star.PathData; + } + else + { + newShapePathData = newShape; + } + + ratingView.ChangeRatingItemShape(newShapePathData); + } + + static void OnItemPaddingPropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + var ratingView = (RatingView)bindable; + + for (var element = 0; element < ratingView.RatingLayout.Count; element++) + { + ((Border)ratingView.RatingLayout.Children[element]).Padding = (Thickness)newValue; + } + } + + static void OnItemShapeBorderColorChanged(BindableObject bindable, object oldValue, object newValue) + { + var ratingView = (RatingView)bindable; + + for (var element = 0; element < ratingView.RatingLayout.Count; element++) + { + var border = (Border)ratingView.RatingLayout.Children[element]; + if (border.Content is not null) + { + ((Path)border.Content.GetVisualTreeDescendants()[0]).Stroke = (Color)newValue; + } + } + } + + static void OnItemShapeBorderThicknessChanged(BindableObject bindable, object oldValue, object newValue) + { + var ratingView = (RatingView)bindable; + + for (var element = 0; element < ratingView.RatingLayout.Count; element++) + { + var border = (Border)ratingView.RatingLayout.Children[element]; + if (border.Content is not null) + { + ((Path)border.Content.GetVisualTreeDescendants()[0]).StrokeThickness = (double)newValue; + } + } + } + + static void OnItemShapePropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + var ratingView = (RatingView)bindable; + + ratingView.ChangeRatingItemShape(ratingView.GetShapePathData((RatingViewShape)newValue)); + } + + static void OnItemShapeSizeChanged(BindableObject bindable, object oldValue, object newValue) + { + var ratingView = (RatingView)bindable; + + for (var element = 0; element < ratingView.RatingLayout.Count; element++) + { + var border = (Border)ratingView.RatingLayout.Children[element]; + if (border.Content is null) + { + continue; + } + + var rating = (Path)border.Content.GetVisualTreeDescendants()[0]; + rating.WidthRequest = (double)newValue; + rating.HeightRequest = (double)newValue; + } + } + + void AddChildrenToLayout(int minimumRating, int maximumRating) + { + RatingLayout.Spacing = Spacing; + var shape = GetShapePathData(ItemShape); + for (var i = minimumRating; i < maximumRating; i++) + { + var child = CreateChild(shape, ItemPadding, ShapeBorderThickness, ItemShapeSize, ShapeBorderColor, BackgroundColor); + if (!IsReadOnly) + { + TapGestureRecognizer tapGestureRecognizer = new(); + tapGestureRecognizer.Tapped += OnItemTapped; + child.GestureRecognizers.Add(tapGestureRecognizer); + } + + RatingLayout.Children.Add(child); + } + + UpdateAllRatingsFills(RatingFill); + } + + void ChangeRatingItemShape(string shape) + { + for (var element = 0; element < RatingLayout.Count; element++) + { + var border = (Border)RatingLayout.Children[element]; + if (border.Content is not null) + { + ((Path)border.Content.GetVisualTreeDescendants()[0]).Data = (Geometry?)new PathGeometryConverter().ConvertFromInvariantString(shape); + } + } + } + + string GetShapePathData(RatingViewShape shape) => shape switch + { + RatingViewShape.Circle => Core.Primitives.RatingViewShape.Circle.PathData, + RatingViewShape.Custom => CustomItemShape ?? Core.Primitives.RatingViewShape.Star.PathData, + RatingViewShape.Dislike => Core.Primitives.RatingViewShape.Dislike.PathData, + RatingViewShape.Heart => Core.Primitives.RatingViewShape.Heart.PathData, + RatingViewShape.Like => Core.Primitives.RatingViewShape.Like.PathData, + _ => Core.Primitives.RatingViewShape.Star.PathData + }; + + void OnItemTapped(object? sender, TappedEventArgs? e) + { + ArgumentNullException.ThrowIfNull(sender); + + var border = (Border)sender; + var itemIndex = RatingLayout.Children.IndexOf(border); + Rating = MaximumRating > 1 ? itemIndex + 1 : GetRatingWhenMaximumRatingEqualsOne(Rating); + } + + void OnRatingChangedEvent(RatingChangedEventArgs e) + { + weakEventManager.HandleEvent(this, e, nameof(RatingChanged)); + } + + void UpdateAllRatingsFills(RatingFillElement ratingFill) + { + var isShapeFill = ratingFill is RatingFillElement.Shape; + var visualElements = GetVisualTreeDescendantsWithBorderAndShape((VisualElement)RatingLayout.GetVisualTreeDescendants()[0], isShapeFill); + + if (isShapeFill) + { + UpdateAllRatingShapeFills(visualElements, Rating, FilledColor, EmptyColor); + } + else + { + UpdateRatingItemsFills(visualElements, Rating, FilledColor, EmptyColor, BackgroundColor); + } + + return; + + static void UpdateAllRatingShapeFills(ReadOnlyCollection ratingItems, double rating, Color filledColor, Color emptyColor) + { + var fullFillCount = (int)Math.Floor(rating); // Determine the number of fully filled shapes + var partialFillCount = rating - fullFillCount; // Determine the fraction for the partially filled shape (if any) + + for (var i = 0; i < ratingItems.Count; i++) + { + var ratingShape = (Shape)ratingItems[i]; + if (i < fullFillCount) + { + ratingShape.Fill = filledColor; // Fully filled shape + } + else if (i == fullFillCount && partialFillCount > 0) + { + ratingShape.Fill = GetPartialFillBrush(filledColor, partialFillCount, emptyColor); // Partial fill + } + else + { + ratingShape.Fill = emptyColor; // Empty fill + } + } + } + + static void UpdateRatingItemsFills(ReadOnlyCollection ratingItems, double rating, Color filledColor, Color emptyColor, Color? backgroundColor) + { + var fullFillCount = (int)Math.Floor(rating); // Determine the number of fully filled rating items + var partialFillCount = rating - fullFillCount; // Determine the fraction for the partially filled rating item (if any) + + backgroundColor ??= Colors.Transparent; + + for (var i = 0; i < ratingItems.Count; i++) + { + var border = (Border)ratingItems[i]; + if (border.Content is not Shape shape) + { + continue; + } + + shape.Fill = emptyColor; + if (i < fullFillCount) + { + border.Background = new SolidColorBrush(filledColor); // Fully filled shape + } + else if (i == fullFillCount && partialFillCount > 0) + { + border.Background = GetPartialFillBrush(filledColor, partialFillCount, backgroundColor); // Partial fill + } + else + { + border.Background = new SolidColorBrush(backgroundColor); // Empty fill + } + } + } + } +} +