Implement basic usercategories
This commit is contained in:
@@ -125,6 +125,7 @@ public class AddonInventoryWindow : NativeAddon
|
|||||||
private void RefreshCategoriesCore(bool autosize)
|
private void RefreshCategoriesCore(bool autosize)
|
||||||
{
|
{
|
||||||
_footerNode.SlotAmountText = InventoryState.GetEmptyItemSlotsString();
|
_footerNode.SlotAmountText = InventoryState.GetEmptyItemSlotsString();
|
||||||
|
_footerNode.RefreshCurrencies();
|
||||||
|
|
||||||
string filter = _searchInputNode.SearchString.ExtractText();
|
string filter = _searchInputNode.SearchString.ExtractText();
|
||||||
IReadOnlyList<CategorizedInventory> categories = InventoryState.GetInventoryItemCategories(filter);
|
IReadOnlyList<CategorizedInventory> categories = InventoryState.GetInventoryItemCategories(filter);
|
||||||
@@ -141,12 +142,9 @@ public class AddonInventoryWindow : NativeAddon
|
|||||||
node.CategorizedInventory = data;
|
node.CategorizedInventory = data;
|
||||||
node.ItemsPerLine = Math.Min(data.Items.Count, maxItemsPerLine);
|
node.ItemsPerLine = Math.Min(data.Items.Count, maxItemsPerLine);
|
||||||
},
|
},
|
||||||
createNodeMethod: _ =>
|
createNodeMethod: _ => new InventoryCategoryNode
|
||||||
{
|
{
|
||||||
return new InventoryCategoryNode
|
Size = ContentSize with { Y = 120 },
|
||||||
{
|
|
||||||
Size = ContentSize with { Y = 120 },
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
WireHoverHandlers();
|
WireHoverHandlers();
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using KamiToolKit.Classes;
|
||||||
|
|
||||||
|
namespace AetherBags.Configuration;
|
||||||
|
|
||||||
|
public class CategorySettings
|
||||||
|
{
|
||||||
|
public bool GameCategoriesEnabled { get; set; } = true;
|
||||||
|
public bool UserCategoriesEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public List<UserCategoryDefinition> UserCategories { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UserCategoryDefinition
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||||
|
public string Name { get; set; } = "New Category";
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int Order { get; set; }
|
||||||
|
public int Priority { get; set; } = 100;
|
||||||
|
public Vector4 Color { get; set; } = ColorHelper.GetColor(50);
|
||||||
|
|
||||||
|
public CategoryRuleSet Rules { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CategoryRuleSet
|
||||||
|
{
|
||||||
|
public List<uint> AllowedItemIds { get; set; } = new();
|
||||||
|
public List<string> AllowedItemNamePatterns { get; set; } = new();
|
||||||
|
public List<uint> AllowedUiCategoryIds { get; set; } = new();
|
||||||
|
public List<int> AllowedRarities { get; set; } = new();
|
||||||
|
|
||||||
|
public RangeFilter<int> ItemLevel { get; set; } = new() { Enabled = false, Min = 0, Max = 2000 };
|
||||||
|
public RangeFilter<uint> VendorPrice { get; set; } = new() { Enabled = false, Min = 0, Max = 9_999_999 };
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RangeFilter<T> where T : struct, IComparable<T>
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public T Min { get; set; }
|
||||||
|
public T Max { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AetherBags.Configuration.Import;
|
||||||
|
|
||||||
|
// Possible Mapping:
|
||||||
|
// Index -> Order
|
||||||
|
// Color/Id/Name
|
||||||
|
// AllowedItemNames -> AllowedItemNamePatterns
|
||||||
|
// AllowedItemTypes -> AllowedUiCategoryIds
|
||||||
|
// AllowedItemRarities -> AllowedRarities
|
||||||
|
// ItemLevelFilter / VendorPriceFilter -> RangeFilter
|
||||||
|
|
||||||
|
public sealed class SortaKindaCategory
|
||||||
|
{
|
||||||
|
public Vector4 Color { get; set; }
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public int Index { get; set; }
|
||||||
|
|
||||||
|
public List<string> AllowedItemNames { get; set; } = new();
|
||||||
|
public List<uint> AllowedItemTypes { get; set; } = new();
|
||||||
|
public List<int> AllowedItemRarities { get; set; } = new();
|
||||||
|
|
||||||
|
public ExternalRangeFilterDto<int> ItemLevelFilter { get; set; } = new();
|
||||||
|
public ExternalRangeFilterDto<uint> VendorPriceFilter { get; set; } = new();
|
||||||
|
|
||||||
|
public int Direction { get; set; }
|
||||||
|
public int FillMode { get; set; }
|
||||||
|
public int SortMode { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExternalRangeFilterDto<T> where T : struct
|
||||||
|
{
|
||||||
|
public bool Enable { get; set; }
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public T MinValue { get; set; }
|
||||||
|
public T MaxValue { get; set; }
|
||||||
|
}
|
||||||
@@ -8,4 +8,5 @@ public class SystemConfiguration
|
|||||||
public const string FileName = "AetherBags.json";
|
public const string FileName = "AetherBags.json";
|
||||||
|
|
||||||
public CurrencySettings Currency { get; set; } = new();
|
public CurrencySettings Currency { get; set; } = new();
|
||||||
|
public CategorySettings Categories { get; set; } = new();
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using AetherBags.Configuration;
|
||||||
|
|
||||||
namespace AetherBags.Inventory;
|
namespace AetherBags.Inventory;
|
||||||
|
|
||||||
@@ -57,23 +58,38 @@ public static unsafe class InventoryState
|
|||||||
|
|
||||||
private static readonly List<uint> RemoveKeysScratch = new(capacity: 256);
|
private static readonly List<uint> RemoveKeysScratch = new(capacity: 256);
|
||||||
|
|
||||||
|
private const uint UserCategoryKeyFlag = 0x8000_0000;
|
||||||
|
|
||||||
|
private static uint MakeUserCategoryKey(int order)
|
||||||
|
=> UserCategoryKeyFlag | (uint)(order & 0x7FFF_FFFF);
|
||||||
|
|
||||||
|
private static bool IsUserCategoryKey(uint key)
|
||||||
|
=> (key & UserCategoryKeyFlag) != 0;
|
||||||
|
|
||||||
public static bool Contains(this IReadOnlyCollection<InventoryType> inventoryTypes, GameInventoryType type)
|
public static bool Contains(this IReadOnlyCollection<InventoryType> inventoryTypes, GameInventoryType type)
|
||||||
=> inventoryTypes.Contains((InventoryType)type);
|
=> inventoryTypes.Contains((InventoryType)type);
|
||||||
|
|
||||||
public static void RefreshFromGame()
|
public static void RefreshFromGame()
|
||||||
{
|
{
|
||||||
InventoryManager* mgr = InventoryManager.Instance();
|
InventoryManager* inventoryManager = InventoryManager.Instance();
|
||||||
if (mgr == null)
|
if (inventoryManager == null)
|
||||||
{
|
{
|
||||||
ClearAll();
|
ClearAll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var config = System.Config;
|
||||||
|
|
||||||
|
bool userCategoriesEnabled = config.Categories.UserCategoriesEnabled;
|
||||||
|
bool gameCategoriesEnabled = config.Categories.GameCategoriesEnabled;
|
||||||
|
|
||||||
|
List<UserCategoryDefinition> userCategories = config.Categories.UserCategories;
|
||||||
|
|
||||||
AggByItemId.Clear();
|
AggByItemId.Clear();
|
||||||
|
|
||||||
for (int invIndex = 0; invIndex < BagInventories.Length; invIndex++)
|
for (int inventoryIndex = 0; inventoryIndex < BagInventories.Length; inventoryIndex++)
|
||||||
{
|
{
|
||||||
var container = mgr->GetInventoryContainer(BagInventories[invIndex]);
|
var container = inventoryManager->GetInventoryContainer(BagInventories[inventoryIndex]);
|
||||||
if (container == null)
|
if (container == null)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -85,26 +101,26 @@ public static unsafe class InventoryState
|
|||||||
if (id == 0)
|
if (id == 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
int qty = item.Quantity;
|
int quantity = item.Quantity;
|
||||||
|
|
||||||
if (AggByItemId.TryGetValue(id, out AggregatedItem agg))
|
if (AggByItemId.TryGetValue(id, out AggregatedItem agg))
|
||||||
{
|
{
|
||||||
agg.Total += qty;
|
agg.Total += quantity;
|
||||||
AggByItemId[id] = agg;
|
AggByItemId[id] = agg;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
AggByItemId.Add(id, new AggregatedItem { First = item, Total = qty });
|
AggByItemId.Add(id, new AggregatedItem { First = item, Total = quantity });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var kvp in BucketsByKey)
|
foreach (var kvp in BucketsByKey)
|
||||||
{
|
{
|
||||||
CategoryBucket b = kvp.Value;
|
CategoryBucket bucket = kvp.Value;
|
||||||
b.Used = false;
|
bucket.Used = false;
|
||||||
b.Items.Clear();
|
bucket.Items.Clear();
|
||||||
b.FilteredItems.Clear();
|
bucket.FilteredItems.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var kvp in AggByItemId)
|
foreach (var kvp in AggByItemId)
|
||||||
@@ -126,27 +142,135 @@ public static unsafe class InventoryState
|
|||||||
info.Item = agg.First;
|
info.Item = agg.First;
|
||||||
info.ItemCount = agg.Total;
|
info.ItemCount = agg.Total;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uint catKey = info.UiCategory.RowId;
|
// Bucket by user category
|
||||||
|
HashSet<uint> claimedItemIds = new(capacity: ItemInfoByItemId.Count);
|
||||||
|
|
||||||
if (!BucketsByKey.TryGetValue(catKey, out CategoryBucket? bucket))
|
if (userCategoriesEnabled && userCategories.Count > 0)
|
||||||
|
{
|
||||||
|
for (int c = 0; c < userCategories.Count; c++)
|
||||||
{
|
{
|
||||||
bucket = new CategoryBucket
|
UserCategoryDefinition category = userCategories[c];
|
||||||
|
uint key = MakeUserCategoryKey(category.Order);
|
||||||
|
|
||||||
|
if (!BucketsByKey.TryGetValue(key, out CategoryBucket? bucket))
|
||||||
{
|
{
|
||||||
Key = catKey,
|
bucket = new CategoryBucket
|
||||||
Category = GetCategoryInfoForKeyCached(catKey, info),
|
{
|
||||||
|
Key = key,
|
||||||
|
Category = new CategoryInfo
|
||||||
|
{
|
||||||
|
Name = category.Name,
|
||||||
|
Description = category.Description,
|
||||||
|
Color = category.Color,
|
||||||
|
},
|
||||||
|
Items = new List<ItemInfo>(capacity: 16),
|
||||||
|
FilteredItems = new List<ItemInfo>(capacity: 16),
|
||||||
|
Used = true,
|
||||||
|
};
|
||||||
|
BucketsByKey.Add(key, bucket);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bucket.Used = true;
|
||||||
|
bucket.Category.Name = category.Name;
|
||||||
|
bucket.Category.Description = category.Description;
|
||||||
|
bucket.Category.Color = category.Color;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var itemKvp in ItemInfoByItemId)
|
||||||
|
{
|
||||||
|
ItemInfo item = itemKvp.Value;
|
||||||
|
|
||||||
|
if (UserCategoryMatcher.Matches(item, category))
|
||||||
|
{
|
||||||
|
bucket.Items.Add(item);
|
||||||
|
claimedItemIds.Add(item.Item.ItemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bucket.Items.Count == 0)
|
||||||
|
bucket.Used = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game category bucket
|
||||||
|
if (gameCategoriesEnabled)
|
||||||
|
{
|
||||||
|
foreach (var itemKvp in ItemInfoByItemId)
|
||||||
|
{
|
||||||
|
ItemInfo info = itemKvp.Value;
|
||||||
|
|
||||||
|
if (userCategoriesEnabled && claimedItemIds.Contains(info.Item.ItemId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
uint categoryKey = info.UiCategory.RowId;
|
||||||
|
|
||||||
|
if (!BucketsByKey.TryGetValue(categoryKey, out CategoryBucket? bucket))
|
||||||
|
{
|
||||||
|
bucket = new CategoryBucket
|
||||||
|
{
|
||||||
|
Key = categoryKey,
|
||||||
|
Category = GetCategoryInfoForKeyCached(categoryKey, info),
|
||||||
|
Items = new List<ItemInfo>(capacity: 16),
|
||||||
|
FilteredItems = new List<ItemInfo>(capacity: 16),
|
||||||
|
Used = true,
|
||||||
|
};
|
||||||
|
BucketsByKey.Add(categoryKey, bucket);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bucket.Used = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.Items.Add(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unclaimed items
|
||||||
|
if (!gameCategoriesEnabled)
|
||||||
|
{
|
||||||
|
if (!BucketsByKey.TryGetValue(0u, out CategoryBucket? miscBucket))
|
||||||
|
{
|
||||||
|
CategoryInfo miscInfo;
|
||||||
|
if (ItemInfoByItemId.Count > 0)
|
||||||
|
{
|
||||||
|
var sample = ItemInfoByItemId.Values.First();
|
||||||
|
miscInfo = GetCategoryInfoForKeyCached(0u, sample);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
miscInfo = new CategoryInfo { Name = "Misc", Description = "Uncategorized items" };
|
||||||
|
}
|
||||||
|
|
||||||
|
miscBucket = new CategoryBucket
|
||||||
|
{
|
||||||
|
Key = 0u,
|
||||||
|
Category = miscInfo,
|
||||||
Items = new List<ItemInfo>(capacity: 16),
|
Items = new List<ItemInfo>(capacity: 16),
|
||||||
FilteredItems = new List<ItemInfo>(capacity: 16),
|
FilteredItems = new List<ItemInfo>(capacity: 16),
|
||||||
Used = true,
|
Used = true,
|
||||||
};
|
};
|
||||||
BucketsByKey.Add(catKey, bucket);
|
BucketsByKey.Add(0u, miscBucket);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
bucket.Used = true;
|
miscBucket.Used = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bucket.Items.Add(info);
|
foreach (var itemKvp in ItemInfoByItemId)
|
||||||
|
{
|
||||||
|
ItemInfo info = itemKvp.Value;
|
||||||
|
|
||||||
|
if (userCategoriesEnabled && claimedItemIds.Contains(info.Item.ItemId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
miscBucket.Items.Add(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (miscBucket.Items.Count == 0)
|
||||||
|
miscBucket.Used = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ItemInfoByItemId.Count != AggByItemId.Count)
|
if (ItemInfoByItemId.Count != AggByItemId.Count)
|
||||||
@@ -176,7 +300,13 @@ public static unsafe class InventoryState
|
|||||||
SortedCategoryKeys.Add(bucket.Key);
|
SortedCategoryKeys.Add(bucket.Key);
|
||||||
}
|
}
|
||||||
|
|
||||||
SortedCategoryKeys.Sort();
|
SortedCategoryKeys.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
bool au = IsUserCategoryKey(a);
|
||||||
|
bool bu = IsUserCategoryKey(b);
|
||||||
|
if (au != bu) return au ? -1 : 1;
|
||||||
|
return a.CompareTo(b);
|
||||||
|
});
|
||||||
|
|
||||||
AllCategories.Clear();
|
AllCategories.Clear();
|
||||||
AllCategories.Capacity = Math.Max(AllCategories.Capacity, SortedCategoryKeys.Count);
|
AllCategories.Capacity = Math.Max(AllCategories.Capacity, SortedCategoryKeys.Count);
|
||||||
@@ -316,7 +446,6 @@ public static unsafe class InventoryState
|
|||||||
isCapped = weeklyAcquired >= weeklyLimit;
|
isCapped = weeklyAcquired >= weeklyLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
Services.Logger.Info($"Currency {currencyItem.ItemId} amount: {amount}, max: {maxAmount}");
|
|
||||||
return new CurrencyInfo
|
return new CurrencyInfo
|
||||||
{
|
{
|
||||||
Amount = amount,
|
Amount = amount,
|
||||||
@@ -389,10 +518,15 @@ public static unsafe class InventoryState
|
|||||||
{
|
{
|
||||||
public static readonly ItemCountDescComparer Instance = new();
|
public static readonly ItemCountDescComparer Instance = new();
|
||||||
|
|
||||||
public int Compare(ItemInfo x, ItemInfo y)
|
public int Compare(ItemInfo? x, ItemInfo? y)
|
||||||
{
|
{
|
||||||
|
if (ReferenceEquals(x, y)) return 0;
|
||||||
|
if (x is null) return 1; // nulls last
|
||||||
|
if (y is null) return -1;
|
||||||
|
|
||||||
int a = x.ItemCount;
|
int a = x.ItemCount;
|
||||||
int b = y.ItemCount;
|
int b = y.ItemCount;
|
||||||
|
|
||||||
if (a > b) return -1;
|
if (a > b) return -1;
|
||||||
if (a < b) return 1;
|
if (a < b) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ public sealed class ItemInfo : IEquatable<ItemInfo>
|
|||||||
public int Level => Row.LevelEquip;
|
public int Level => Row.LevelEquip;
|
||||||
public int ItemLevel => (int)Row.LevelItem.RowId;
|
public int ItemLevel => (int)Row.LevelItem.RowId;
|
||||||
public int Rarity => Row.Rarity;
|
public int Rarity => Row.Rarity;
|
||||||
|
public uint VendorPrice => Row.PriceLow;
|
||||||
|
|
||||||
public RowRef<ItemUICategory> UiCategory => Row.ItemUICategory;
|
public RowRef<ItemUICategory> UiCategory => Row.ItemUICategory;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using AetherBags.Configuration;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace AetherBags.Inventory;
|
||||||
|
|
||||||
|
internal static class UserCategoryMatcher
|
||||||
|
{
|
||||||
|
public static bool Matches(ItemInfo item, UserCategoryDefinition userCategory)
|
||||||
|
{
|
||||||
|
var rules = userCategory.Rules;
|
||||||
|
|
||||||
|
if (rules.AllowedUiCategoryIds.Count > 0)
|
||||||
|
{
|
||||||
|
uint uiCategoryId = item.UiCategory.RowId;
|
||||||
|
if (!rules.AllowedUiCategoryIds.Contains(uiCategoryId))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.AllowedItemIds.Count > 0 && !rules.AllowedItemIds.Contains(item.Item.ItemId))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (rules.AllowedRarities.Count > 0 && !rules.AllowedRarities.Contains(item.Rarity))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (rules.ItemLevel.Enabled && !InRange(item.ItemLevel, rules.ItemLevel.Min, rules.ItemLevel.Max))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (rules.VendorPrice.Enabled && !InRange(item.VendorPrice, rules.VendorPrice.Min, rules.VendorPrice.Max))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (rules.AllowedItemNamePatterns.Count > 0)
|
||||||
|
{
|
||||||
|
bool any = false;
|
||||||
|
for (int i = 0; i < rules.AllowedItemNamePatterns.Count; i++)
|
||||||
|
{
|
||||||
|
string pattern = rules.AllowedItemNamePatterns[i];
|
||||||
|
if (string.IsNullOrWhiteSpace(pattern))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Treat patterns as regex for now.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Regex.IsMatch(item.Name, pattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase))
|
||||||
|
{
|
||||||
|
any = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Invalid regex: ignore it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!any)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool InRange<T>(T value, T min, T max) where T : struct, IComparable<T>
|
||||||
|
=> value.CompareTo(min) >= 0 && value.CompareTo(max) <= 0;
|
||||||
|
}
|
||||||
@@ -6,9 +6,5 @@ namespace AetherBags.Nodes;
|
|||||||
|
|
||||||
public class CurrencyListNode : HorizontalListNode
|
public class CurrencyListNode : HorizontalListNode
|
||||||
{
|
{
|
||||||
public List<CurrencyInfo> CurrencyInfoList
|
public List<CurrencyInfo>? CurrencyInfoList { get; set; }
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user