Skip to content

Commit

Permalink
Implement a fuzzy search over the master exercise list
Browse files Browse the repository at this point in the history
  • Loading branch information
LiamMorrow committed Jul 10, 2024
1 parent fa36e09 commit 2b3cc44
Show file tree
Hide file tree
Showing 21 changed files with 16,414 additions and 23 deletions.
3 changes: 2 additions & 1 deletion LiftLog.App/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ public static MauiApp CreateMauiApp()
AppPurchaseService,
OsEncryptionService,
AppHapticFeedbackService,
AppDeviceService
AppDeviceService,
AppBuiltInExerciseLoader
>();

builder.UseMaterialColors<ThemeColorUpdateService>(opts =>
Expand Down
15 changes: 0 additions & 15 deletions LiftLog.App/Resources/Raw/AboutAssets.txt

This file was deleted.

File renamed without changes.
29 changes: 29 additions & 0 deletions LiftLog.App/Services/AppBuiltInExerciseLoader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LiftLog.Lib;
using LiftLog.Lib.Serialization;
using LiftLog.Ui.Services;

namespace LiftLog.App.Services;

public class AppBuiltInExerciseLoader : IBuiltInExerciseLoader
{
public async Task<IReadOnlyList<DescribedExercise>> LoadBuiltInExercisesAsync()
{
using var stream = await FileSystem.OpenAppPackageFileAsync("exercises.json");
if (stream == null)
return [];

var json = await JsonSerializer.DeserializeAsync(
stream,
JsonContext.Context.ListDescribedExercise
);
return json ?? [];
}
}

[JsonSerializable(typeof(List<DescribedExercise>))]
internal partial class JsonContext : JsonSerializerContext
{
public static readonly JsonContext Context = new(JsonSerializerSettings.LiftLog);
}
6 changes: 5 additions & 1 deletion LiftLog.Ui/ServiceRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ public static IServiceCollection RegisterUiServices<
TPurchaseService,
TEncryptionService,
TVibrationService,
TDeviceService
TDeviceService,
TBuiltInExerciseService
>(this IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Singleton)
where TKeyValueStore : class, IKeyValueStore
where TPreferenceStore : class, IPreferenceStore
Expand All @@ -36,6 +37,7 @@ public static IServiceCollection RegisterUiServices<
where TEncryptionService : class, IEncryptionService
where TVibrationService : class, IHapticFeedbackService
where TDeviceService : class, IDeviceService
where TBuiltInExerciseService : class, IBuiltInExerciseLoader
{
services.AddFluxor(o =>
o.ScanAssemblies(typeof(CurrentSessionReducers).Assembly)
Expand Down Expand Up @@ -91,6 +93,8 @@ public static IServiceCollection RegisterUiServices<

services.Add<IHapticFeedbackService, TVibrationService>(lifetime);

services.Add<IBuiltInExerciseLoader, TBuiltInExerciseService>(lifetime);

services.Add<FeedApiService>(lifetime);
services.Add<FeedIdentityService>(lifetime);
services.Add<FeedFollowService>(lifetime);
Expand Down
22 changes: 22 additions & 0 deletions LiftLog.Ui/Services/IBuiltInExerciseLoader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace LiftLog.Ui.Services;

public record DescribedExercise(
string Name,
string? Force,
string? Level,
string? Mechanic,
string? Equipment,
string[]? PrimaryMuscles,
string[]? SecondaryMuscles,
string[]? Instructions,
string? Category
)
{
public static DescribedExercise FromName(string name) =>
new(name, null, null, null, null, null, null, null, null);
}

public interface IBuiltInExerciseLoader
{
Task<IReadOnlyList<DescribedExercise>> LoadBuiltInExercisesAsync();
}
17 changes: 15 additions & 2 deletions LiftLog.Ui/Shared/Presentation/ExerciseEditor.razor
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@
TextFieldType="TextFieldType.Filled"
class="w-full mb-2 text-xl text-start"
Label="Exercise"
type="combobox"
aria-controls="menu"
aria-autocomplete="list"
aria-expanded="true"
Value="@Exercise.Name"
id="exerciseTextField"
required
OnChange="@(val => SetExerciseName(val!))">
OnChange="@(val => {SetExerciseName(val!); exerciseListMenu!.Open();})"
OnFocus=@(()=>{exerciseListMenu!.Open();})
OnBlur="()=>exerciseListMenu!.Close()">
</TextField>
<ExerciseSearcher @ref="exerciseListMenu" Value="@Exercise.Name" Anchor="exerciseTextField" ValueChanged=@(v=>SetExerciseName(v)) />
<TextField
data-cy="exercise-notes"
TextFieldType="TextFieldType.Filled"
Expand Down Expand Up @@ -62,6 +70,7 @@
@code
{
private ExerciseBlueprint exercise = null!;
private ExerciseSearcher? exerciseListMenu;
[Parameter] [EditorRequired] public Action<ExerciseBlueprint> UpdateExercise { get; set; } = null!;

[Parameter] [EditorRequired] public ExerciseBlueprint Exercise { get; set; } = null!;
Expand All @@ -74,6 +83,7 @@
protected override void OnParametersSet()
{
exercise = Exercise;
base.OnParametersSet();
}

void DecrementExerciseSets() => UpdateExerciseHandler(exercise with { Sets = Math.Max(exercise.Sets - 1, 1) });
Expand All @@ -84,7 +94,10 @@

void IncrementExerciseRepsPerSet() => UpdateExerciseHandler(exercise with { RepsPerSet = exercise.RepsPerSet + 1 });

void SetExerciseName(string name) => UpdateExerciseHandler(exercise with { Name = name });
void SetExerciseName(string name)
{
UpdateExerciseHandler(exercise with { Name = name });
}

void SetExerciseNotes(string notes) => UpdateExerciseHandler(exercise with { Notes = notes });

Expand Down
6 changes: 6 additions & 0 deletions LiftLog.Ui/Shared/Presentation/Menu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,11 @@
JSRuntime.InvokeVoidAsync("AppUtils.callOn", menu, "show");
}

public void Close()
{
_open = false;
JSRuntime.InvokeVoidAsync("AppUtils.callOn", menu, "close");
}


}
2 changes: 1 addition & 1 deletion LiftLog.Ui/Shared/Presentation/MenuItem.razor
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<md-menu-item @attributes="AdditionalAttributes" @onclick="() => OnClick()" @onclick:stopPropagation=true @onclick:preventDefault=true>
<md-icon slot="start">@Icon</md-icon>
@if(Icon is not ""){<md-icon slot="start">@Icon</md-icon>}
<div slot="headline">@Label</div>
</md-menu-item>

Expand Down
9 changes: 7 additions & 2 deletions LiftLog.Ui/Shared/Presentation/TextField.razor
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
@oninput=@((e) => OnChange?.Invoke((string?)e.Value!))
@onfocusin=SelectOnFocus
@onclick=OnClick
@onblur=@(_=>SetValueOnElement())
@onblur=@(async _=>{await SetValueOnElement(); await OnBlur.InvokeAsync();})
>
@ChildContent
</md-outlined-text-field>
Expand All @@ -21,7 +21,7 @@
@oninput=@((e) => OnChange?.Invoke((string?)e.Value!))
@onfocusin=SelectOnFocus
@onclick=OnClick
@onblur=@(_=>SetValueOnElement())
@onblur=@(async _=>{await SetValueOnElement(); await OnBlur.InvokeAsync();})
>
@ChildContent
</md-filled-text-field>
Expand All @@ -43,6 +43,10 @@

[Parameter] public EventCallback OnClick { get; set; }

[Parameter] public EventCallback OnFocus { get; set; }

[Parameter] public EventCallback OnBlur { get; set; }

[Parameter] public TextFieldType TextFieldType { get; set; } = TextFieldType.Outline;

[Parameter] public bool SelectAllOnFocus { get; set; } = true;
Expand All @@ -57,6 +61,7 @@
{
if (SelectAllOnFocus)
await JSRuntime.InvokeVoidAsync("AppUtils.selectAllText", _textField);
await OnFocus.InvokeAsync();
}

private async Task SetValueOnElement()
Expand Down
61 changes: 61 additions & 0 deletions LiftLog.Ui/Shared/Smart/ExerciseSearcher.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
@inject IState<ExercisesState> State
@inject IDispatcher Dispatcher

@inherits Fluxor.Blazor.Web.Components.FluxorComponent

<Menu
@ref="exerciseListMenu"
default-focus="none"
role="listbox"
anchor="@Anchor"
skip-restore-focus=true
stay-open-on-outside-click=true
stay-open-on-focusout=true
anchor-corner="end-start"
menu-corner="start-start"
class="text-left">
@foreach (var describedExercise in filteredExercises)
{
<MenuItem Label="@describedExercise.Name" Icon="" OnClick="() => ValueChanged.InvokeAsync(describedExercise.Name)"/>
}
</Menu>

@code {
private Menu? exerciseListMenu;
[Parameter] public string Anchor { get; set; } = "";

[Parameter] public string Value { get; set; } = "";

[Parameter] public EventCallback<string> ValueChanged { get; set; }

private IReadOnlyList<DescribedExercise> filteredExercises { get; set; } = new List<DescribedExercise>();

protected override void OnParametersSet()
{
var searchTerm = Value.Replace(" ", "");
var pattern = String.Join(".*", searchTerm.AsEnumerable());
filteredExercises = State.Value.DescribedExercises
.Where(e => IsMatch(e.Name, pattern))
.OrderByDescending(e => MostConsecutiveMatches(e.Name, searchTerm))
.ThenBy(e=>DistanceOfFirstCharacterFromStartOfPattern(e.Name, searchTerm))
.Take(5)
.ToList();

base.OnParametersSet();
}

protected override void OnInitialized(){

base.OnInitialized();
Dispatcher.Dispatch(new FetchDescribedExercisesAction());
}


public void Open(){
exerciseListMenu?.Open();
}

public void Close(){
exerciseListMenu?.Close();
}
}
47 changes: 47 additions & 0 deletions LiftLog.Ui/Shared/Smart/ExerciseSearcher.razor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Text.RegularExpressions;

namespace LiftLog.Ui.Shared.Smart;

public partial class ExerciseSearcher
{
private static bool IsMatch(string? exerciseName, string pattern)
{
if (string.IsNullOrWhiteSpace(exerciseName))
return false;
return Regex.IsMatch(exerciseName, pattern, RegexOptions.IgnoreCase);
}

private static int DistanceOfFirstCharacterFromStartOfPattern(
string exerciseName,
string pattern
)
{
if (pattern.Length == 0)
return int.MaxValue;
var firstLetter = pattern[0];
var index = exerciseName.IndexOf(firstLetter, StringComparison.CurrentCultureIgnoreCase);
return index == -1 ? int.MaxValue : index;
}

private static int MostConsecutiveMatches(string exerciseName, string pattern)
{
if (pattern.Length == 0)
return 0;
var patternIndex = 0;
var matches = 0;
for (var i = 0; i < exerciseName.Length; i++)
{
if (char.ToLower(exerciseName[i]) == char.ToLower(pattern[patternIndex]))
{
patternIndex++;
if (patternIndex == pattern.Length)
return pattern.Length;
}
else
{
patternIndex = 0;
}
}
return matches;
}
}
9 changes: 9 additions & 0 deletions LiftLog.Ui/Store/Exercises/ExercisesActions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using LiftLog.Lib;
using LiftLog.Lib.Models;
using LiftLog.Ui.Services;

namespace LiftLog.Ui.Store.Exercises;

public record SetDescribedExercisesAction(ImmutableListValue<DescribedExercise> DescribedExercises);

public record FetchDescribedExercisesAction();
40 changes: 40 additions & 0 deletions LiftLog.Ui/Store/Exercises/ExercisesEffects.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Collections.Immutable;
using Fluxor;
using LiftLog.Ui.Services;

namespace LiftLog.Ui.Store.Exercises;

public class ExercisesEffects(
IBuiltInExerciseLoader builtInExerciseLoader,
ProgressRepository progressRepository
)
{
[EffectMethod]
public async Task HandleFetchDescribedExercisesAction(
FetchDescribedExercisesAction _,
IDispatcher dispatcher
)
{
var (builtInExercises, usedExercises) = await (
builtInExerciseLoader.LoadBuiltInExercisesAsync(),
Task.Run(
async () =>
await progressRepository
.GetOrderedSessions()
.SelectMany(x =>
x.RecordedExercises.Select(ex => ex.Blueprint.Name).ToAsyncEnumerable()
)
.Distinct()
.Select(DescribedExercise.FromName)
.ToListAsync()
)
);

var describedExercises = builtInExercises
.Concat(usedExercises)
.DistinctBy(x => x.Name)
.ToImmutableList();

dispatcher.Dispatch(new SetDescribedExercisesAction(describedExercises));
}
}
10 changes: 10 additions & 0 deletions LiftLog.Ui/Store/Exercises/ExercisesFeature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Fluxor;

namespace LiftLog.Ui.Store.Exercises;

public class ExercisesFeature : Feature<ExercisesState>
{
public override string GetName() => nameof(ExercisesFeature);

protected override ExercisesState GetInitialState() => new([]);
}
14 changes: 14 additions & 0 deletions LiftLog.Ui/Store/Exercises/ExercisesReducers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Fluxor;
using LiftLog.Lib.Models;
using LiftLog.Ui.Store.Exercises;

namespace LiftLog.Ui.Store.Exercises;

public static class ExercisesReducers
{
[ReducerMethod]
public static ExercisesState ReduceSetDescribedExercisesAction(
ExercisesState state,
SetDescribedExercisesAction action
) => state with { DescribedExercises = action.DescribedExercises };
}
Loading

0 comments on commit 2b3cc44

Please sign in to comment.