diff --git a/fluXis/Graphics/UserInterface/Panel/Presets/FormPanel.cs b/fluXis/Graphics/UserInterface/Panel/Presets/FormPanel.cs index 2326098f6..26c793c97 100644 --- a/fluXis/Graphics/UserInterface/Panel/Presets/FormPanel.cs +++ b/fluXis/Graphics/UserInterface/Panel/Presets/FormPanel.cs @@ -144,11 +144,11 @@ private IEnumerable createInputs(T data) if (group == groupId && !string.IsNullOrEmpty(groupId)) { - groupItems.Add(prop, new GroupItem(createDrawable(prop, data), groupAttr)); + groupItems.Add(prop, new GroupItem(createDrawable(prop, data, OnDataUpdate), groupAttr)); continue; } - yield return createDrawable(prop, data); + yield return createDrawable(prop, data, OnDataUpdate); } if (!string.IsNullOrEmpty(groupId) && groupItems.Count > 0) @@ -194,7 +194,7 @@ private static Drawable createGroup(Dictionary dict) }; } - private static Drawable createDrawable(PropertyInfo prop, T data) + private static Drawable createDrawable(PropertyInfo prop, T data, Action onDataUpdate = null) { var type = prop.PropertyType; @@ -223,6 +223,7 @@ private static Drawable createDrawable(PropertyInfo prop, T data) var bytes = File.ReadAllBytes(file.FullName); var b64 = Convert.ToBase64String(bytes); prop.SetValue(data, b64); + onDataUpdate?.Invoke(data); } }; @@ -240,7 +241,11 @@ private static Drawable createDrawable(PropertyInfo prop, T data) return new SetupColor(name) { Color = Colour4.TryParseHex(val as string ?? "#ffffff", out var c) ? c : Colour4.White, - OnColorChanged = v => prop.SetValue(data, v.ToHex()) + OnColorChanged = v => + { + prop.SetValue(data, v.ToHex()); + onDataUpdate?.Invoke(data); + } }; } } @@ -248,6 +253,7 @@ private static Drawable createDrawable(PropertyInfo prop, T data) return new FluXisSpriteText { Text = $"could not create input for type {owr} ({name})" }; } + // TODO: call onDataUpdate here? if (type.IsEnum) { var getValues = typeof(Enum) @@ -273,10 +279,37 @@ private static Drawable createDrawable(PropertyInfo prop, T data) { Default = val as string, Placeholder = prop.GetCustomAttribute()?.Placeholder ?? string.Empty, + TooltipText = prop.GetCustomAttribute()?.TooltipText ?? string.Empty, MaxLength = prop.GetCustomAttribute()?.Length ?? 256, ReadOnly = prop.GetCustomAttribute()?.IsReadOnly ?? false, Password = prop.GetCustomAttribute()?.Password ?? false, - OnChange = v => prop.SetValue(data, v) + OnChange = v => + { + prop.SetValue(data, v); + onDataUpdate?.Invoke(data); + } + }; + } + + if (type == typeof(float)) + { + var range = prop.GetCustomAttribute(); + var min = range != null ? Convert.ToSingle(range.Minimum) : float.MinValue; + var max = range != null ? Convert.ToSingle(range.Maximum) : float.MaxValue; + + return new SetupNumberBox(name) + { + Default = val as string, + Placeholder = prop.GetCustomAttribute()?.Placeholder ?? string.Empty, + TooltipText = prop.GetCustomAttribute()?.TooltipText ?? string.Empty, + ReadOnly = prop.GetCustomAttribute()?.IsReadOnly ?? false, + Min = min, + Max = max, + OnChange = v => + { + prop.SetValue(data, v); + onDataUpdate?.Invoke(data); + } }; } @@ -286,6 +319,10 @@ void throwInvalidCombo(TypeOverrideAttribute.Type attr) => throw new InvalidOperationException($"Custom type '{attr}' can not be represented with '{prop.PropertyType}'."); } + protected virtual void OnDataUpdate(T data) + { + } + public void Close() { if (Loading) diff --git a/fluXis/Online/API/Payloads/Maps/MapRateVotePayload.cs b/fluXis/Online/API/Payloads/Maps/MapRateVotePayload.cs new file mode 100644 index 000000000..b2d52412b --- /dev/null +++ b/fluXis/Online/API/Payloads/Maps/MapRateVotePayload.cs @@ -0,0 +1,52 @@ +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using fluXis.Utils.Attributes; +using Newtonsoft.Json; + +namespace fluXis.Online.API.Payloads.Maps; + +// TODO: make fluxel use this instead of MapRateVoteRoute's internal 'Payload' class +public class MapRateVotePayload +{ + [Description("Chart Difficulty (0-20)")] + [Tooltip("The base chart difficulty.")] + [Placeholder("0")] + [Range(0.0, 20.0)] + [JsonProperty("base")] + public float Base { get; set; } + + [Description("Read Difficulty (0-5)")] + [Tooltip("SV stuff.")] + [Placeholder("0")] + [Range(0.0, 5.0)] + [JsonProperty("read")] + public float Reading { get; set; } + + [Description("Track Difficulty (0-5)")] + [Tooltip("uuuh.")] + [Placeholder("0")] + [Range(0.0, 5.0)] + [JsonProperty("track")] + public float Tracking { get; set; } + + [Description("Perception Difficulty (0-5)")] + [Tooltip("Blocking elements such as shaders and storyboard.")] + [Placeholder("0")] + [Range(0.0, 5.0)] + [JsonProperty("percept")] + public float Perception { get; set; } + + public bool IsRatingValid() + { + return Base is >= 0 and <= 20 && + Reading is >= 0 and <= 5 && + Tracking is >= 0 and <= 5 && + Perception is >= 0 and <= 5; + } + + public float ComputeRating() + { + return (float)Math.Round(Base + ((Reading + Tracking + Perception) / 3) * 2, 2); + } +} diff --git a/fluXis/Online/API/Requests/Maps/MapRateVoteRequest.cs b/fluXis/Online/API/Requests/Maps/MapRateVoteRequest.cs new file mode 100644 index 000000000..5c206766e --- /dev/null +++ b/fluXis/Online/API/Requests/Maps/MapRateVoteRequest.cs @@ -0,0 +1,28 @@ +using System.Net.Http; +using fluXis.Online.API.Payloads.Maps; +using fluXis.Utils; +using WebRequest = osu.Framework.IO.Network.WebRequest; + +namespace fluXis.Online.API.Requests.Maps; + +public class MapRateVoteRequest : APIRequest +{ + protected override string Path => $"/map/{id}/rate"; + protected override HttpMethod Method => HttpMethod.Post; + + private long id { get; } + private MapRateVotePayload payload { get; } + + public MapRateVoteRequest(long id, MapRateVotePayload payload) + { + this.id = id; + this.payload = payload; + } + + protected override WebRequest CreateWebRequest(string url) + { + var req = base.CreateWebRequest(url); + req.AddRaw(payload.Serialize()); + return req; + } +} diff --git a/fluXis/Online/API/Requests/Maps/MapRequest.cs b/fluXis/Online/API/Requests/Maps/MapRequest.cs new file mode 100644 index 000000000..ad43b05ef --- /dev/null +++ b/fluXis/Online/API/Requests/Maps/MapRequest.cs @@ -0,0 +1,15 @@ +using fluXis.Online.API.Models.Maps; + +namespace fluXis.Online.API.Requests.Maps; + +public class MapRequest : APIRequest +{ + protected override string Path => $"/map/{id}"; + + private long id { get; } + + public MapRequest(long id) + { + this.id = id; + } +} diff --git a/fluXis/Overlay/MapSet/MapSetOverlay.cs b/fluXis/Overlay/MapSet/MapSetOverlay.cs index ec8ac92e7..53b52b0a1 100644 --- a/fluXis/Overlay/MapSet/MapSetOverlay.cs +++ b/fluXis/Overlay/MapSet/MapSetOverlay.cs @@ -3,7 +3,10 @@ using fluXis.Graphics; using fluXis.Graphics.Containers; using fluXis.Graphics.Sprites.Icons; +using fluXis.Graphics.Sprites.Text; +using fluXis.Graphics.UserInterface.Buttons; using fluXis.Graphics.UserInterface.Color; +using fluXis.Graphics.UserInterface.Panel; using fluXis.Graphics.UserInterface.Tabs; using fluXis.Input; using fluXis.Online.API.Models.Maps; @@ -12,6 +15,7 @@ using fluXis.Overlay.MapSet.Buttons; using fluXis.Overlay.MapSet.Sidebar; using fluXis.Overlay.MapSet.Tabs; +using fluXis.Overlay.MapSet.UI; using fluXis.Overlay.MapSet.UI.Difficulties; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -30,6 +34,9 @@ public partial class MapSetOverlay : OverlayContainer, IKeyBindingHandler panels.Content = new RateVoteFormPanel(bindableMap.Value.ID), + Alpha = canVote(bindableMap.Value) ? 1f : 0f + }, + alreadyVotedContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = 40, + Alpha = canVote(bindableMap.Value) ? 0f : 1f, + Child = alreadyVotedText = new FluXisSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + WebFontSize = 16, + Text = "Already voted", //TODO: display the user's rating + } + }, new MapSetSidebarMapper(bindableMap), new MapSetSidebarVoting(set), new MapSetSidebarStats(bindableMap) @@ -222,6 +254,22 @@ private void displayData(APIMapSet set) } } }; + + bindableMap.BindValueChanged(e => updateRateVoteAlpha(e.NewValue)); + } + + private void updateRateVoteAlpha(APIMap map) + { + bool votable = canVote(map); + rateVoteButton.Alpha = votable ? 1.0f : 0.0f; + alreadyVotedContainer.Alpha = votable ? 0.0f : 1.0f; + } + + private bool canVote(APIMap map) + { + // if (!api.User.Value.IsPurifier()) return false; // <- uncomment this if we want to display the button to purifiers only + + return !map.HasVotedRate && api.User.Value.ID != map.Mapper.ID; } private IEnumerable createTabs(APIMapSet set, Bindable bindableMap) diff --git a/fluXis/Overlay/MapSet/UI/RateVoteFormPanel.cs b/fluXis/Overlay/MapSet/UI/RateVoteFormPanel.cs new file mode 100644 index 000000000..1bb2c881f --- /dev/null +++ b/fluXis/Overlay/MapSet/UI/RateVoteFormPanel.cs @@ -0,0 +1,91 @@ +using System; +using fluXis.Graphics.Sprites.Icons; +using fluXis.Graphics.UserInterface.Color; +using fluXis.Graphics.UserInterface.Panel.Presets; +using fluXis.Graphics.UserInterface.Text; +using fluXis.Online.API.Payloads.Maps; +using fluXis.Online.API.Requests.Maps; +using fluXis.Online.Fluxel; +using fluXis.Overlay.Notifications; +using fluXis.Utils; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; + +namespace fluXis.Overlay.MapSet.UI; + +public partial class RateVoteFormPanel : FormPanel +{ + [Resolved] + private IAPIClient api { get; set; } + + [Resolved] + private NotificationManager notifications { get; set; } + + private readonly FluXisTextFlow ratingText; + private readonly long mapId; + private readonly Action onSuccess; + + public RateVoteFormPanel(long mapId, Action onSuccess = null) + : base(FontAwesome6.Solid.Check, new LocalisableString($"Rate Vote (id {mapId})"), new MapRateVotePayload(), (form, data) => ((RateVoteFormPanel)form).onVote(form, data), new LocalisableString("Vote")) + { + this.mapId = mapId; + this.onSuccess = onSuccess; + + BottomContainer.Add(new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Children = new Drawable[] + { + new FluXisTextFlow + { + AutoSizeAxes = Axes.Both, + Text = "Your Rating: ", + WebFontSize = 16, + }, + ratingText = new FluXisTextFlow + { + AutoSizeAxes = Axes.Both, + Text = "0", + Colour = Theme.GetDifficultyColor(0f), + WebFontSize = 16, + } + } + }); + } + + protected override void OnDataUpdate(MapRateVotePayload data) + { + float rating = data.ComputeRating(); + ratingText.Text = rating.ToStringInvariant(); + ratingText.Colour = Theme.GetDifficultyColor(rating); + } + + private bool onVote(FormPanel form, MapRateVotePayload data) + { + // just in case + if (!data.IsRatingValid()) + return false; + + form.StartLoading(); + + var req = new MapRateVoteRequest(mapId, data); + req.Success += _ => Schedule(() => + { + form.StopLoading(); + form.Close(); + onSuccess?.Invoke(data.ComputeRating()); + }); + req.Failure += ex => Schedule(() => + { + form.StopLoading(); + notifications.SendError("Failed to vote!", ex.Message); + }); + api.PerformRequestAsync(req); + + return false; + } +} diff --git a/fluXis/Screens/Edit/Tabs/Setup/Entries/SetupNumberBox.cs b/fluXis/Screens/Edit/Tabs/Setup/Entries/SetupNumberBox.cs new file mode 100644 index 000000000..00d7cea59 --- /dev/null +++ b/fluXis/Screens/Edit/Tabs/Setup/Entries/SetupNumberBox.cs @@ -0,0 +1,103 @@ +using System; +using System.Text.RegularExpressions; +using fluXis.Utils; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace fluXis.Screens.Edit.Tabs.Setup.Entries; + +public partial class SetupNumberBox : CompositeDrawable +{ + private static readonly Regex invalid_chars_regex = new Regex(@"[^0-9.\-]", RegexOptions.Compiled); // only allow digits, '.' and '-' + + private SetupTextBox textBox; + private readonly string title; + + public float Min { get; init; } = float.MinValue; + public float Max { get; init; } = float.MaxValue; + public string Default { get; init; } = string.Empty; + public string Placeholder { get; init; } = string.Empty; + public string TooltipText { get; init; } = string.Empty; + public bool ReadOnly { get; init; } = false; + public Action OnChange { get; init; } = _ => { }; + + public float Value + { + get + { + if (float.TryParse(textBox.Value, out float value)) + return Math.Clamp(value, Min, Max); + + if (float.TryParse(Placeholder, out value)) + return Math.Clamp(value, Min, Max); + + return 0; // worth throwing an exception? + } + set => textBox.Value = Math.Clamp(value, Min, Max).ToStringInvariant(); + } + + public float PlaceholderValue => float.TryParse(Placeholder, out float value) ? Math.Clamp(value, Min, Max) : 0; + + public SetupNumberBox(string title) + { + this.title = title; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = textBox = new SetupTextBox(title) + { + Default = Default, + Placeholder = Placeholder, + ReadOnly = ReadOnly, + TooltipText = TooltipText, + OnChange = v => OnChange.Invoke(sanitiseInput(textBox, v) ? Value : PlaceholderValue), + OnCommit = _ => clampAndCommit(textBox) + }; + } + + private bool sanitiseInput(SetupTextBox field, string newValue) + { + newValue = invalid_chars_regex.Replace(newValue, ""); + + // keep only one minus sign + if (newValue.Contains('-')) + newValue = "-" + newValue.Replace("-", ""); + + // keep only the first dot + int firstDot = newValue.IndexOf('.'); + + if (firstDot != -1) + { + newValue = newValue.Substring(0, firstDot + 1) + + newValue.Substring(firstDot + 1).Replace(".", ""); + } + + if (field.Value != newValue) + field.Value = newValue; + + // this could return false if the field is left empty of is there is only a minus or dot + return float.TryParse(newValue, out _); + } + + private void clampAndCommit(SetupTextBox field) + { + if (!float.TryParse(field.Value, out float value)) + { + field.Value = ""; + return; + } + + float clamped = Math.Clamp(value, Min, Max); + + if (clamped != value) + field.Value = clamped.ToStringInvariant(); + + OnChange.Invoke(clamped); + } +} diff --git a/fluXis/Screens/Edit/Tabs/Setup/Entries/SetupTextBox.cs b/fluXis/Screens/Edit/Tabs/Setup/Entries/SetupTextBox.cs index ceef54a71..7221ba795 100644 --- a/fluXis/Screens/Edit/Tabs/Setup/Entries/SetupTextBox.cs +++ b/fluXis/Screens/Edit/Tabs/Setup/Entries/SetupTextBox.cs @@ -23,6 +23,7 @@ public string Value public string Default { get; init; } = string.Empty; public string Placeholder { get; init; } = string.Empty; public Action OnChange { get; init; } = _ => { }; + public Action OnCommit { get; init; } = null; public int MaxLength { get; init; } = 256; public bool ReadOnly { get; init; } public bool Password { get; init; } @@ -51,7 +52,11 @@ public SetupTextBox(string title) BackgroundActive = BackgroundColor, BackgroundInactive = BackgroundColor, OnTextChanged = () => OnChange.Invoke(textBox.Text), - OnCommitAction = () => OnChange.Invoke(textBox.Text), + OnCommitAction = () => + { + OnChange.Invoke(textBox.Text); // if OnCommit is set maybe OnChange shouldn't be invoked there? + OnCommit?.Invoke(textBox.Text); + }, OnFocusAction = StartHighlight, OnFocusLostAction = StopHighlight, CommitOnFocusLost = true, diff --git a/fluXis/Screens/Result/Sides/Types/ResultsSideVoting.cs b/fluXis/Screens/Result/Sides/Types/ResultsSideVoting.cs index 2b6dd3bcc..c9ac9e7df 100644 --- a/fluXis/Screens/Result/Sides/Types/ResultsSideVoting.cs +++ b/fluXis/Screens/Result/Sides/Types/ResultsSideVoting.cs @@ -4,11 +4,15 @@ using fluXis.Graphics.Sprites; using fluXis.Graphics.Sprites.Icons; using fluXis.Graphics.Sprites.Text; +using fluXis.Graphics.UserInterface.Buttons; using fluXis.Graphics.UserInterface.Color; using fluXis.Graphics.UserInterface.Interaction; +using fluXis.Graphics.UserInterface.Panel; using fluXis.Online.API.Models.Maps; +using fluXis.Online.API.Requests.Maps; using fluXis.Online.API.Requests.MapSets.Votes; using fluXis.Online.Fluxel; +using fluXis.Overlay.MapSet.UI; using Humanizer; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -30,14 +34,20 @@ public partial class ResultsSideVoting : ResultsSideContainer [Resolved] private IAPIClient api { get; set; } + [Resolved] + private PanelContainer panels { get; set; } + private bool sendingRequest; private int currentVote; - private Container container; + private FillFlowContainer container; private VoteButton upButton; private FluXisSpriteText count; private VoteButton downButton; + private FluXisButton voteRateButton; + private Container alreadyVotedContainer; + private FluXisSpriteText alreadyVotedText; private FluXisSpriteText error; private LoadingIcon loading; @@ -109,31 +119,74 @@ private void setData(APIMapVotes votes) loading.FadeOut(300); container.Delay(300).FadeIn(300); sendingRequest = false; + + //TODO : for this to work properly, fluxel's MapRoute need to pass the userid to map.ToAPI() in order for map.HasVotedRate to be correct + // also, to avoid performing two requests, maybe we should create another route that returns both the up/down-vote status and whether the user already voted on rating + // ideally we'd also want to retrieve the user's vote value so we can display it (would require more fluxel changes) + var req = new MapRequest(map.OnlineID); + req.Success += apiMap => displayRateVoteButton(!apiMap.Data.HasVotedRate); + req.Failure += ex => displayRateVoteButton(false, "Failed to get vote status"); + + api.PerformRequestAsync(req); } protected override Drawable CreateContent() => new Container { RelativeSizeAxes = Axes.X, - Height = 48, + AutoSizeAxes = Axes.Y, + AutoSizeDuration = 400, + AutoSizeEasing = Easing.Out, Children = new Drawable[] { - container = new Container + container = new FillFlowContainer { - RelativeSizeAxes = Axes.Both, Alpha = 0, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), Children = new Drawable[] { - count = new FluXisSpriteText + new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Children = new Drawable[] + { + count = new FluXisSpriteText + { + WebFontSize = 20, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + upButton = new VoteButton(Theme.VoteUp, FontAwesome6.Solid.AngleUp, () => setVote(1)), + downButton = new VoteButton(Theme.VoteDown, FontAwesome6.Solid.AngleDown, () => setVote(-1)) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight + } + } + }, + voteRateButton = new FluXisButton { - WebFontSize = 20, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = 40, + Text = "Rate Vote", + Action = () => panels.Content = new RateVoteFormPanel(map.OnlineID, displayUserRateVote), + Alpha = 0f }, - upButton = new VoteButton(Theme.VoteUp, FontAwesome6.Solid.AngleUp, () => setVote(1)), - downButton = new VoteButton(Theme.VoteDown, FontAwesome6.Solid.AngleDown, () => setVote(-1)) + alreadyVotedContainer = new Container { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight + RelativeSizeAxes = Axes.X, + Height = 40, + Alpha = 0f, + Child = alreadyVotedText = new FluXisSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + WebFontSize = 16, + Text = "Your rate vote: TODO" //TODO: display current vote here (would require changes in fluxel) + } } } }, @@ -154,6 +207,20 @@ private void setData(APIMapVotes votes) } }; + private void displayRateVoteButton(bool display, string customText = "") + { + voteRateButton.Alpha = display ? 1f : 0f; + alreadyVotedContainer.Alpha = display ? 0f : 1f; + if (customText != "") alreadyVotedText.Text = customText; + } + + private void displayUserRateVote(float rating) + { + voteRateButton.Alpha = 0f; + alreadyVotedContainer.Alpha = 1f; + alreadyVotedText.Text = $"Your rate vote: {rating}"; + } + private partial class VoteButton : CompositeDrawable { [Resolved] diff --git a/fluXis/Utils/Attributes/FormAttributes.cs b/fluXis/Utils/Attributes/FormAttributes.cs index a569a60f7..7a5f6e4e1 100644 --- a/fluXis/Utils/Attributes/FormAttributes.cs +++ b/fluXis/Utils/Attributes/FormAttributes.cs @@ -16,6 +16,18 @@ public PlaceholderAttribute(string placeholder) } } +[MeansImplicitUse] +[AttributeUsage(AttributeTargets.Property)] +public class TooltipAttribute : Attribute +{ + public string TooltipText { get; } + + public TooltipAttribute(string tooltipText) + { + TooltipText = tooltipText; + } +} + [MeansImplicitUse] [AttributeUsage(AttributeTargets.Property)] public class GroupAttribute : Attribute