* Initial implementation.

* Added handler implementation and AppBuilder extension.

* Removed unnecessary csproj additions made by VS

* Moved default image enum to Primatives folder.

* removed unnecessary create and await task.

* Use Math.Clamp instead of Switch.

* Return Stream.Null instead of Null.

* Removed pragma

* Made SourceService use base or UriImageSourceService.

* Added unit tests

* Removed GravatarImageSourceService
Mapped to use UriImageSourceService
Use IUriImageSource instead of IStreamImageSource
Request Gravatars image if no EmailProperty
Updated and added unit tests to handle gravatars image

* Wording change for samples.

* Moved enum to seperate class.

* Fixed spelling mistake

* Removed unnecessary interfaces from core.

* Removed comment

* Static singleton HttpClient

* ReadOnlySpan<char> instead of string?.

* Removed unnecessary configuring of image sources.

* Reorganised to follow Style.Cop rules.

* Get size of parent without being control type specific.
Unit tests for ALL controls that support ImageSource

* Fix for formatting errors (an extra SPACE!?!?!)

* Eliminate multiple calls with Delay and ContinueWith.

* Using debug instead of ImageSourceService logger.

* Replaced reflection with is VisualElement.
Allow smaller than default size.

* Improved accessibility for sample.

* Cancellation token source timeout constant added.
Tried to improve efficiency.

* Moved to ImageSources folder.
Moved tests to mirror folder.
Added XamlNameSpace alias.

* Move sample out to ImageSources section

* Moved samples to ImageSources.
Added ImageSources gallery.

* Improved test code coverage.

* Push to get a rebuild

* Added tests to cover DefaultStream.

* Changes IsEmpty to check email instead of Uri.
Improved unit test code coverage.

* Improved unit test code coverage.
Added parent size invalidation, to help get size.

* Initial play with parental bindings

* Binding to parent Width/Height.
Better size handling.
Removed redundant code.

* Removed nullable from BindableProperty as not required.

* Test cater for parent sizing.

* Improved code coverage.
Removed redundant code.

* Removed debug for debounce sanity checks.

* Renamed tests

* Swapped to Pascal case

* Update src/CommunityToolkit.Maui/ImageSources/GravatarImageSource.shared.cs

* Update src/CommunityToolkit.Maui/ImageSources/GravatarImageSource.shared.cs

* Update src/CommunityToolkit.Maui/ImageSources/GravatarImageSource.shared.cs

* Update src/CommunityToolkit.Maui/ImageSources/GravatarImageSource.shared.cs

* Fixed naming rules

* Removed variable and used directly in IF

* Added cryptography string extension.

* Updated comments to make clear size restrictions.

* Changed to use unset value of -1 instead of 0.

* Removed HTTP call verification debug output.

* Use IsEmpty property.

* Unified spaces after semicolons.

* Moved conversion to after if, for efficiency.

* Updated test for string with spaces after semicolons.

* Added HttpClient extensions.

* Update src/CommunityToolkit.Maui/Extensions/HttpClientExtensions.shares.cs

Co-authored-by: Vladislav Antonyuk <33021114+VladislavAntonyuk@users.noreply.github.com>

* Update src/CommunityToolkit.Maui/Extensions/HttpClientExtensions.shares.cs

Co-authored-by: Vladislav Antonyuk <33021114+VladislavAntonyuk@users.noreply.github.com>

* Update src/CommunityToolkit.Maui/Extensions/HttpClientExtensions.shares.cs

Co-authored-by: Vladislav Antonyuk <33021114+VladislavAntonyuk@users.noreply.github.com>

* Fix of bad formatted param.

* Updated summary for internal GETs.

* Only dispatch HTTP request if not matching previous.

* Added IDisposable

* Added disposable to test that disposable types are behaving as expected.

* Improved code coverage testing.
Renamed to IsDisposed to match standard.

* Resolved issue where size to small, then to larger didn't result in the larger size being requested.

* Fix for breaking merge differences AddTransient-> AddTransientWithShellRoute

* Refactor async/await

* Change `public` to `internal`

* Ensure `SingletonHttpClient` is thread-safe

* Update `ToString()` test

* Update Sample App

* Add `ArgumentNullException.ThrowIfNull(uri)`

* `dotnet format`

Co-authored-by: Brandon Minnick <13558917+brminnick@users.noreply.github.com>
Co-authored-by: Shaun Lawrence <shaunrlawrence@gmail.com>
Co-authored-by: Pedro Jesus <pedrojesus.cefet@gmail.com>
Co-authored-by: Vladislav Antonyuk <33021114+VladislavAntonyuk@users.noreply.github.com>
This commit is contained in:
George Leithead 2022-09-16 05:31:52 +01:00 коммит произвёл GitHub
Родитель 328e27c581
Коммит 7908c8878c
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
16 изменённых файлов: 1003 добавлений и 5 удалений

Просмотреть файл

@ -79,7 +79,7 @@ dotnet_diagnostic.IDE1006.severity = error
## Public Fields are kept Pascal Case
dotnet_naming_symbols.public_symbols.applicable_kinds = field
dotnet_naming_symbols.public_symbols.applicable_accessibilities = public
dotnet_naming_symbols.public_symbols.applicable_accessibilities = public, internal
dotnet_naming_style.first_word_upper_case_style.capitalization = first_word_upper
@ -104,7 +104,7 @@ dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style
dotnet_naming_symbols.static_fields.applicable_kinds = field
dotnet_naming_symbols.static_fields.required_modifiers = static
dotnet_naming_symbols.static_fields.applicable_accessibilities = internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_style.static_field_style.capitalization = camel_case
dotnet_naming_style.static_field_style.required_prefix =

Просмотреть файл

@ -7,6 +7,7 @@
xmlns:behaviors="clr-namespace:CommunityToolkit.Maui.Sample.Pages.Behaviors"
xmlns:converters="clr-namespace:CommunityToolkit.Maui.Sample.Pages.Converters"
xmlns:extensions="clr-namespace:CommunityToolkit.Maui.Sample.Pages.Extensions"
xmlns:imagesources="clr-namespace:CommunityToolkit.Maui.Sample.Pages.ImageSources"
xmlns:layouts="clr-namespace:CommunityToolkit.Maui.Sample.Pages.Layouts"
xmlns:views="clr-namespace:CommunityToolkit.Maui.Sample.Pages.Views"
xmlns:pages="clr-namespace:CommunityToolkit.Maui.Sample.Pages"
@ -57,6 +58,12 @@
<ShellContent ContentTemplate="{DataTemplate extensions:ExtensionsGalleryPage}" />
</FlyoutItem>
<FlyoutItem Title="ImageSources"
Route="ImageSourcesGalleryPage"
Icon="{OnPlatform Default='dotnet_bot.png', MacCatalyst=''}">
<ShellContent ContentTemplate="{DataTemplate imagesources:ImageSourcesGalleryPage}" />
</FlyoutItem>
<FlyoutItem Title="Layouts"
Route="LayoutsGalleryPage"
Icon="{OnPlatform Default='dotnet_bot.png', MacCatalyst=''}">

Просмотреть файл

@ -3,12 +3,14 @@ using CommunityToolkit.Maui.Sample.Pages.Alerts;
using CommunityToolkit.Maui.Sample.Pages.Behaviors;
using CommunityToolkit.Maui.Sample.Pages.Converters;
using CommunityToolkit.Maui.Sample.Pages.Extensions;
using CommunityToolkit.Maui.Sample.Pages.ImageSources;
using CommunityToolkit.Maui.Sample.Pages.Layouts;
using CommunityToolkit.Maui.Sample.Pages.Views;
using CommunityToolkit.Maui.Sample.ViewModels;
using CommunityToolkit.Maui.Sample.ViewModels.Alerts;
using CommunityToolkit.Maui.Sample.ViewModels.Behaviors;
using CommunityToolkit.Maui.Sample.ViewModels.Converters;
using CommunityToolkit.Maui.Sample.ViewModels.ImageSources;
using CommunityToolkit.Maui.Sample.ViewModels.Layouts;
using CommunityToolkit.Maui.Sample.ViewModels.Views;
using CommunityToolkit.Maui.Sample.ViewModels.Views.AvatarView;
@ -78,6 +80,9 @@ public partial class AppShell : Shell
// Add Extensions View Models
CreateViewModelMapping<ColorAnimationExtensionsPage, ColorAnimationExtensionsViewModel, ExtensionsGalleryPage, ExtensionsGalleryViewModel>(),
// Add ImageSources View Models
CreateViewModelMapping<GravatarImageSourcePage, GravatarImageSourceViewModel, ImageSourcesGalleryPage, ImageSourcesGalleryViewModel>(),
// Add Layouts View Models
CreateViewModelMapping<UniformItemsLayoutPage, UniformItemsLayoutViewModel, LayoutsGalleryPage, LayoutsGalleryViewModel>(),

Просмотреть файл

@ -5,12 +5,14 @@ using CommunityToolkit.Maui.Sample.Pages.Alerts;
using CommunityToolkit.Maui.Sample.Pages.Behaviors;
using CommunityToolkit.Maui.Sample.Pages.Converters;
using CommunityToolkit.Maui.Sample.Pages.Extensions;
using CommunityToolkit.Maui.Sample.Pages.ImageSources;
using CommunityToolkit.Maui.Sample.Pages.Layouts;
using CommunityToolkit.Maui.Sample.Pages.Views;
using CommunityToolkit.Maui.Sample.ViewModels;
using CommunityToolkit.Maui.Sample.ViewModels.Alerts;
using CommunityToolkit.Maui.Sample.ViewModels.Behaviors;
using CommunityToolkit.Maui.Sample.ViewModels.Converters;
using CommunityToolkit.Maui.Sample.ViewModels.ImageSources;
using CommunityToolkit.Maui.Sample.ViewModels.Layouts;
using CommunityToolkit.Maui.Sample.ViewModels.Views;
using CommunityToolkit.Maui.Sample.ViewModels.Views.AvatarView;
@ -58,6 +60,7 @@ public static class MauiProgram
services.AddTransient<BehaviorsGalleryPage, BehaviorsGalleryViewModel>();
services.AddTransient<ConvertersGalleryPage, ConvertersGalleryViewModel>();
services.AddTransient<ExtensionsGalleryPage, ExtensionsGalleryViewModel>();
services.AddTransient<ImageSourcesGalleryPage, ImageSourcesGalleryViewModel>();
services.AddTransient<LayoutsGalleryPage, LayoutsGalleryViewModel>();
services.AddTransient<ViewsGalleryPage, ViewsGalleryViewModel>();
@ -133,6 +136,9 @@ public static class MauiProgram
// Add Extensions Pages + ViewModels
services.AddTransientWithShellRoute<ColorAnimationExtensionsPage, ColorAnimationExtensionsViewModel>();
// Add ImageSources pages + ViewModels
services.AddTransientWithShellRoute<GravatarImageSourcePage, GravatarImageSourceViewModel>();
// Add Layouts Pages + ViewModels
services.AddTransientWithShellRoute<UniformItemsLayoutPage, UniformItemsLayoutViewModel>();

Просмотреть файл

@ -0,0 +1,157 @@
<?xml version="1.0" encoding="utf-8" ?>
<pages:BasePage
x:Class="CommunityToolkit.Maui.Sample.Pages.ImageSources.GravatarImageSourcePage"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:mct="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:pages="clr-namespace:CommunityToolkit.Maui.Sample.Pages"
xmlns:vm="clr-namespace:CommunityToolkit.Maui.Sample.ViewModels.ImageSources"
Title="GravatarImageSource"
x:DataType="vm:GravatarImageSourceViewModel"
x:TypeArguments="vm:GravatarImageSourceViewModel">
<ScrollView>
<VerticalStackLayout Spacing="12">
<VerticalStackLayout.Resources>
<ResourceDictionary>
<Style x:Key="Description" TargetType="Label">
<Setter Property="VerticalTextAlignment" Value="Start" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
<Setter Property="LineBreakMode" Value="WordWrap" />
<Setter Property="Margin" Value="4,0" />
</Style>
<Style x:Key="HR" TargetType="Line">
<Setter Property="Stroke" Value="{AppThemeBinding Light=Black, Dark=White}" />
<Setter Property="X2" Value="300" />
<Setter Property="HorizontalOptions" Value="Center" />
</Style>
<Style TargetType="Switch">
<Setter Property="OnColor" Value="{StaticResource Gray400}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="On">
<VisualState.Setters>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Off">
<VisualState.Setters>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Slider">
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
</Style>
</ResourceDictionary>
</VerticalStackLayout.Resources>
<Label SemanticProperties.HeadingLevel="Level1" Style="{StaticResource Description}">
<Label.FormattedText>
<FormattedString>
<Span Text="GravatarImageSource allows you to use as an Image " />
<Span FontAttributes="Italic" Text="source" />
<Span Text=", a users Gravatar registered image via their email address." />
</FormattedString>
</Label.FormattedText>
</Label>
<Line Style="{StaticResource HR}" />
<Label SemanticProperties.HeadingLevel="Level2" Text="with Image control" />
<Image HeightRequest="128" WidthRequest="128">
<Image.Source>
<mct:GravatarImageSource
CacheValidity="{Binding CacheValidityTimespan}"
CachingEnabled="{Binding EnableCache}"
Email="{Binding Email}"
Image="{Binding DefaultGravatarSelected}"
SemanticProperties.Description="GravatarImageSource for an Image control, binding all properties." />
</Image.Source>
</Image>
<Label SemanticProperties.HeadingLevel="Level2" Text="with Button control" />
<Button Text="Button">
<Button.ImageSource>
<mct:GravatarImageSource
CacheValidity="{Binding CacheValidityTimespan}"
CachingEnabled="{Binding EnableCache}"
Email="{Binding Email}"
Image="{Binding DefaultGravatarSelected}"
SemanticProperties.Description="GravatarImageSource for a Button control, binding all properties." />
</Button.ImageSource>
</Button>
<Label SemanticProperties.HeadingLevel="Level2" Text="with ImageButton control" />
<ImageButton HeightRequest="73" WidthRequest="72">
<ImageButton.Source>
<mct:GravatarImageSource
CacheValidity="{Binding CacheValidityTimespan}"
CachingEnabled="{Binding EnableCache}"
Email="{Binding Email}"
Image="{Binding DefaultGravatarSelected}"
SemanticProperties.Description="GravatarImageSource for an ImageButton control, binding all properties." />
</ImageButton.Source>
</ImageButton>
<Label SemanticProperties.HeadingLevel="Level2" Text="with AvatarView Control" />
<mct:AvatarView
BorderWidth="2"
CornerRadius="32,0,0,32"
HeightRequest="128"
WidthRequest="128">
<mct:AvatarView.Stroke>
<LinearGradientBrush EndPoint="0,1">
<GradientStop Offset="0.1" Color="Blue" />
<GradientStop Offset="1.0" Color="Red" />
</LinearGradientBrush>
</mct:AvatarView.Stroke>
<mct:AvatarView.ImageSource>
<mct:GravatarImageSource
CacheValidity="{Binding CacheValidityTimespan}"
CachingEnabled="{Binding EnableCache}"
Email="{Binding Email}"
Image="{Binding DefaultGravatarSelected}"
SemanticProperties.Description="GravatarImageSourse used with AvatarView." />
</mct:AvatarView.ImageSource>
</mct:AvatarView>
<Label x:Name="LabelEmail" Text="Email:" />
<Entry
AutomationProperties.LabeledBy="{x:Reference LabelEmail}"
Keyboard="Email"
SemanticProperties.Hint="Enter your own gravatar registered email address"
Text="{Binding Email}" />
<Label x:Name="LabelDefaultImage" Text="Default Image:" />
<Picker
AutomationProperties.LabeledBy="{x:Reference LabelDefaultImage}"
ItemsSource="{Binding DefaultGravatarItems}"
SelectedItem="{Binding DefaultGravatarSelected}"
SemanticProperties.Hint="A default image is displayed if there is no image associated with the requested email hash." />
<Label x:Name="LabelEnableCache" Text="Enable cache:" />
<Switch
AutomationProperties.LabeledBy="{x:Reference LabelEnableCache}"
IsToggled="{Binding EnableCache}"
SemanticProperties.Hint="Defines whether image caching is enabled. The default value of this property is: True" />
<Label
x:Name="LabelCacheValidity"
IsEnabled="{Binding EnableCache}"
SemanticProperties.Description="Display how many days for cache validity.">
<Label.FormattedText>
<FormattedString>
<Span Text="Cache Validity (in days): '" />
<Span Text="{Binding CacheValidityInDays}" />
<Span Text="'" />
</FormattedString>
</Label.FormattedText>
</Label>
<Slider
AutomationProperties.LabeledBy="{x:Reference LabelCacheValidity}"
IsEnabled="{Binding EnableCache}"
Maximum="365"
Minimum="1"
SemanticProperties.Hint="Specify how long the image will be stored locally for. The default of this property is 1 day."
Value="{Binding CacheValidityInDays}" />
</VerticalStackLayout>
</ScrollView>
</pages:BasePage>

Просмотреть файл

@ -0,0 +1,13 @@
namespace CommunityToolkit.Maui.Sample.Pages.ImageSources;
using CommunityToolkit.Maui.Sample.ViewModels.ImageSources;
public partial class GravatarImageSourcePage : BasePage<GravatarImageSourceViewModel>
{
public GravatarImageSourcePage(GravatarImageSourceViewModel viewModel) : base(viewModel)
{
InitializeComponent();
Padding = new Thickness(Padding.Left, Padding.Top, Padding.Right, 0);
}
}

Просмотреть файл

@ -0,0 +1,11 @@
using CommunityToolkit.Maui.Sample.ViewModels.ImageSources;
namespace CommunityToolkit.Maui.Sample.Pages.ImageSources;
public class ImageSourcesGalleryPage : BaseGalleryPage<ImageSourcesGalleryViewModel>
{
public ImageSourcesGalleryPage(IDeviceInfo deviceInfo, ImageSourcesGalleryViewModel imageSourcesGalleryViewModel)
: base("Image Sources", deviceInfo, imageSourcesGalleryViewModel)
{
}
}

Просмотреть файл

@ -0,0 +1,34 @@
namespace CommunityToolkit.Maui.Sample.ViewModels.ImageSources;
using CommunityToolkit.Maui.ImageSources;
using CommunityToolkit.Mvvm.ComponentModel;
public partial class GravatarImageSourceViewModel : BaseViewModel
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CacheValidityTimespan))]
int cacheValidityInDays = 1;
[ObservableProperty]
DefaultImage defaultGravatarSelected = DefaultImage.MysteryPerson;
[ObservableProperty]
string email = "dsiegel@avantipoint.com";
[ObservableProperty]
bool enableCache = true;
public TimeSpan CacheValidityTimespan => TimeSpan.FromDays(CacheValidityInDays);
public IReadOnlyList<DefaultImage> DefaultGravatarItems { get; } = new[]
{
DefaultImage.MysteryPerson,
DefaultImage.FileNotFound,
DefaultImage.Identicon,
DefaultImage.MonsterId,
DefaultImage.Retro,
DefaultImage.Robohash,
DefaultImage.Wavatar,
DefaultImage.Blank
};
}

Просмотреть файл

@ -0,0 +1,14 @@
using CommunityToolkit.Maui.Sample.Models;
namespace CommunityToolkit.Maui.Sample.ViewModels.ImageSources;
public class ImageSourcesGalleryViewModel : BaseGalleryViewModel
{
public ImageSourcesGalleryViewModel()
: base(new[]
{
SectionModel.Create<GravatarImageSourceViewModel>("GravatarImageSource", Colors.Red, "GravatarImageSource allows you to use as an Image source, a users Gravatar registered image via their email address."),
})
{
}
}

Просмотреть файл

@ -35,7 +35,7 @@ public class PlatformSnackbar : PlatformToast
NFloat padding)
: base(message, backgroundColor, cornerRadius, textColor, textFont, characterSpacing, padding)
{
padding += defaultPadding;
padding += DefaultPadding;
actionButton = new PaddedButton(padding, padding, padding, padding);
actionButton.SetContentCompressionResistancePriority((float)UILayoutPriority.Required, UILayoutConstraintAxis.Horizontal);

Просмотреть файл

@ -9,8 +9,9 @@ namespace CommunityToolkit.Maui.Core.Views;
/// </summary>
public class PlatformToast : Alert, IDisposable
{
internal const float DefaultPadding = 10;
readonly PaddedLabel messageLabel;
internal const float defaultPadding = 10;
bool isDisposed;
@ -33,7 +34,7 @@ public class PlatformToast : Alert, IDisposable
double characterSpacing,
NFloat padding)
{
padding += defaultPadding;
padding += DefaultPadding;
messageLabel = new PaddedLabel(padding, padding, padding, padding)
{

Просмотреть файл

@ -0,0 +1,438 @@
namespace CommunityToolkit.Maui.UnitTests.ImageSources.GravatarImageSource;
using CommunityToolkit.Maui.ImageSources;
using Xunit;
public class GravatarImageSourceTests : BaseHandlerTest
{
readonly TimeSpan cacheValidity = new(1, 0, 0);
readonly bool cachingEnabled = false;
readonly string email = "dsiegel@avantipoint.com";
readonly DefaultImage image = DefaultImage.MonsterId;
[Fact]
public void ChangingEmailAndImageWithNoSizeDoesNotUpdateUri()
{
var gravatarImageSource = new GravatarImageSource
{
Email = email,
Image = image
};
Assert.Equal(image, gravatarImageSource.Image);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), gravatarImageSource.Uri);
}
[Fact]
public void ChangingEmailWithNoSizeDoesNotUpdateUri()
{
var gravatarImageSource = new GravatarImageSource
{
Email = email
};
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), gravatarImageSource.Uri);
}
[Fact]
public void ChangingImageWithNoSizeDoesNotUpdateUri()
{
var gravatarImageSource = new GravatarImageSource
{
Image = image
};
Assert.Equal(image, gravatarImageSource.Image);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), gravatarImageSource.Uri);
}
[Fact]
public void ConstructorTest()
{
var gravatarImageSource = new GravatarImageSource()
{
CacheValidity = cacheValidity,
CachingEnabled = cachingEnabled,
Email = email,
Image = image
};
Assert.Equal(cacheValidity, gravatarImageSource.CacheValidity);
Assert.True(cachingEnabled == gravatarImageSource.CachingEnabled);
Assert.Equal(email, gravatarImageSource.Email);
Assert.Equal(image, gravatarImageSource.Image);
}
[Fact]
public void Default404Image()
{
Image testControl = new()
{
Source = new GravatarImageSource()
{
Email = email,
}
};
((GravatarImageSource)testControl.Source).Image = DefaultImage.FileNotFound;
testControl.Layout(new Rect(0, 0, 37, 73));
Assert.Equal(DefaultImage.FileNotFound, ((GravatarImageSource)testControl.Source).Image);
}
[Fact]
public void DefaultCacheValidity()
{
var gravatarImageSource = new GravatarImageSource();
Assert.Equal(new TimeSpan(1, 0, 0, 0), gravatarImageSource.CacheValidity);
}
[Fact]
public void DefaultCachingEnabled()
{
var gravatarImageSource = new GravatarImageSource();
Assert.True(gravatarImageSource.CachingEnabled);
}
[Fact]
public void DefaultDefaultImage()
{
var gravatarImageSource = new GravatarImageSource();
Assert.Equal(DefaultImage.MysteryPerson, gravatarImageSource.Image);
}
[Fact]
public void DefaultEmail()
{
var gravatarImageSource = new GravatarImageSource();
Assert.Null(gravatarImageSource.Email);
}
[Fact]
public void DefaultUri()
{
var gravatarImageSource = new GravatarImageSource();
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), gravatarImageSource.Uri);
}
[Fact]
public void IsDisposed()
{
var gravatarImageSource = new GravatarImageSource();
Assert.False(gravatarImageSource.IsDisposed);
gravatarImageSource.Dispose();
Assert.True(gravatarImageSource.IsDisposed);
}
[Fact]
public void IsDisposedDisposeTokenSource()
{
Image testControl = new()
{
Source = new GravatarImageSource()
};
Assert.True(testControl.Source is GravatarImageSource);
testControl.Layout(new Rect(0, 0, 37, 73));
Assert.False(((GravatarImageSource)testControl.Source).IsDisposed);
((GravatarImageSource)testControl.Source).Dispose();
Assert.True(((GravatarImageSource)testControl.Source).IsDisposed);
}
[Fact]
public void IsEmpty()
{
var gravatarImageSource = new GravatarImageSource();
Assert.True(gravatarImageSource.IsEmpty);
}
[Fact]
public void IsEmptyNot()
{
var gravatarImageSource = new GravatarImageSource()
{
Email = email,
};
Assert.False(gravatarImageSource.IsEmpty);
}
[Fact]
public void NullEmailDoesNotCrash()
{
var loader = new GravatarImageSource();
var exception = Record.Exception(() => loader.Email = null);
Assert.Null(exception);
}
[Fact]
public void TestBindableObjectBackButtonBehavior()
{
BackButtonBehavior testControl = new()
{
IconOverride = new GravatarImageSource(),
};
Assert.True(testControl.IconOverride is GravatarImageSource);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.IconOverride).Uri);
}
[Fact]
public void TestBindableObjectSearchHandler()
{
SearchHandler testControl = new()
{
QueryIcon = new GravatarImageSource(),
ClearPlaceholderIcon = new GravatarImageSource(),
ClearIcon = new GravatarImageSource(),
};
Assert.True(testControl.QueryIcon is GravatarImageSource);
Assert.True(testControl.ClearPlaceholderIcon is GravatarImageSource);
Assert.True(testControl.ClearIcon is GravatarImageSource);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.QueryIcon).Uri);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.ClearPlaceholderIcon).Uri);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.ClearIcon).Uri);
}
[Fact]
public void TestControlButton()
{
Button testControl = new()
{
ImageSource = new GravatarImageSource(),
};
testControl.Layout(new Rect(0, 0, 37, 73));
Assert.True(testControl.ImageSource is GravatarImageSource);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/?s=37"), ((GravatarImageSource)testControl.ImageSource).Uri);
}
[Fact]
public void TestControlButtonWithEmail()
{
Button testControl = new()
{
ImageSource = new GravatarImageSource() { Email = email },
};
testControl.Layout(new Rect(0, 0, 37, 73));
Assert.True(testControl.ImageSource is GravatarImageSource);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/b65a519785f69fbe7236dd0fd6396094?s=37&d=mp"), ((GravatarImageSource)testControl.ImageSource).Uri);
}
[Fact]
public void TestControlImage()
{
Image testControl = new()
{
Source = new GravatarImageSource()
};
Assert.True(testControl.Source is GravatarImageSource);
testControl.Layout(new Rect(0, 0, 37, 73));
Assert.Equal(new Uri("https://www.gravatar.com/avatar/?s=37"), ((GravatarImageSource)testControl.Source).Uri);
}
[Fact]
public void TestControlImageButton()
{
ImageButton testControl = new()
{
Source = new GravatarImageSource(),
};
Assert.True(testControl.Source is GravatarImageSource);
testControl.Layout(new Rect(0, 0, 37, 73));
Assert.Equal(new Uri("https://www.gravatar.com/avatar/?s=37"), ((GravatarImageSource)testControl.Source).Uri);
}
[Fact]
public void TestControlImageButtonWithEmail()
{
ImageButton testControl = new()
{
Source = new GravatarImageSource() { Email = email },
};
Assert.True(testControl.Source is GravatarImageSource);
testControl.Layout(new Rect(0, 0, 37, 73));
Assert.Equal(new Uri("https://www.gravatar.com/avatar/b65a519785f69fbe7236dd0fd6396094?s=37&d=mp"), ((GravatarImageSource)testControl.Source).Uri);
}
[Fact]
public void TestControlImageButtonWithoutEmail()
{
ImageButton testControl = new()
{
Source = new GravatarImageSource() { Image = image },
};
Assert.True(testControl.Source is GravatarImageSource);
testControl.Layout(new Rect(0, 0, 37, 73));
Assert.Equal(new Uri("https://www.gravatar.com/avatar/?s=37"), ((GravatarImageSource)testControl.Source).Uri);
}
[Fact]
public void TestControlImageWithEmail()
{
Image testControl = new()
{
Source = new GravatarImageSource()
{
Email = email,
}
};
Assert.True(testControl.Source is GravatarImageSource);
testControl.Layout(new Rect(0, 0, 37, 73));
Assert.Equal(new Uri("https://www.gravatar.com/avatar/b65a519785f69fbe7236dd0fd6396094?s=37&d=mp"), ((GravatarImageSource)testControl.Source).Uri);
}
[Fact]
public void TestControlImageWithEmailAndImage()
{
Image testControl = new()
{
Source = new GravatarImageSource()
{
Email = email,
Image = image
}
};
Assert.True(testControl.Source is GravatarImageSource);
testControl.Layout(new Rect(0, 0, 37, 73));
Assert.Equal(new Uri("https://www.gravatar.com/avatar/b65a519785f69fbe7236dd0fd6396094?s=37&d=monsterid"), ((GravatarImageSource)testControl.Source).Uri);
}
[Fact]
public void TestControlPage()
{
Page testControl = new()
{
IconImageSource = new GravatarImageSource(),
};
Assert.True(testControl.IconImageSource is GravatarImageSource);
testControl.Layout(new Rect(0, 0, 37, 73));
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.IconImageSource).Uri);
}
[Fact]
public void TestControlShell()
{
Shell testControl = new()
{
FlyoutBackgroundImage = new GravatarImageSource(),
FlyoutIcon = new GravatarImageSource(),
};
Assert.True(testControl.FlyoutBackgroundImage is GravatarImageSource);
Assert.True(testControl.FlyoutIcon is GravatarImageSource);
testControl.Layout(new Rect(0, 0, 37, 73));
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.FlyoutBackgroundImage).Uri);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.FlyoutIcon).Uri);
}
[Fact]
public void TestControlSlider()
{
Slider testControl = new()
{
ThumbImageSource = new GravatarImageSource(),
};
Assert.True(testControl.ThumbImageSource is GravatarImageSource);
testControl.Layout(new Rect(0, 0, 37, 73));
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.ThumbImageSource).Uri);
}
[Fact]
public void TestDataPackage()
{
DataPackage testControl = new()
{
Image = new GravatarImageSource(),
};
Assert.True(testControl.Image is GravatarImageSource);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.Image).Uri);
}
[Fact]
public async Task TestDefaultStream()
{
CancellationTokenSource cts = new();
var gravatarImageSource = new GravatarImageSource();
Stream stream = await gravatarImageSource.Stream(cts.Token);
Assert.Equal(2637, stream.Length);
}
[Fact]
public async Task TestDefaultStreamCanceled()
{
CancellationTokenSource cts = new();
var gravatarImageSource = new GravatarImageSource();
cts.Cancel();
Stream stream = await gravatarImageSource.Stream(cts.Token);
Assert.Equal(Stream.Null, stream);
}
[Fact]
public void TestElementAppLinkEntry()
{
AppLinkEntry testControl = new()
{
Thumbnail = new GravatarImageSource(),
};
Assert.True(testControl.Thumbnail is GravatarImageSource);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.Thumbnail).Uri);
}
[Fact]
public void TestElementAppLinkEntryWithEmail()
{
AppLinkEntry testControl = new()
{
Thumbnail = new GravatarImageSource() { Email = email },
};
Assert.True(testControl.Thumbnail is GravatarImageSource);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.Thumbnail).Uri);
}
[Fact]
public void TestElementImageCell()
{
ImageCell testControl = new()
{
ImageSource = new GravatarImageSource(),
};
Assert.True(testControl.ImageSource is GravatarImageSource);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.ImageSource).Uri);
}
[Fact]
public void TestElementMenuItem()
{
MenuItem testControl = new()
{
IconImageSource = new GravatarImageSource(),
};
Assert.True(testControl.IconImageSource is GravatarImageSource);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.IconImageSource).Uri);
}
[Fact]
public void TestNavigableElementBaseShellItem()
{
BaseShellItem testControl = new()
{
Icon = new GravatarImageSource(),
FlyoutIcon = new GravatarImageSource(),
};
Assert.True(testControl.Icon is GravatarImageSource);
Assert.True(testControl.FlyoutIcon is GravatarImageSource);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.Icon).Uri);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.FlyoutIcon).Uri);
}
[Fact]
public void TestToolbar()
{
Toolbar testControl = new(new View())
{
TitleIcon = new GravatarImageSource(),
};
Assert.True(testControl.TitleIcon is GravatarImageSource);
Assert.Equal(new Uri("https://www.gravatar.com/avatar/"), ((GravatarImageSource)testControl.TitleIcon).Uri);
}
[Fact]
public void ToStringTest()
{
var gravatarImageSource = new GravatarImageSource();
Assert.Equal("Uri: https://www.gravatar.com/avatar/\nEmail: \nSize: -1\nImage: mp\nCacheValidity: 1.00:00:00\nCachingEnabled: True", gravatarImageSource.ToString());
}
}

Просмотреть файл

@ -2,6 +2,7 @@
[assembly: XmlnsDefinition(Constants.XamlNamespace, Constants.CommunityToolkitNamespacePrefix + nameof(CommunityToolkit.Maui.Alerts))]
[assembly: XmlnsDefinition(Constants.XamlNamespace, Constants.CommunityToolkitNamespacePrefix + nameof(CommunityToolkit.Maui.Behaviors))]
[assembly: XmlnsDefinition(Constants.XamlNamespace, Constants.CommunityToolkitNamespacePrefix + nameof(CommunityToolkit.Maui.Converters))]
[assembly: XmlnsDefinition(Constants.XamlNamespace, Constants.CommunityToolkitNamespacePrefix + nameof(CommunityToolkit.Maui.ImageSources))]
[assembly: XmlnsDefinition(Constants.XamlNamespace, Constants.CommunityToolkitNamespacePrefix + nameof(CommunityToolkit.Maui.Views))]
[assembly: XmlnsDefinition(Constants.XamlNamespace, Constants.CommunityToolkitNamespacePrefix + nameof(CommunityToolkit.Maui.Layouts))]

Просмотреть файл

@ -0,0 +1,20 @@
using System.Security.Cryptography;
using System.Text;
namespace CommunityToolkit.Maui.Extensions;
/// <summary>Cryptography extensions.</summary>
static class CryptographyExtensions
{
/// <summary>A hash function producing a 128-bit hash value (16 Bytes, 32 Hexadecimal characters)</summary>
/// <param name="source">Source string.</param>
/// <param name="separator">Separator.</param>
/// <returns>Array of 16 bytes.</returns>
public static string GetMd5Hash(this string source, string separator = "-")
{
using var md5 = MD5.Create();
var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(source.AsSpan().ToArray())).AsSpan();
return BitConverter.ToString(hash.ToArray(), 0, hash.Length).Replace("-", separator, StringComparison.OrdinalIgnoreCase);
}
}

Просмотреть файл

@ -0,0 +1,28 @@
using System.Diagnostics;
namespace CommunityToolkit.Maui.Extensions;
/// <summary>HttpClient extensions.</summary>
static partial class HttpClientExtensions
{
/// <summary>Get stream from Uri.</summary>
/// <param name="client"><see href="HttpClient" />.</param>
/// <param name="uri">Target Uri.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task <see cref="Task{TResult}"/> result.</returns>
public static async Task<Stream> DownloadStreamAsync(this HttpClient client, Uri uri, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(uri);
try
{
return await StreamWrapper.GetStreamAsync(uri, cancellationToken, client).ConfigureAwait(false);
}
catch (Exception ex)
{
Debug.WriteLine($"Error getting stream for {uri}: {ex}");
return Stream.Null;
}
}
}

Просмотреть файл

@ -0,0 +1,263 @@
namespace CommunityToolkit.Maui.ImageSources;
using CommunityToolkit.Maui.Extensions;
using Microsoft.Maui.Controls;
/// <summary>Gravatar image source.</summary>
/// <remarks>Note that <see cref="UriImageSource"/> is sealed and can't be used as a parent!</remarks>
public class GravatarImageSource : StreamImageSource, IDisposable
{
/// <summary>The backing store for the <see cref="CacheValidity" /> bindable property.</summary>
public static readonly BindableProperty CacheValidityProperty = BindableProperty.Create(nameof(CacheValidity), typeof(TimeSpan), typeof(GravatarImageSource), TimeSpan.FromDays(1));
/// <summary>The backing store for the <see cref="CachingEnabled" /> bindable property.</summary>
public static readonly BindableProperty CachingEnabledProperty = BindableProperty.Create(nameof(CachingEnabled), typeof(bool), typeof(GravatarImageSource), true);
/// <summary>The backing store for the <see cref="Email" /> bindable property.</summary>
public static readonly BindableProperty EmailProperty = BindableProperty.Create(nameof(Email), typeof(string), typeof(GravatarImageSource), defaultValue: null, propertyChanged: OnEmailPropertyChanged);
/// <summary>The backing store for the <see cref="Image" /> bindable property.</summary>
public static readonly BindableProperty ImageProperty = BindableProperty.Create(nameof(Image), typeof(DefaultImage), typeof(GravatarImageSource), defaultValue: DefaultImage.MysteryPerson, propertyChanged: OnDefaultImagePropertyChanged);
/// <summary>The backing store for the <see cref="ParentHeight" /> bindable property.</summary>
internal static readonly BindableProperty ParentHeightProperty = BindableProperty.Create(nameof(ParentHeight), typeof(int), typeof(GravatarImageSource), defaultValue: defaultSize, propertyChanged: OnSizePropertyChanged);
/// <summary>The backing store for the <see cref="ParentWidth" /> bindable property.</summary>
internal static readonly BindableProperty ParentWidthProperty = BindableProperty.Create(nameof(ParentWidth), typeof(int), typeof(GravatarImageSource), defaultValue: defaultSize, propertyChanged: OnSizePropertyChanged);
const int cancellationTokenSourceTimeout = 737;
const string defaultGravatarImageAddress = "https://www.gravatar.com/avatar/";
const int defaultSize = 80;
static readonly Lazy<HttpClient> singletonHttpClientHolder = new();
readonly CancellationTokenSource? tokenSource;
int gravatarSize = -1;
Uri? lastDispatch;
/// <summary>Initializes a new instance of the <see cref="GravatarImageSource"/> class.</summary>
public GravatarImageSource()
{
Uri = new Uri(defaultGravatarImageAddress);
Stream = new Func<CancellationToken, Task<Stream>>(cancelationToken => SingletonHttpClient.DownloadStreamAsync(Uri, cancelationToken));
}
/// <summary>Gets a value indicating whether the control email is empty.</summary>
public override bool IsEmpty => string.IsNullOrEmpty(Email);
/// <summary>Gets or sets a <see cref="TimeSpan"/> structure that indicates the length of time after which the image cache becomes invalid.</summary>
public TimeSpan CacheValidity
{
get => (TimeSpan)GetValue(CacheValidityProperty);
set => SetValue(CacheValidityProperty, value);
}
/// <summary>Gets or sets a Boolean value that indicates whether caching is enabled on this <see cref="GravatarImageSource"/> object.</summary>
public bool CachingEnabled
{
get => (bool)GetValue(CachingEnabledProperty);
set => SetValue(CachingEnabledProperty, value);
}
/// <summary>Gets or sets a value indicating whether <see cref="GravatarImageSource"/> has been disposed.</summary>
public bool IsDisposed { get; set; }
/// <summary>Gets or sets the email address.</summary>
public string? Email
{
get => (string)GetValue(EmailProperty);
set => SetValue(EmailProperty, value);
}
/// <summary>Gets or sets the default image.</summary>
public DefaultImage Image
{
get => (DefaultImage)GetValue(ImageProperty);
set => SetValue(ImageProperty, value);
}
/// <summary>Gets or sets the URI for the image to get.</summary>
[System.ComponentModel.TypeConverter(typeof(UriTypeConverter))]
public Uri Uri { get; set; }
/// <summary>Gets the parent height.</summary>
internal int ParentHeight => (int)GetValue(ParentHeightProperty);
/// <summary>Gets the parent width.</summary>
internal int ParentWidth => (int)GetValue(ParentWidthProperty);
/// <summary>Gets or sets the image size.</summary>
/// <remarks>Size is limited to be in the range of 1 to 2048.</remarks>
int GravatarSize
{
get => gravatarSize;
set => gravatarSize = Math.Clamp(value, 1, 2048);
}
HttpClient SingletonHttpClient => singletonHttpClientHolder.Value;
/// <summary>Dispose <see cref="GravatarImageSource"/>.</summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>Returns the Uri as a string.</summary>
/// <returns>String of the URI.</returns>
public override string ToString()
{
return $"Uri: {Uri}\nEmail: {Email}\nSize: {GravatarSize}\nImage: {DefaultGravatarName(Image)}\nCacheValidity: {CacheValidity}\nCachingEnabled: {CachingEnabled}";
}
/// <summary>Dispose <see cref="GravatarImageSource"/>.</summary>
/// <param name="isDisposing">Is disposing.</param>
protected virtual void Dispose(bool isDisposing)
{
if (!IsDisposed)
{
IsDisposed = true;
if (isDisposing)
{
tokenSource?.Dispose();
}
}
}
/// <summary>On parent set.</summary>
protected override void OnParentSet()
{
base.OnParentSet();
if (Parent is not VisualElement parentElement || parentElement is null)
{
GravatarSize = defaultSize;
return;
}
SetBinding(ParentWidthProperty, new Binding(nameof(VisualElement.Width), BindingMode.OneWay, source: parentElement));
SetBinding(ParentHeightProperty, new Binding(nameof(VisualElement.Height), BindingMode.OneWay, source: parentElement));
}
static string DefaultGravatarName(DefaultImage defaultGravatar) => defaultGravatar switch
{
DefaultImage.FileNotFound => "404",
DefaultImage.MysteryPerson => "mp",
_ => defaultGravatar.ToString().ToLower(),
};
static async void OnDefaultImagePropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
GravatarImageSource gravatarImageSource = (GravatarImageSource)bindable;
await gravatarImageSource.HandleNewUriRequested(gravatarImageSource.Email, (DefaultImage)newValue);
}
static async void OnEmailPropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
GravatarImageSource gravatarImageSource = (GravatarImageSource)bindable;
await gravatarImageSource.HandleNewUriRequested((string?)newValue, gravatarImageSource.Image);
}
static async void OnSizePropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
if (newValue is not int intNewValue || intNewValue <= -1)
{
return;
}
GravatarImageSource gravatarImageSource = (GravatarImageSource)bindable;
if (gravatarImageSource.GravatarSize is -1)
{
gravatarImageSource.GravatarSize = intNewValue;
return;
}
gravatarImageSource.GravatarSize = Math.Min(gravatarImageSource.ParentWidth, gravatarImageSource.ParentHeight);
await gravatarImageSource.HandleNewUriRequested(gravatarImageSource.Email, gravatarImageSource.Image);
}
Task HandleNewUriRequested(string? email, DefaultImage image)
{
if (GravatarSize is -1)
{
return Task.CompletedTask;
}
Uri = IsEmpty
? new Uri($"{defaultGravatarImageAddress}?s={GravatarSize}")
: new Uri($"{defaultGravatarImageAddress}{email?.GetMd5Hash(string.Empty).ToLowerInvariant()}?s={GravatarSize}&d={DefaultGravatarName(image)}");
return OnUriChanged();
}
async Task OnUriChanged()
{
if (tokenSource is not null)
{
try
{
tokenSource.Cancel();
tokenSource.Dispose();
}
catch
{
// Left intentionally empty, as we don't need to catch anything.
}
}
if (Uri.Equals(lastDispatch))
{
return;
}
try
{
if (tokenSource?.Token is not null)
{
await Task.Delay(cancellationTokenSourceTimeout, tokenSource.Token);
}
else
{
await Task.Delay(cancellationTokenSourceTimeout);
}
CancellationTokenSource?.Cancel();
lastDispatch = Uri;
await Dispatcher.DispatchIfRequiredAsync(OnSourceChanged);
}
catch (TaskCanceledException)
{
}
}
}
/// <summary>Default image enumerator.</summary>
public enum DefaultImage
{
/// <summary>(mystery-person) A simple, cartoon-style silhouetted outline of a person (does not vary by email hash)</summary>
MysteryPerson = 0,
/// <summary>404: Do not load any image if none is associated with the email hash, instead return an HTTP 404 (File Not Found) response.</summary>
FileNotFound,
/// <summary>A geometric pattern based on an email hash.</summary>
Identicon,
/// <summary>A generated 'monster' with different colours, faces, etc.</summary>
MonsterId,
/// <summary>Generated faces with differing features and backgrounds.</summary>
Wavatar,
/// <summary>Awesome generated, 8-bit arcade-style pixilated faces.</summary>
Retro,
/// <summary>A generated robot with different colours, faces, etc.</summary>
Robohash,
/// <summary>A transparent PNG image.</summary>
Blank,
}