diff --git a/drinksInfo.0lcm/README.md b/drinksInfo.0lcm/README.md new file mode 100644 index 00000000..4e3b86b3 --- /dev/null +++ b/drinksInfo.0lcm/README.md @@ -0,0 +1,45 @@ +# Drinks Info +This is a learning project created for and following the requirments set out in [this](https://thecsharpacademy.com/project/15/drinks) webpage. +This app deals with getting data from [The Cocktail Db's API](https://www.thecocktaildb.com/api.php) and presents it to the user in a nicer Ui. + +# Features +* Features searching mechanics for drinks and drink ingredients, both by name, or by id. + ![rum search results part 1](https://i.imgur.com/P8Nt0WG.png) ![rum search results part 2](https://i.imgur.com/QLhxm7B.png) +* The random cocktail option gives users a way to randomize their searches. +* When searching drinks an image taken from the API is presented to the user, along with the drink's info. This is also toggleable in settings. + ![image of a piña colada](https://i.imgur.com/3PtPbhH.png) +* The max image width setting allows the user to change the size of images to as small or as big as they could want. + ![halloween punch image part 1](https://i.imgur.com/86jrqA7.png) ![halloween punch image part 2](https://i.imgur.com/XA2gfM4.png) ![halloween punch image part 3](https://i.imgur.com/0m6e3Na.png) +* The favorites feature allows users to save their favorite drinks for quick lookup later. + ![favorite drink list](https://i.imgur.com/0xBwRx3.png) +* The view count leaderboard offers an easy way for the user to see which drinks have been viewed the most. Both storing view counts, and the amount of drinks shown on the leaderboard are toggleable in settings. + ![view count leaderboard](https://i.imgur.com/WAz9AXN.png) +* The settings menu allows the user to change a variety of settings, or reset them to defaults with just a few clicks. + ![settings menu](https://i.imgur.com/yWqycv2.png) +* Settings, favorites, and view counts are all stored locally accross app sessions. favorites and view counts are stored in an sqlite file using ef core, and settings are written to a .json file. + +# Resources used +[.NET (10.0)](https://learn.microsoft.com/en-us/dotnet/) +[Spectre.Console (0.54.1-alpha.0.68)](https://spectreconsole.net/cli) - Ui +[Spectre.Console.ImageSharp (0.54.1-alpha.0.63)](https://spectreconsole.net/cli) - Image creation +[Microsoft.Extensions.Hosting (11.0.0-preview.1.26104.118)](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.hosting?view=net-10.0-pp) +[Microsoft.Extensions.Http (11.0.0-preview.1.26104.118)](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.http?view=net-10.0-pp) +[Microsoft.EntityFrameworkCore.Sqlite (10.0.0)](https://learn.microsoft.com/en-us/ef/core/providers/sqlite/?tabs=dotnet-core-cli) +[Microsoft.EntityFrameworkCore.Design (10.0.0)](https://learn.microsoft.com/en-us/ef/core/cli/services) +[Microsoft.Extensions.Logging (11.0.0-preview.1.26104.118)](https://learn.microsoft.com/en-us/dotnet/core/extensions/logging/overview?tabs=command-line) - Logging +[Microsoft.Extensions.Logging.Abstractions (11.0.0-preview.1.26104.118)](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.abstractions?view=net-10.0-pp) +[Microsoft.Extensions.Logging.Console (11.0.0-preview.1.26104.118)](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.console?view=net-8.0-pp) + +# Personal thoughts +This project took longer than I had hoped to finish, but I finally got everything up and working and I can finally submit it. Working with APIs for the first time was definitly +something I hadn't done before, it took a little bit of time to figure it out but afterwards I enjoyed working with it. Seeing my app actually get real world data from a real +website was really exciting, and it made me feel almost suprised as if I was getting closer to being able to make something real. I think the realization that I wasn't working +with test data, or small local data, but real data made me think that what I was making really was evolving and progressing as I move forward and learn more. I'm sure as I continue +to learn this project will start to feel small or of bad quality in comparison to what I'll learn later, but it's nice to see that the things you're making are becoming bigger, +more complex, and can actually show signs of progress, signs that even if you have to take small steps one day you will reach where you need to be. I also really enjoyed the fact +that I didn't have to actually write a thousand different descriptions for drinks. Being able to get the entire backstory of rum or being able to see all the drinks made with a +certain glass just with a single get request was very cool to experience. I also liked making the settings menu, which was one of the things that I dont think was a requirment, +but I just wanted to make it to try and improve my project. Learning how to save the user's settings to a local .json file was intresting too. I also used entity framework core +for handling an sqlite file of the favorites and view counts, as well as trying to improve the dependancy injection in my Program.cs, which were both really intresting things to +learn. It took me about a week and a half, maybe two weeks to finish this project, which wasn't exactly what I was hoping for, but I'm glad I got it done. I'm excitied to see +what the next project will be, and what I'll be able to learn from that project as well. diff --git a/drinksInfo.0lcm/drinksInfo.0lcm.sln b/drinksInfo.0lcm/drinksInfo.0lcm.sln new file mode 100644 index 00000000..094268a5 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "drinksInfo.0lcm", "drinksInfo.0lcm\drinksInfo.0lcm.csproj", "{50D33F9E-68B9-459A-BC3E-1734271B1C53}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {50D33F9E-68B9-459A-BC3E-1734271B1C53}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50D33F9E-68B9-459A-BC3E-1734271B1C53}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50D33F9E-68B9-459A-BC3E-1734271B1C53}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50D33F9E-68B9-459A-BC3E-1734271B1C53}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Configuration/AppDbContext.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Configuration/AppDbContext.cs new file mode 100644 index 00000000..9118ffa8 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Configuration/AppDbContext.cs @@ -0,0 +1,27 @@ +using drinksInfo._0lcm.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace drinksInfo._0lcm.Configuration; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet FavoritedDrinks { get; set; } + public DbSet ViewedDrinks { get; set; } +} + +public class AppDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var dbPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "drinksInfo.0lcm", + "app.db"); + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite($"Data Source={dbPath}"); + + return new AppDbContext(optionsBuilder.Options); + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Configuration/AppSettings.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Configuration/AppSettings.cs new file mode 100644 index 00000000..c10ea965 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Configuration/AppSettings.cs @@ -0,0 +1,13 @@ +namespace drinksInfo._0lcm.Configuration; + +public class AppSettings +{ + public bool ShowImages { get; set; } = true; + public bool ShowImagesDefault { get; } = true; + public int MaxImageWidth { get; set; } = 25; + public int MaxImageWidthDefault { get; } = 25; + public bool StoreDrinkCounts { get; set; } = true; + public bool StoreDrinkCountsDefault { get; } = true; + public int MaxLeaderboardSpots { get; set; } = 20; + public int MaxLeaderboardSpotsDefault { get; } = 20; +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Configuration/SettingsManager.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Configuration/SettingsManager.cs new file mode 100644 index 00000000..33e26ed4 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Configuration/SettingsManager.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +namespace drinksInfo._0lcm.Configuration; + +public class SettingsManager +{ + private static readonly string SettingsPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "drinksInfo.0lcm", + "settings.json"); + + public AppSettings Load() + { + if (!File.Exists(SettingsPath)) + return new AppSettings(); + + var json = File.ReadAllText(SettingsPath); + return JsonSerializer.Deserialize(json) ?? new AppSettings(); + } + + public void Save(AppSettings settings) + { + var directory = Path.GetDirectoryName(SettingsPath)!; + Directory.CreateDirectory(directory); + + var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(SettingsPath, json); + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Enums/EnumExtender.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Enums/EnumExtender.cs new file mode 100644 index 00000000..46d3ef74 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Enums/EnumExtender.cs @@ -0,0 +1,15 @@ +using System.Text.RegularExpressions; + +namespace drinksInfo._0lcm.Enums; + +internal static class EnumExtender +{ + internal static string ToDisplayString(this Enum value) + { + return Regex.Replace( + value.ToString(), + "([a-z])([A-Z])", + "$1 $2" + ); + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Enums/Enums.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Enums/Enums.cs new file mode 100644 index 00000000..775d49d4 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Enums/Enums.cs @@ -0,0 +1,59 @@ +namespace drinksInfo._0lcm.Enums; + +internal static class Enums +{ + internal enum MainMenuOption + { + LookupBySpecifics, + FilterByCategories, + ReviewCategories, + SeeFavoritesAndViewCounts, + Settings, + Exit + } + + internal enum ReviewCategoriesOption + { + SeeDrinks, + SeeGlasses, + SeeIngredients, + SeeAlcoholicTypes, + Back + } + + internal enum FilterCategoriesOption + { + FilterByAlcoholContent, + FilterByDrinkCategory, + FilterByIngredient, + FilterByGlassType, + Back + } + + internal enum LookupSpecificsOption + { + LookupCocktailByName, + LookupIngredientByName, + LookupCocktailById, + LookupIngredientById, + LookupRandomCocktail, + Back + } + + internal enum SettingsMenuOption + { + ToggleShowImages, + ChangeImageMaxWidth, + ToggleStoreViewCounts, + ChangeMaxLeaderboardSpots, + ResetSettings, + Back + } + + internal enum FavoritesAndLeaderboardMenuOption + { + ShowFavorites, + ShowViewLeaderboard, + Back + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Logging/Logging.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Logging/Logging.cs new file mode 100644 index 00000000..5ea86246 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Logging/Logging.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; + +namespace drinksInfo._0lcm.Logging; + +internal class CustomFormatter : ConsoleFormatter +{ + public CustomFormatter() : base("customFormatter") + { + } + + public override void Write( + in LogEntry logEntry, + IExternalScopeProvider? scopeProvider, + TextWriter textWriter + ) + { + var message = logEntry.Formatter(logEntry.State, logEntry.Exception); + if (string.IsNullOrEmpty(message)) return; + + var originalColor = Console.ForegroundColor; + try + { + Console.ForegroundColor = GetLogLevelColor(logEntry.LogLevel); + + textWriter.Write($"[{DateTimeOffset.Now:HH:mm:ss}]"); + + textWriter.Write($"[{logEntry.LogLevel,-12}]"); + + textWriter.Write($"[{logEntry.Category}]"); + + textWriter.Write(message); + + if (logEntry.Exception != null) textWriter.Write(logEntry.Exception.ToString()); + } + finally + { + Console.ForegroundColor = originalColor; + } + } + + private static ConsoleColor GetLogLevelColor(LogLevel logLevel) + { + return logLevel switch + { + LogLevel.Trace => ConsoleColor.Gray, + LogLevel.Debug => ConsoleColor.Gray, + LogLevel.Information => ConsoleColor.Green, + LogLevel.Warning => ConsoleColor.Yellow, + LogLevel.Error => ConsoleColor.Red, + LogLevel.Critical => ConsoleColor.Magenta, + _ => ConsoleColor.White + }; + } +} + +internal class AppLogger +{ + private static readonly ILoggerFactory AppLoggerFactory = + LoggerFactory.Create(builder => + { + builder + .SetMinimumLevel(LogLevel.Debug) + .AddConsole(options => { options.FormatterName = "customFormatter"; }) + .AddConsoleFormatter(); + }); + + internal static ILogger CreateLogger() + { + return AppLoggerFactory.CreateLogger(); + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Migrations/20260312022243_InitialCreate.Designer.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Migrations/20260312022243_InitialCreate.Designer.cs new file mode 100644 index 00000000..c9b4eb85 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Migrations/20260312022243_InitialCreate.Designer.cs @@ -0,0 +1,73 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using drinksInfo._0lcm.Configuration; + +#nullable disable + +namespace drinksInfo._0lcm.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260312022243_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("drinksInfo._0lcm.Models.FavoritedDrink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IdDrink") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("StrDrink") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("FavoritedDrinks"); + }); + + modelBuilder.Entity("drinksInfo._0lcm.Models.ViewedDrink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IdDrink") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("StrDrink") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ViewedDrinks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Migrations/20260312022243_InitialCreate.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Migrations/20260312022243_InitialCreate.cs new file mode 100644 index 00000000..c99e4a55 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Migrations/20260312022243_InitialCreate.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace drinksInfo._0lcm.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "FavoritedDrinks", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + IdDrink = table.Column(type: "TEXT", maxLength: 150, nullable: false), + StrDrink = table.Column(type: "TEXT", maxLength: 150, nullable: false), + SavedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FavoritedDrinks", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ViewedDrinks", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + IdDrink = table.Column(type: "TEXT", maxLength: 150, nullable: false), + StrDrink = table.Column(type: "TEXT", maxLength: 150, nullable: false), + ViewCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ViewedDrinks", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FavoritedDrinks"); + + migrationBuilder.DropTable( + name: "ViewedDrinks"); + } + } +} diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Migrations/AppDbContextModelSnapshot.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 00000000..80abdb61 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,70 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using drinksInfo._0lcm.Configuration; + +#nullable disable + +namespace drinksInfo._0lcm.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("drinksInfo._0lcm.Models.FavoritedDrink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IdDrink") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("StrDrink") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("FavoritedDrinks"); + }); + + modelBuilder.Entity("drinksInfo._0lcm.Models.ViewedDrink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IdDrink") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("StrDrink") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ViewedDrinks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Models/AlcoholContentCategory.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Models/AlcoholContentCategory.cs new file mode 100644 index 00000000..b2893c7e --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Models/AlcoholContentCategory.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace drinksInfo._0lcm.Models; + +public class AlcoholContentCategoryResponse +{ + [JsonPropertyName("drinks")] public required List AlcoholContentCategory { get; set; } +} + +public class AlcoholContentCategory +{ + [JsonPropertyName("strAlcoholic")] public required string StrAlcoholic { get; set; } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Models/ApiConstants.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Models/ApiConstants.cs new file mode 100644 index 00000000..f58cda02 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Models/ApiConstants.cs @@ -0,0 +1,24 @@ +namespace drinksInfo._0lcm.Models; + +public static class ApiConstants +{ + public const string ApiName = "thecocktaildb.com"; + public const string ApiBaseUrl = "https://www.thecocktaildb.com/api/json/v1/1/"; + + public const string LookupRandomCocktailPath = "random.php"; + + public const string IncompleteLookupCocktailByNamePath = "search.php?s="; + public const string IncompleteLookupIngredientByNamePath = "search.php?i="; + public const string IncompleteLookupCocktailByIdPath = "lookup.php?i="; + public const string IncompleteLookupIngredientByIdPath = "lookup.php?iid="; + + public const string IncompleteAlcoholContentFilterPath = "filter.php?a="; + public const string IncompleteCategoryFilterPath = "filter.php?c="; + public const string IncompleteGlassFilterPath = "filter.php?g="; + public const string IncompleteIngredientFilterPath = "filter.php?i="; + + public const string DrinkCategoriesPath = "list.php?c=list"; + public const string GlassCategoriesPath = "list.php?g=list"; + public const string IngredientCategoriesPath = "list.php?i=list"; + public const string AlcoholContentCategoryPath = "list.php?a=list"; +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Models/DrinkCategory.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Models/DrinkCategory.cs new file mode 100644 index 00000000..22c7bef1 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Models/DrinkCategory.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace drinksInfo._0lcm.Models; + +public class DrinkCategoryResponse +{ + [JsonPropertyName("drinks")] public required List Drinks { get; set; } +} + +public class DrinkCategory +{ + [JsonPropertyName("strCategory")] public required string StrCategory { get; set; } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Models/DrinkStates.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Models/DrinkStates.cs new file mode 100644 index 00000000..9a5f2e82 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Models/DrinkStates.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace drinksInfo._0lcm.Models; + +public class FavoritedDrink +{ + public int Id { get; set; } + + [MaxLength(150)] public required string IdDrink { get; set; } + + [MaxLength(150)] public required string StrDrink { get; set; } + + public DateTime SavedAt { get; set; } = DateTime.Now; +} + +public class ViewedDrink +{ + public int Id { get; set; } + + [MaxLength(150)] public required string IdDrink { get; set; } + + [MaxLength(150)] public required string StrDrink { get; set; } + + public int ViewCount { get; set; } = 1; +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Models/FilterClass.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Models/FilterClass.cs new file mode 100644 index 00000000..84c9e0d8 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Models/FilterClass.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace drinksInfo._0lcm.Models; + +public class FilterClassResponse +{ + [JsonPropertyName("drinks")] public required List FilterElements { get; set; } +} + +public class FilterClass +{ + [JsonPropertyName("strDrink")] public required string StrDrink { get; set; } + [JsonPropertyName("strDrinkThumb")] public required string StrDrinkThumb { get; set; } + [JsonPropertyName("idDrink")] public required string IdDrink { get; set; } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Models/GlassCategory.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Models/GlassCategory.cs new file mode 100644 index 00000000..4d0ceeda --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Models/GlassCategory.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace drinksInfo._0lcm.Models; + +public class GlassCategoryResponse +{ + [JsonPropertyName("drinks")] public required List GlassCategory { get; set; } +} + +public class GlassCategory +{ + [JsonPropertyName("strGlass")] public required string StrGlass { get; set; } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Models/IngredientCategory.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Models/IngredientCategory.cs new file mode 100644 index 00000000..0beef02c --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Models/IngredientCategory.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace drinksInfo._0lcm.Models; + +public class IngredientCategoryResponse +{ + [JsonPropertyName("drinks")] public required List IngredientCategory { get; set; } +} + +public class IngredientCategory +{ + [JsonPropertyName("strIngredient1")] public required string StrIngredient { get; set; } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Models/LookupDrinkClass.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Models/LookupDrinkClass.cs new file mode 100644 index 00000000..dd3b82e5 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Models/LookupDrinkClass.cs @@ -0,0 +1,124 @@ +using System.Text.Json.Serialization; + +namespace drinksInfo._0lcm.Models; + +public class LookupDrinkClassResponse +{ + [JsonPropertyName("drinks")] public required List Drinks { get; set; } +} + +public class LookupDrinkClass +{ + [JsonPropertyName("idDrink")] public string? IdDrink { get; set; } + [JsonPropertyName("strDrink")] public string? StrDrink { get; set; } + + [JsonPropertyName("strDrinkAlternate")] + public string? StrDrinkAlternate { get; set; } + + [JsonPropertyName("strTags")] public string? StrTags { get; set; } + [JsonPropertyName("strVideo")] public string? StrVideo { get; set; } + [JsonPropertyName("strCategory")] public string? StrCategory { get; set; } + [JsonPropertyName("strIBA")] public string? StrIba { get; set; } + [JsonPropertyName("strAlcoholic")] public string? StrAlcoholic { get; set; } + [JsonPropertyName("strGlass")] public string? StrGlass { get; set; } + [JsonPropertyName("strInstructions")] public string? StrInstructions { get; set; } + + [JsonPropertyName("strDrinkThumb")] public string? StrDrinkThumb { get; set; } + [JsonPropertyName("strImageSource")] public string? StrImageSource { get; set; } + + [JsonPropertyName("strImageAttribution")] + public string? StrImageAttribution { get; set; } + + [JsonPropertyName("strCreativeCommonsConfirmed")] + public string? StrCreativeCommonsConfirmed { get; set; } + + [JsonPropertyName("dataModified")] public string? DateModified { get; set; } + + [JsonPropertyName("strIngredient1")] public string? StrIngredient1 { get; set; } + [JsonPropertyName("strIngredient2")] public string? StrIngredient2 { get; set; } + [JsonPropertyName("strIngredient3")] public string? StrIngredient3 { get; set; } + [JsonPropertyName("strIngredient4")] public string? StrIngredient4 { get; set; } + [JsonPropertyName("strIngredient5")] public string? StrIngredient5 { get; set; } + [JsonPropertyName("strIngredient6")] public string? StrIngredient6 { get; set; } + [JsonPropertyName("strIngredient7")] public string? StrIngredient7 { get; set; } + [JsonPropertyName("strIngredient8")] public string? StrIngredient8 { get; set; } + [JsonPropertyName("strIngredient9")] public string? StrIngredient9 { get; set; } + [JsonPropertyName("strIngredient10")] public string? StrIngredient10 { get; set; } + [JsonPropertyName("strIngredient11")] public string? StrIngredient11 { get; set; } + [JsonPropertyName("strIngredient12")] public string? StrIngredient12 { get; set; } + [JsonPropertyName("strIngredient13")] public string? StrIngredient13 { get; set; } + [JsonPropertyName("strIngredient14")] public string? StrIngredient14 { get; set; } + [JsonPropertyName("strIngredient15")] public string? StrIngredient15 { get; set; } + + [JsonPropertyName("strMeasure1")] public string? StrMeasure1 { get; set; } + [JsonPropertyName("strMeasure2")] public string? StrMeasure2 { get; set; } + [JsonPropertyName("strMeasure3")] public string? StrMeasure3 { get; set; } + [JsonPropertyName("strMeasure4")] public string? StrMeasure4 { get; set; } + [JsonPropertyName("strMeasure5")] public string? StrMeasure5 { get; set; } + [JsonPropertyName("strMeasure6")] public string? StrMeasure6 { get; set; } + [JsonPropertyName("strMeasure7")] public string? StrMeasure7 { get; set; } + [JsonPropertyName("strMeasure8")] public string? StrMeasure8 { get; set; } + [JsonPropertyName("strMeasure9")] public string? StrMeasure9 { get; set; } + [JsonPropertyName("strMeasure10")] public string? StrMeasure10 { get; set; } + [JsonPropertyName("strMeasure11")] public string? StrMeasure11 { get; set; } + [JsonPropertyName("strMeasure12")] public string? StrMeasure12 { get; set; } + [JsonPropertyName("strMeasure13")] public string? StrMeasure13 { get; set; } + [JsonPropertyName("strMeasure14")] public string? StrMeasure14 { get; set; } + [JsonPropertyName("strMeasure15")] public string? StrMeasure15 { get; set; } + + [JsonIgnore] + public List<(string Ingredient, string Measure)> Ingredients => + new List<(string?, string?)> + { + (StrIngredient1, StrMeasure1), + (StrIngredient2, StrMeasure2), + (StrIngredient3, StrMeasure3), + (StrIngredient4, StrMeasure4), + (StrIngredient5, StrMeasure5), + (StrIngredient6, StrMeasure6), + (StrIngredient7, StrMeasure7), + (StrIngredient8, StrMeasure8), + (StrIngredient9, StrMeasure9), + (StrIngredient10, StrMeasure10), + (StrIngredient11, StrMeasure11), + (StrIngredient12, StrMeasure12), + (StrIngredient13, StrMeasure13), + (StrIngredient14, StrMeasure14), + (StrIngredient15, StrMeasure15) + } + .Where(x => !string.IsNullOrEmpty(x.Item1) && x.Item1 != "null") + .Select(x => (x.Item1!, x.Item2 ?? string.Empty)) + .ToList(); + + [JsonIgnore] + public List<(string Field, string Value)> DrinkInfo => + new List<(string, string?)> + { + ("Name", StrDrink), + ("Alternate Name", StrDrinkAlternate), + ("Drink Id", IdDrink), + ("Drink Category", StrCategory), + ("Alcohol Content", StrAlcoholic), + ("Drink Glass", StrGlass), + ("Drink Tags", StrTags), + ("Drink IBA", StrIba), + ("Drink Video", StrVideo), + ("Instructions", StrInstructions) + } + .Where(x => !string.IsNullOrEmpty(x.Item2) && x.Item2 != "null") + .Select(x => (x.Item1, x.Item2!)) + .ToList(); + + [JsonIgnore] + public List<(string Field, string Value)> DrinkPhotoInfo => + new List<(string, string?)> + { + ("Image Source", StrImageSource), + ("Image Attribution", StrImageAttribution), + ("Creative Commons Confirmed", StrCreativeCommonsConfirmed), + ("Date modified", DateModified) + } + .Where(x => !string.IsNullOrEmpty(x.Item2) && x.Item2 != "null") + .Select(x => (x.Item1, x.Item2!)) + .ToList(); +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Models/LookupIngredientClass.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Models/LookupIngredientClass.cs new file mode 100644 index 00000000..ad74e946 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Models/LookupIngredientClass.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace drinksInfo._0lcm.Models; + +public class LookupIngredientClassResponse +{ + [JsonPropertyName("ingredients")] public required List Ingredients { get; set; } +} + +public class LookupIngredientClass +{ + [JsonPropertyName("strIngredient")] public string? StrIngredient { get; set; } + [JsonPropertyName("idIngredient")] public string? IdIngredient { get; set; } + [JsonPropertyName("strDescription")] public string? StrDescription { get; set; } + [JsonPropertyName("strType")] public string? StrType { get; set; } + [JsonPropertyName("strAlcohol")] public string? StrAlcohol { get; set; } + [JsonPropertyName("strABV")] public string? StrAbv { get; set; } + + [JsonIgnore] + public List<(string Field, string Value)> IngredientInfo => + new List<(string, string?)> + { + ("Ingredient Name", StrIngredient), + ("Ingredient Id", IdIngredient), + ("Ingredient Description", StrDescription), + ("Ingredient Type", StrType), + ("Ingredient Alcohol", StrAlcohol), + ("Ingredient Abv", StrAbv) + } + .Where(x => !string.IsNullOrEmpty(x.Item2) && x.Item2 != "null") + .Select(x => (x.Item1, x.Item2!)) + .ToList(); +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Program.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Program.cs new file mode 100644 index 00000000..f35cb2a4 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Program.cs @@ -0,0 +1,78 @@ +using drinksInfo._0lcm.Configuration; +using drinksInfo._0lcm.Logging; +using drinksInfo._0lcm.Models; +using drinksInfo._0lcm.ServiceContracts; +using drinksInfo._0lcm.Services; +using drinksInfo._0lcm.UserInterface; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; + +var builder = Host.CreateApplicationBuilder(args); +var settingsManager = new SettingsManager(); +var appSettings = settingsManager.Load(); + +var dbPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "drinksInfo.0lcm", + "app.db"); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(appSettings); +builder.Services.AddSingleton(settingsManager); +builder.Services.AddDbContext(options => + options.UseSqlite($"Data Source={dbPath}")); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +builder.Services.AddSingleton(); + +builder.Services.AddHttpClient(ApiConstants.ApiName, + client => { client.BaseAddress = new Uri(ApiConstants.ApiBaseUrl); }); +builder.Services.AddHostedService(); + +builder.Logging.ClearProviders(); +builder.Logging.SetMinimumLevel(LogLevel.Warning); +builder.Logging.AddConsole(options => { options.FormatterName = "customFormatter"; }); +builder.Logging.AddConsoleFormatter(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); +} + +await app.RunAsync(); + +public class Worker : BackgroundService +{ + private readonly ConsoleUi _console; + + public Worker(ConsoleUi consoleUi) + { + _console = consoleUi; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await _console.MainMenu(); + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/IDrinkStateService.cs b/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/IDrinkStateService.cs new file mode 100644 index 00000000..b64daa69 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/IDrinkStateService.cs @@ -0,0 +1,15 @@ +using drinksInfo._0lcm.Models; + +namespace drinksInfo._0lcm.ServiceContracts; + +public interface IDrinkStateService +{ + public Task CheckDrinkIdIsFavorited(string id); + public Task FavoriteDrink(LookupDrinkClassResponse? drinkClass); + public Task RemoveFavorite(string id); + public Task> GetFavorites(); + + public Task AddViewCount(LookupDrinkClassResponse? drinkClass); + public Task DeleteViewCounts(); + public Task> GetViewedDrinks(); +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/IFilterCategoriesService.cs b/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/IFilterCategoriesService.cs new file mode 100644 index 00000000..73cb103d --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/IFilterCategoriesService.cs @@ -0,0 +1,11 @@ +using drinksInfo._0lcm.Models; + +namespace drinksInfo._0lcm.ServiceContracts; + +public interface IFilterCategoriesService +{ + public Task FilterByGlassType(string path); + public Task FilterByDrinkCategory(string path); + public Task FilterByAlcoholContent(string path); + public Task FilterByIngredient(string path); +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/ILookupSpecificsService.cs b/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/ILookupSpecificsService.cs new file mode 100644 index 00000000..9393fc55 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/ILookupSpecificsService.cs @@ -0,0 +1,14 @@ +using drinksInfo._0lcm.Models; +using Spectre.Console; + +namespace drinksInfo._0lcm.ServiceContracts; + +public interface ILookupSpecificsService +{ + public Task LookupCocktailByName(string name); + public Task LookupIngredientByName(string ingredient); + public Task LookupCocktailById(string ingredient); + public Task LookupIngredientById(string ingredient); + public Task LookupRandomCocktail(); + public Task CreateCanvasImageFromUrl(string path); +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/IReviewCategoriesService.cs b/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/IReviewCategoriesService.cs new file mode 100644 index 00000000..9849f87c --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/IReviewCategoriesService.cs @@ -0,0 +1,11 @@ +using drinksInfo._0lcm.Models; + +namespace drinksInfo._0lcm.ServiceContracts; + +public interface IReviewCategoriesService +{ + public Task GetDrinkCategories(); + public Task GetGlassesCategories(); + public Task GetIngredientCategories(); + public Task GetAlcoholContentCategories(); +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/ISettingsService.cs b/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/ISettingsService.cs new file mode 100644 index 00000000..68bc4b28 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/ServiceContracts/ISettingsService.cs @@ -0,0 +1,10 @@ +namespace drinksInfo._0lcm.ServiceContracts; + +public interface ISettingsService +{ + public void ToggleShowImages(); + public void ChangeMaxWidth(int width); + public void ResetSettings(); + public void ToggleStoreViewCounts(); + public void ChangeMaxLeaderboardSpots(int maxLeaderboardSpots); +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Services/ApiService.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Services/ApiService.cs new file mode 100644 index 00000000..4f050e12 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Services/ApiService.cs @@ -0,0 +1,112 @@ +using drinksInfo._0lcm.Models; +using Microsoft.Extensions.Logging; + +namespace drinksInfo._0lcm.Services; + +public class ApiService(IHttpClientFactory httpClientFactory, ILogger logger) +{ + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + private readonly ILogger _logger = logger; + + //------- Lookup Methods ------- + internal async Task LookupCocktailByName(string name) + { + return await GetAsync($"{ApiConstants.IncompleteLookupCocktailByNamePath}{name}"); + } + + internal async Task LookupIngredientByName(string name) + { + return await GetAsync($"{ApiConstants.IncompleteLookupIngredientByNamePath}{name}"); + } + + internal async Task LookupCocktailById(string id) + { + return await GetAsync($"{ApiConstants.IncompleteLookupCocktailByIdPath}{id}"); + } + + internal async Task LookupIngredientById(string id) + { + return await GetAsync($"{ApiConstants.IncompleteLookupIngredientByIdPath}{id}"); + } + + internal async Task LookupRandomCocktail() + { + return await GetAsync(ApiConstants.LookupRandomCocktailPath); + } + + //------- Filter Methods ------- + internal async Task FilterForAlcoholContentByPath(string path) + { + return await GetAsync($"{ApiConstants.IncompleteAlcoholContentFilterPath}{path}"); + } + + internal async Task FilterForDrinkCategoryByPath(string path) + { + return await GetAsync($"{ApiConstants.IncompleteCategoryFilterPath}{path}"); + } + + internal async Task FilterForGlassesByPath(string path) + { + return await GetAsync($"{ApiConstants.IncompleteGlassFilterPath}{path}"); + } + + internal async Task FilterForIngredientByPath(string path) + { + return await GetAsync($"{ApiConstants.IncompleteIngredientFilterPath}{path}"); + } + + //------- Category List Methods ------- + internal async Task GetCategories() + { + return await GetAsync(ApiConstants.DrinkCategoriesPath); + } + + internal async Task GetGlasses() + { + return await GetAsync(ApiConstants.GlassCategoriesPath); + } + + internal async Task GetIngredients() + { + return await GetAsync(ApiConstants.IngredientCategoriesPath); + } + + internal async Task GetAlcoholContentCategory() + { + return await GetAsync(ApiConstants.AlcoholContentCategoryPath); + } + + //------- Image Methods ------- + internal async Task DownloadImageBytes(string path) + { + var client = _httpClientFactory.CreateClient(); + return await client.GetByteArrayAsync(path); + } + + //------- Helper Methods ------- + private async Task GetAsync(string path) + { + var client = _httpClientFactory.CreateClient(ApiConstants.ApiName); + var response = await client.GetAsync(path); + + return await HandleResponse(response); + } + + private async Task HandleResponse(HttpResponseMessage response) + { + if (response.IsSuccessStatusCode) return await response.Content.ReadAsStringAsync(); + + if ((int)response.StatusCode == 418) + throw new HttpRequestException("This server is not a teapot and refuses to brew coffee", null, + response.StatusCode); + + if ((int)response.StatusCode >= 400 && (int)response.StatusCode < 500) + throw new HttpRequestException($"Client side error: {response.StatusCode}", null, response.StatusCode); + + if ((int)response.StatusCode >= 500) + throw new HttpRequestException($"Server side error: {response.StatusCode}", null, response.StatusCode); + + _logger.LogWarning("Unexpected status code occurred from API: {StatusCode}", response.StatusCode); + return string.Empty; + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Services/DrinkStateService.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Services/DrinkStateService.cs new file mode 100644 index 00000000..058f77f4 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Services/DrinkStateService.cs @@ -0,0 +1,69 @@ +using drinksInfo._0lcm.Configuration; +using drinksInfo._0lcm.Models; +using drinksInfo._0lcm.ServiceContracts; +using Microsoft.EntityFrameworkCore; + +namespace drinksInfo._0lcm.Services; + +public class DrinkStateService(AppDbContext db, AppSettings appSettings) : IDrinkStateService +{ + //------- Favorite Drink Methods ------- + public async Task CheckDrinkIdIsFavorited(string id) + { + var isFavorited = await db.FavoritedDrinks.AnyAsync(d => d.IdDrink == id); + + return isFavorited; + } + + public async Task FavoriteDrink(LookupDrinkClassResponse? drinkClass) + { + var drink = drinkClass!.Drinks[0]; + + var isFavorited = await CheckDrinkIdIsFavorited(drink.IdDrink!); + if (isFavorited) return; + + db.FavoritedDrinks.Add(new FavoritedDrink { IdDrink = drink.IdDrink!, StrDrink = drink.StrDrink! }); + await db.SaveChangesAsync(); + } + + public async Task RemoveFavorite(string id) + { + var drink = await db.FavoritedDrinks.FirstOrDefaultAsync(d => d.IdDrink == id); + if (drink == null) return; + + db.FavoritedDrinks.Remove(drink); + await db.SaveChangesAsync(); + } + + public async Task> GetFavorites() + { + return await db.FavoritedDrinks.ToListAsync(); + } + + //------- View Count Methods ------- + public async Task AddViewCount(LookupDrinkClassResponse? drinkClass) + { + if (!appSettings.StoreDrinkCounts) return; + + var drink = drinkClass!.Drinks[0]; + var existingDrink = await db.ViewedDrinks.FirstOrDefaultAsync(d => d.IdDrink == drink.IdDrink); + + if (existingDrink is null) + db.ViewedDrinks.Add(new ViewedDrink { IdDrink = drink.IdDrink!, StrDrink = drink.StrDrink! }); + else + existingDrink.ViewCount++; + + await db.SaveChangesAsync(); + } + + public async Task DeleteViewCounts() + { + db.ViewedDrinks.RemoveRange(db.ViewedDrinks); + await db.SaveChangesAsync(); + } + + public async Task> GetViewedDrinks() + { + return await db.ViewedDrinks.ToListAsync(); + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Services/FilterCategoriesService.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Services/FilterCategoriesService.cs new file mode 100644 index 00000000..187bf8c1 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Services/FilterCategoriesService.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using drinksInfo._0lcm.Models; +using drinksInfo._0lcm.ServiceContracts; + +namespace drinksInfo._0lcm.Services; + +public class FilterCategoriesService(ApiService apiService) : IFilterCategoriesService +{ + private readonly ApiService _apiService = apiService; + + public async Task FilterByGlassType(string path) + { + var rawJson = await _apiService.FilterForGlassesByPath(path); + + return JsonSerializer.Deserialize(rawJson)!; + } + + public async Task FilterByDrinkCategory(string path) + { + var rawJson = await _apiService.FilterForDrinkCategoryByPath(path); + + return JsonSerializer.Deserialize(rawJson)!; + } + + public async Task FilterByAlcoholContent(string path) + { + var rawJson = await _apiService.FilterForAlcoholContentByPath(path); + + return JsonSerializer.Deserialize(rawJson)!; + } + + public async Task FilterByIngredient(string path) + { + var rawJson = await _apiService.FilterForIngredientByPath(path); + + return JsonSerializer.Deserialize(rawJson)!; + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Services/LookupSpecificsService.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Services/LookupSpecificsService.cs new file mode 100644 index 00000000..218b5870 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Services/LookupSpecificsService.cs @@ -0,0 +1,82 @@ +using System.Net; +using System.Text.Json; +using drinksInfo._0lcm.Configuration; +using drinksInfo._0lcm.Models; +using drinksInfo._0lcm.ServiceContracts; +using Spectre.Console; + +namespace drinksInfo._0lcm.Services; + +public class LookupSpecificsService(ApiService apiService, AppSettings appSettings) : ILookupSpecificsService +{ + private readonly ApiService _apiService = apiService; + + public async Task LookupCocktailByName(string name) + { + var rawJson = await _apiService.LookupCocktailByName(name); + + var result = JsonSerializer.Deserialize(rawJson)!; + + if (result?.Drinks == null) + throw new HttpRequestException("Client side error: 404 not found", null, HttpStatusCode.NotFound); + return result; + } + + public async Task LookupIngredientByName(string name) + { + var rawJson = await _apiService.LookupIngredientByName(name); + + + var result = JsonSerializer.Deserialize(rawJson)!; + + if (result?.Ingredients == null) + throw new HttpRequestException("Client side error: 404 not found", null, HttpStatusCode.NotFound); + + return result; + } + + public async Task LookupCocktailById(string id) + { + var rawJson = await _apiService.LookupCocktailById(id); + + var result = JsonSerializer.Deserialize(rawJson)!; + + if (result?.Drinks == null) + throw new HttpRequestException("Client side error: 404 not found", null, HttpStatusCode.NotFound); + + return result; + } + + public async Task LookupIngredientById(string id) + { + var rawJson = await _apiService.LookupIngredientById(id); + + var result = JsonSerializer.Deserialize(rawJson)!; + + if (result?.Ingredients == null) + throw new HttpRequestException("Client side error: 404 not found", null, HttpStatusCode.NotFound); + + return result; + } + + public async Task LookupRandomCocktail() + { + var rawJson = await _apiService.LookupRandomCocktail(); + + var result = JsonSerializer.Deserialize(rawJson)!; + + if (result?.Drinks == null) + throw new HttpRequestException("Client side error: 404 not found", null, HttpStatusCode.NotFound); + + return result; + } + + public async Task CreateCanvasImageFromUrl(string path) + { + var imageBytes = await _apiService.DownloadImageBytes(path); + var image = new CanvasImage(imageBytes); + + image.MaxWidth(appSettings.MaxImageWidth); + return image; + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Services/ReviewCategoriesCategoriesService.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Services/ReviewCategoriesCategoriesService.cs new file mode 100644 index 00000000..9e9a73b0 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Services/ReviewCategoriesCategoriesService.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using drinksInfo._0lcm.Models; +using drinksInfo._0lcm.ServiceContracts; + +namespace drinksInfo._0lcm.Services; + +public class ReviewCategoriesCategoriesService(ApiService apiService) : IReviewCategoriesService +{ + private readonly ApiService _apiService = apiService; + + public async Task GetDrinkCategories() + { + var rawJson = await _apiService.GetCategories(); + + return JsonSerializer.Deserialize(rawJson)!; + } + + public async Task GetGlassesCategories() + { + var rawJson = await _apiService.GetGlasses(); + + return JsonSerializer.Deserialize(rawJson)!; + } + + public async Task GetIngredientCategories() + { + var rawJson = await _apiService.GetIngredients(); + + return JsonSerializer.Deserialize(rawJson)!; + } + + public async Task GetAlcoholContentCategories() + { + var rawJson = await _apiService.GetAlcoholContentCategory(); + + return JsonSerializer.Deserialize(rawJson)!; + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/Services/SettingsService.cs b/drinksInfo.0lcm/drinksInfo.0lcm/Services/SettingsService.cs new file mode 100644 index 00000000..dacb6b9a --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/Services/SettingsService.cs @@ -0,0 +1,52 @@ +using drinksInfo._0lcm.Configuration; +using drinksInfo._0lcm.ServiceContracts; + +namespace drinksInfo._0lcm.Services; + +public class SettingsService( + AppSettings appSettings, + SettingsManager settingsManager, + IDrinkStateService drinkStateService) : ISettingsService +{ + private readonly IDrinkStateService _drinkStateService = drinkStateService; + + public void ToggleShowImages() + { + appSettings.ShowImages = !appSettings.ShowImages; + + settingsManager.Save(appSettings); + } + + public void ChangeMaxWidth(int width) + { + appSettings.MaxImageWidth = width; + + settingsManager.Save(appSettings); + } + + public void ToggleStoreViewCounts() + { + appSettings.StoreDrinkCounts = !appSettings.StoreDrinkCounts; + + if (!appSettings.StoreDrinkCounts) _drinkStateService.DeleteViewCounts(); + + settingsManager.Save(appSettings); + } + + public void ChangeMaxLeaderboardSpots(int maxLeaderboardSpots) + { + appSettings.MaxLeaderboardSpots = maxLeaderboardSpots; + + settingsManager.Save(appSettings); + } + + public void ResetSettings() + { + appSettings.ShowImages = appSettings.ShowImagesDefault; + appSettings.MaxImageWidth = appSettings.MaxImageWidthDefault; + appSettings.StoreDrinkCounts = appSettings.StoreDrinkCountsDefault; + appSettings.MaxLeaderboardSpots = appSettings.MaxLeaderboardSpotsDefault; + + settingsManager.Save(appSettings); + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/ConsoleUi.cs b/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/ConsoleUi.cs new file mode 100644 index 00000000..7fed02ca --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/ConsoleUi.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Logging; + +namespace drinksInfo._0lcm.UserInterface; + +public class ConsoleUi( + ReviewCategoriesUi reviewCategoriesUi, + FilterCategoriesUi filterCategoriesUi, + LookupSpecificsUi lookupSpecificsUi, + SettingsUi settingsUi, + FavoritesAndLeaderboardUi favoritesAndLeaderboardUi, + ILogger logger) +{ + private readonly FavoritesAndLeaderboardUi _favoritesAndLeaderboardUi = favoritesAndLeaderboardUi; + private readonly FilterCategoriesUi _filterCategoriesUi = filterCategoriesUi; + private readonly ILogger _logger = logger; + private readonly LookupSpecificsUi _lookupSpecificsUi = lookupSpecificsUi; + private readonly ReviewCategoriesUi _reviewCategoriesUi = reviewCategoriesUi; + private readonly SettingsUi _settingsUi = settingsUi; + + + //------- Main Menu Methods ------- + internal async Task MainMenu() + { + while (true) + { + Console.Clear(); + var option = DisplayHelper.DisplayMenu(); + + try + { + await HandleMainMenu(option); + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception caught in the main menu loop."); + + DisplayHelper.DisplayError("\n\nAn error has occured, please press enter to go back to the main menu."); + Console.ReadLine(); + } + } + } + + private async Task HandleMainMenu(Enums.Enums.MainMenuOption option) + { + switch (option) + { + case Enums.Enums.MainMenuOption.LookupBySpecifics: + await _lookupSpecificsUi.LookupSpecifics(); + break; + case Enums.Enums.MainMenuOption.FilterByCategories: + await _filterCategoriesUi.FilterCategories(); + break; + case Enums.Enums.MainMenuOption.ReviewCategories: + await _reviewCategoriesUi.ReviewMenu(); + break; + case Enums.Enums.MainMenuOption.SeeFavoritesAndViewCounts: + await _favoritesAndLeaderboardUi.FavoritesAndLeaderboard(); + break; + case Enums.Enums.MainMenuOption.Settings: + _settingsUi.Settings(); + break; + case Enums.Enums.MainMenuOption.Exit: + await ExitApplication(); + break; + } + } + + private static async Task ExitApplication() + { + await DisplayHelper.DisplaySpinner("Exiting console..", 2500); + Environment.Exit(0); + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/DisplayHelper.cs b/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/DisplayHelper.cs new file mode 100644 index 00000000..8629080a --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/DisplayHelper.cs @@ -0,0 +1,134 @@ +using drinksInfo._0lcm.Enums; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace drinksInfo._0lcm.UserInterface; + +internal class DisplayHelper +{ + //------- Colors ------- + internal const string White = "#f1f1f1"; + internal const string Grey = "#8c8e8f"; + internal const string Green = "#32aa3b"; + private const string Red = "#cd2d2d"; + private const string Yellow = "#e2b929"; + private const string Error = "#870c00"; + + //------- Basic Outputs ------- + internal static void DisplayMessage(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{White}]{message}[/]"); + else + AnsiConsole.Markup($"[{White}]{message}[/]"); + } + + internal static void DisplayRows(List rows, bool writeLine = true) + { + var rowsLayout = new Rows(rows); + AnsiConsole.Write(rowsLayout); + } + + internal static void DisplayInfo(string info, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Grey}]{info}[/]"); + else + AnsiConsole.Markup($"[{Grey}]{info}[/]"); + } + + internal static void DisplaySuccess(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Green}]{message}[/]"); + else + AnsiConsole.Markup($"[{Green}]{message}[/]"); + } + + internal static void DisplayUrgent(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Red}]{message}[/]"); + else + AnsiConsole.Markup($"[{Red}]{message}[/]"); + } + + internal static void DisplayWarning(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Yellow}]{message}[/]"); + else + AnsiConsole.Markup($"[{Yellow}]{message}[/]"); + } + + internal static void DisplayError(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Error}]{message}[/]"); + else + AnsiConsole.Markup($"[{Error}]{message}[/]"); + } + + //------- Menus & Prompts ------- + internal static T DisplayMenu(string? title = null) where T : Enum + { + var menuChoice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title(title ?? "Please Select An Option:") + .HighlightStyle(Style.Parse("darkviolet")) + .AddChoices(Enum.GetValues(typeof(T)).Cast()) + .UseConverter(e => e.ToDisplayString()) + ); + + + return menuChoice; + } + + internal static string DisplayPrompt(List choiceList, string? title = null) + { + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title(title ?? "Please Select An Option:") + .HighlightStyle(Style.Parse("darkviolet")) + .AddChoices(choiceList)); + + return choice; + } + + internal static List DisplayMultiPrompt(List choiceList, string? title = null, + bool requireChoice = true) + { + var prompt = new MultiSelectionPrompt() + .Title(title ?? "Please Select An Option:") + .HighlightStyle(Style.Parse("darkviolet")) + .InstructionsText($"[{Grey}]Press[/] [{White}][/] to Toggle, and [{White}][/] to Confirm") + .AddChoices(choiceList); + + if (requireChoice) + prompt.Required(); + else + prompt.NotRequired(); + + return AnsiConsole.Prompt(prompt); + } + + internal static string DisplayQuestion(string question) + { + var response = AnsiConsole.Ask($"[{White}]{question}[/]"); + return response; + } + + internal static async Task DisplaySpinner(string waitMessage, int waitTimeInMs = 3000) + { + await AnsiConsole.Status() + .Spinner(Spinner.Known.Star) + .StartAsync($"[{White}]{waitMessage}[/]", async ctx => { await Task.Delay(waitTimeInMs); }); + } + + internal static async Task DisplaySpinnerForTask(string waitMessage, Task task) + { + await AnsiConsole.Status() + .Spinner(Spinner.Known.Star) + .StartAsync($"[{White}]{waitMessage}[/]", async ctx => { await task; }); + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/FavoritesAndLeaderboardUi.cs b/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/FavoritesAndLeaderboardUi.cs new file mode 100644 index 00000000..326648ac --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/FavoritesAndLeaderboardUi.cs @@ -0,0 +1,104 @@ +using drinksInfo._0lcm.Configuration; +using drinksInfo._0lcm.Models; +using drinksInfo._0lcm.ServiceContracts; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace drinksInfo._0lcm.UserInterface; + +public class FavoritesAndLeaderboardUi(IDrinkStateService drinkStateService, AppSettings appSettings) +{ + private const string White = DisplayHelper.White; + private const string Green = DisplayHelper.Green; + private const string Grey = DisplayHelper.Grey; + private readonly IDrinkStateService _drinkStateService = drinkStateService; + + //------- Ui Methods ------- + internal async Task FavoritesAndLeaderboard() + { + var showFavorites = true; + + while (true) + { + Console.Clear(); + + if (showFavorites) + { + DisplayHelper.DisplaySuccess("Favorites:"); + + var favoritedDrinks = await _drinkStateService.GetFavorites(); + var iRenderables = BuildFavoritedDrinks(favoritedDrinks); + + DisplayHelper.DisplayRows(iRenderables); + } + else + { + DisplayHelper.DisplaySuccess("View Count Leaderboard:"); + var viewedDrinks = await _drinkStateService.GetViewedDrinks(); + var iRenderables = BuildViewedDrinks(viewedDrinks); + + DisplayHelper.DisplayRows(iRenderables); + } + + var option = + DisplayHelper.DisplayMenu("\nPlease Select an Option:"); + var shouldShowFavorites = HandleMenuOption(option); + + if (shouldShowFavorites == null) return; + showFavorites = shouldShowFavorites is true; + } + } + + /// + /// handles the main menu's options + /// + /// + /// true if menu should show favorites, false if menu should show view count, and null if menu should return. + private static bool? HandleMenuOption(Enums.Enums.FavoritesAndLeaderboardMenuOption option) + { + switch (option) + { + case Enums.Enums.FavoritesAndLeaderboardMenuOption.ShowFavorites: + return true; + case Enums.Enums.FavoritesAndLeaderboardMenuOption.ShowViewLeaderboard: + return false; + case Enums.Enums.FavoritesAndLeaderboardMenuOption.Back: + default: + return null; + } + } + + //------- Helper Methods ------- + private static List BuildFavoritedDrinks(List favoritedDrinks) + { + List iRenderables = []; + + foreach (var drink in favoritedDrinks) + iRenderables.Add(new Markup( + $"[{White}]Drink Name: [/][{Green}]{drink.StrDrink}[/] | [{White}]Id: [/][{Grey}]{drink.IdDrink}[/]")); + + return iRenderables; + } + + private List BuildViewedDrinks(List viewedDrinks) + { + List iRenderables = []; + viewedDrinks = viewedDrinks.OrderByDescending(d => d.ViewCount).ToList(); + + if (viewedDrinks.Count > appSettings.MaxLeaderboardSpots) + { + var viewedDrinksTrimmed = viewedDrinks.Take(appSettings.MaxLeaderboardSpots).ToList(); + foreach (var drink in viewedDrinksTrimmed) + iRenderables.Add(new Markup( + $"[{White}]Drink Name: [/][{Green}]{drink.StrDrink}[/] | [{White}]Id: [/][{Grey}]{drink.IdDrink}[/] | [{White}]View Count: [/][{Grey}]{drink.ViewCount}[/]")); + + return iRenderables; + } + + foreach (var drink in viewedDrinks) + iRenderables.Add(new Markup( + $"[{White}]Drink Name: [/][{Green}]{drink.StrDrink}[/] | [{White}]Id: [/][{Grey}]{drink.IdDrink}[/] | [{White}]View Count: [/][{Grey}]{drink.ViewCount}[/]")); + + return iRenderables; + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/FilterCategoriesUi.cs b/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/FilterCategoriesUi.cs new file mode 100644 index 00000000..9c6c94b0 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/FilterCategoriesUi.cs @@ -0,0 +1,132 @@ +using drinksInfo._0lcm.Models; +using drinksInfo._0lcm.ServiceContracts; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace drinksInfo._0lcm.UserInterface; + +public class FilterCategoriesUi( + IFilterCategoriesService filterService, + ReviewCategoriesUi reviewCategoriesUi, + ILogger logger) +{ + private readonly IFilterCategoriesService _filterService = filterService; + private readonly ILogger _logger = logger; + private readonly ReviewCategoriesUi _reviewCategoriesUi = reviewCategoriesUi; + + //------- Menu Methods ------- + internal async Task FilterCategories() + { + while (true) + { + Console.Clear(); + var option = DisplayHelper.DisplayMenu(); + + try + { + var shouldReturn = await HandleMenuOption(option); + if (shouldReturn) return; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while filtering categories."); + throw; + } + } + } + + /// + /// Handles the menu's option + /// + /// + /// returns true if the menu should return, and false if not + private async Task HandleMenuOption(Enums.Enums.FilterCategoriesOption option) + { + switch (option) + { + case Enums.Enums.FilterCategoriesOption.FilterByAlcoholContent: + await FilterByAlcoholContent(); + break; + case Enums.Enums.FilterCategoriesOption.FilterByDrinkCategory: + await FilterByDrinkCategory(); + break; + case Enums.Enums.FilterCategoriesOption.FilterByGlassType: + await FilterByGlassType(); + break; + case Enums.Enums.FilterCategoriesOption.FilterByIngredient: + await FilterByIngredient(); + break; + case Enums.Enums.FilterCategoriesOption.Back: + return true; + } + + return false; + } + + //------- Viewing Methods ------- + private async Task FilterByAlcoholContent() + { + Console.Clear(); + + var selection = await _reviewCategoriesUi.ViewAlcoholContentCategory(); + var filterClass = await _filterService.FilterByAlcoholContent(selection); + + var rows = CreateFilterDisplayRows(filterClass); + + DisplayHelper.DisplayRows(rows); + DisplayHelper.DisplayPrompt(new List { "Back" }, "\nPress back to return:"); + } + + private async Task FilterByDrinkCategory() + { + Console.Clear(); + + var selection = await _reviewCategoriesUi.ViewDrinks(true); + var filterClass = await _filterService.FilterByDrinkCategory(selection); + + var rows = CreateFilterDisplayRows(filterClass); + + DisplayHelper.DisplayRows(rows); + DisplayHelper.DisplayPrompt(new List { "Back" }, "\nPress back to return:"); + } + + private async Task FilterByIngredient() + { + Console.Clear(); + + var selection = await _reviewCategoriesUi.ViewIngredients(true); + var filterClass = await _filterService.FilterByIngredient(selection); + + var rows = CreateFilterDisplayRows(filterClass); + + DisplayHelper.DisplayRows(rows); + DisplayHelper.DisplayPrompt(new List { "Back" }, "\nPress back to return:"); + } + + private async Task FilterByGlassType() + { + Console.Clear(); + + var selection = await _reviewCategoriesUi.ViewGlasses(true); + var filterClass = await _filterService.FilterByGlassType(selection); + + var rows = CreateFilterDisplayRows(filterClass); + + DisplayHelper.DisplayRows(rows); + DisplayHelper.DisplayPrompt(new List { "Back" }, "\nPress back to return:"); + } + + //------- Helper Methods ------- + private static List CreateFilterDisplayRows(FilterClassResponse filterClass) + { + var rows = new List(); + + foreach (var filtered in filterClass.FilterElements) + rows.Add(new Markup( + $"[{DisplayHelper.White}]Drink Name: [/][{DisplayHelper.Green}]{filtered.StrDrink}[/] | " + + $"[{DisplayHelper.White}]Drink Id: [/][{DisplayHelper.Grey}]{filtered.IdDrink}[/]")); + + return rows; + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/LookupSpecificsUi.cs b/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/LookupSpecificsUi.cs new file mode 100644 index 00000000..ce010dc7 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/LookupSpecificsUi.cs @@ -0,0 +1,360 @@ +using drinksInfo._0lcm.Configuration; +using drinksInfo._0lcm.Models; +using drinksInfo._0lcm.ServiceContracts; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace drinksInfo._0lcm.UserInterface; + +public class LookupSpecificsUi( + ILookupSpecificsService lookupService, + ILogger logger, + AppSettings appSettings, + IDrinkStateService drinkStateService) +{ + private const string PromptOptionAddFavorite = "Favorite Drink"; + private const string PromptOptionRemoveFavorite = "Remove Drink From Favorites"; + private const string PromptOptionBack = "Back"; + private readonly IDrinkStateService _drinkStateService = drinkStateService; + private readonly ILogger _logger = logger; + private readonly ILookupSpecificsService _lookupService = lookupService; + + //------- Menu Methods ------- + internal async Task LookupSpecifics() + { + while (true) + { + Console.Clear(); + try + { + Console.Clear(); + var option = DisplayHelper.DisplayMenu(); + + var shouldReturn = await HandleMenuOption(option); + if (shouldReturn) return; + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception caught while looking up specifics"); + throw; + } + } + } + + /// + /// Handles the menu options + /// + /// + /// Returns true if the menu should return, false if not. + private async Task HandleMenuOption(Enums.Enums.LookupSpecificsOption option) + { + switch (option) + { + case Enums.Enums.LookupSpecificsOption.LookupCocktailByName: + await LookupCocktailByName(); + break; + case Enums.Enums.LookupSpecificsOption.LookupIngredientByName: + await LookupIngredientByName(); + break; + case Enums.Enums.LookupSpecificsOption.LookupCocktailById: + await LookupCocktailById(); + break; + case Enums.Enums.LookupSpecificsOption.LookupIngredientById: + await LookupIngredientById(); + break; + case Enums.Enums.LookupSpecificsOption.LookupRandomCocktail: + await LookupRandomCocktail(); + break; + case Enums.Enums.LookupSpecificsOption.Back: + return true; + } + + return false; + } + + //------- Viewing Methods ------- + private async Task LookupCocktailByName() + { + while (true) + { + Console.Clear(); + var lookupDetail = GetStringInput(); + + try + { + Console.Clear(); + var drinkInfo = await _lookupService.LookupCocktailByName(lookupDetail); + + var rows = await CreateDrinkRows(drinkInfo); + DisplayHelper.DisplayRows(rows); + + var promptList = await CreatePromptList(drinkInfo); + var option = DisplayHelper.DisplayPrompt(promptList, "\nPlease select an option:"); + + await HandleDrinkStateChanges(option, drinkInfo); + } + catch (HttpRequestException ex) + { + var shouldContinue = CatchHttpRequestException(ex); + + if (shouldContinue) continue; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Exception caught while looking up cocktail by name."); + throw; + } + + return; + } + } + + private async Task LookupCocktailById() + { + while (true) + { + Console.Clear(); + var lookupDetail = GetStringInput(); + + try + { + Console.Clear(); + var drinkInfo = await _lookupService.LookupCocktailById(lookupDetail); + + var rows = await CreateDrinkRows(drinkInfo); + DisplayHelper.DisplayRows(rows); + + var promptList = await CreatePromptList(drinkInfo); + var option = DisplayHelper.DisplayPrompt(promptList, "\nPlease select an option:"); + + await HandleDrinkStateChanges(option, drinkInfo); + } + catch (HttpRequestException ex) + { + var shouldContinue = CatchHttpRequestException(ex); + + if (shouldContinue) continue; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Exception caught while looking up cocktail by name."); + throw; + } + + return; + } + } + + private async Task LookupIngredientByName() + { + while (true) + { + Console.Clear(); + var lookupDetail = GetStringInput(); + + try + { + Console.Clear(); + var ingredientInfo = await _lookupService.LookupIngredientByName(lookupDetail); + + var rows = CreateIngredientRows(ingredientInfo); + DisplayHelper.DisplayRows(rows); + + var promptList = await CreatePromptList(null); + DisplayHelper.DisplayPrompt(promptList, "\nPlease select an option:"); + } + catch (HttpRequestException ex) + { + var shouldContinue = CatchHttpRequestException(ex); + + if (shouldContinue) continue; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Exception caught while looking up ingredient by name."); + throw; + } + + return; + } + } + + private async Task LookupIngredientById() + { + while (true) + { + Console.Clear(); + var lookupDetail = GetStringInput(); + + try + { + Console.Clear(); + var ingredientInfo = await _lookupService.LookupIngredientById(lookupDetail); + + var rows = CreateIngredientRows(ingredientInfo); + DisplayHelper.DisplayRows(rows); + + var promptList = await CreatePromptList(null); + DisplayHelper.DisplayPrompt(promptList, "\nPlease select an option:"); + } + catch (HttpRequestException ex) + { + var shouldContinue = CatchHttpRequestException(ex); + + if (shouldContinue) continue; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Exception caught while looking up ingredient by name."); + throw; + } + + return; + } + } + + private async Task LookupRandomCocktail() + { + while (true) + { + Console.Clear(); + try + { + Console.Clear(); + var drinkInfo = await _lookupService.LookupRandomCocktail(); + + var rows = await CreateDrinkRows(drinkInfo); + DisplayHelper.DisplayRows(rows); + + var promptList = await CreatePromptList(drinkInfo); + var option = DisplayHelper.DisplayPrompt(promptList, "\nPlease select an option:"); + + await HandleDrinkStateChanges(option, drinkInfo); + } + catch (HttpRequestException ex) + { + var shouldContinue = CatchHttpRequestException(ex); + + if (shouldContinue) continue; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Exception caught while looking up cocktail by name."); + throw; + } + + return; + } + } + + //------- Helper Methods ------- + private static string GetStringInput() + { + while (true) + { + Console.Clear(); + var response = DisplayHelper.DisplayQuestion("Please enter a name/id for lookup:"); + + if (string.IsNullOrEmpty(response)) + { + DisplayHelper.DisplayWarning("Lookup cannot be null or empty. Press enter to continue."); + Console.ReadLine(); + continue; + } + + var confirmation = AnsiConsole.Confirm($"Is: {response} the correct name/id?"); + if (!confirmation) continue; + + return response; + } + } + + private async Task> CreateDrinkRows(LookupDrinkClassResponse? drinkClass) + { + List rows = []; + + foreach (var drink in drinkClass!.Drinks) + { + rows.Add(new Markup($"[{DisplayHelper.Grey}]\n--------------------------------------------------[/]\n")); + + if (!string.IsNullOrEmpty(drink.StrDrinkThumb) && appSettings.ShowImages) + { + var image = await _lookupService.CreateCanvasImageFromUrl(drink.StrDrinkThumb); + rows.Add(image); + } + + foreach (var (field, value) in drink.DrinkInfo) + rows.Add(new Markup($"[{DisplayHelper.White}]{field}: [/][{DisplayHelper.Green}]{value}[/]")); + } + + return rows; + } + + private static List CreateIngredientRows(LookupIngredientClassResponse? ingredientClass) + { + List rows = []; + + foreach (var ingredient in ingredientClass!.Ingredients) + foreach (var (field, value) in ingredient.IngredientInfo) + rows.Add(new Markup($"[{DisplayHelper.White}]{field}: [/][{DisplayHelper.Green}]{value}[/]")); + + return rows; + } + + private async Task> CreatePromptList(LookupDrinkClassResponse? drinkClass) + { + List promptList = []; + + if (drinkClass?.Drinks is { Count: 1 } drinks) + { + var isFavorited = await _drinkStateService.CheckDrinkIdIsFavorited(drinks[0].IdDrink!); + promptList.Add(!isFavorited ? PromptOptionAddFavorite : PromptOptionRemoveFavorite); + } + + promptList.Add(PromptOptionBack); + return promptList; + } + + /// + /// catches and deals with an HttpRequestException + /// + /// + /// true if the user wants to restart the search, false if not + private static bool CatchHttpRequestException(HttpRequestException ex) + { + if ((int)ex.StatusCode! == 404) + { + Console.Clear(); + DisplayHelper.DisplayWarning("A 404: Not found exception was received, " + + "please make sure you check for correct spelling, " + + "or make sure the drink is in the database."); + } + else + { + DisplayHelper.DisplayWarning(ex.Message); + } + + return AnsiConsole.Confirm("Would you like to retry the search?"); + } + + /// + /// Takes the action prompt's action option and takes applicable actions, and then updates drink view count. + /// + /// action prompt's output + /// + private async Task HandleDrinkStateChanges(string option, LookupDrinkClassResponse? drinkClass) + { + switch (option) + { + case PromptOptionAddFavorite: + await _drinkStateService.FavoriteDrink(drinkClass); + break; + case PromptOptionRemoveFavorite: + await _drinkStateService.RemoveFavorite(drinkClass!.Drinks[0].IdDrink!); + break; + } + + await _drinkStateService.AddViewCount(drinkClass); + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/ReviewCategoriesUi.cs b/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/ReviewCategoriesUi.cs new file mode 100644 index 00000000..0afb3136 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/ReviewCategoriesUi.cs @@ -0,0 +1,243 @@ +using drinksInfo._0lcm.ServiceContracts; +using Microsoft.Extensions.Logging; + +namespace drinksInfo._0lcm.UserInterface; + +public class ReviewCategoriesUi +{ + private readonly ILogger _logger; + private readonly IReviewCategoriesService _reviewCategoriesService; + + public ReviewCategoriesUi(IReviewCategoriesService reviewCategoriesService, ILogger logger) + { + _reviewCategoriesService = reviewCategoriesService; + _logger = logger; + } + + //------- Menu Methods ------- + internal async Task ReviewMenu() + { + while (true) + { + Console.Clear(); + var option = DisplayHelper.DisplayMenu(); + + try + { + var shouldReturn = await HandleMenuOption(option); + if (shouldReturn) return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "An error occured during review menu"); + throw; + } + } + } + + /// + /// Handles review menu options and returns true if user wants to leave the menu + /// + /// + /// a bool specifying true if the user wants to leave the menu + private async Task HandleMenuOption(Enums.Enums.ReviewCategoriesOption option) + { + switch (option) + { + case Enums.Enums.ReviewCategoriesOption.SeeDrinks: + await ViewDrinks(); + break; + case Enums.Enums.ReviewCategoriesOption.SeeGlasses: + await ViewGlasses(); + break; + case Enums.Enums.ReviewCategoriesOption.SeeIngredients: + await ViewIngredients(); + break; + case Enums.Enums.ReviewCategoriesOption.SeeAlcoholicTypes: + await ViewAlcoholContentCategory(); + break; + case Enums.Enums.ReviewCategoriesOption.Back: + return true; + } + + return false; + } + + //------- Viewing Methods ------- + + /// + /// Shows the user a list of drink categories + /// + /// + /// Decides if the method should allow the user to select a drink category. set to false by + /// default + /// + /// returns the string selected by the user if returnSelection is set to true, or returns empty string otherwise + internal async Task ViewDrinks(bool returnSelection = false) + { + Console.Clear(); + + try + { + var categories = await _reviewCategoriesService.GetDrinkCategories(); + + if (returnSelection) + { + List drinkCategories = []; + foreach (var drink in categories.Drinks) drinkCategories.Add(drink.StrCategory); + + var selection = DisplayHelper.DisplayPrompt(drinkCategories, "Please select a drink category:"); + return selection; + } + + foreach (var drink in categories.Drinks) DisplayHelper.DisplayMessage(drink.StrCategory); + + DisplayHelper.DisplayPrompt(new List { "Back" }, "\nPress Back to return:"); + } + catch (HttpRequestException ex) + { + DisplayHelper.DisplayError($"\n\nAn error occurred: {ex.Message}. Press Enter to go back to the menu."); + Console.ReadLine(); + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error has occurred while viewing drink categories"); + throw; + } + + return string.Empty; + } + + /// + /// Shows the user a list of glasses + /// + /// + /// Decides if the method should allow the user to select a type of glass. set to false by + /// default + /// + /// returns the string selected by the user if returnSelection is set to true, or returns empty string otherwise + internal async Task ViewGlasses(bool returnSelection = false) + { + Console.Clear(); + + try + { + var glasses = await _reviewCategoriesService.GetGlassesCategories(); + + if (returnSelection) + { + List glassesList = []; + foreach (var glass in glasses.GlassCategory) glassesList.Add(glass.StrGlass); + + var selection = DisplayHelper.DisplayPrompt(glassesList, "Please select a type of glass:"); + return selection; + } + + foreach (var glass in glasses.GlassCategory) DisplayHelper.DisplayMessage(glass.StrGlass); + + DisplayHelper.DisplayPrompt(new List { "Back" }, "\nPress back to return:"); + } + catch (HttpRequestException ex) + { + DisplayHelper.DisplayError($"\n\nAn Error occurred: {ex.Message}. Press enter to go back to the menu."); + Console.ReadLine(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error occurred while viewing glasses."); + throw; + } + + return string.Empty; + } + + /// + /// Shows the user a list of alcohol content levels + /// + /// + /// Decides if the method should allow the user to select a level of alcohol content. set to + /// false by default + /// + /// returns the string selected by the user if returnSelection is set to true, or returns empty string otherwise + internal async Task ViewIngredients(bool returnSelection = false) + { + Console.Clear(); + + try + { + var ingredients = await _reviewCategoriesService.GetIngredientCategories(); + + if (returnSelection) + { + List ingredientList = []; + foreach (var ingredient in ingredients.IngredientCategory) ingredientList.Add(ingredient.StrIngredient); + + var selection = DisplayHelper.DisplayPrompt(ingredientList, "Please select an ingredient:"); + return selection; + } + + foreach (var ingredient in ingredients.IngredientCategory) + DisplayHelper.DisplayMessage(ingredient.StrIngredient); + + DisplayHelper.DisplayPrompt(new List { "Back" }, "\nPress back to return:"); + } + catch (HttpRequestException ex) + { + DisplayHelper.DisplayError($"An error occurred: {ex.Message}. Press enter to go back to the menu."); + Console.ReadLine(); + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error occurred while viewing ingredients."); + throw; + } + + return string.Empty; + } + + /// + /// Shows the user a list of alcohol content levels + /// + /// + /// Decides if the method should allow the user to select a level of alcohol content. set to + /// false by default + /// + /// returns the string selected by the user if returnSelection is set to true, or returns empty string otherwise + internal async Task ViewAlcoholContentCategory(bool returnSelection = true) + { + Console.Clear(); + + try + { + var alcoholContents = await _reviewCategoriesService.GetAlcoholContentCategories(); + + if (returnSelection) + { + List alcoholContentsList = []; + foreach (var alcohol in alcoholContents.AlcoholContentCategory) + alcoholContentsList.Add(alcohol.StrAlcoholic); + + var selection = DisplayHelper.DisplayPrompt(alcoholContentsList, + "Please select an alcohol content level:"); + return selection; + } + + foreach (var alcoholContent in alcoholContents.AlcoholContentCategory) + DisplayHelper.DisplayMessage(alcoholContent.StrAlcoholic); + + DisplayHelper.DisplayPrompt(new List { "Back" }, "\nPress back to return:"); + } + catch (HttpRequestException ex) + { + DisplayHelper.DisplayError($"An error has occurred: {ex.Message}. Press enter to go back to the menu."); + Console.ReadLine(); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while viewing alcoholic content categories."); + throw; + } + + return string.Empty; + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/SettingsUi.cs b/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/SettingsUi.cs new file mode 100644 index 00000000..7700e501 --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/UserInterface/SettingsUi.cs @@ -0,0 +1,179 @@ +using drinksInfo._0lcm.Configuration; +using drinksInfo._0lcm.ServiceContracts; + +namespace drinksInfo._0lcm.UserInterface; + +public class SettingsUi(ISettingsService settingsService, AppSettings appSettings) +{ + private readonly ISettingsService _settingsService = settingsService; + + //------- Menu Methods ------- + internal void Settings() + { + while (true) + { + Console.Clear(); + var showImagesDescription = + @$"* Show Images - turn images on/off when searching drinks. |Currently: {appSettings.ShowImages}| +Turning off images can increase app performance in some cases."; + var imageMaxWidthDescription = + $@"* Change Image Max Width - limits the images to a certain width. |Currently: {appSettings.MaxImageWidth}| +A higher max width can make the image more detailed, but can also decrease performance."; + var storeViewCountsDescription = + $"* Store View Counts - allows storing view counts for searched drinks |Currently: {appSettings.StoreDrinkCounts}|"; + var maxLeaderboardSpotsDescription = + $"* Max Leaderboard Spots - Changed the max amount of drinks that can be displayed in the view count leaderboard (1-50) |Currently: {appSettings.MaxLeaderboardSpots}|"; + DisplayHelper.DisplayMessage( + $"\n{showImagesDescription}\n\n{imageMaxWidthDescription}\n\n{storeViewCountsDescription}\n\n{maxLeaderboardSpotsDescription}"); + var option = DisplayHelper.DisplayMenu("\nPlease select an option:"); + + var shouldReturn = HandleMenuOption(option); + if (shouldReturn) return; + } + } + + /// + /// Handles menu option + /// + /// + /// true if the menu should return, else false + private bool HandleMenuOption(Enums.Enums.SettingsMenuOption option) + { + switch (option) + { + case Enums.Enums.SettingsMenuOption.ToggleShowImages: + ToggleShowImages(); + break; + case Enums.Enums.SettingsMenuOption.ChangeImageMaxWidth: + ChangeMaxWidth(); + break; + case Enums.Enums.SettingsMenuOption.ToggleStoreViewCounts: + ToggleStoreViewCounts(); + break; + case Enums.Enums.SettingsMenuOption.ChangeMaxLeaderboardSpots: + ChangeMaxLeaderboardSpots(); + break; + case Enums.Enums.SettingsMenuOption.ResetSettings: + ResetSettings(); + break; + case Enums.Enums.SettingsMenuOption.Back: + return true; + } + + return false; + } + + //------- Setting Methods ------- + private void ToggleShowImages() + { + Console.Clear(); + _settingsService.ToggleShowImages(); + + if (appSettings.ShowImages) + DisplayHelper.DisplaySuccess("Images will now be shown when searching drinks."); + else + DisplayHelper.DisplaySuccess("Images will now be hidden when searching drinks."); + + DisplayHelper.DisplayMessage("Press enter to continue."); + Console.ReadLine(); + } + + private void ChangeMaxWidth() + { + while (true) + { + Console.Clear(); + var maxWidth = DisplayHelper.DisplayQuestion("Please enter a new max width (1-100):"); + + if (string.IsNullOrEmpty(maxWidth)) + { + DisplayWarning("Please enter a non null value"); + continue; + } + + if (!int.TryParse(maxWidth, out var newMaxWidth)) + { + DisplayWarning("Please enter a number"); + continue; + } + + if (newMaxWidth > 100 || newMaxWidth < 1) + { + DisplayWarning("Please enter a number between 1 and 100"); + continue; + } + + _settingsService.ChangeMaxWidth(newMaxWidth); + + DisplayHelper.DisplaySuccess("\nMax width has been changed."); + DisplayHelper.DisplayMessage("Press enter to continue."); + Console.ReadLine(); + return; + } + } + + private void ToggleStoreViewCounts() + { + Console.Clear(); + _settingsService.ToggleStoreViewCounts(); + + if (appSettings.StoreDrinkCounts) + DisplayHelper.DisplaySuccess("View counts will now be stored locally."); + else + DisplayHelper.DisplaySuccess("View counts will no longer be stored."); + + DisplayHelper.DisplayMessage("Press enter to continue."); + Console.ReadLine(); + } + + private void ChangeMaxLeaderboardSpots() + { + while (true) + { + Console.Clear(); + var maxSpots = DisplayHelper.DisplayQuestion("Please enter a new amount of max leaderboard spots (1-50):"); + + if (string.IsNullOrEmpty(maxSpots)) + { + DisplayWarning("Please enter a non null value"); + continue; + } + + if (!int.TryParse(maxSpots, out var newMaxSpots)) + { + DisplayWarning("Please enter a number"); + continue; + } + + if (newMaxSpots > 50 || newMaxSpots < 1) + { + DisplayWarning("Please enter a number between 1 and 50"); + continue; + } + + _settingsService.ChangeMaxLeaderboardSpots(newMaxSpots); + + DisplayHelper.DisplaySuccess("\nThe max amount of leaderboard spots has been changed."); + DisplayHelper.DisplayMessage("Press enter to continue."); + Console.ReadLine(); + return; + } + } + + private void ResetSettings() + { + _settingsService.ResetSettings(); + + DisplayHelper.DisplaySuccess("Settings have been reset."); + DisplayHelper.DisplayMessage("Press enter to continue."); + Console.ReadLine(); + } + + //------- Helper Methods ------- + private void DisplayWarning(string warningMessage) + { + DisplayHelper.DisplayWarning(warningMessage); + DisplayHelper.DisplayMessage("Press enter to continue."); + Console.ReadLine(); + } +} \ No newline at end of file diff --git a/drinksInfo.0lcm/drinksInfo.0lcm/drinksInfo.0lcm.csproj b/drinksInfo.0lcm/drinksInfo.0lcm/drinksInfo.0lcm.csproj new file mode 100644 index 00000000..a369ea3d --- /dev/null +++ b/drinksInfo.0lcm/drinksInfo.0lcm/drinksInfo.0lcm.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + drinksInfo._0lcm + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + +