feat(ci): add supporter verification and auto-hide functionality
Implement BmcSupporterService to verify supporters via BMC API with configurable caching. Add SupporterEmail and HideIfSupporter properties to all controls to automatically hide donation prompts for existing supporters. Replace PNG logo with SVG for crisp rendering at all scales. Add BmcOptions and BmcConfiguration for library-wide settings. Bump version to 1.1.0.
This commit is contained in:
15
BuyMeCofee.Maui/BmcConfiguration.cs
Normal file
15
BuyMeCofee.Maui/BmcConfiguration.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using BuyMeCofee.Maui.Services;
|
||||
|
||||
namespace BuyMeCofee.Maui;
|
||||
|
||||
/// <summary>
|
||||
/// Static configuration holder for the Buy Me a Coffee library.
|
||||
/// Populated by <see cref="BuyMeACoffeeExtensions.UseBuyMeACoffee"/>.
|
||||
/// </summary>
|
||||
public static class BmcConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// The supporter lookup service. Null if no AccessToken was configured.
|
||||
/// </summary>
|
||||
public static BmcSupporterService? SupporterService { get; internal set; }
|
||||
}
|
||||
18
BuyMeCofee.Maui/BmcOptions.cs
Normal file
18
BuyMeCofee.Maui/BmcOptions.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace BuyMeCofee.Maui;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Buy Me a Coffee library.
|
||||
/// </summary>
|
||||
public class BmcOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Your Buy Me a Coffee API access token (from https://developers.buymeacoffee.com).
|
||||
/// Required for supporter verification. This is the creator's token.
|
||||
/// </summary>
|
||||
public string? AccessToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How long to cache the supporter list before re-fetching. Default: 1 hour.
|
||||
/// </summary>
|
||||
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromHours(1);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using BuyMeCofee.Maui.Services;
|
||||
using SkiaSharp.Views.Maui.Controls.Hosting;
|
||||
|
||||
namespace BuyMeCofee.Maui;
|
||||
@@ -13,4 +14,32 @@ public static class BuyMeACoffeeExtensions
|
||||
builder.UseSkiaSharp();
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Buy Me a Coffee controls with optional supporter verification.
|
||||
/// When an AccessToken is provided, controls with HideIfSupporter=true
|
||||
/// will auto-hide for verified supporters.
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// builder.UseBuyMeACoffee(options =>
|
||||
/// {
|
||||
/// options.AccessToken = "your-bmc-api-token";
|
||||
/// options.CacheDuration = TimeSpan.FromHours(2);
|
||||
/// });
|
||||
/// </example>
|
||||
public static MauiAppBuilder UseBuyMeACoffee(this MauiAppBuilder builder, Action<BmcOptions> configure)
|
||||
{
|
||||
builder.UseSkiaSharp();
|
||||
|
||||
var options = new BmcOptions();
|
||||
configure(options);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.AccessToken))
|
||||
{
|
||||
BmcConfiguration.SupporterService = new BmcSupporterService(
|
||||
options.AccessToken, options.CacheDuration);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageTags>maui;buymeacoffee;bmc;donation;tip;controls</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<Version>1.1.0</Version>
|
||||
<PackageReleaseNotes>v1.1.0: Crisp SVG branding via MAUI asset pipeline, supporter verification API (auto-hide controls for existing supporters), SupporterEmail/HideIfSupporter properties on all controls.</PackageReleaseNotes>
|
||||
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
|
||||
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
||||
@@ -47,6 +49,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<MauiImage Include="Resources\Images\bmc_logo.svg" />
|
||||
<EmbeddedResource Include="Resources\Images\bmc_logo.png"
|
||||
LogicalName="BuyMeCofee.Maui.Resources.Images.bmc_logo.png" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -45,6 +45,14 @@ public class BuyMeACoffeeButton : ContentView
|
||||
BindableProperty.Create(nameof(CupSize), typeof(double), typeof(BuyMeACoffeeButton),
|
||||
28.0, propertyChanged: OnVisualPropertyChanged);
|
||||
|
||||
public static readonly BindableProperty SupporterEmailProperty =
|
||||
BindableProperty.Create(nameof(SupporterEmail), typeof(string), typeof(BuyMeACoffeeButton),
|
||||
null, propertyChanged: OnSupporterPropertyChanged);
|
||||
|
||||
public static readonly BindableProperty HideIfSupporterProperty =
|
||||
BindableProperty.Create(nameof(HideIfSupporter), typeof(bool), typeof(BuyMeACoffeeButton),
|
||||
false, propertyChanged: OnSupporterPropertyChanged);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
@@ -105,6 +113,25 @@ public class BuyMeACoffeeButton : ContentView
|
||||
set => SetValue(CupSizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Email address to check against your BMC supporters list.
|
||||
/// Requires AccessToken configured via UseBuyMeACoffee(options => ...).
|
||||
/// </summary>
|
||||
public string? SupporterEmail
|
||||
{
|
||||
get => (string?)GetValue(SupporterEmailProperty);
|
||||
set => SetValue(SupporterEmailProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When true, the control auto-hides if SupporterEmail is a verified supporter. Default: false.
|
||||
/// </summary>
|
||||
public bool HideIfSupporter
|
||||
{
|
||||
get => (bool)GetValue(HideIfSupporterProperty);
|
||||
set => SetValue(HideIfSupporterProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private Border _border = null!;
|
||||
@@ -230,4 +257,10 @@ public class BuyMeACoffeeButton : ContentView
|
||||
if (bindable is BuyMeACoffeeButton button)
|
||||
button.ApplyTheme();
|
||||
}
|
||||
|
||||
private static void OnSupporterPropertyChanged(BindableObject bindable, object oldValue, object newValue)
|
||||
{
|
||||
if (bindable is BuyMeACoffeeButton button)
|
||||
BmcSupporterCheck.CheckAndHide(button, button.SupporterEmail, button.HideIfSupporter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@ public class BuyMeACoffeeQrCode : ContentView
|
||||
BindableProperty.Create(nameof(LogoSizeFraction), typeof(double), typeof(BuyMeACoffeeQrCode),
|
||||
0.25, propertyChanged: OnVisualPropertyChanged);
|
||||
|
||||
public static readonly BindableProperty SupporterEmailProperty =
|
||||
BindableProperty.Create(nameof(SupporterEmail), typeof(string), typeof(BuyMeACoffeeQrCode),
|
||||
null, propertyChanged: OnSupporterPropertyChanged);
|
||||
|
||||
public static readonly BindableProperty HideIfSupporterProperty =
|
||||
BindableProperty.Create(nameof(HideIfSupporter), typeof(bool), typeof(BuyMeACoffeeQrCode),
|
||||
false, propertyChanged: OnSupporterPropertyChanged);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
@@ -67,6 +75,25 @@ public class BuyMeACoffeeQrCode : ContentView
|
||||
set => SetValue(BackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Email address to check against your BMC supporters list.
|
||||
/// Requires AccessToken configured via UseBuyMeACoffee(options => ...).
|
||||
/// </summary>
|
||||
public string? SupporterEmail
|
||||
{
|
||||
get => (string?)GetValue(SupporterEmailProperty);
|
||||
set => SetValue(SupporterEmailProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When true, the control auto-hides if SupporterEmail is a verified supporter. Default: false.
|
||||
/// </summary>
|
||||
public bool HideIfSupporter
|
||||
{
|
||||
get => (bool)GetValue(HideIfSupporterProperty);
|
||||
set => SetValue(HideIfSupporterProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>Logo size as fraction of QR code size (0.0 - 0.35). Default: 0.25</summary>
|
||||
public double LogoSizeFraction
|
||||
{
|
||||
@@ -99,6 +126,11 @@ public class BuyMeACoffeeQrCode : ContentView
|
||||
try
|
||||
{
|
||||
using var stream = BmcBrandAssets.GetCupLogoStream();
|
||||
if (stream is null)
|
||||
{
|
||||
_logoBitmap = null;
|
||||
return;
|
||||
}
|
||||
_logoBitmap = SKBitmap.Decode(stream);
|
||||
}
|
||||
catch
|
||||
@@ -215,5 +247,11 @@ public class BuyMeACoffeeQrCode : ContentView
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnSupporterPropertyChanged(BindableObject bindable, object oldValue, object newValue)
|
||||
{
|
||||
if (bindable is BuyMeACoffeeQrCode qr)
|
||||
BmcSupporterCheck.CheckAndHide(qr, qr.SupporterEmail, qr.HideIfSupporter);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@ public class BuyMeACoffeeWidget : ContentView
|
||||
BindableProperty.Create(nameof(SupportButtonText), typeof(string), typeof(BuyMeACoffeeWidget),
|
||||
BmcConstants.DefaultSupportButtonText);
|
||||
|
||||
public static readonly BindableProperty SupporterEmailProperty =
|
||||
BindableProperty.Create(nameof(SupporterEmail), typeof(string), typeof(BuyMeACoffeeWidget),
|
||||
null, propertyChanged: OnSupporterPropertyChanged);
|
||||
|
||||
public static readonly BindableProperty HideIfSupporterProperty =
|
||||
BindableProperty.Create(nameof(HideIfSupporter), typeof(bool), typeof(BuyMeACoffeeWidget),
|
||||
false, propertyChanged: OnSupporterPropertyChanged);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
@@ -94,6 +102,25 @@ public class BuyMeACoffeeWidget : ContentView
|
||||
set => SetValue(SupportButtonTextProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Email address to check against your BMC supporters list.
|
||||
/// Requires AccessToken configured via UseBuyMeACoffee(options => ...).
|
||||
/// </summary>
|
||||
public string? SupporterEmail
|
||||
{
|
||||
get => (string?)GetValue(SupporterEmailProperty);
|
||||
set => SetValue(SupporterEmailProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When true, the control auto-hides if SupporterEmail is a verified supporter. Default: false.
|
||||
/// </summary>
|
||||
public bool HideIfSupporter
|
||||
{
|
||||
get => (bool)GetValue(HideIfSupporterProperty);
|
||||
set => SetValue(HideIfSupporterProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>Raised when the user taps the Support button, before opening the browser.</summary>
|
||||
@@ -438,5 +465,11 @@ public class BuyMeACoffeeWidget : ContentView
|
||||
widget._monthlyRow.IsVisible = (bool)newValue;
|
||||
}
|
||||
|
||||
private static void OnSupporterPropertyChanged(BindableObject bindable, object oldValue, object newValue)
|
||||
{
|
||||
if (bindable is BuyMeACoffeeWidget widget)
|
||||
BmcSupporterCheck.CheckAndHide(widget, widget.SupporterEmail, widget.HideIfSupporter);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace BuyMeCofee.Maui.Helpers;
|
||||
|
||||
internal static class BmcBrandAssets
|
||||
{
|
||||
private const string LogoResourceName = "BuyMeCofee.Maui.Resources.Images.bmc_logo.png";
|
||||
// MAUI converts bmc_logo.svg → bmc_logo.png at build time with proper DPI scaling
|
||||
private const string LogoFileName = "bmc_logo.png";
|
||||
|
||||
// Fallback: embedded PNG for QR code overlay (SkiaSharp needs a raw stream)
|
||||
private const string EmbeddedLogoName = "BuyMeCofee.Maui.Resources.Images.bmc_logo.png";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cup logo as an ImageSource for MAUI Image controls.
|
||||
/// Uses the MAUI asset pipeline (SVG → crisp multi-DPI PNG).
|
||||
/// </summary>
|
||||
internal static ImageSource GetCupLogo()
|
||||
{
|
||||
var assembly = typeof(BmcBrandAssets).Assembly;
|
||||
return ImageSource.FromStream(() => assembly.GetManifestResourceStream(LogoResourceName)!);
|
||||
return ImageSource.FromFile(LogoFileName);
|
||||
}
|
||||
|
||||
internal static Stream GetCupLogoStream()
|
||||
/// <summary>
|
||||
/// Returns the cup logo as a raw stream for SkiaSharp rendering (QR code overlay).
|
||||
/// Falls back to embedded PNG resource.
|
||||
/// </summary>
|
||||
internal static Stream? GetCupLogoStream()
|
||||
{
|
||||
// Try embedded resource first (for QR code SkiaSharp rendering)
|
||||
var assembly = typeof(BmcBrandAssets).Assembly;
|
||||
return assembly.GetManifestResourceStream(LogoResourceName)!;
|
||||
var stream = assembly.GetManifestResourceStream(EmbeddedLogoName);
|
||||
if (stream != null) return stream;
|
||||
|
||||
// If embedded PNG was removed, return null (QR code will render without logo)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
26
BuyMeCofee.Maui/Helpers/BmcSupporterCheck.cs
Normal file
26
BuyMeCofee.Maui/Helpers/BmcSupporterCheck.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace BuyMeCofee.Maui.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Shared logic for controls to check supporter status and auto-hide.
|
||||
/// </summary>
|
||||
internal static class BmcSupporterCheck
|
||||
{
|
||||
public static async void CheckAndHide(ContentView control, string? email, bool hideIfSupporter)
|
||||
{
|
||||
if (!hideIfSupporter || string.IsNullOrWhiteSpace(email)) return;
|
||||
|
||||
var service = BmcConfiguration.SupporterService;
|
||||
if (service == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var isSupporter = await service.IsSupporterAsync(email);
|
||||
if (isSupporter)
|
||||
control.IsVisible = false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently fail — keep control visible
|
||||
}
|
||||
}
|
||||
}
|
||||
16
BuyMeCofee.Maui/Resources/Images/bmc_logo.svg
Normal file
16
BuyMeCofee.Maui/Resources/Images/bmc_logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.0 KiB |
93
BuyMeCofee.Maui/Services/BmcSupporterService.cs
Normal file
93
BuyMeCofee.Maui/Services/BmcSupporterService.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace BuyMeCofee.Maui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the Buy Me a Coffee API to determine whether a given email
|
||||
/// belongs to a supporter (one-time or member). Results are cached.
|
||||
/// </summary>
|
||||
public class BmcSupporterService
|
||||
{
|
||||
private const string ApiBase = "https://developers.buymeacoffee.com/api/v1/";
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly TimeSpan _cacheDuration;
|
||||
private HashSet<string>? _cachedEmails;
|
||||
private DateTime _cacheExpiry;
|
||||
|
||||
public BmcSupporterService(string accessToken, TimeSpan cacheDuration)
|
||||
{
|
||||
_cacheDuration = cacheDuration;
|
||||
_http = new HttpClient { BaseAddress = new Uri(ApiBase) };
|
||||
_http.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the given email matches any one-time supporter
|
||||
/// or active/inactive member on the creator's BMC account.
|
||||
/// </summary>
|
||||
public async Task<bool> IsSupporterAsync(string email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email)) return false;
|
||||
|
||||
var emails = await GetAllSupporterEmailsAsync();
|
||||
return emails.Contains(email.Trim());
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> GetAllSupporterEmailsAsync()
|
||||
{
|
||||
if (_cachedEmails != null && DateTime.UtcNow < _cacheExpiry)
|
||||
return _cachedEmails;
|
||||
|
||||
var emails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
await FetchEmailsFromEndpoint("supporters", emails);
|
||||
await FetchEmailsFromEndpoint("subscriptions", emails);
|
||||
|
||||
_cachedEmails = emails;
|
||||
_cacheExpiry = DateTime.UtcNow + _cacheDuration;
|
||||
return emails;
|
||||
}
|
||||
|
||||
private async Task FetchEmailsFromEndpoint(string endpoint, HashSet<string> emails)
|
||||
{
|
||||
try
|
||||
{
|
||||
var page = 1;
|
||||
while (true)
|
||||
{
|
||||
var response = await _http.GetAsync($"{endpoint}?page={page}");
|
||||
if (!response.IsSuccessStatusCode) break;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("data", out var data))
|
||||
{
|
||||
foreach (var item in data.EnumerateArray())
|
||||
{
|
||||
if (item.TryGetProperty("payer_email", out var emailProp))
|
||||
{
|
||||
var email = emailProp.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
emails.Add(email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("next_page_url", out var nextPage) ||
|
||||
nextPage.ValueKind == JsonValueKind.Null)
|
||||
break;
|
||||
|
||||
page++;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently fail — don't block the app if BMC API is unreachable
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user