diff --git a/WheelWizard.Test/Features/MiiDbServiceTests.cs b/WheelWizard.Test/Features/MiiDbServiceTests.cs index 6f4ffef8..3d471544 100644 --- a/WheelWizard.Test/Features/MiiDbServiceTests.cs +++ b/WheelWizard.Test/Features/MiiDbServiceTests.cs @@ -1,4 +1,5 @@ using NSubstitute.ExceptionExtensions; +using Testably.Abstractions; using WheelWizard.Shared; using WheelWizard.WiiManagement; using WheelWizard.WiiManagement.Domain.Mii; @@ -7,15 +8,17 @@ namespace WheelWizard.Test.Features { public class MiiDbServiceTests { - private readonly IMiiRepository _repository; + private readonly IMiiRepositoryService _repositoryService; + private readonly IRandomSystem _randomSystemService; private readonly MiiDbService _service; // --- Test Setup --- public MiiDbServiceTests() { - _repository = Substitute.For(); - _service = new(_repository); + _repositoryService = Substitute.For(); + _randomSystemService = Substitute.For(); + _service = new(_repositoryService, _randomSystemService); } // --- Helper Methods --- @@ -28,13 +31,13 @@ private OperationResult CreateValidMii(uint id = 1, string name = "TestMii" var weight = MiiScale.Create(50); var miiFacial = MiiFacialFeatures.Create(MiiFaceShape.Bread, MiiSkinColor.Brown, MiiFacialFeature.Beard, false, false); var miiHair = MiiHair.Create(1, HairColor.Black, false); - var miiEyebrows = MiiEyebrow.Create(1, 1, EyebrowColor.Black, 1, 1, 1); - var miiEyes = MiiEye.Create(1, 1, 1, EyeColor.Black, 1, 1); - var miiNose = MiiNose.Create(NoseType.Default, 1, 1); - var miiLips = MiiLip.Create(1, LipColor.Pink, 1, 1); - var miiGlasses = MiiGlasses.Create(GlassesType.None, GlassesColor.Blue, 1, 1); - var miiFacialHair = MiiFacialHair.Create(MustacheType.None, BeardType.None, MustacheColor.Black, 1, 1); - var miiMole = MiiMole.Create(true, 1, 1, 1); + var miiEyebrows = MiiEyebrow.Create(3, 3, EyebrowColor.Black, 3, 3, 3); + var miiEyes = MiiEye.Create(3, 3, 3, EyeColor.Black, 3, 3); + var miiNose = MiiNose.Create(NoseType.Default, 3, 3); + var miiLips = MiiLip.Create(3, LipColor.Pink, 3, 3); + var miiGlasses = MiiGlasses.Create(GlassesType.None, GlassesColor.Blue, 3, 3); + var miiFacialHair = MiiFacialHair.Create(MustacheType.None, BeardType.None, MustacheColor.Black, 3, 3); + var miiMole = MiiMole.Create(true, 3, 3, 3); var creatorName = MiiName.Create("Creator"); var miiFavoriteColor = MiiFavoriteColor.Red; var EveryResult = new List @@ -149,14 +152,14 @@ public void MiiSerializer_Deserialize_ShouldFail_ForNullData() public void GetAllMiis_ShouldReturnEmptyList_WhenRepositoryReturnsEmptyList() { // Arrange - _repository.LoadAllBlocks().Returns([]); + _repositoryService.LoadAllBlocks().Returns([]); // Act var result = _service.GetAllMiis(); // Assert Assert.Empty(result); - _repository.Received(1).LoadAllBlocks(); + _repositoryService.Received(1).LoadAllBlocks(); } [Fact] @@ -169,7 +172,7 @@ public void GetAllMiis_ShouldReturnListOfMiis_WhenRepositoryReturnsValidBlocks() var mii1Bytes = GetSerializedBytes(mii1Result.Value); var mii2Bytes = GetSerializedBytes(mii2Result.Value); - _repository.LoadAllBlocks().Returns([mii1Bytes, mii2Bytes]); + _repositoryService.LoadAllBlocks().Returns([mii1Bytes, mii2Bytes]); // Act var result = _service.GetAllMiis(); @@ -178,7 +181,7 @@ public void GetAllMiis_ShouldReturnListOfMiis_WhenRepositoryReturnsValidBlocks() Assert.Equal(2, result.Count); Assert.Contains(result, m => m.MiiId == 1 && m.Name.ToString() == "MiiOne"); Assert.Contains(result, m => m.MiiId == 2 && m.Name.ToString() == "MiiTwo"); - _repository.Received(1).LoadAllBlocks(); + _repositoryService.Received(1).LoadAllBlocks(); } [Fact] @@ -192,7 +195,7 @@ public void GetAllMiis_ShouldSkipInvalidBlocks_AndReturnOnlyValidMiis() var invalidBytesNull = (byte[])null; // Null entry (if possible from repo) // Simulate a block that's the right size but contains garbage data causing deserialization failure var potentiallyBadBytes = new byte[MiiSerializer.MiiBlockSize]; - _repository.LoadAllBlocks().Returns([invalidBytesShort, mii1Bytes, potentiallyBadBytes, invalidBytesNull!]); + _repositoryService.LoadAllBlocks().Returns([invalidBytesShort, mii1Bytes, potentiallyBadBytes, invalidBytesNull!]); // Act var result = _service.GetAllMiis(); @@ -202,7 +205,7 @@ public void GetAllMiis_ShouldSkipInvalidBlocks_AndReturnOnlyValidMiis() Assert.Single(result); Assert.Equal(1u, result[0].MiiId); Assert.Equal("ValidMii", result[0].Name.ToString()); - _repository.Received(1).LoadAllBlocks(); + _repositoryService.Received(1).LoadAllBlocks(); } [Fact] @@ -210,12 +213,12 @@ public void GetAllMiis_ShouldHandleRepositoryException() { // Arrange var expectedException = new IOException("Disk read error"); - _repository.LoadAllBlocks().Throws(expectedException); + _repositoryService.LoadAllBlocks().Throws(expectedException); // Act & Assert var actualException = Assert.Throws(() => _service.GetAllMiis()); Assert.Same(expectedException, actualException); // Ensure the original exception is propagated - _repository.Received(1).LoadAllBlocks(); + _repositoryService.Received(1).LoadAllBlocks(); } [Fact] @@ -227,7 +230,7 @@ public void GetByClientId_ShouldReturnMii_WhenRepositoryReturnsValidBlock() Assert.True(miiResult.IsSuccess, "Setup Failed: Could not create valid Mii"); var miiBytes = GetSerializedBytes(miiResult.Value); - _repository.GetRawBlockByAvatarId(targetId).Returns(miiBytes); + _repositoryService.GetRawBlockByAvatarId(targetId).Returns(miiBytes); // Act var result = _service.GetByAvatarId(targetId); @@ -237,7 +240,7 @@ public void GetByClientId_ShouldReturnMii_WhenRepositoryReturnsValidBlock() Assert.NotNull(result.Value); Assert.Equal(targetId, result.Value.MiiId); Assert.Equal("TargetMii", result.Value.Name.ToString()); - _repository.Received(1).GetRawBlockByAvatarId(targetId); + _repositoryService.Received(1).GetRawBlockByAvatarId(targetId); } [Fact] @@ -245,14 +248,14 @@ public void GetByClientId_ShouldReturnFailure_WhenRepositoryReturnsNull() { // Arrange uint targetId = 404; - _repository.GetRawBlockByAvatarId(targetId).Returns((byte[]?)null); + _repositoryService.GetRawBlockByAvatarId(targetId).Returns((byte[]?)null); // Act var result = _service.GetByAvatarId(targetId); // Assert Assert.True(result.IsFailure); - _repository.Received(1).GetRawBlockByAvatarId(targetId); + _repositoryService.Received(1).GetRawBlockByAvatarId(targetId); } [Fact] @@ -261,14 +264,14 @@ public void GetByClientId_ShouldReturnFailure_WhenRepositoryReturnsInvalidLength // Arrange uint targetId = 500; var invalidBytes = new byte[MiiSerializer.MiiBlockSize + 10]; // Wrong size - _repository.GetRawBlockByAvatarId(targetId).Returns(invalidBytes); + _repositoryService.GetRawBlockByAvatarId(targetId).Returns(invalidBytes); // Act var result = _service.GetByAvatarId(targetId); // Assert Assert.True(result.IsFailure); - _repository.Received(1).GetRawBlockByAvatarId(targetId); + _repositoryService.Received(1).GetRawBlockByAvatarId(targetId); } [Fact] @@ -282,7 +285,7 @@ public void GetByClientId_ShouldReturnFailure_WhenDeserializationFails() badBytes[i] = (byte)(i % 256); } - _repository.GetRawBlockByAvatarId(targetId).Returns(badBytes); + _repositoryService.GetRawBlockByAvatarId(targetId).Returns(badBytes); // Act var result = _service.GetByAvatarId(targetId); @@ -290,7 +293,7 @@ public void GetByClientId_ShouldReturnFailure_WhenDeserializationFails() // Assert Assert.True(result.IsFailure); Assert.Equal(result.IsFailure, true); - _repository.Received(1).GetRawBlockByAvatarId(targetId); + _repositoryService.Received(1).GetRawBlockByAvatarId(targetId); } [Fact] @@ -299,12 +302,12 @@ public void GetByClientId_ShouldHandleRepositoryException() // Arrange uint targetId = 777; var expectedException = new InvalidOperationException("Repository error"); - _repository.GetRawBlockByAvatarId(targetId).Throws(expectedException); + _repositoryService.GetRawBlockByAvatarId(targetId).Throws(expectedException); // Act & Assert var actualException = Assert.Throws(() => _service.GetByAvatarId(targetId)); Assert.Same(expectedException, actualException); - _repository.Received(1).GetRawBlockByAvatarId(targetId); + _repositoryService.Received(1).GetRawBlockByAvatarId(targetId); } // --- Update Tests --- @@ -318,14 +321,14 @@ public void Update_ShouldReturnSuccess_WhenSerializationAndRepositorySucceed() var miiToUpdate = miiResult.Value; var expectedBytes = GetSerializedBytes(miiToUpdate); // Get expected bytes *before* setting up mock - _repository.UpdateBlockByClientId(miiToUpdate.MiiId, Arg.Is(b => b.SequenceEqual(expectedBytes))).Returns(Ok()); + _repositoryService.UpdateBlockByClientId(miiToUpdate.MiiId, Arg.Is(b => b.SequenceEqual(expectedBytes))).Returns(Ok()); // Act var result = _service.Update(miiToUpdate); // Assert Assert.True(result.IsSuccess); - _repository.Received(1).UpdateBlockByClientId(miiToUpdate.MiiId, Arg.Is(b => b.SequenceEqual(expectedBytes))); + _repositoryService.Received(1).UpdateBlockByClientId(miiToUpdate.MiiId, Arg.Is(b => b.SequenceEqual(expectedBytes))); } [Fact] @@ -338,7 +341,9 @@ public void Update_ShouldReturnFailure_WhenRepositoryUpdateFails() var expectedBytes = GetSerializedBytes(miiToUpdate); var repoError = Fail("Repository write failed"); - _repository.UpdateBlockByClientId(miiToUpdate.MiiId, Arg.Is(b => b.SequenceEqual(expectedBytes))).Returns(repoError); + _repositoryService + .UpdateBlockByClientId(miiToUpdate.MiiId, Arg.Is(b => b.SequenceEqual(expectedBytes))) + .Returns(repoError); // Act var result = _service.Update(miiToUpdate); @@ -346,7 +351,7 @@ public void Update_ShouldReturnFailure_WhenRepositoryUpdateFails() // Assert Assert.True(result.IsFailure); Assert.Equal(repoError.Error, result.Error); // Propagate the exact error - _repository.Received(1).UpdateBlockByClientId(miiToUpdate.MiiId, Arg.Is(b => b.SequenceEqual(expectedBytes))); + _repositoryService.Received(1).UpdateBlockByClientId(miiToUpdate.MiiId, Arg.Is(b => b.SequenceEqual(expectedBytes))); } [Fact] @@ -359,14 +364,14 @@ public void Update_ShouldHandleRepositoryExceptionDuringUpdate() var expectedBytes = GetSerializedBytes(miiToUpdate); var expectedException = new IOException("Cannot write to file"); - _repository + _repositoryService .UpdateBlockByClientId(miiToUpdate.MiiId, Arg.Is(b => b.SequenceEqual(expectedBytes))) .Throws(expectedException); // Act & Assert var actualException = Assert.Throws(() => _service.Update(miiToUpdate)); Assert.Same(expectedException, actualException); - _repository.Received(1).UpdateBlockByClientId(miiToUpdate.MiiId, Arg.Is(b => b.SequenceEqual(expectedBytes))); + _repositoryService.Received(1).UpdateBlockByClientId(miiToUpdate.MiiId, Arg.Is(b => b.SequenceEqual(expectedBytes))); } // Note: Testing serialization failure within Update is harder if CreateValidMii guarantees a serializable Mii. @@ -388,19 +393,19 @@ public void UpdateName_ShouldReturnSuccess_WhenGetAndUpdateSucceed() var originalBytes = GetSerializedBytes(originalMii); // Setup Get - _repository.GetRawBlockByAvatarId(targetId).Returns(originalBytes); + _repositoryService.GetRawBlockByAvatarId(targetId).Returns(originalBytes); // Setup Update (capture the updated Mii's bytes) byte[]? updatedBytes = null; - _repository.UpdateBlockByClientId(targetId, Arg.Do(bytes => updatedBytes = bytes)).Returns(Ok()); + _repositoryService.UpdateBlockByClientId(targetId, Arg.Do(bytes => updatedBytes = bytes)).Returns(Ok()); // Act var result = _service.UpdateName(targetId, newName); // Assert Assert.True(result.IsSuccess); - _repository.Received(1).GetRawBlockByAvatarId(targetId); - _repository.Received(1).UpdateBlockByClientId(targetId, Arg.Any()); + _repositoryService.Received(1).GetRawBlockByAvatarId(targetId); + _repositoryService.Received(1).UpdateBlockByClientId(targetId, Arg.Any()); // Verify the name was actually changed in the serialized data sent to the repo Assert.NotNull(updatedBytes); @@ -416,7 +421,7 @@ public void UpdateName_ShouldReturnFailure_WhenGetByClientIdFails_NotFound() // Arrange uint targetId = 404; string newName = "NewName"; - _repository.GetRawBlockByAvatarId(targetId).Returns((byte[]?)null); + _repositoryService.GetRawBlockByAvatarId(targetId).Returns((byte[]?)null); // Act var result = _service.UpdateName(targetId, newName); @@ -424,8 +429,8 @@ public void UpdateName_ShouldReturnFailure_WhenGetByClientIdFails_NotFound() // Assert Assert.True(result.IsFailure); Assert.Equal("Mii block not found or invalid.", result.Error.Message); // Error from GetByClientId - _repository.Received(1).GetRawBlockByAvatarId(targetId); - _repository.DidNotReceive().UpdateBlockByClientId(Arg.Any(), Arg.Any()); + _repositoryService.Received(1).GetRawBlockByAvatarId(targetId); + _repositoryService.DidNotReceive().UpdateBlockByClientId(Arg.Any(), Arg.Any()); } [Fact] @@ -435,7 +440,7 @@ public void UpdateName_ShouldReturnFailure_WhenGetByClientIdFails_Deserializatio uint targetId = 666; string newName = "NewName"; var badBytes = new byte[MiiSerializer.MiiBlockSize]; // Correct size, bad content - _repository.GetRawBlockByAvatarId(targetId).Returns(badBytes); + _repositoryService.GetRawBlockByAvatarId(targetId).Returns(badBytes); // Act var result = _service.UpdateName(targetId, newName); @@ -443,8 +448,8 @@ public void UpdateName_ShouldReturnFailure_WhenGetByClientIdFails_Deserializatio // Assert Assert.True(result.IsFailure); Assert.Contains("Mii data is empty.", result.Error.Message); - _repository.Received(1).GetRawBlockByAvatarId(targetId); - _repository.DidNotReceive().UpdateBlockByClientId(Arg.Any(), Arg.Any()); + _repositoryService.Received(1).GetRawBlockByAvatarId(targetId); + _repositoryService.DidNotReceive().UpdateBlockByClientId(Arg.Any(), Arg.Any()); } [Fact] @@ -458,7 +463,7 @@ public void UpdateName_ShouldReturnFailure_WhenNewNameIsInvalid() Assert.True(miiResult.IsSuccess, "Setup Failed: Could not create original Mii"); var originalBytes = GetSerializedBytes(miiResult.Value); - _repository.GetRawBlockByAvatarId(targetId).Returns(originalBytes); + _repositoryService.GetRawBlockByAvatarId(targetId).Returns(originalBytes); // Act var result = _service.UpdateName(targetId, invalidNewName); @@ -466,8 +471,8 @@ public void UpdateName_ShouldReturnFailure_WhenNewNameIsInvalid() // Assert Assert.True(result.IsFailure); Assert.Equal(result.IsFailure, true); - _repository.Received(1).GetRawBlockByAvatarId(targetId); - _repository.DidNotReceive().UpdateBlockByClientId(Arg.Any(), Arg.Any()); + _repositoryService.Received(1).GetRawBlockByAvatarId(targetId); + _repositoryService.DidNotReceive().UpdateBlockByClientId(Arg.Any(), Arg.Any()); } [Fact] @@ -482,8 +487,8 @@ public void UpdateName_ShouldReturnFailure_WhenRepositoryUpdateFails() var originalBytes = GetSerializedBytes(miiResult.Value); var repoError = Fail("Disk full"); - _repository.GetRawBlockByAvatarId(targetId).Returns(originalBytes); - _repository + _repositoryService.GetRawBlockByAvatarId(targetId).Returns(originalBytes); + _repositoryService .UpdateBlockByClientId(targetId, Arg.Any()) // We know name is valid, get succeeds .Returns(repoError); @@ -493,8 +498,8 @@ public void UpdateName_ShouldReturnFailure_WhenRepositoryUpdateFails() // Assert Assert.True(result.IsFailure); Assert.Equal(repoError.Error, result.Error); // Error from the repository update propagated - _repository.Received(1).GetRawBlockByAvatarId(targetId); - _repository.Received(1).UpdateBlockByClientId(targetId, Arg.Any()); + _repositoryService.Received(1).GetRawBlockByAvatarId(targetId); + _repositoryService.Received(1).UpdateBlockByClientId(targetId, Arg.Any()); } [Fact] @@ -504,13 +509,13 @@ public void UpdateName_ShouldHandleExceptionDuringGet() uint targetId = 111; string newName = "New"; var expectedException = new TimeoutException("Timeout contacting repository"); - _repository.GetRawBlockByAvatarId(targetId).Throws(expectedException); + _repositoryService.GetRawBlockByAvatarId(targetId).Throws(expectedException); // Act & Assert var actualException = Assert.Throws(() => _service.UpdateName(targetId, newName)); Assert.Same(expectedException, actualException); - _repository.Received(1).GetRawBlockByAvatarId(targetId); - _repository.DidNotReceive().UpdateBlockByClientId(Arg.Any(), Arg.Any()); + _repositoryService.Received(1).GetRawBlockByAvatarId(targetId); + _repositoryService.DidNotReceive().UpdateBlockByClientId(Arg.Any(), Arg.Any()); } [Fact] @@ -525,14 +530,14 @@ public void UpdateName_ShouldHandleExceptionDuringUpdate() var originalBytes = GetSerializedBytes(miiResult.Value); var expectedException = new UnauthorizedAccessException("Access denied"); - _repository.GetRawBlockByAvatarId(targetId).Returns(originalBytes); - _repository.UpdateBlockByClientId(targetId, Arg.Any()).Throws(expectedException); + _repositoryService.GetRawBlockByAvatarId(targetId).Returns(originalBytes); + _repositoryService.UpdateBlockByClientId(targetId, Arg.Any()).Throws(expectedException); // Act & Assert var actualException = Assert.Throws(() => _service.UpdateName(targetId, newName)); Assert.Same(expectedException, actualException); - _repository.Received(1).GetRawBlockByAvatarId(targetId); - _repository.Received(1).UpdateBlockByClientId(targetId, Arg.Any()); + _repositoryService.Received(1).GetRawBlockByAvatarId(targetId); + _repositoryService.Received(1).UpdateBlockByClientId(targetId, Arg.Any()); } } } diff --git a/WheelWizard/Features/MiiImages/Domain/IMiiIMagesApi.cs b/WheelWizard/Features/MiiImages/Domain/IMiiIMagesApi.cs index dce96ce0..db9ccb11 100644 --- a/WheelWizard/Features/MiiImages/Domain/IMiiIMagesApi.cs +++ b/WheelWizard/Features/MiiImages/Domain/IMiiIMagesApi.cs @@ -18,8 +18,6 @@ Task GetImageAsync( int instanceCount = 1, int cameraXRotate = 0, int cameraYRotate = 0, - int cameraZRotate = 0, - string lightDirectionMode = "none", - string instanceRotationMode = "model" + int cameraZRotate = 0 ); } diff --git a/WheelWizard/Features/MiiImages/Domain/MiiImageSpecifications.cs b/WheelWizard/Features/MiiImages/Domain/MiiImageSpecifications.cs index 8a08c6c7..ef044f5a 100644 --- a/WheelWizard/Features/MiiImages/Domain/MiiImageSpecifications.cs +++ b/WheelWizard/Features/MiiImages/Domain/MiiImageSpecifications.cs @@ -6,6 +6,7 @@ namespace WheelWizard.MiiImages.Domain; public class MiiImageSpecifications { + // IMPORTANT: if you change this, make sure you also edit the Clone method in the extensions of this feature public string Name { get; set; } = string.Empty; public ImageSize Size { get; set; } = ImageSize.small; public FaceExpression Expression { get; set; } = FaceExpression.normal; @@ -23,7 +24,11 @@ public class MiiImageSpecifications public override string ToString() { // If we put all the things in this string, then the Key at least is unique - return $"{Name}_{Size}{Expression}{Type}_{BackgroundColor}{InstanceCount}_{CharacterRotate}{CameraRotate}_{CachePriority}"; + var parts = $"{Name}_{Size}{Expression}{Type}"; + parts += $"{BackgroundColor}{InstanceCount}"; + parts += $"{CharacterRotate}{CameraRotate}"; + parts += $"{CachePriority}"; + return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(parts)); } #region Enums @@ -32,12 +37,24 @@ public override string ToString() public enum FaceExpression { normal, + normal_open_mouth, smile, + smile_open_mouth, frustrated, anger, + anger_open_mouth, blink, + blink_open_mouth, sorrow, + sorrow_open_mouth, surprise, + surprise_open_mouth, + wink_right, + wink_left, + like_wink_left, + like_wink_right, + wink_left_open_mouth, + wink_right_open_mouth, } public enum ImageSize @@ -49,6 +66,7 @@ public enum ImageSize public enum BodyType { face, + face_only, all_body, } diff --git a/WheelWizard/Features/MiiImages/Domain/MiiImageVariants.cs b/WheelWizard/Features/MiiImages/Domain/MiiImageVariants.cs index 084b5b2c..d359f76f 100644 --- a/WheelWizard/Features/MiiImages/Domain/MiiImageVariants.cs +++ b/WheelWizard/Features/MiiImages/Domain/MiiImageVariants.cs @@ -14,6 +14,14 @@ public static class MiiImageVariants CachePriority = CacheItemPriority.High, }; + public static readonly MiiImageSpecifications MiiBlockProfile = new() + { + Name = "MiiBlockProfile", + Expression = MiiImageSpecifications.FaceExpression.normal, + Type = MiiImageSpecifications.BodyType.face, + Size = MiiImageSpecifications.ImageSize.medium, + }; + public static readonly MiiImageSpecifications OnlinePlayerSmall = new() { Name = "OnlinePlayerSmall", @@ -23,8 +31,29 @@ public static class MiiImageVariants CachePriority = CacheItemPriority.Low, }; + public static readonly MiiImageSpecifications MiiEditorSmall = new() + { + Name = "MiiEditorPreviewSmall", + Expression = MiiImageSpecifications.FaceExpression.normal, + Type = MiiImageSpecifications.BodyType.face, + Size = MiiImageSpecifications.ImageSize.medium, + ExpirationSeconds = TimeSpan.FromSeconds(30), + CachePriority = CacheItemPriority.Low, + }; + public static readonly MiiImageSpecifications MiiEditorPreviewCarousel = new() + { + Name = "MiiEditorPreviewCarousel", + Expression = MiiImageSpecifications.FaceExpression.normal, + Type = MiiImageSpecifications.BodyType.all_body, + Size = MiiImageSpecifications.ImageSize.medium, + CachePriority = CacheItemPriority.Low, + ExpirationSeconds = TimeSpan.FromSeconds(30), + InstanceCount = 8, + }; + public static readonly MiiImageSpecifications CurrentUserSideProfile = new() { + Name = "CurrentUserSideProfile", Expression = MiiImageSpecifications.FaceExpression.normal, Type = MiiImageSpecifications.BodyType.face, Size = MiiImageSpecifications.ImageSize.medium, @@ -33,6 +62,7 @@ public static class MiiImageVariants }; public static readonly MiiImageSpecifications FriendsSideProfile = new() { + Name = "FriendsSideProfile", Expression = MiiImageSpecifications.FaceExpression.normal, Type = MiiImageSpecifications.BodyType.face, Size = MiiImageSpecifications.ImageSize.medium, @@ -43,9 +73,11 @@ public static class MiiImageVariants public static readonly MiiImageSpecifications FullBodyCarousel = new() { + Name = "FullBodyCarousel", Expression = MiiImageSpecifications.FaceExpression.normal, Type = MiiImageSpecifications.BodyType.all_body, Size = MiiImageSpecifications.ImageSize.medium, + ExpirationSeconds = TimeSpan.FromMinutes(10), InstanceCount = 8, }; } diff --git a/WheelWizard/Features/MiiImages/MiiImagesExtensions.cs b/WheelWizard/Features/MiiImages/MiiImagesExtensions.cs index 550b548e..082ed4de 100644 --- a/WheelWizard/Features/MiiImages/MiiImagesExtensions.cs +++ b/WheelWizard/Features/MiiImages/MiiImagesExtensions.cs @@ -1,3 +1,4 @@ +using System.Numerics; using WheelWizard.MiiImages.Domain; using WheelWizard.Services; @@ -13,4 +14,24 @@ public static IServiceCollection AddMiiImages(this IServiceCollection services) return services; } + + public static MiiImageSpecifications Clone(this MiiImageSpecifications specifications) + { + return new MiiImageSpecifications + { + Name = specifications.Name, + Size = specifications.Size, + Expression = specifications.Expression, + Type = specifications.Type, + BackgroundColor = specifications.BackgroundColor, + InstanceCount = specifications.InstanceCount, + CharacterRotate = new(specifications.CharacterRotate.X, specifications.CharacterRotate.Y, specifications.CharacterRotate.Z), + CameraRotate = new(specifications.CameraRotate.X, specifications.CameraRotate.Y, specifications.CameraRotate.Z), + ExpirationSeconds = + specifications.ExpirationSeconds?.TotalSeconds == null + ? null + : TimeSpan.FromSeconds(specifications.ExpirationSeconds.Value.TotalSeconds), + CachePriority = specifications.CachePriority, + }; + } } diff --git a/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs b/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs index 74f48062..c540266f 100644 --- a/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs +++ b/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Avalonia.Media.Imaging; using Microsoft.Extensions.Caching.Memory; using WheelWizard.MiiImages.Domain; @@ -13,6 +14,9 @@ public interface IMiiImagesSingletonService public class MiiImagesSingletonService(IApiCaller apiCaller, IMemoryCache cache) : IMiiImagesSingletonService { + // Track in-flight requests to prevent duplicate API calls + private readonly ConcurrentDictionary _inFlightRequests = new(); + public async Task> GetImageAsync(Mii? mii, MiiImageSpecifications specifications) { var data = MiiStudioDataSerializer.Serialize(mii); @@ -20,23 +24,46 @@ public async Task> GetImageAsync(Mii? mii, MiiImageSpeci return data.Error; var miiConfigKey = data.Value + specifications; - var isCached = cache.TryGetValue(miiConfigKey, out Bitmap? cachedValue); - if (isCached) + + // Even tho we also check it in the semaphore section, we also check here if it's in the cache, just to be tad faster. + if (cache.TryGetValue(miiConfigKey, out Bitmap? cachedValue)) return cachedValue ?? Fail("Cached image is null."); - var newImageResult = await apiCaller.CallApiAsync(api => GetBitmapAsync(api, data.Value, specifications)); - Bitmap? newImage = null; - if (newImageResult.IsSuccess) - newImage = newImageResult.Value; + var requestSemaphore = _inFlightRequests.GetOrAdd(miiConfigKey, _ => new(1, 1)); - using (var entry = cache.CreateEntry(miiConfigKey)) + try { - entry.Value = newImage; - entry.SlidingExpiration = specifications.ExpirationSeconds; - entry.Priority = specifications.CachePriority; - } + // Wait to acquire the semaphore - only the first request will proceed immediately + await requestSemaphore.WaitAsync(); + + // Double-check the cache after acquiring the semaphore + // Another thread might have completed the request while we were waiting + if (cache.TryGetValue(miiConfigKey, out Bitmap? doubleCheckCached)) + return doubleCheckCached ?? Fail("Cached image is null."); + + // If we get here, we're the first request and need to call the API + var newImageResult = await apiCaller.CallApiAsync(api => GetBitmapAsync(api, data.Value, specifications)); - return newImage ?? Fail("Failed to get new image."); + Bitmap? newImage = null; + if (newImageResult.IsSuccess) + newImage = newImageResult.Value; + + using (var entry = cache.CreateEntry(miiConfigKey)) + { + entry.Value = newImage; + entry.SlidingExpiration = specifications.ExpirationSeconds; + entry.Priority = specifications.CachePriority; + } + + return newImage ?? Fail("Failed to get new image."); + } + finally + { + // We can also do it all without try catch. But we need to make sure that whatever happens, we release the semaphore + // So just to be safe, if anything happens, we release the semaphore anyway. + requestSemaphore.Release(); + _inFlightRequests.TryRemove(miiConfigKey, out _); + } } private static async Task GetBitmapAsync(IMiiIMagesApi api, string data, MiiImageSpecifications specifications) diff --git a/WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs b/WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs index 010d60b4..37ea725a 100644 --- a/WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs +++ b/WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs @@ -35,7 +35,8 @@ public static OperationResult Serialize(Mii? mii) visualMiiClone.MiiMole = mii.MiiMole; visualMiiClone.MiiFavoriteColor = mii.MiiFavoriteColor; visualMiiClone.MiiFacial = mii.MiiFacial; - visualMiiClone.MiiId = 1; // Mii ID cant be 0 if you want to serialize it so... + // If id is 0, we keep it 0, any other ID will be set to 1. + visualMiiClone.MiiId = (uint)(mii.MiiId == 0 ? 0 : 1); var serialized = MiiSerializer.Serialize(visualMiiClone); if (serialized.IsFailure) diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs index 092f439b..4d5e45cf 100644 --- a/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs @@ -12,8 +12,25 @@ public class Mii public MiiScale Height { get; set; } = new(1); public MiiScale Weight { get; set; } = new(1); - //Mii ID is also referred as Avatar ID - public uint MiiId { get; set; } + public bool IsForeign => (MiiId1 >> 5) == 0b110; // checks if the top 3 bits are set to 110, if so it got blue pants + + //Mii ID is also refered as Avatar ID + public byte MiiId1 { get; set; } + public byte MiiId2 { get; set; } + public byte MiiId3 { get; set; } + public byte MiiId4 { get; set; } + + public uint MiiId + { + get => (uint)(MiiId1 << 24 | MiiId2 << 16 | MiiId3 << 8 | MiiId4); + set + { + MiiId1 = (byte)(value >> 24); + MiiId2 = (byte)(value >> 16); + MiiId3 = (byte)(value >> 8); + MiiId4 = (byte)(value); + } + } //This is also referred as Client ID public byte SystemId0 { get; set; } @@ -21,16 +38,27 @@ public class Mii public byte SystemId2 { get; set; } public byte SystemId3 { get; set; } + public uint SystemId + { + get => (uint)(SystemId0 << 24 | SystemId1 << 16 | SystemId2 << 8 | SystemId3); + set + { + SystemId0 = (byte)(value >> 24); + SystemId1 = (byte)(value >> 16); + SystemId2 = (byte)(value >> 8); + SystemId3 = (byte)(value); + } + } + public MiiFacialFeatures MiiFacial { get; set; } = new(MiiFaceShape.Bread, MiiSkinColor.Light, MiiFacialFeature.None, false, false); - public MiiHair MiiHair { get; set; } = new(0, HairColor.Black, false); - public MiiEyebrow MiiEyebrows { get; set; } = new(0, 0, EyebrowColor.Black, 1, 1, 1); - public MiiEye MiiEyes { get; set; } = new(0, 0, 0, EyeColor.Black, 0, 0); - public MiiNose MiiNose { get; set; } = new(NoseType.Default, 0, 0); - public MiiLip MiiLips { get; set; } = new(0, LipColor.Skin, 0, 0); - public MiiGlasses MiiGlasses { get; set; } = new(GlassesType.None, GlassesColor.Dark, 0, 0); - public MiiFacialHair MiiFacialHair { get; set; } = new(MustacheType.None, BeardType.None, MustacheColor.Black, 0, 0); + public MiiHair MiiHair { get; set; } = new(1, HairColor.Black, false); + public MiiEyebrow MiiEyebrows { get; set; } = new(1, 0, EyebrowColor.Black, 4, 10, 1); + public MiiEye MiiEyes { get; set; } = new(1, 6, 7, EyeColor.Black, 3, 6); + public MiiNose MiiNose { get; set; } = new(NoseType.Default, 6, 4); + public MiiLip MiiLips { get; set; } = new(1, LipColor.Skin, 4, 9); + public MiiGlasses MiiGlasses { get; set; } = new(GlassesType.None, GlassesColor.Dark, 4, 1); + public MiiFacialHair MiiFacialHair { get; set; } = new(MustacheType.None, BeardType.None, MustacheColor.Black, 1, 1); public MiiMole MiiMole { get; set; } = new(false, 0, 0, 0); - public MiiName CreatorName { get; set; } = new("no name"); } diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEnums.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEnums.cs index 6e1df9db..c70a8f47 100644 --- a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEnums.cs +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEnums.cs @@ -5,6 +5,7 @@ public enum MiiFavoriteColor : uint Red, Orange, Yellow, + LightGreen, Green, Blue, LightBlue, @@ -13,16 +14,15 @@ public enum MiiFavoriteColor : uint Brown, White, Black, - Gray, } public enum MiiFaceShape { - RoundPointChin, + Teardrop, Circle, Oval, - BlobFatChin, - RightAnglePointChin, + Glob, + Pointy, Bread, Octagon, Square, @@ -31,8 +31,8 @@ public enum MiiFaceShape public enum MiiSkinColor { Light, - LightTan, - Tan, + Yellow, + Red, Pink, DarkBrown, Brown, @@ -51,7 +51,7 @@ public enum MiiFacialFeature EyeShadow, Beard, MouthCorners, - Old, + Wrinkles, } public enum HairColor @@ -82,7 +82,7 @@ public enum EyeColor : uint { Black, Grey, - Red, + Brown, Gold, Blue, Green, @@ -95,7 +95,7 @@ public enum NoseType Dots, VShape, FullNose, - Triangle, + UShape, FlatC, UpsideDownC, Squidward, @@ -142,16 +142,16 @@ public enum MustacheColor LightRed, Grey, LightBrown, - Blonde, + Tan, White, } public enum MustacheType { None, - Fat, - Thin, - Goatee, + Normal, + Lines, + Droopy, } public enum BeardType diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEyebrow.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEyebrow.cs index 0b67603a..d5524134 100644 --- a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEyebrow.cs +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEyebrow.cs @@ -17,7 +17,7 @@ public MiiEyebrow(int type, int rotation, EyebrowColor color, int size, int vert throw new ArgumentException("Rotation invalid"); if (size is < 0 or > 8) throw new ArgumentException("Size invalid"); - if (vertical is < 0 or > 18) + if (vertical is < 3 or > 18) throw new ArgumentException("Vertical position invalid"); if (spacing is < 0 or > 12) throw new ArgumentException("Spacing invalid"); diff --git a/WheelWizard/Features/WiiManagement/GameDataLoaderService.cs b/WheelWizard/Features/WiiManagement/GameLicenseService.cs similarity index 88% rename from WheelWizard/Features/WiiManagement/GameDataLoaderService.cs rename to WheelWizard/Features/WiiManagement/GameLicenseService.cs index 170143e4..4bc2bb37 100644 --- a/WheelWizard/Features/WiiManagement/GameDataLoaderService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicenseService.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using WheelWizard.Models.Enums; using WheelWizard.Models.GameData; +using WheelWizard.Models.Settings; using WheelWizard.Services; using WheelWizard.Services.LiveData; using WheelWizard.Services.Other; @@ -64,6 +65,11 @@ public interface IGameLicenseSingletonService /// Subscribes a listener to the repeated task manager. /// void Subscribe(IRepeatedTaskListener subscriber); + + /// + /// Changes the Mii for a specific user index. + /// + OperationResult ChangeMii(int userIndex, Mii? newMii); } public class GameLicenseSingletonService : RepeatedTaskManager, IGameLicenseSingletonService @@ -128,16 +134,16 @@ public OperationResult LoadLicense() // If the file was invalid or not found, create 4 dummy licenses Licenses.Users.Clear(); for (var i = 0; i < MaxPlayerNum; i++) - Licenses.Users.Add(CreateDummyUser()); + Licenses.Users.Add(CreateDummyLicense()); return Ok(); } - private static LicenseProfile CreateDummyUser() + private static LicenseProfile CreateDummyLicense() { - var dummyUser = new LicenseProfile + var dummyLicense = new LicenseProfile { FriendCode = "0000-0000-0000", - Mii = new(), + Mii = new() { Name = new MiiName(SettingValues.NoLicense) }, Vr = 5000, Br = 5000, TotalRaceCount = 0, @@ -146,7 +152,7 @@ private static LicenseProfile CreateDummyUser() RegionId = 10, // 10 => “unknown” IsOnline = false, }; - return dummyUser; + return dummyLicense; } private OperationResult ParseUsers() @@ -161,14 +167,14 @@ private OperationResult ParseUsers() var rkpdCheck = Encoding.ASCII.GetString(_rksysData, rkpdOffset, RkpdMagic.Length) == RkpdMagic; if (!rkpdCheck) { - Licenses.Users.Add(CreateDummyUser()); + Licenses.Users.Add(CreateDummyLicense()); continue; } var user = ParseLicenseUser(rkpdOffset); if (user.IsFailure) { - Licenses.Users.Add(CreateDummyUser()); + Licenses.Users.Add(CreateDummyLicense()); continue; } Licenses.Users.Add(user.Value); @@ -177,7 +183,7 @@ private OperationResult ParseUsers() // Keep this here so we always have 4 users if the code above were to be changed while (Licenses.Users.Count < 4) { - Licenses.Users.Add(CreateDummyUser()); + Licenses.Users.Add(CreateDummyLicense()); } return Ok(); } @@ -262,6 +268,45 @@ private void ParseFriends(LicenseProfile licenseProfile, int userOffset) } } + public OperationResult ChangeMii(int userIndex, Mii? newMii) + { + if (newMii is null) + return "Mii cannot be null."; + if (userIndex is < 0 or >= MaxPlayerNum) + return "Invalid license index. Please select a valid license."; + + var serialised = MiiSerializer.Serialize(newMii); + if (serialised.IsFailure) + return serialised.Error!.Message; + + var existing = _miiService.GetByAvatarId(newMii.MiiId); + if (existing.IsFailure) + return existing.Error!.Message; + + var licence = Licenses.Users[userIndex]; + licence.Mii = newMii; + + if (_rksysData is null || _rksysData.Length < RksysSize) + return "Invalid or unloaded rksys.dat data."; + + var rkpdOffset = 0x08 + userIndex * RkpdSize; + BigEndianBinaryReader.WriteUInt32BigEndian(_rksysData, rkpdOffset + 0x28, newMii.MiiId); // Avatar ID + + var systemid = newMii.SystemId0 << 24 | newMii.SystemId1 << 16 | newMii.SystemId2 << 8 | newMii.SystemId3; + + BigEndianBinaryReader.WriteUInt32BigEndian(_rksysData, rkpdOffset + 0x2C, (uint)systemid); + + var nameWrite = WriteLicenseNameToSaveData(userIndex, newMii.Name.ToString()); + if (nameWrite.IsFailure) + return nameWrite.Error!.Message; + + var saveResult = SaveRksysToFile(); + if (saveResult.IsFailure) + return saveResult.Error!.Message; + + return Ok(); + } + private bool CheckForMiiData(int offset) { // If the entire 0x4A bytes are zero, we treat it as empty / no Mii data diff --git a/WheelWizard/Features/WiiManagement/MiiDbService.cs b/WheelWizard/Features/WiiManagement/MiiDbService.cs index 94cebe51..f30a80c4 100644 --- a/WheelWizard/Features/WiiManagement/MiiDbService.cs +++ b/WheelWizard/Features/WiiManagement/MiiDbService.cs @@ -1,4 +1,5 @@ -using WheelWizard.WiiManagement.Domain.Mii; +using Testably.Abstractions; +using WheelWizard.WiiManagement.Domain.Mii; namespace WheelWizard.WiiManagement; @@ -34,21 +35,31 @@ public interface IMiiDbService /// The new name to assign to the Mii. /// An indicating success or failure. OperationResult UpdateName(uint clientId, string newName); -} -public class MiiDbService : IMiiDbService -{ - private readonly IMiiRepository _repository; + /// + /// Adds a new Mii to the database. + /// + /// + /// + OperationResult AddToDatabase(Mii? newMii, string macAddress); - public MiiDbService(IMiiRepository repository) - { - _repository = repository; - } + /// + /// Removes (deletes) the Mii with the given client ID by zeroing out its slot. + /// + OperationResult Remove(uint clientId); + + /// + /// Whether the database exists or not. + /// + bool Exists(); +} +public class MiiDbService(IMiiRepositoryService repository, IRandomSystem randomSystem) : IMiiDbService +{ public List GetAllMiis() { var result = new List(); - var blocks = _repository.LoadAllBlocks(); + var blocks = repository.LoadAllBlocks(); foreach (var block in blocks) { @@ -60,9 +71,11 @@ public List GetAllMiis() return result; } + public bool Exists() => repository.Exists(); + public OperationResult GetByAvatarId(uint avatarId) { - var raw = _repository.GetRawBlockByAvatarId(avatarId); + var raw = repository.GetRawBlockByAvatarId(avatarId); if (raw == null || raw.Length != MiiSerializer.MiiBlockSize) return "Mii block not found or invalid."; @@ -74,7 +87,7 @@ public OperationResult Update(Mii updatedMii) var serialized = MiiSerializer.Serialize(updatedMii); if (serialized.IsFailure) return serialized; - var value = _repository.UpdateBlockByClientId(updatedMii.MiiId, serialized.Value); + var value = repository.UpdateBlockByClientId(updatedMii.MiiId, serialized.Value); return value; } @@ -93,4 +106,110 @@ public OperationResult UpdateName(uint clientId, string newName) mii.Name = nameResult.Value; return Update(mii); } + + public OperationResult AddToDatabase(Mii? newMii, string macAddress) + { + if (newMii == null) + return Fail("Mii cannot be null or have an invalid ID."); + + var macParts = macAddress.Split(':'); + if (macParts.Length != 6) + return Fail("Invalid MAC address format."); + newMii.IsInvalid = false; + + var getMacAddress = TryCatch(() => + { + var macBytes = new byte[6]; + for (var i = 0; i < 6; i++) + macBytes[i] = byte.Parse(macParts[i], System.Globalization.NumberStyles.HexNumber); + newMii.SystemId0 = (byte)((macBytes[0] + macBytes[1] + macBytes[2]) & 0xFF); + newMii.SystemId1 = macBytes[3]; + newMii.SystemId2 = macBytes[4]; + newMii.SystemId3 = macBytes[5]; + }); + if (getMacAddress.IsFailure) + return getMacAddress; + + var miiId = GenerateMiiId(); + newMii.MiiId1 = miiId[0]; + newMii.MiiId2 = miiId[1]; + newMii.MiiId3 = miiId[2]; + newMii.MiiId4 = miiId[3]; + + var serialized = MiiSerializer.Serialize(newMii); + + if (serialized.IsFailure) + return serialized; + + var result = repository.AddMiiToBlocks(serialized.Value); + return result; + } + + private static readonly object _miiIdLock = new(); + private static uint _lastCounter; + private static uint _sequenceOffset; + + // This took me days to figure out :)))) + // The Mii ID is a 32-bit unsigned integer, + // where the first 3 bits are used to indicate the type of Mii i think + // The remaining 29 bits are a counter that increments every 4 seconds from a fixed epoch (January 1, 2006). + // For our implementation the counter is incremented by a sequence offset to ensure uniqueness even if the function is called multiple times in the same tick. + private static byte[] GenerateMiiId(bool isBlue = false) + { + // Epoch for Wii: January 1, 2006 UTC + var epoch = new DateTime(2006, 1, 1, 0, 0, 0, DateTimeKind.Utc); + // Current time in UTC + var now = DateTime.UtcNow; + + // Compute base tick (4‑second resolution) + uint baseCounter = (uint)((now - epoch).TotalSeconds / 4u); + + uint actualCounter; + lock (_miiIdLock) + { + if (baseCounter == _lastCounter) + { + // same tick as last time: bump the offset + _sequenceOffset++; + } + else + { + // new tick: reset offset + _lastCounter = baseCounter; + _sequenceOffset = 0; + } + actualCounter = baseCounter + _sequenceOffset; + } + + var prefixBits = isBlue ? 0b110u : 0b100u; + var miiId = + (prefixBits << 29) // top 3 bits + | (actualCounter & 0x1FFFFFFFu); // lower 29 bits + + return [(byte)(miiId >> 24), (byte)(miiId >> 16), (byte)(miiId >> 8), (byte)(miiId)]; + } + + public OperationResult Remove(uint clientId) + { + if (clientId == 0) + return Fail("Invalid client ID."); + var emptyBlock = new byte[74]; + return repository.UpdateBlockByClientId(clientId, emptyBlock); + } + + public static uint GenerateCustomMiiId() + { + var rng = Random.Shared; + + // Byte 0: ensure high bit = 1 (so ID ≥ 0x80000000) + var b0 = (byte)(rng.Next(0, 0x40) | 0x80); + + // Bytes 1–3: fully random + var b1 = (byte)rng.Next(0, 0x100); + var b2 = (byte)rng.Next(0, 0x100); + var b3 = (byte)rng.Next(0, 0x100); + + // Combine into big‑endian uint: + return ((uint)b0 << 24) | ((uint)b1 << 16) | ((uint)b2 << 8) | (uint)b3; + } } diff --git a/WheelWizard/Features/WiiManagement/MiiRepositoryService.cs b/WheelWizard/Features/WiiManagement/MiiRepositoryService.cs index 18efe912..f0252a28 100644 --- a/WheelWizard/Features/WiiManagement/MiiRepositoryService.cs +++ b/WheelWizard/Features/WiiManagement/MiiRepositoryService.cs @@ -4,7 +4,7 @@ namespace WheelWizard.WiiManagement; -public interface IMiiRepository +public interface IMiiRepositoryService { /// /// Loads all 100 Mii data blocks from the Wii Mii database @@ -32,16 +32,34 @@ public interface IMiiRepository /// The unique ID of the mii to search for /// the new raw Mii data OperationResult UpdateBlockByClientId(uint clientId, byte[] newBlock); + + /// + /// Adds a new Mii block to the database. + /// + /// + OperationResult AddMiiToBlocks(byte[] rawMiiData); + + /// + /// Whether the database file exists or not. + /// + bool Exists(); + + /// + /// Forcefully creates a new database file. + /// + /// + OperationResult ForceCreateDatabase(); } -public class MiiRepositoryService(IFileSystem fileSystem) : IMiiRepository +public class MiiRepositoryServiceService(IFileSystem fileSystem) : IMiiRepositoryService { + private readonly IFileSystem _fileSystem; private const int MiiLength = 74; private const int MaxMiiSlots = 100; private const int CrcOffset = 0x1F1DE; private const int HeaderOffset = 0x04; - private static readonly byte[] EmptyMii = Enumerable.Repeat((byte)0xFF, MiiLength).ToArray(); - private readonly string _filePath = PathManager.WiiDbFile; + private static readonly byte[] EmptyMii = Enumerable.Repeat((byte)0x00, MiiLength).ToArray(); + private string _miiDbFilePath => PathManager.MiiDbFile; public List LoadAllBlocks() { @@ -69,14 +87,26 @@ public List LoadAllBlocks() public OperationResult SaveAllBlocks(List blocks) { - if (!fileSystem.File.Exists(_filePath)) + if (!fileSystem.File.Exists(_miiDbFilePath)) return "RFL_DB.dat not found."; var db = ReadDatabase(); + if (db.Length >= CrcOffset + 2) + { + // compute CRC over everything before CrcOffset + ushort existingCrc = (ushort)((db[CrcOffset] << 8) | db[CrcOffset + 1]); + ushort calcCrc = CalculateCrc16(db, 0, CrcOffset); + + if (existingCrc != calcCrc) + { + return Fail($"Corrupt Mii database (bad CRC 0x{existingCrc:X4}, expected 0x{calcCrc:X4})."); + } + } + using var ms = new MemoryStream(db); ms.Seek(HeaderOffset, SeekOrigin.Begin); - for (int i = 0; i < MaxMiiSlots; i++) + for (var i = 0; i < MaxMiiSlots; i++) { var block = i < blocks.Count ? blocks[i] : EmptyMii; ms.Write(block, 0, MiiLength); @@ -89,7 +119,7 @@ public OperationResult SaveAllBlocks(List blocks) db[CrcOffset + 1] = (byte)(crc & 0xFF); } - fileSystem.File.WriteAllBytes(_filePath, db); + fileSystem.File.WriteAllBytes(_miiDbFilePath, db); return Ok(); } @@ -112,13 +142,53 @@ public OperationResult SaveAllBlocks(List blocks) return null; } + public bool Exists() => fileSystem.File.Exists(_miiDbFilePath); + + public OperationResult ForceCreateDatabase() + { + if (fileSystem.File.Exists(_miiDbFilePath)) + return "Database already exists."; + + var directory = Path.GetDirectoryName(_miiDbFilePath); + if (!string.IsNullOrEmpty(directory) && !fileSystem.Directory.Exists(directory)) + { + fileSystem.Directory.CreateDirectory(directory); + } + + var db = new byte[779_968]; + // first 4 bytes should be the RNOD magic "RNOD" + db[0] = 0x52; + db[1] = 0x4E; + db[2] = 0x4F; + db[3] = 0x44; + + db[0x1CE0 + 0x0C] = 0x80; + //and at offset 0x01d00 we have the RNHD magic "RNHD" + db[0x1D00] = 0x52; + db[0x1D01] = 0x4E; + db[0x1D02] = 0x48; + db[0x1D03] = 0x44; + + db[0x1D04] = 0xFF; + db[0x1D05] = 0xFF; + db[0x1D06] = 0xFF; + db[0x1D07] = 0xFF; + + var crc = CalculateCrc16(db, 0, CrcOffset); + db[CrcOffset] = (byte)(crc >> 8); + db[CrcOffset + 1] = (byte)(crc & 0xFF); + fileSystem.File.WriteAllBytes(_miiDbFilePath, db); + + return Ok(); + } + public OperationResult UpdateBlockByClientId(uint clientId, byte[] newBlock) { if (clientId == 0) return "Invalid ClientId."; if (newBlock.Length != MiiLength) return "Mii block size invalid."; - if (!fileSystem.File.Exists(_filePath)) + if (!fileSystem.File.Exists(_miiDbFilePath)) return "RFL_DB.dat not found."; var allBlocks = LoadAllBlocks(); @@ -149,7 +219,7 @@ private byte[] ReadDatabase() { try { - return fileSystem.File.Exists(_filePath) ? fileSystem.File.ReadAllBytes(_filePath) : []; + return Exists() ? fileSystem.File.ReadAllBytes(_miiDbFilePath) : []; } catch { @@ -160,13 +230,40 @@ private byte[] ReadDatabase() private static ushort CalculateCrc16(byte[] buf, int off, int len) { const ushort poly = 0x1021; - ushort crc = 0xFFFF; // correct seed - for (int i = off; i < off + len; i++) + ushort crc = 0x0000; + for (var i = off; i < off + len; i++) { crc ^= (ushort)(buf[i] << 8); - for (int b = 0; b < 8; b++) + for (var b = 0; b < 8; b++) crc = (crc & 0x8000) != 0 ? (ushort)((crc << 1) ^ poly) : (ushort)(crc << 1); } return crc; } + + public OperationResult AddMiiToBlocks(byte[]? rawMiiData) + { + if (rawMiiData is not { Length: MiiLength }) + return "Invalid Mii block size."; + + // Load all 100 blocks. + var blocks = LoadAllBlocks(); + var inserted = false; + + // Look for an empty slot. + for (var i = 0; i < blocks.Count; i++) + { + if (!blocks[i].SequenceEqual(EmptyMii)) + continue; + + blocks[i] = rawMiiData; + inserted = true; + break; + } + + if (!inserted) + return "No empty Mii slot available."; + + // Save the updated blocks back to the database. + return SaveAllBlocks(blocks); + } } diff --git a/WheelWizard/Features/WiiManagement/MiiSerializer.cs b/WheelWizard/Features/WiiManagement/MiiSerializer.cs index 50275c81..057b86be 100644 --- a/WheelWizard/Features/WiiManagement/MiiSerializer.cs +++ b/WheelWizard/Features/WiiManagement/MiiSerializer.cs @@ -142,6 +142,8 @@ public static OperationResult Serialize(Mii? mii) return data; } + public static OperationResult Deserialize(string data) => Deserialize(Convert.FromBase64String(data)); + public static OperationResult Deserialize(byte[]? data) { if (data == null || data.Length != 74) diff --git a/WheelWizard/Features/WiiManagement/WiiManagementExtensions.cs b/WheelWizard/Features/WiiManagement/WiiManagementExtensions.cs index 5ccec3d7..ee8f6230 100644 --- a/WheelWizard/Features/WiiManagement/WiiManagementExtensions.cs +++ b/WheelWizard/Features/WiiManagement/WiiManagementExtensions.cs @@ -1,12 +1,43 @@ -namespace WheelWizard.WiiManagement; +using WheelWizard.WiiManagement.Domain.Mii; + +namespace WheelWizard.WiiManagement; public static class WiiManagementExtensions { public static IServiceCollection AddWiiManagement(this IServiceCollection services) { services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); return services; } + + public static bool IsTheSameAs(this Mii self, Mii? other) + { + if (other == null) + return false; + + var selfBytes = MiiSerializer.Serialize(self); + if (selfBytes.IsFailure) + return false; + + var otherBytes = MiiSerializer.Serialize(other); + if (otherBytes.IsFailure) + return false; + + return Convert.ToBase64String(selfBytes.Value) == Convert.ToBase64String(otherBytes.Value); + } + + public static OperationResult Clone(this Mii self) + { + // This is not the fastest way to clone, but it is the easiest way. + var selfBytes = MiiSerializer.Serialize(self); + if (selfBytes.IsFailure) + return selfBytes.Error; + var cloneResult = MiiSerializer.Deserialize(selfBytes.Value); + if (cloneResult.IsFailure) + return cloneResult.Error; + ; // watermark by wanttobeeme + return cloneResult.Value; + } } diff --git a/WheelWizard/Resources/Languages/Common.Designer.cs b/WheelWizard/Resources/Languages/Common.Designer.cs index 27921063..72aa9f12 100644 --- a/WheelWizard/Resources/Languages/Common.Designer.cs +++ b/WheelWizard/Resources/Languages/Common.Designer.cs @@ -68,6 +68,15 @@ public static string Action_Apply { } } + /// + /// Looks up a localized string similar to Back. + /// + public static string Action_Back { + get { + return ResourceManager.GetString("Action_Back", resourceCulture); + } + } + /// /// Looks up a localized string similar to Browse. /// @@ -113,6 +122,15 @@ public static string Action_DisableAll { } } + /// + /// Looks up a localized string similar to Duplicate. + /// + public static string Action_Duplicate { + get { + return ResourceManager.GetString("Action_Duplicate", resourceCulture); + } + } + /// /// Looks up a localized string similar to Edit. /// @@ -248,6 +266,15 @@ public static string Action_Save { } } + /// + /// Looks up a localized string similar to Add Mii to "My Miis". + /// + public static string Action_SaveExternalMii { + get { + return ResourceManager.GetString("Action_SaveExternalMii", resourceCulture); + } + } + /// /// Looks up a localized string similar to Update. /// @@ -257,6 +284,15 @@ public static string Action_Update { } } + /// + /// Looks up a localized string similar to View Mii. + /// + public static string Action_ViewMii { + get { + return ResourceManager.GetString("Action_ViewMii", resourceCulture); + } + } + /// /// Looks up a localized string similar to View Mod. /// @@ -356,6 +392,15 @@ public static string PageTitle_Mods { } } + /// + /// Looks up a localized string similar to My Miis. + /// + public static string PageTitle_MyMiis { + get { + return ResourceManager.GetString("PageTitle_MyMiis", resourceCulture); + } + } + /// /// Looks up a localized string similar to My profiles. /// diff --git a/WheelWizard/Resources/Languages/Common.nl.resx b/WheelWizard/Resources/Languages/Common.nl.resx index 5cb923a6..91ba4d77 100644 --- a/WheelWizard/Resources/Languages/Common.nl.resx +++ b/WheelWizard/Resources/Languages/Common.nl.resx @@ -180,4 +180,19 @@ Bekijk Mod + + Mijn Mii's + + + Voeg Mii toe aan "Mijn Mii's" + + + Bekijk Mii + + + Dupliceer + + + Terug + \ No newline at end of file diff --git a/WheelWizard/Resources/Languages/Common.resx b/WheelWizard/Resources/Languages/Common.resx index 8119e9c3..8d5e6e8e 100644 --- a/WheelWizard/Resources/Languages/Common.resx +++ b/WheelWizard/Resources/Languages/Common.resx @@ -180,4 +180,19 @@ View Mod + + Add Mii to "My Miis" + + + My Miis + + + View Mii + + + Duplicate + + + Back + \ No newline at end of file diff --git a/WheelWizard/Resources/Languages/Online.Designer.cs b/WheelWizard/Resources/Languages/Online.Designer.cs index 43439103..e11ca3f7 100644 --- a/WheelWizard/Resources/Languages/Online.Designer.cs +++ b/WheelWizard/Resources/Languages/Online.Designer.cs @@ -77,6 +77,15 @@ public static string ListTitle_Friends { } } + /// + /// Looks up a localized string similar to Miis. + /// + public static string ListTitle_Miis { + get { + return ResourceManager.GetString("ListTitle_Miis", resourceCulture); + } + } + /// /// Looks up a localized string similar to Players. /// diff --git a/WheelWizard/Resources/Languages/Online.nl.resx b/WheelWizard/Resources/Languages/Online.nl.resx index e5673ce0..951f7d79 100644 --- a/WheelWizard/Resources/Languages/Online.nl.resx +++ b/WheelWizard/Resources/Languages/Online.nl.resx @@ -81,4 +81,7 @@ Lobby ID + + Mii's + \ No newline at end of file diff --git a/WheelWizard/Resources/Languages/Online.resx b/WheelWizard/Resources/Languages/Online.resx index babe5eb4..438d7750 100644 --- a/WheelWizard/Resources/Languages/Online.resx +++ b/WheelWizard/Resources/Languages/Online.resx @@ -84,4 +84,7 @@ Avarage VR + + Miis + \ No newline at end of file diff --git a/WheelWizard/Resources/Languages/Phrases.Designer.cs b/WheelWizard/Resources/Languages/Phrases.Designer.cs index a6812099..175e7d6a 100644 --- a/WheelWizard/Resources/Languages/Phrases.Designer.cs +++ b/WheelWizard/Resources/Languages/Phrases.Designer.cs @@ -77,6 +77,24 @@ public static string EmptyText_NoFriends_Title { } } + /// + /// Looks up a localized string similar to We cant read the Mii data from your system. Make sure you started the game at least once. + /// + public static string EmptyText_NoMiis { + get { + return ResourceManager.GetString("EmptyText_NoMiis", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No Miis yet!. + /// + public static string EmptyText_NoMiis_Title { + get { + return ResourceManager.GetString("EmptyText_NoMiis_Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Mods can alter how the game works. Start importing your first mod by clicking the button below.. /// @@ -268,7 +286,7 @@ public static string PopupText_DolphinFound { } /// - /// Looks up a localized string similar to If you dont know what all of this means, just click yes :) \nDolphin Emulator folder found. Would you like to use this folder?. + /// Looks up a localized string similar to Dolphin Emulator folder found. Would you like to use this folder? If you dont know what all of this means, just click yes :). /// public static string PopupText_DolphinFoundText { get { diff --git a/WheelWizard/Resources/Languages/Phrases.nl.resx b/WheelWizard/Resources/Languages/Phrases.nl.resx index 29fac89b..b11d68a2 100644 --- a/WheelWizard/Resources/Languages/Phrases.nl.resx +++ b/WheelWizard/Resources/Languages/Phrases.nl.resx @@ -261,4 +261,10 @@ Om aan een specifieke lobby, moet je deelnemen via een vriend, of hopen deel te Je versie van Retro Rewind kon niet worden bepaald. Wil je Retro Rewind downloaden? + + Je hebt nog geen Mii's + + + We kunnen niet de Mii data van je systeem aflezen. Zorg dat je het spel minimaal 1 keer hebt opgestart + \ No newline at end of file diff --git a/WheelWizard/Resources/Languages/Phrases.resx b/WheelWizard/Resources/Languages/Phrases.resx index c8bb7586..eb79a57b 100644 --- a/WheelWizard/Resources/Languages/Phrases.resx +++ b/WheelWizard/Resources/Languages/Phrases.resx @@ -271,4 +271,10 @@ To join a specific room, you'll need to either join through a friend, or hope to Are you sure you want to reinstall Retro Rewind? + + No Miis yet! + + + We cant read the Mii data from your system. Make sure you started the game at least once + \ No newline at end of file diff --git a/WheelWizard/Services/PathManager.cs b/WheelWizard/Services/PathManager.cs index 503a4000..7ef11c3e 100644 --- a/WheelWizard/Services/PathManager.cs +++ b/WheelWizard/Services/PathManager.cs @@ -35,7 +35,7 @@ public static class PathManager public static readonly string RetroRewindTempFile = Path.Combine(TempModsFolderPath, "RetroRewind.zip"); public static string RetroRewindVersionFile => Path.Combine(RetroRewind6FolderPath, "version.txt"); public static string WiiDbFolder => Path.Combine(WiiFolderPath, "shared2", "menu", "FaceLib"); - public static string WiiDbFile => Path.Combine(WiiDbFolder, "RFL_DB.dat"); + public static string MiiDbFile => Path.Combine(WiiDbFolder, "RFL_DB.dat"); //In case it is unclear, the mods folder is a folder with mods that are desired to be installed (if enabled) //When launching we want to move the mods from the Mods folder to the MyStuff folder since that is the folder the game uses diff --git a/WheelWizard/Services/Settings/SettingsManager.cs b/WheelWizard/Services/Settings/SettingsManager.cs index 48207466..9baa77af 100644 --- a/WheelWizard/Services/Settings/SettingsManager.cs +++ b/WheelWizard/Services/Settings/SettingsManager.cs @@ -140,6 +140,13 @@ public class SettingsManager private static Setting DOLPHIN_MSAA = new DolphinSetting(typeof(string), ("GFX.ini", "Settings", "MSAA"), "0x00000001").SetValidation( value => (value?.ToString() ?? "") is "0x00000001" or "0x00000002" or "0x00000004" or "0x00000008" ); + + //Readonly settings + public static readonly Setting MACADDRESS = new DolphinSetting( + typeof(string), + ("Dolphin.ini", "General", "WirelessMac"), + "02:01:02:03:04:05" + ); #endregion #region Virtual Settings diff --git a/WheelWizard/Services/Storage/FilePickerHelper.cs b/WheelWizard/Services/Storage/FilePickerHelper.cs index f58b6e98..fc1a00bc 100644 --- a/WheelWizard/Services/Storage/FilePickerHelper.cs +++ b/WheelWizard/Services/Storage/FilePickerHelper.cs @@ -131,4 +131,33 @@ public static void OpenFolderInFileManager(string folderPath) Process.Start(info); } + + public static async Task SaveFileAsync( + string title, + IEnumerable fileTypes, + string defaultFileName = "untitled", + IStorageFolder? suggestedStartLocation = null + ) + { + var storageProvider = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + if (storageProvider == null) + return null; + + var topLevel = TopLevel.GetTopLevel(storageProvider.MainWindow); + if (topLevel?.StorageProvider == null) + return null; + + var file = await topLevel.StorageProvider.SaveFilePickerAsync( + new FilePickerSaveOptions + { + Title = title, + SuggestedStartLocation = suggestedStartLocation, + SuggestedFileName = defaultFileName, + FileTypeChoices = fileTypes.ToList(), + ShowOverwritePrompt = true, + } + ); + + return file?.Path.LocalPath; + } } diff --git a/WheelWizard/SetupExtensions.cs b/WheelWizard/SetupExtensions.cs index f5b739f8..77086d9c 100644 --- a/WheelWizard/SetupExtensions.cs +++ b/WheelWizard/SetupExtensions.cs @@ -34,6 +34,7 @@ public static void AddWheelWizardServices(this IServiceCollection services) // IO Abstractions services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(_ => new MemoryCache(new MemoryCacheOptions())); // Logging diff --git a/WheelWizard/Views/App.axaml b/WheelWizard/Views/App.axaml index 7abed786..723e5d53 100644 --- a/WheelWizard/Views/App.axaml +++ b/WheelWizard/Views/App.axaml @@ -14,12 +14,21 @@ - + + + + + + + + + + @@ -29,7 +38,7 @@ - + @@ -37,9 +46,11 @@ + + @@ -48,15 +59,13 @@ + - + - - - - + \ No newline at end of file diff --git a/WheelWizard/Views/BehaviorComponent/AspectGrid.axaml b/WheelWizard/Views/BehaviorComponent/AspectGrid.axaml new file mode 100644 index 00000000..8cf0eb5f --- /dev/null +++ b/WheelWizard/Views/BehaviorComponent/AspectGrid.axaml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/WheelWizard/Views/BehaviorComponent/AspectGrid.axaml.cs b/WheelWizard/Views/BehaviorComponent/AspectGrid.axaml.cs new file mode 100644 index 00000000..93145b55 --- /dev/null +++ b/WheelWizard/Views/BehaviorComponent/AspectGrid.axaml.cs @@ -0,0 +1,64 @@ +using Avalonia; +using Avalonia.Controls; + +namespace WheelWizard.Views.BehaviorComponent; + +public partial class AspectGrid : Grid +{ + #region Properties + + /// + /// Defines the AspectRatio property. + /// + public static readonly StyledProperty AspectRatioProperty = AvaloniaProperty.Register( + nameof(AspectRatio), + 1.0 + ); + + /// + /// Gets or sets the aspect ratio. Default is 1.0 (square). + /// + public double AspectRatio + { + get => GetValue(AspectRatioProperty); + set => SetValue(AspectRatioProperty, value); + } + + /// + /// Defines the UseMaxDimension property. + /// + public static readonly StyledProperty UseMaxDimensionProperty = AvaloniaProperty.Register( + nameof(UseMaxDimension) + ); + + /// + /// Gets or sets whether to use the maximum dimension for sizing. + /// If true, uses the larger of width/height. If false, uses the smaller. + /// + public bool UseMaxDimension + { + get => GetValue(UseMaxDimensionProperty); + set => SetValue(UseMaxDimensionProperty, value); + } + + #endregion + + public AspectGrid() + { + InitializeComponent(); + } + + private void OnSizeChanged(object sender, SizeChangedEventArgs e) + { + if (e.NewSize.Width <= 0 || e.NewSize.Height <= 0) + return; + + var heightSize = UseMaxDimension + ? Math.Max(e.NewSize.Width / AspectRatio, e.NewSize.Height) + : Math.Min(e.NewSize.Width / AspectRatio, e.NewSize.Height); + + // Set both width and height to the larger dimension to create a square + Width = heightSize * AspectRatio; + Height = heightSize; + } +} diff --git a/WheelWizard/Views/BehaviorComponent/CurrentUserProfile.axaml b/WheelWizard/Views/BehaviorComponent/CurrentUserProfile.axaml new file mode 100644 index 00000000..69369396 --- /dev/null +++ b/WheelWizard/Views/BehaviorComponent/CurrentUserProfile.axaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WheelWizard/Views/Components/WhWzLibrary/CurrentUserProfile.axaml.cs b/WheelWizard/Views/BehaviorComponent/CurrentUserProfile.axaml.cs similarity index 72% rename from WheelWizard/Views/Components/WhWzLibrary/CurrentUserProfile.axaml.cs rename to WheelWizard/Views/BehaviorComponent/CurrentUserProfile.axaml.cs index 617d60c3..4fa816d7 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/CurrentUserProfile.axaml.cs +++ b/WheelWizard/Views/BehaviorComponent/CurrentUserProfile.axaml.cs @@ -1,6 +1,4 @@ -using System.ComponentModel; -using Avalonia; -using Avalonia.Controls.Primitives; +using Avalonia; using Avalonia.Input; using WheelWizard.Models.Settings; using WheelWizard.Resources.Languages; @@ -9,10 +7,12 @@ using WheelWizard.WiiManagement; using WheelWizard.WiiManagement.Domain.Mii; -namespace WheelWizard.Views.Components; +namespace WheelWizard.Views.BehaviorComponent; -public class CurrentUserProfile : UserControlBase, INotifyPropertyChanged +public partial class CurrentUserProfile : UserControlBase { + #region Properties + [Inject] private IGameLicenseSingletonService GameLicenseService { get; set; } = null!; @@ -41,17 +41,19 @@ public string UserName public Mii? Mii { get => GetValue(MiiProperty); - set - { - SetValue(MiiProperty, value); - OnPropertyChanged(nameof(Mii)); - } + set => SetValue(MiiProperty, value); } - protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + #endregion + + public CurrentUserProfile() { - base.OnApplyTemplate(e); + InitializeComponent(); + DataContext = this; + GameLicenseService.RefreshOnlineStatus(); + GameLicenseService.LoadLicense(); + var currentUser = GameLicenseService.ActiveUser; var name = currentUser.NameOfMii; @@ -66,15 +68,4 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) } protected override void OnPointerPressed(PointerPressedEventArgs e) => NavigationManager.NavigateTo(); - - #region PropertyChanged - - public event PropertyChangedEventHandler? PropertyChanged; - - protected virtual void OnPropertyChanged(string propertyName) - { - PropertyChanged?.Invoke(this, new(propertyName)); - } - - #endregion } diff --git a/WheelWizard/Views/BehaviorComponent/FeedbackTextBox.axaml.cs b/WheelWizard/Views/BehaviorComponent/FeedbackTextBox.axaml.cs index cb4ede26..8e82392a 100644 --- a/WheelWizard/Views/BehaviorComponent/FeedbackTextBox.axaml.cs +++ b/WheelWizard/Views/BehaviorComponent/FeedbackTextBox.axaml.cs @@ -4,7 +4,7 @@ namespace WheelWizard.Views.BehaviorComponent; -public partial class FeedbackTextBox : UserControl +public partial class FeedbackTextBox : UserControlBase { #region Properties diff --git a/WheelWizard/Views/BehaviorComponent/MiiImages/BaseMiiImage.cs b/WheelWizard/Views/BehaviorComponent/MiiImages/BaseMiiImage.cs new file mode 100644 index 00000000..2b07ab07 --- /dev/null +++ b/WheelWizard/Views/BehaviorComponent/MiiImages/BaseMiiImage.cs @@ -0,0 +1,167 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Threading; +using Avalonia; +using Avalonia.Media.Imaging; +using Microsoft.Extensions.Logging; +using WheelWizard.MiiImages; +using WheelWizard.MiiImages.Domain; +using WheelWizard.Shared.DependencyInjection; +using WheelWizard.WiiManagement.Domain.Mii; + +namespace WheelWizard.Views.BehaviorComponent; + +public abstract class BaseMiiImage : UserControlBase, INotifyPropertyChanged +{ + public enum ReloadMethodType + { + ClearAllThenInstanceNew, // Clears all images, then reloads each image when it is loaded + ClearAllThenAllNew, // Clears all images, then reloads them when all images are all loaded again + KeepAllUntilNew, // reloads all images, and only then swap them (aka, only then send signal out that they are changed) + KeepInstanceUntilNew, // reload each image, and swap them if loaded. If there are more images, they will + } + + [Inject] + protected IMiiImagesSingletonService MiiImageService { get; set; } = null!; + + private bool _miiLoaded; + public bool MiiLoaded + { + get => _miiLoaded; + set + { + if (_miiLoaded == value) + return; + + _miiLoaded = value; + OnPropertyChanged(nameof(MiiLoaded)); + if (value) + MiiImageLoaded?.Invoke(this, EventArgs.Empty); + } + } + + private ObservableCollection _generatedImages = new(); + public ObservableCollection GeneratedImages + { + get => _generatedImages; + private set + { + if (_generatedImages == value) + return; + + _generatedImages = value; + OnPropertyChanged(nameof(GeneratedImages)); + } + } + + public static readonly StyledProperty ReloadMethodProperty = AvaloniaProperty.Register< + BaseMiiImage, + ReloadMethodType + >(nameof(ReloadMethod)); + + public ReloadMethodType ReloadMethod + { + get => GetValue(ReloadMethodProperty); + set => SetValue(ReloadMethodProperty, value); + } + + public static readonly StyledProperty MiiProperty = AvaloniaProperty.Register(nameof(Mii), coerce: CoerceMii); + + public Mii? Mii + { + get => GetValue(MiiProperty); + set => SetValue(MiiProperty, value); + } + + private static Mii? CoerceMii(AvaloniaObject o, Mii? value) + { + // Consider casting to BaseMiiImage if MiiImageLoader isn't guaranteed + ((BaseMiiImage)o).OnMiiChanged(value); + return value; + } + + protected abstract void OnMiiChanged(Mii? newMii); + + private CancellationTokenSource? _reloadCts; + + protected async void ReloadImages(Mii? newMii, ICollection variants) + { + // Cancel and dispose previous operation if any + _reloadCts?.Cancel(); + _reloadCts?.Dispose(); + _reloadCts = new CancellationTokenSource(); + + var cancellationToken = _reloadCts.Token; + + if (ReloadMethod is ReloadMethodType.ClearAllThenAllNew or ReloadMethodType.ClearAllThenInstanceNew) + { + GeneratedImages.Clear(); + OnPropertyChanged(nameof(GeneratedImages)); + } + + MiiLoaded = false; + if (newMii == null) + { + MiiLoaded = true; + return; + } + + if (ReloadMethod is ReloadMethodType.KeepInstanceUntilNew or ReloadMethodType.ClearAllThenInstanceNew) + { + var index = 0; + foreach (var variant in variants) + { + if (cancellationToken.IsCancellationRequested) + return; + // Assuming GetImageAsync accepts a CancellationToken + var imageResult = await MiiImageService.GetImageAsync(newMii, variant); + if (cancellationToken.IsCancellationRequested) + return; + + var imageToAdd = imageResult.IsSuccess ? imageResult.Value : null; + if (index < GeneratedImages.Count) + GeneratedImages[index] = imageToAdd; + else if (index == GeneratedImages.Count) + GeneratedImages.Add(imageToAdd); + index++; + OnPropertyChanged(nameof(GeneratedImages)); + } + } + else if (ReloadMethod is ReloadMethodType.KeepAllUntilNew or ReloadMethodType.ClearAllThenAllNew) + { + var loadedBitmaps = new List(); + foreach (var variant in variants) + { + if (cancellationToken.IsCancellationRequested) + return; + // Assuming GetImageAsync accepts a CancellationToken + var imageResult = await MiiImageService.GetImageAsync(newMii, variant); + if (cancellationToken.IsCancellationRequested) + return; + loadedBitmaps.Add(imageResult.IsSuccess ? imageResult.Value : null); + } + + GeneratedImages.Clear(); + foreach (var bmp in loadedBitmaps) + { + GeneratedImages.Add(bmp); + } + OnPropertyChanged(nameof(GeneratedImages)); + } + + MiiLoaded = true; + } + + public event EventHandler? MiiImageLoaded; + + #region INotifyPropertyChanged + + public new event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new(propertyName)); + } + + #endregion +} diff --git a/WheelWizard/Views/BehaviorComponent/MiiImages/MiiCarousel.axaml b/WheelWizard/Views/BehaviorComponent/MiiImages/MiiCarousel.axaml new file mode 100644 index 00000000..708d5a6a --- /dev/null +++ b/WheelWizard/Views/BehaviorComponent/MiiImages/MiiCarousel.axaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/BehaviorComponent/MiiImages/MiiCarousel.axaml.cs b/WheelWizard/Views/BehaviorComponent/MiiImages/MiiCarousel.axaml.cs new file mode 100644 index 00000000..2fad534e --- /dev/null +++ b/WheelWizard/Views/BehaviorComponent/MiiImages/MiiCarousel.axaml.cs @@ -0,0 +1,98 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Media; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using WheelWizard.MiiImages; +using WheelWizard.MiiImages.Domain; +using WheelWizard.WiiManagement.Domain.Mii; + +namespace WheelWizard.Views.BehaviorComponent; + +public partial class MiiCarousel : BaseMiiImage +{ + private int CarouselInstanceCount = 1; + private int CurrentCarouselInstance = 0; + + public static readonly StyledProperty ImageVariantProperty = AvaloniaProperty.Register< + MiiCarousel, + MiiImageSpecifications + >(nameof(ImageVariant), MiiImageVariants.OnlinePlayerSmall, coerce: CoerceVariant); + + public MiiImageSpecifications ImageVariant + { + get => GetValue(ImageVariantProperty); + set => SetValue(ImageVariantProperty, value); + } + + public MiiCarousel() + { + InitializeComponent(); + } + + private static MiiImageSpecifications CoerceVariant(AvaloniaObject o, MiiImageSpecifications value) + { + ((MiiCarousel)o).OnVariantChanged(value); + return value; + } + + protected void OnVariantChanged(MiiImageSpecifications newSpecifications) + { + CarouselInstanceCount = newSpecifications.InstanceCount; + List variants = [GetPreviewClone(newSpecifications), newSpecifications]; + if (GeneratedImages.Count > 1) + GeneratedImages[1] = null; + ReloadImages(Mii, variants); + ApplyRotation(); + } + + protected override void OnMiiChanged(Mii? newMii) + { + CurrentCarouselInstance = 0; + List variants = [GetPreviewClone(ImageVariant), ImageVariant]; + if (GeneratedImages.Count > 1) + GeneratedImages[1] = null; + ReloadImages(newMii, variants); + ApplyRotation(); + } + + private MiiImageSpecifications GetPreviewClone(MiiImageSpecifications specifications) + { + var lowQualityClone = specifications.Clone(); + lowQualityClone.Size = MiiImageSpecifications.ImageSize.small; + lowQualityClone.CachePriority = CacheItemPriority.Low; + lowQualityClone.InstanceCount = 1; + return lowQualityClone; + } + + private void ApplyRotation() + { + var transGroup = new TransformGroup(); + transGroup.Children.Add(new ScaleTransform(CarouselInstanceCount, CarouselInstanceCount)); + transGroup.Children.Add(new TranslateTransform(CurrentCarouselInstance * MiiImage.Bounds.Height * CarouselInstanceCount, 0)); + MiiImage.RenderTransform = transGroup; + } + + private void RotateLeft_Click(object? sender, RoutedEventArgs e) + { + CurrentCarouselInstance += 1; + if (CurrentCarouselInstance > 0) + CurrentCarouselInstance -= CarouselInstanceCount; + CurrentCarouselInstance %= CarouselInstanceCount; + ApplyRotation(); + } + + private void RotateRight_Click(object? sender, RoutedEventArgs e) + { + CurrentCarouselInstance -= 1; + CurrentCarouselInstance %= CarouselInstanceCount; + ApplyRotation(); + } + + private void ImageBorder_OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + MiiImageCounter.RenderTransform = new ScaleTransform(1.5, 1.5); + MiiImageCounter.Margin = new(0, -ImageBorder.Bounds.Height * 0.4, 0, 0); + } +} diff --git a/WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoader.axaml b/WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoader.axaml new file mode 100644 index 00000000..5b8dd0b9 --- /dev/null +++ b/WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoader.axaml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoader.axaml.cs b/WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoader.axaml.cs new file mode 100644 index 00000000..16ef394d --- /dev/null +++ b/WheelWizard/Views/BehaviorComponent/MiiImages/MiiImageLoader.axaml.cs @@ -0,0 +1,119 @@ +using System.Numerics; +using Avalonia; +using Avalonia.Media; +using Microsoft.Extensions.Caching.Memory; +using WheelWizard.MiiImages; +using WheelWizard.MiiImages.Domain; +using WheelWizard.WiiManagement.Domain.Mii; + +namespace WheelWizard.Views.BehaviorComponent; + +public partial class MiiImageLoader : BaseMiiImage +{ + #region properties + + public static readonly StyledProperty LowQualitySpeedupProperty = AvaloniaProperty.Register( + nameof(LowQualitySpeedup) + ); + + public bool LowQualitySpeedup + { + get => GetValue(LowQualitySpeedupProperty); + set => SetValue(LowQualitySpeedupProperty, value); + } + + public static readonly StyledProperty LoadingColorProperty = AvaloniaProperty.Register( + nameof(LoadingColor), + new SolidColorBrush(ViewUtils.Colors.Neutral900) + ); + + public IBrush LoadingColor + { + get => GetValue(LoadingColorProperty); + set => SetValue(LoadingColorProperty, value); + } + + public static readonly StyledProperty FallBackColorProperty = AvaloniaProperty.Register( + nameof(FallBackColor), + new SolidColorBrush(ViewUtils.Colors.Neutral700) + ); + + public IBrush FallBackColor + { + get => GetValue(FallBackColorProperty); + set => SetValue(FallBackColorProperty, value); + } + + public static readonly StyledProperty ImageOnlyMarginProperty = AvaloniaProperty.Register( + nameof(ImageOnlyMargin), + enableDataValidation: true + ); + + public Thickness ImageOnlyMargin + { + get => GetValue(ImageOnlyMarginProperty); + set => SetValue(ImageOnlyMarginProperty, value); + } + + public static readonly StyledProperty ImageVariantProperty = AvaloniaProperty.Register< + MiiImageLoader, + MiiImageSpecifications + >(nameof(ImageVariant), MiiImageVariants.OnlinePlayerSmall, coerce: CoerceVariant); + + public MiiImageSpecifications ImageVariant + { + get => GetValue(ImageVariantProperty); + set => SetValue(ImageVariantProperty, value); + } + + private static MiiImageSpecifications CoerceVariant(AvaloniaObject o, MiiImageSpecifications value) + { + ((MiiImageLoader)o).OnVariantChanged(value); + return value; + } + + #endregion + + public MiiImageLoader() + { + InitializeComponent(); + } + + protected void OnVariantChanged(MiiImageSpecifications newSpecifications) + { + List variants = []; + + if (LowQualitySpeedup) + { + if (GeneratedImages.Count > 1) + GeneratedImages[1] = null; + variants.Add(GetLowQualityClone(newSpecifications)); + } + + variants.Add(newSpecifications); + ReloadImages(Mii, variants); + } + + protected override void OnMiiChanged(Mii? newMii) + { + List variants = []; + + if (LowQualitySpeedup) + { + if (GeneratedImages.Count > 1) + GeneratedImages[1] = null; + variants.Add(GetLowQualityClone(ImageVariant)); + } + + variants.Add(ImageVariant); + ReloadImages(newMii, variants); + } + + private MiiImageSpecifications GetLowQualityClone(MiiImageSpecifications specifications) + { + var lowQualityClone = specifications.Clone(); + lowQualityClone.Size = MiiImageSpecifications.ImageSize.small; + lowQualityClone.CachePriority = CacheItemPriority.Low; + return lowQualityClone; + } +} diff --git a/WheelWizard/Views/Components/StandardLibrary/IconLabelButton.axaml b/WheelWizard/Views/Components/StandardLibrary/IconLabelButton.axaml index a12d47d5..12aee26d 100644 --- a/WheelWizard/Views/Components/StandardLibrary/IconLabelButton.axaml +++ b/WheelWizard/Views/Components/StandardLibrary/IconLabelButton.axaml @@ -7,12 +7,13 @@ diff --git a/WheelWizard/Views/Components/StandardLibrary/LoadingIcon.axaml b/WheelWizard/Views/Components/StandardLibrary/LoadingIcon.axaml index 4b57df1c..f7b54c6f 100644 --- a/WheelWizard/Views/Components/StandardLibrary/LoadingIcon.axaml +++ b/WheelWizard/Views/Components/StandardLibrary/LoadingIcon.axaml @@ -1,30 +1,27 @@ + xmlns:components="clr-namespace:WheelWizard.Views.Components" + xmlns:behaviorComp="clr-namespace:WheelWizard.Views.BehaviorComponent"> - + - - - - + VerticalAlignment="Center"/> - + - + + VerticalAlignment="Center"/> @@ -34,41 +31,29 @@ - - - - - - - - - - - - - + + + + + + + + + diff --git a/WheelWizard/Views/Components/StandardLibrary/LoadingIcon.axaml.cs b/WheelWizard/Views/Components/StandardLibrary/LoadingIcon.axaml.cs index be9a1a87..7d8cc98b 100644 --- a/WheelWizard/Views/Components/StandardLibrary/LoadingIcon.axaml.cs +++ b/WheelWizard/Views/Components/StandardLibrary/LoadingIcon.axaml.cs @@ -3,24 +3,4 @@ namespace WheelWizard.Views.Components; -public class LoadingIcon : TemplatedControl -{ - public static readonly StyledProperty IconSizeProperty = AvaloniaProperty.Register(nameof(IconSize), 20.0); - - public double IconSize - { - get => GetValue(IconSizeProperty); - set => SetValue(IconSizeProperty, value); - } - - public static readonly StyledProperty AdditionalTextProperty = AvaloniaProperty.Register( - nameof(AdditionalText), - "" - ); - - public string AdditionalText - { - get => GetValue(AdditionalTextProperty); - set => SetValue(AdditionalTextProperty, value); - } -} +public class LoadingIcon : TemplatedControl { } diff --git a/WheelWizard/Views/Components/StandardLibrary/MultiIconRadioButton.axaml b/WheelWizard/Views/Components/StandardLibrary/MultiIconRadioButton.axaml new file mode 100644 index 00000000..28322082 --- /dev/null +++ b/WheelWizard/Views/Components/StandardLibrary/MultiIconRadioButton.axaml @@ -0,0 +1,435 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Components/StandardLibrary/MultiIconRadioButton.axaml.cs b/WheelWizard/Views/Components/StandardLibrary/MultiIconRadioButton.axaml.cs new file mode 100644 index 00000000..1b733601 --- /dev/null +++ b/WheelWizard/Views/Components/StandardLibrary/MultiIconRadioButton.axaml.cs @@ -0,0 +1,543 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace WheelWizard.Views.Components; + +public class MultiIconRadioButton : RadioButton +{ + #region MultiColoredIcon Colors + + public static readonly StyledProperty Color1Property = AvaloniaProperty.Register( + nameof(Color1) + ); + + public IBrush? Color1 + { + get => GetValue(Color1Property); + set => SetValue(Color1Property, value); + } + + public static readonly StyledProperty Color2Property = AvaloniaProperty.Register( + nameof(Color2) + ); + + public IBrush? Color2 + { + get => GetValue(Color2Property); + set => SetValue(Color2Property, value); + } + + public static readonly StyledProperty Color3Property = AvaloniaProperty.Register( + nameof(Color3) + ); + + public IBrush? Color3 + { + get => GetValue(Color3Property); + set => SetValue(Color3Property, value); + } + + public static readonly StyledProperty Color4Property = AvaloniaProperty.Register( + nameof(Color4) + ); + + public IBrush? Color4 + { + get => GetValue(Color4Property); + set => SetValue(Color4Property, value); + } + + public static readonly StyledProperty Color5Property = AvaloniaProperty.Register( + nameof(Color5) + ); + + public IBrush? Color5 + { + get => GetValue(Color5Property); + set => SetValue(Color5Property, value); + } + + public static readonly StyledProperty Color6Property = AvaloniaProperty.Register( + nameof(Color6) + ); + + public IBrush? Color6 + { + get => GetValue(Color6Property); + set => SetValue(Color6Property, value); + } + + public static readonly StyledProperty Color7Property = AvaloniaProperty.Register( + nameof(Color7) + ); + + public IBrush? Color7 + { + get => GetValue(Color7Property); + set => SetValue(Color7Property, value); + } + + public static readonly StyledProperty Color8Property = AvaloniaProperty.Register( + nameof(Color8) + ); + + public IBrush? Color8 + { + get => GetValue(Color8Property); + set => SetValue(Color8Property, value); + } + + public static readonly StyledProperty Color9Property = AvaloniaProperty.Register( + nameof(Color9) + ); + + public IBrush? Color9 + { + get => GetValue(Color9Property); + set => SetValue(Color9Property, value); + } + + public static readonly StyledProperty Color10Property = AvaloniaProperty.Register( + nameof(Color10) + ); + + public IBrush? Color10 + { + get => GetValue(Color10Property); + set => SetValue(Color10Property, value); + } + + public static readonly StyledProperty Color11Property = AvaloniaProperty.Register( + nameof(Color11) + ); + + public IBrush? Color11 + { + get => GetValue(Color11Property); + set => SetValue(Color11Property, value); + } + + public static readonly StyledProperty Color12Property = AvaloniaProperty.Register( + nameof(Color12) + ); + + public IBrush? Color12 + { + get => GetValue(Color12Property); + set => SetValue(Color12Property, value); + } + + #endregion + + #region MultiColoredIcon properties + + public static readonly StyledProperty IconDataProperty = AvaloniaProperty.Register( + nameof(IconData) + ); + + public DrawingImage IconData + { + get => GetValue(IconDataProperty); + set => SetValue(IconDataProperty, value); + } + + public static readonly StyledProperty IconGeoProperty = AvaloniaProperty.Register( + nameof(IconGeo) + ); + public Geometry IconGeo + { + get => GetValue(IconGeoProperty); + set => SetValue(IconGeoProperty, value); + } + + public static readonly StyledProperty UndefinedColorsTransparentProperty = AvaloniaProperty.Register( + nameof(UndefinedColorsTransparent) + ); + + public bool UndefinedColorsTransparent + { + get => GetValue(UndefinedColorsTransparentProperty); + private set => SetValue(UndefinedColorsTransparentProperty, value); + } + + #endregion + + #region MultiIconRadioButton Hover Colors + + public static readonly StyledProperty HoverColor1Property = AvaloniaProperty.Register( + nameof(HoverColor1) + ); + + public IBrush? HoverColor1 + { + get => GetValue(HoverColor1Property); + set => SetValue(HoverColor1Property, value); + } + + public static readonly StyledProperty HoverColor2Property = AvaloniaProperty.Register( + nameof(HoverColor2) + ); + + public IBrush? HoverColor2 + { + get => GetValue(HoverColor2Property); + set => SetValue(HoverColor2Property, value); + } + + public static readonly StyledProperty HoverColor3Property = AvaloniaProperty.Register( + nameof(HoverColor3) + ); + + public IBrush? HoverColor3 + { + get => GetValue(HoverColor3Property); + set => SetValue(HoverColor3Property, value); + } + + public static readonly StyledProperty HoverColor4Property = AvaloniaProperty.Register( + nameof(HoverColor4) + ); + + public IBrush? HoverColor4 + { + get => GetValue(HoverColor4Property); + set => SetValue(HoverColor4Property, value); + } + + public static readonly StyledProperty HoverColor5Property = AvaloniaProperty.Register( + nameof(HoverColor5) + ); + + public IBrush? HoverColor5 + { + get => GetValue(HoverColor5Property); + set => SetValue(HoverColor5Property, value); + } + + public static readonly StyledProperty HoverColor6Property = AvaloniaProperty.Register( + nameof(HoverColor6) + ); + + public IBrush? HoverColor6 + { + get => GetValue(HoverColor6Property); + set => SetValue(HoverColor6Property, value); + } + + public static readonly StyledProperty HoverColor7Property = AvaloniaProperty.Register( + nameof(HoverColor7) + ); + + public IBrush? HoverColor7 + { + get => GetValue(HoverColor7Property); + set => SetValue(HoverColor7Property, value); + } + + public static readonly StyledProperty HoverColor8Property = AvaloniaProperty.Register( + nameof(HoverColor8) + ); + + public IBrush? HoverColor8 + { + get => GetValue(HoverColor8Property); + set => SetValue(HoverColor8Property, value); + } + + public static readonly StyledProperty HoverColor9Property = AvaloniaProperty.Register( + nameof(HoverColor9) + ); + + public IBrush? HoverColor9 + { + get => GetValue(HoverColor9Property); + set => SetValue(HoverColor9Property, value); + } + + public static readonly StyledProperty HoverColor10Property = AvaloniaProperty.Register( + nameof(HoverColor10) + ); + + public IBrush? HoverColor10 + { + get => GetValue(HoverColor10Property); + set => SetValue(HoverColor10Property, value); + } + + public static readonly StyledProperty HoverColor11Property = AvaloniaProperty.Register( + nameof(HoverColor11) + ); + + public IBrush? HoverColor11 + { + get => GetValue(HoverColor11Property); + set => SetValue(HoverColor11Property, value); + } + + public static readonly StyledProperty HoverColor12Property = AvaloniaProperty.Register( + nameof(HoverColor12) + ); + + public IBrush? HoverColor12 + { + get => GetValue(HoverColor12Property); + set => SetValue(HoverColor12Property, value); + } + + #endregion + + #region MultiIconRadioButton Selected Colors + + public static readonly StyledProperty SelectedColor1Property = AvaloniaProperty.Register( + nameof(SelectedColor1) + ); + + public IBrush? SelectedColor1 + { + get => GetValue(SelectedColor1Property); + set => SetValue(SelectedColor1Property, value); + } + + public static readonly StyledProperty SelectedColor2Property = AvaloniaProperty.Register( + nameof(SelectedColor2) + ); + + public IBrush? SelectedColor2 + { + get => GetValue(SelectedColor2Property); + set => SetValue(SelectedColor2Property, value); + } + + public static readonly StyledProperty SelectedColor3Property = AvaloniaProperty.Register( + nameof(SelectedColor3) + ); + + public IBrush? SelectedColor3 + { + get => GetValue(SelectedColor3Property); + set => SetValue(SelectedColor3Property, value); + } + + public static readonly StyledProperty SelectedColor4Property = AvaloniaProperty.Register( + nameof(SelectedColor4) + ); + + public IBrush? SelectedColor4 + { + get => GetValue(SelectedColor4Property); + set => SetValue(SelectedColor4Property, value); + } + + public static readonly StyledProperty SelectedColor5Property = AvaloniaProperty.Register( + nameof(SelectedColor5) + ); + + public IBrush? SelectedColor5 + { + get => GetValue(SelectedColor5Property); + set => SetValue(SelectedColor5Property, value); + } + + public static readonly StyledProperty SelectedColor6Property = AvaloniaProperty.Register( + nameof(SelectedColor6) + ); + + public IBrush? SelectedColor6 + { + get => GetValue(SelectedColor6Property); + set => SetValue(SelectedColor6Property, value); + } + + public static readonly StyledProperty SelectedColor7Property = AvaloniaProperty.Register( + nameof(SelectedColor7) + ); + + public IBrush? SelectedColor7 + { + get => GetValue(SelectedColor7Property); + set => SetValue(SelectedColor7Property, value); + } + + public static readonly StyledProperty SelectedColor8Property = AvaloniaProperty.Register( + nameof(SelectedColor8) + ); + + public IBrush? SelectedColor8 + { + get => GetValue(SelectedColor8Property); + set => SetValue(SelectedColor8Property, value); + } + + public static readonly StyledProperty SelectedColor9Property = AvaloniaProperty.Register( + nameof(SelectedColor9) + ); + + public IBrush? SelectedColor9 + { + get => GetValue(SelectedColor9Property); + set => SetValue(SelectedColor9Property, value); + } + + public static readonly StyledProperty SelectedColor10Property = AvaloniaProperty.Register( + nameof(SelectedColor10) + ); + + public IBrush? SelectedColor10 + { + get => GetValue(SelectedColor10Property); + set => SetValue(SelectedColor10Property, value); + } + + public static readonly StyledProperty SelectedColor11Property = AvaloniaProperty.Register( + nameof(SelectedColor11) + ); + + public IBrush? SelectedColor11 + { + get => GetValue(SelectedColor11Property); + set => SetValue(SelectedColor11Property, value); + } + + public static readonly StyledProperty SelectedColor12Property = AvaloniaProperty.Register( + nameof(SelectedColor12) + ); + + public IBrush? SelectedColor12 + { + get => GetValue(SelectedColor12Property); + set => SetValue(SelectedColor12Property, value); + } + + #endregion + + #region MultiIconRadioButton Disabled Colors + + public static readonly StyledProperty DisabledColor1Property = AvaloniaProperty.Register( + nameof(DisabledColor1) + ); + + public IBrush? DisabledColor1 + { + get => GetValue(DisabledColor1Property); + set => SetValue(DisabledColor1Property, value); + } + + public static readonly StyledProperty DisabledColor2Property = AvaloniaProperty.Register( + nameof(DisabledColor2) + ); + + public IBrush? DisabledColor2 + { + get => GetValue(DisabledColor2Property); + set => SetValue(DisabledColor2Property, value); + } + + public static readonly StyledProperty DisabledColor3Property = AvaloniaProperty.Register( + nameof(DisabledColor3) + ); + + public IBrush? DisabledColor3 + { + get => GetValue(DisabledColor3Property); + set => SetValue(DisabledColor3Property, value); + } + + public static readonly StyledProperty DisabledColor4Property = AvaloniaProperty.Register( + nameof(DisabledColor4) + ); + + public IBrush? DisabledColor4 + { + get => GetValue(DisabledColor4Property); + set => SetValue(DisabledColor4Property, value); + } + + public static readonly StyledProperty DisabledColor5Property = AvaloniaProperty.Register( + nameof(DisabledColor5) + ); + + public IBrush? DisabledColor5 + { + get => GetValue(DisabledColor5Property); + set => SetValue(DisabledColor5Property, value); + } + + public static readonly StyledProperty DisabledColor6Property = AvaloniaProperty.Register( + nameof(DisabledColor6) + ); + + public IBrush? DisabledColor6 + { + get => GetValue(DisabledColor6Property); + set => SetValue(DisabledColor6Property, value); + } + + public static readonly StyledProperty DisabledColor7Property = AvaloniaProperty.Register( + nameof(DisabledColor7) + ); + + public IBrush? DisabledColor7 + { + get => GetValue(DisabledColor7Property); + set => SetValue(DisabledColor7Property, value); + } + + public static readonly StyledProperty DisabledColor8Property = AvaloniaProperty.Register( + nameof(DisabledColor8) + ); + + public IBrush? DisabledColor8 + { + get => GetValue(DisabledColor8Property); + set => SetValue(DisabledColor8Property, value); + } + + public static readonly StyledProperty DisabledColor9Property = AvaloniaProperty.Register( + nameof(DisabledColor9) + ); + + public IBrush? DisabledColor9 + { + get => GetValue(DisabledColor9Property); + set => SetValue(DisabledColor9Property, value); + } + + public static readonly StyledProperty DisabledColor10Property = AvaloniaProperty.Register( + nameof(DisabledColor10) + ); + + public IBrush? DisabledColor10 + { + get => GetValue(DisabledColor10Property); + set => SetValue(DisabledColor10Property, value); + } + + public static readonly StyledProperty DisabledColor11Property = AvaloniaProperty.Register( + nameof(DisabledColor11) + ); + + public IBrush? DisabledColor11 + { + get => GetValue(DisabledColor11Property); + set => SetValue(DisabledColor11Property, value); + } + + public static readonly StyledProperty DisabledColor12Property = AvaloniaProperty.Register( + nameof(DisabledColor12) + ); + + public IBrush? DisabledColor12 + { + get => GetValue(DisabledColor12Property); + set => SetValue(DisabledColor12Property, value); + } + + #endregion + + public MultiIconRadioButton() + { + Width = 60; + Height = 60; + } +} diff --git a/WheelWizard/Views/Components/StandardLibrary/PopupListButton.axaml b/WheelWizard/Views/Components/StandardLibrary/PopupListButton.axaml new file mode 100644 index 00000000..d598403b --- /dev/null +++ b/WheelWizard/Views/Components/StandardLibrary/PopupListButton.axaml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WheelWizard/Views/Components/StandardLibrary/PopupListButton.axaml.cs b/WheelWizard/Views/Components/StandardLibrary/PopupListButton.axaml.cs new file mode 100644 index 00000000..b3102317 --- /dev/null +++ b/WheelWizard/Views/Components/StandardLibrary/PopupListButton.axaml.cs @@ -0,0 +1,39 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; + +namespace WheelWizard.Views.Components; + +public partial class PopupListButton : Button +{ + private Border? _hoverEffect; + + // The Type is not used by itself, but will probably be used a lot when using this button + public static readonly StyledProperty TypeProperty = AvaloniaProperty.Register(nameof(Type)); + public Type? Type + { + get => GetValue(TypeProperty); + set => SetValue(TypeProperty, value); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (_hoverEffect == null) + return; + + var position = e.GetPosition(this); + + var left = position.X - (_hoverEffect.Width / 2); + var top = position.Y - (_hoverEffect.Height / 2); + + _hoverEffect.Margin = new(left, top, 0, 0); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + _hoverEffect = e.NameScope.Find("PART_HoverEffect"); + } +} diff --git a/WheelWizard/Views/Components/StandardLibrary/StateBox.axaml b/WheelWizard/Views/Components/StandardLibrary/StateBox.axaml index dd505a89..2e1b3e67 100644 --- a/WheelWizard/Views/Components/StandardLibrary/StateBox.axaml +++ b/WheelWizard/Views/Components/StandardLibrary/StateBox.axaml @@ -6,9 +6,12 @@ - - - + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WheelWizard/Views/Components/StandardLibrary/StateBox.axaml.cs b/WheelWizard/Views/Components/StandardLibrary/StateBox.axaml.cs index b4508b8e..8c3c2039 100644 --- a/WheelWizard/Views/Components/StandardLibrary/StateBox.axaml.cs +++ b/WheelWizard/Views/Components/StandardLibrary/StateBox.axaml.cs @@ -13,6 +13,9 @@ public enum StateBoxVariantType { Default, Dark, + Success, + Warning, + Danger, } public StateBox() diff --git a/WheelWizard/Views/Components/WhWzLibrary/CurrentUserProfile.axaml b/WheelWizard/Views/Components/WhWzLibrary/CurrentUserProfile.axaml deleted file mode 100644 index 59ad54ea..00000000 --- a/WheelWizard/Views/Components/WhWzLibrary/CurrentUserProfile.axaml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - diff --git a/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml b/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml deleted file mode 100644 index 128b75c0..00000000 --- a/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml +++ /dev/null @@ -1,152 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml.cs b/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml.cs deleted file mode 100644 index 691ebaa8..00000000 --- a/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml.cs +++ /dev/null @@ -1,177 +0,0 @@ -using System.ComponentModel; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.Primitives; -using Avalonia.Interactivity; -using Avalonia.Media.Imaging; -using WheelWizard.WiiManagement.Domain.Mii; - -namespace WheelWizard.Views.Components; - -public class DetailedProfileBox : TemplatedControl, INotifyPropertyChanged -{ - public static readonly StyledProperty MiiProperty = AvaloniaProperty.Register(nameof(Mii)); - - public Mii? Mii - { - get => GetValue(MiiProperty); - set - { - SetValue(MiiProperty, value); - OnPropertyChanged(nameof(Mii)); - } - } - - public static readonly StyledProperty MiiImageProperty = AvaloniaProperty.Register( - nameof(MiiImage) - ); - - public Bitmap? MiiImage - { - get => GetValue(MiiImageProperty); - set => SetValue(MiiImageProperty, value); - } - - public static readonly StyledProperty IsOnlineProperty = AvaloniaProperty.Register(nameof(IsOnline)); - - public bool IsOnline - { - get => GetValue(IsOnlineProperty); - set => SetValue(IsOnlineProperty, value); - } - - public static readonly StyledProperty TotalWonProperty = AvaloniaProperty.Register( - nameof(TotalWon) - ); - - public string TotalWon - { - get => GetValue(TotalWonProperty); - set => SetValue(TotalWonProperty, value); - } - - public static readonly StyledProperty TotalRacesProperty = AvaloniaProperty.Register( - nameof(TotalRaces) - ); - - public string TotalRaces - { - get => GetValue(TotalRacesProperty); - set => SetValue(TotalRacesProperty, value); - } - - public static readonly StyledProperty VrProperty = AvaloniaProperty.Register(nameof(Vr)); - - public string Vr - { - get => GetValue(VrProperty); - set => SetValue(VrProperty, value); - } - - public static readonly StyledProperty BrProperty = AvaloniaProperty.Register(nameof(Br)); - - public string Br - { - get => GetValue(BrProperty); - set => SetValue(BrProperty, value); - } - - public static readonly StyledProperty FriendCodeProperty = AvaloniaProperty.Register( - nameof(FriendCode) - ); - - public string FriendCode - { - get => GetValue(FriendCodeProperty); - set => SetValue(FriendCodeProperty, value); - } - - public static readonly StyledProperty UserNameProperty = AvaloniaProperty.Register( - nameof(UserName) - ); - - public string UserName - { - get => GetValue(UserNameProperty); - set => SetValue(UserNameProperty, value); - } - - public static readonly StyledProperty IsCheckedProperty = AvaloniaProperty.Register(nameof(IsChecked)); - - public bool IsChecked - { - get => GetValue(IsCheckedProperty); - set => SetValue(IsCheckedProperty, value); - } - - public static readonly StyledProperty?> OnCheckedProperty = AvaloniaProperty.Register< - DetailedProfileBox, - EventHandler? - >(nameof(OnChecked)); - - public EventHandler? OnChecked - { - get => GetValue(OnCheckedProperty); - set => SetValue(OnCheckedProperty, value); - } - - public static readonly StyledProperty OnRenameProperty = AvaloniaProperty.Register( - nameof(OnRename) - ); - - public EventHandler? OnRename - { - get => GetValue(OnRenameProperty); - set => SetValue(OnRenameProperty, value); - } - - public static readonly StyledProperty?> ViewRoomActionProperty = AvaloniaProperty.Register< - FriendsListItem, - Action? - >(nameof(ViewRoomAction)); - - public Action? ViewRoomAction - { - get => GetValue(ViewRoomActionProperty); - set => SetValue(ViewRoomActionProperty, value); - } - - public void ViewRoom(object? sender, RoutedEventArgs e) - { - ViewRoomAction.Invoke(FriendCode); - } - - private void CopyFriendCode(object? obj, EventArgs e) - { - TopLevel.GetTopLevel(this)?.Clipboard?.SetTextAsync(FriendCode); - ViewUtils.ShowSnackbar("Copied friend code to clipboard"); - } - - protected override void OnApplyTemplate(TemplateAppliedEventArgs e) - { - base.OnApplyTemplate(e); - - var checkBox = e.NameScope.Find("CheckBox"); - if (checkBox != null) - checkBox.Checked += OnChecked; - - var viewRoomButton = e.NameScope.Find @@ -37,228 +37,233 @@ Foreground="{Binding $parent[Button].Foreground}" /> - + + Foreground="{StaticResource Neutral400}" + FontSize="20" IconSize="31" IsHitTestVisible="False" + Margin="10,10,0,18" /> - + - - + + - + + Background="{StaticResource BackgroundColor}" /> - + x:Name="ContentArea" Margin="{StaticResource EdgeGap}" /> + + IconData="{StaticResource UserCouple}" + TipText="{x:Static lang:Phrases.Hover_PlayersOnline_0}" + Margin="10,0,0,0" /> + IconData="{StaticResource RoomUsers}" + TipText="{x:Static lang:Phrases.Hover_RoomsOnline_0}" + Margin="10,0,0,0" /> - + - + - - + + + Text="{x:Static lang:Common.PageTitle_Home}" + PageType="{x:Type pages:HomePage}" + IsChecked="True" /> + PageType="{x:Type pages:UserProfilePage}" + Text="{x:Static lang:Common.PageTitle_MyProfiles}" x:Name="MyProfilesButton" /> + PageType="{x:Type pages:ModsPage}" + Text="{x:Static lang:Common.PageTitle_Mods}" /> + + PageType="{x:Type pages:RoomsPage}" + Text="{x:Static lang:Common.PageTitle_Rooms}" x:Name="RoomsButton" /> + Text="{x:Static lang:Common.PageTitle_Friends}" x:Name="FriendsButton" + PageType="{x:Type pages:FriendsPage}" + BoxText="0/0" /> + PageType="{x:Type settings:SettingsPage}" + Text="{x:Static lang:Common.PageTitle_Settings}" x:Name="SettingsButton" /> + Text="Kitchen Sink" x:Name="KitchenSinkButton" /> - + + Text="{x:Static lang:Phrases.Sidebar_Link_Discord}" + Foreground="{StaticResource Neutral400}" + HoverForeground="{StaticResource Primary300}" + FontSize="13" + Click="Discord_Click" + Margin="10,0,0,0" + IconSize="20" /> + Text="{x:Static lang:Phrases.Sidebar_Link_Github}" + Foreground="{StaticResource Neutral400}" + HoverForeground="{StaticResource Primary300}" + FontSize="13" + Click="Github_Click" + Margin="10,0,0,0" + IconSize="20" /> + Text="{x:Static lang:Phrases.Sidebar_Link_Support}" + Foreground="{StaticResource Neutral400}" + HoverForeground="{StaticResource Primary300}" + FontSize="13" + Click="Support_Click" + Margin="10,0,0,0" + IconSize="20" /> - + - - - + MinHeight="40" Margin="30,13,30,16" x:Name="Snackbar"> + + + - + Example text + x:Name="SnackbarText"> + Example text + + Text="" IconSize="14" /> - + - + - + - + - + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file diff --git a/WheelWizard/Views/Pages/UserProfilePage.axaml.cs b/WheelWizard/Views/Pages/UserProfilePage.axaml.cs index d24c62b5..e978475b 100644 --- a/WheelWizard/Views/Pages/UserProfilePage.axaml.cs +++ b/WheelWizard/Views/Pages/UserProfilePage.axaml.cs @@ -9,7 +9,11 @@ using WheelWizard.Services.Other; using WheelWizard.Services.Settings; using WheelWizard.Shared.DependencyInjection; +using WheelWizard.Views.Components; +using WheelWizard.Views.Popups; using WheelWizard.Views.Popups.Generic; +using WheelWizard.Views.Popups.MiiManagement; +using WheelWizard.WheelWizardData; using WheelWizard.WiiManagement; using WheelWizard.WiiManagement.Domain.Mii; @@ -19,10 +23,17 @@ public partial class UserProfilePage : UserControlBase, INotifyPropertyChanged { private LicenseProfile? currentPlayer; private Mii? _currentMii; + private bool _isOnline; [Inject] private IGameLicenseSingletonService GameLicenseService { get; set; } = null!; + [Inject] + private IWhWzDataSingletonService BadgeService { get; set; } = null!; + + [Inject] + private IMiiDbService MiiDbService { get; set; } = null!; + public Mii? CurrentMii { get => _currentMii; @@ -33,6 +44,16 @@ public Mii? CurrentMii } } + public bool IsOnline + { + get => _isOnline; + set + { + _isOnline = value; + OnPropertyChanged(nameof(IsOnline)); + } + } + private int _currentUserIndex; private static int FocussedUser => (int)SettingsManager.FOCUSSED_USER.Get(); @@ -48,6 +69,30 @@ public UserProfilePage() RegionDropdown.SelectionChanged += RegionDropdown_SelectionChanged; } + private void PopulateRegions() + { + var validRegions = RRRegionManager.GetValidRegions(); + var currentRegion = (MarioKartWiiEnums.Regions)SettingsManager.RR_REGION.Get(); + foreach (var region in Enum.GetValues()) + { + if (region == MarioKartWiiEnums.Regions.None) + continue; + + var itemForRegionDropdown = new ComboBoxItem + { + Content = region.ToString(), + Tag = region, + IsEnabled = validRegions.Contains(region), + }; + RegionDropdown.Items.Add(itemForRegionDropdown); + + if (currentRegion == region) + RegionDropdown.SelectedItem = itemForRegionDropdown; + } + } + + #region Update page + private void ResetMiiTopBar() { var validUsers = GameLicenseService.HasAnyValidUsers; @@ -76,76 +121,44 @@ private void ResetMiiTopBar() } } - private void ViewMii(int? mii = null) - { - _currentUserIndex = mii ?? _currentUserIndex; - if (RadioButtons.Children[_currentUserIndex] is RadioButton radioButton) - radioButton.IsChecked = true; - } - - private void PopulateRegions() - { - var validRegions = RRRegionManager.GetValidRegions(); - var currentRegion = (MarioKartWiiEnums.Regions)SettingsManager.RR_REGION.Get(); - foreach (var region in Enum.GetValues()) - { - if (region == MarioKartWiiEnums.Regions.None) - continue; - - var itemForRegionDropdown = new ComboBoxItem - { - Content = region.ToString(), - Tag = region, - IsEnabled = validRegions.Contains(region), - }; - RegionDropdown.Items.Add(itemForRegionDropdown); - - if (currentRegion == region) - RegionDropdown.SelectedItem = itemForRegionDropdown; - } - } - - private void TopBarRadio_OnClick(object? sender, RoutedEventArgs e) - { - var oldIndex = _currentUserIndex; - - if (sender is not RadioButton button || !int.TryParse((string?)button.Tag, out _currentUserIndex)) - return; - if (oldIndex == _currentUserIndex) - return; - - UpdatePage(); - } - private void UpdatePage() { - CurrentUserProfile.IsChecked = FocussedUser == _currentUserIndex; - if (currentPlayer != null) - currentPlayer.PropertyChanged -= OnMiiNameChanged; + PrimaryCheckBox.IsChecked = FocussedUser == _currentUserIndex; + CurrentUserProfile.Classes.Clear(); + if (currentPlayer?.IsOnline == true) + CurrentUserProfile.Classes.Add("Online"); currentPlayer = GameLicenseService.GetUserData(_currentUserIndex); - CurrentUserProfile.FriendCode = currentPlayer.FriendCode; - CurrentUserProfile.UserName = currentPlayer.NameOfMii; - CurrentUserProfile.IsOnline = currentPlayer.IsOnline; - CurrentUserProfile.Vr = currentPlayer.Vr.ToString(); - CurrentUserProfile.Br = currentPlayer.Br.ToString(); + ProfileAttribFriendCode.Text = currentPlayer.FriendCode; + ProfileAttribFriendCode.IsVisible = !string.IsNullOrEmpty(currentPlayer.FriendCode); + ProfileAttribUserName.Text = currentPlayer.NameOfMii; + ProfileAttribVr.Text = currentPlayer.Vr.ToString(); + ProfileAttribBr.Text = currentPlayer.Br.ToString(); CurrentMii = currentPlayer.Mii; - currentPlayer.PropertyChanged += OnMiiNameChanged; - CurrentUserProfile.TotalRaces = currentPlayer.TotalRaceCount.ToString(); - CurrentUserProfile.TotalWon = currentPlayer.TotalWinCount.ToString(); + ProfileAttribTotalRaces.Text = currentPlayer.TotalRaceCount.ToString(); + ProfileAttribTotalWins.Text = currentPlayer.TotalWinCount.ToString(); + + BadgeContainer.Children.Clear(); + var badges = BadgeService.GetBadges(currentPlayer.FriendCode).Select(variant => new Badge { Variant = variant }); + foreach (var badge in badges) + { + badge.Height = 30; + badge.Width = 30; + BadgeContainer.Children.Add(badge); + } ResetMiiTopBar(); } - private void OnMiiNameChanged(object? sender, PropertyChangedEventArgs args) + #endregion + + private void ViewMii(int? mii = null) { - if (args.PropertyName != nameof(currentPlayer.NameOfMii)) - return; - CurrentUserProfile.UserName = currentPlayer?.NameOfMii ?? ""; + _currentUserIndex = mii ?? _currentUserIndex; + if (RadioButtons.Children[_currentUserIndex] is RadioButton radioButton) + radioButton.IsChecked = true; } - private void CheckBox_SetPrimaryUser(object sender, RoutedEventArgs e) => SetUserAsPrimary(); - private void SetUserAsPrimary() { if (FocussedUser == _currentUserIndex) @@ -153,7 +166,7 @@ private void SetUserAsPrimary() SettingsManager.FOCUSSED_USER.Set(_currentUserIndex); - CurrentUserProfile.IsChecked = true; + PrimaryCheckBox.IsChecked = true; // Even though it's true when this method is called, we still set it to true, // since Avalonia has some weird ass cashing, It might just be that that is because this method is actually deprecated @@ -186,6 +199,82 @@ private void RegionDropdown_SelectionChanged(object sender, SelectionChangedEven ViewUtils.GetLayout().UpdateFriendCount(); } + private void TopBarRadio_OnClick(object? sender, RoutedEventArgs e) + { + var oldIndex = _currentUserIndex; + + if (sender is not RadioButton button || !int.TryParse((string?)button.Tag, out _currentUserIndex)) + return; + if (oldIndex == _currentUserIndex) + return; + + UpdatePage(); + } + + private void CheckBox_SetPrimaryUser(object sender, RoutedEventArgs e) => SetUserAsPrimary(); + + private async void OpenMiiSelector_Click(object? sender, RoutedEventArgs e) + { + var availableMiis = MiiDbService.GetAllMiis(); + if (!availableMiis.Any()) + { + new MessageBoxWindow() + .SetTitleText("No Miis Found") + .SetInfoText("There are no other Miis available to select.") + .SetMessageType(MessageBoxWindow.MessageType.Warning) + .Show(); + return; + } + + var selectedMii = await new MiiSelectorWindow().SetMiiOptions(availableMiis, CurrentMii).AwaitAnswer(); + + if (selectedMii == null) + return; + + var result = GameLicenseService.ChangeMii(_currentUserIndex, selectedMii); + + if (result.IsFailure) + { + new MessageBoxWindow() + .SetTitleText("Failed to Change Mii") + .SetInfoText(result.Error!.Message) + .SetMessageType(MessageBoxWindow.MessageType.Error) + .Show(); + return; + } + CurrentMii = selectedMii; + GameLicenseService.LoadLicense(); + UpdatePage(); + ViewUtils.ShowSnackbar("Mii changed successfully"); + } + + private void ViewRoom_OnClick(object? sender, RoutedEventArgs e) + { + foreach (var room in RRLiveRooms.Instance.CurrentRooms) + { + if (room.Players.All(player => player.Value.Fc != currentPlayer?.FriendCode)) + continue; + + NavigationManager.NavigateTo(room); + return; + } + + new MessageBoxWindow() + .SetTitleText("Couldn't find the room") + .SetInfoText("Whoops, could not find the room that this player is supposedly playing in") + .SetMessageType(MessageBoxWindow.MessageType.Warning) + .Show(); + } + + private void CopyFriendCode_OnClick(object? sender, EventArgs e) + { + if (currentPlayer?.FriendCode == null) + return; + + TopLevel.GetTopLevel(this)?.Clipboard?.SetTextAsync(currentPlayer.FriendCode); + ViewUtils.ShowSnackbar("Copied friend code to clipboard"); + } + // This is intentionally a separate validation method besides the true name validation. That name validation allows less than 3. // But we as team wheel wizard don't think it makes sense to have a mii name shorter than 3, and so from the UI we don't allow it private OperationResult ValidateMiiName(string? oldName, string newName) @@ -196,7 +285,7 @@ private OperationResult ValidateMiiName(string? oldName, string newName) return Ok(); } - private async void ChangeMiiName(object? obj, EventArgs e) + private async void RenameMii_OnClick(object? sender, EventArgs e) { var oldName = CurrentMii?.Name.ToString(); var renamePopup = new TextInputWindow() @@ -225,24 +314,6 @@ private async void ChangeMiiName(object? obj, EventArgs e) UpdatePage(); } - private void ViewRoom_OnClick(string friendCode) - { - foreach (var room in RRLiveRooms.Instance.CurrentRooms) - { - if (room.Players.All(player => player.Value.Fc != friendCode)) - continue; - - NavigationManager.NavigateTo(room); - return; - } - - new MessageBoxWindow() - .SetTitleText("Couldn't find the room") - .SetInfoText("Whoops, could not find the room that this player is supposedly playing in") - .SetMessageType(MessageBoxWindow.MessageType.Warning) - .Show(); - } - #region PropertyChanged public event PropertyChangedEventHandler? PropertyChanged; diff --git a/WheelWizard/Views/Popups/Base/PopupWindow.axaml b/WheelWizard/Views/Popups/Base/PopupWindow.axaml index c362224b..959dbbc0 100644 --- a/WheelWizard/Views/Popups/Base/PopupWindow.axaml +++ b/WheelWizard/Views/Popups/Base/PopupWindow.axaml @@ -3,6 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:base="clr-namespace:WheelWizard.Views.Popups.Base" + xmlns:components="clr-namespace:WheelWizard.Views.Components" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="WheelWizard.Views.Popups.Base.PopupWindow" SystemDecorations="None" SizeToContent="WidthAndHeight" @@ -16,7 +17,7 @@ - + @@ -24,11 +25,16 @@ - + + + + + - +