Limited
2
0

feat(ci): add supporter verification and auto-hide functionality
Some checks failed
Release / Verify Apple Build (push) Successful in 14s
Release / Build & Pack (push) Successful in 8h59m34s
Release / Publish (push) Failing after 16s

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:
2026-03-04 00:35:05 -05:00
parent 7ec645b9a6
commit 66c8ec8ecf
12 changed files with 436 additions and 329 deletions

View 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; }
}

View 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);
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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;
}
}

View 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
}
}
}

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

View 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
}
}
}