(entity =>
{
diff --git a/subtrack.MAUI/MauiProgram.cs b/subtrack.MAUI/MauiProgram.cs
index bb914e1..eb1eced 100644
--- a/subtrack.MAUI/MauiProgram.cs
+++ b/subtrack.MAUI/MauiProgram.cs
@@ -101,9 +101,9 @@ private static void SeedDb(SubtrackDbContext dbContext)
var todayLastMonth = DateTime.Now.AddMonths(-1);
var availableBackgroundColors = CssUtil.AvailableBackgroundColors;
dbContext.Subscriptions.AddRange(
- new DAL.Entities.Subscription() { Name = "paramount", LastPayment = todayLastMonth.AddDays(-1), FirstPaymentDay = todayLastMonth.AddDays(-1).Day, Cost = 3m, BillingOccurrence = DAL.Entities.BillingOccurrence.Week, BillingInterval = 2, PrimaryColor = availableBackgroundColors.First(), SecondaryColor = "#2a9fd6", Icon = "fa fa-tv" },
- new DAL.Entities.Subscription() { Name = "Disney+", LastPayment = todayLastMonth, FirstPaymentDay = todayLastMonth.Day, Cost = 3m, BillingOccurrence = DAL.Entities.BillingOccurrence.Month, BillingInterval = 1, PrimaryColor = availableBackgroundColors.First(), SecondaryColor = "#2a9fd6", Icon = "fa fa-circle-play" },
- new DAL.Entities.Subscription() { Name = "Netflix Premium Plan", LastPayment = DateTime.Now.AddDays(-1), FirstPaymentDay = DateTime.Now.AddDays(-1).Day, IsAutoPaid = true, Description = "family plan", Cost = 1000, BillingOccurrence = DAL.Entities.BillingOccurrence.Month, BillingInterval = 1, PrimaryColor = availableBackgroundColors.First(), SecondaryColor = "#2a9fd6" },
+ new DAL.Entities.Subscription() { Name = "paramount", LastPayment = todayLastMonth.AddDays(-1), FirstPaymentDay = todayLastMonth.AddDays(-1).Day, Cost = 3m, BillingOccurrence = DAL.Entities.BillingOccurrence.Week, BillingInterval = 2, PrimaryColor = availableBackgroundColors.First(), SecondaryColor = "#2a9fd6", Icon = "fa fa-tv", NotificationDays = 0 },
+ new DAL.Entities.Subscription() { Name = "Disney+", LastPayment = todayLastMonth, FirstPaymentDay = todayLastMonth.Day, Cost = 3m, BillingOccurrence = DAL.Entities.BillingOccurrence.Month, BillingInterval = 1, PrimaryColor = availableBackgroundColors.First(), SecondaryColor = "#2a9fd6", Icon = "fa fa-circle-play", NotificationDays = 0 },
+ new DAL.Entities.Subscription() { Name = "Netflix Premium Plan", LastPayment = DateTime.Now.AddDays(-1), FirstPaymentDay = DateTime.Now.AddDays(-1).Day, IsAutoPaid = true, Description = "family plan", Cost = 1000, BillingOccurrence = DAL.Entities.BillingOccurrence.Month, BillingInterval = 1, PrimaryColor = availableBackgroundColors.First(), SecondaryColor = "#2a9fd6", NotificationDays = 1 },
new DAL.Entities.Subscription() { Name = "Something Family Plan", LastPayment = DateTime.Now.AddDays(-150), FirstPaymentDay = DateTime.Now.AddDays(-150).Day, IsAutoPaid = false, Description = "family plan", Cost = 1000, BillingOccurrence = DAL.Entities.BillingOccurrence.Week, BillingInterval = 2, PrimaryColor = availableBackgroundColors.First(), SecondaryColor = "#2a9fd6", Icon = "fa fa-shield" },
new DAL.Entities.Subscription() { Name = "hbo", LastPayment = DateTime.Now, FirstPaymentDay = DateTime.Now.Day, Cost = 1.5m, BillingOccurrence = DAL.Entities.BillingOccurrence.Year, BillingInterval = 1, PrimaryColor = availableBackgroundColors.First(), SecondaryColor = "#2a9fd6" },
new DAL.Entities.Subscription() { Name = "hulu Standard plan", LastPayment = DateTime.Now.AddMonths(-2), FirstPaymentDay = 1, Cost = 1.5m, IsAutoPaid = true, BillingOccurrence = DAL.Entities.BillingOccurrence.Month, BillingInterval = 3, PrimaryColor = availableBackgroundColors.First(), SecondaryColor = "#2a9fd6", Icon = "fa fa-circle-play" }
diff --git a/subtrack.MAUI/Pages/UpsertSubscription.razor b/subtrack.MAUI/Pages/UpsertSubscription.razor
index ff4b49a..9a8f01b 100644
--- a/subtrack.MAUI/Pages/UpsertSubscription.razor
+++ b/subtrack.MAUI/Pages/UpsertSubscription.razor
@@ -1,11 +1,14 @@
@page "/CreateSubscription"
@page "/EditSubscription/{Id:int}"
+
@using System.Globalization;
@using subtrack.MAUI.Shared.Components
@using subtrack.MAUI.Shared.JsInterop;
+
@inject NavigationManager NavigationManager
@inject ISubscriptionService SubscriptionService
@implements IAsyncDisposable
+
-
+
@@ -66,6 +69,20 @@
+
+
+
+ await OnNotificationsEnabledChanged())">
+
+ @if (_isNotificationsEnabled)
+ {
+
+ Notify
+
+ days before due
+
+ }
+
@@ -83,6 +100,7 @@
[SupplyParameterFromQuery]
public string ReturnUrl { get; set; }
+ bool _isNotificationsEnabled = false;
string _currency = "";
private ElementReference costInputElement;
@@ -106,10 +124,13 @@
protected override async Task OnParametersSetAsync()
{
- if (Id != null)
+ if (Id == null)
{
- _subscription = await SubscriptionService.GetByIdIfExists(Id.Value);
+ return;
}
+
+ _subscription = await SubscriptionService.GetByIdIfExists(Id.Value);
+ _isNotificationsEnabled = _subscription.NotificationDays.HasValue;
}
protected async Task HightlightHandler() => await HighlightJsInterop.HighLight(costInputElement);
@@ -140,6 +161,12 @@
return billingOccurrence.ToQuantity(_subscription.BillingInterval, showQuantityAs: ShowQuantityAs.None);
}
+ private async Task OnNotificationsEnabledChanged()
+ {
+ _subscription.NotificationDays = _isNotificationsEnabled ? 0 : null;
+ await NotificationsUtil.EnsureNotificationsAreEnabled();
+ }
+
private void UpdateBackgroundColor(ChangeEventArgs e) => _subscription.PrimaryColor = e.Value!.ToString();
private void UpdateSecondaryColor(ChangeEventArgs e) => _subscription.SecondaryColor = e.Value!.ToString();
diff --git a/subtrack.MAUI/Services/Abstractions/IDateProvider.cs b/subtrack.MAUI/Services/Abstractions/IDateProvider.cs
index 2141f9a..e16e2c5 100644
--- a/subtrack.MAUI/Services/Abstractions/IDateProvider.cs
+++ b/subtrack.MAUI/Services/Abstractions/IDateProvider.cs
@@ -3,5 +3,6 @@
public interface IDateProvider
{
public DateTime Today { get; }
+ public DateTime Now { get; }
}
}
diff --git a/subtrack.MAUI/Services/Android/NotifyDueSubscriptionsJob.cs b/subtrack.MAUI/Services/Android/NotifyDueSubscriptionsJob.cs
index 3c8599b..1077f17 100644
--- a/subtrack.MAUI/Services/Android/NotifyDueSubscriptionsJob.cs
+++ b/subtrack.MAUI/Services/Android/NotifyDueSubscriptionsJob.cs
@@ -1,5 +1,9 @@
-using Plugin.LocalNotification;
+using Humanizer;
+using Plugin.LocalNotification;
using Shiny.Jobs;
+using subtrack.DAL.Entities;
+using subtrack.MAUI.Services.Abstractions;
+using subtrack.MAUI.Utilities;
namespace subtrack.MAUI.Services.Android;
@@ -13,15 +17,6 @@ public NotifyDueSubscriptionsJob(IServiceScopeFactory serviceScopeFactory)
_serviceScopeFactory = serviceScopeFactory;
}
- private static async Task EnsureNotificationsAreEnabled()
- {
- var hasEnabledNotifications = await LocalNotificationCenter.Current.AreNotificationsEnabled();
- if (!hasEnabledNotifications)
- {
- await LocalNotificationCenter.Current.RequestNotificationPermission();
- }
- }
-
private static void SendNotification(int notificationId, string title, string group)
{
var sampleNotification = new NotificationRequest()
@@ -42,16 +37,66 @@ public async Task Run(JobInfo jobInfo, CancellationToken cancellationToken)
if (cancellationToken.IsCancellationRequested)
break;
- // jobs are singletons, use this to create a scope for fetching transient/scoped dependencies
using var scope = _serviceScopeFactory.CreateScope();
+ var serviceProvider = scope.ServiceProvider;
+ var settingsService = serviceProvider.GetRequiredService();
+ var dateProvider = serviceProvider.GetRequiredService();
+
+ var lastSubscriptionReminderTimeStamp = await settingsService.GetByIdAsync(DateTimeSetting.LastSubscriptionReminderTimeStampKey);
- await EnsureNotificationsAreEnabled();
+ var now = dateProvider.Now;
+ var today = dateProvider.Today;
- var notificationGroup = DateTime.Today.Day.ToString();
- SendNotification(1, "Netflix is due in 2 days", notificationGroup);
- SendNotification(2, "Missed payment for disney", notificationGroup);
+ if (lastSubscriptionReminderTimeStamp.Value?.Date != today)
+ {
+ await NotifyDueSubscriptionsJob.RunInternal(serviceProvider, today);
- await Task.Delay(TimeSpan.FromHours(12), cancellationToken);
+ lastSubscriptionReminderTimeStamp.Value = now;
+ await settingsService.UpdateAsync(lastSubscriptionReminderTimeStamp);
+ }
+
+ var tomorrow5PM = today.AddDays(1).AddHours(17);
+ var timeUntilTomorrow5PM = tomorrow5PM - now;
+
+ await Task.Delay(timeUntilTomorrow5PM, cancellationToken);
}
}
+
+ private static async Task RunInternal(IServiceProvider serviceProvider, DateTime today)
+ {
+ var subscriptionService = serviceProvider.GetRequiredService();
+ var subscriptionsCalculator = serviceProvider.GetRequiredService();
+
+ var subscriptions = await subscriptionService.GetAllAsync();
+ var subscriptionsWithNotificationsEnabled = subscriptions.Where(x => x.NotificationDays.HasValue).ToList();
+ if (!subscriptionsWithNotificationsEnabled.Any())
+ {
+ return;
+ }
+
+ await NotificationsUtil.EnsureNotificationsAreEnabled();
+
+ var notificationGroup = today.Day.ToString();
+ subscriptionsWithNotificationsEnabled.ForEach(sub =>
+ {
+ var dueDate = subscriptionsCalculator.GetNextPaymentDate(sub);
+ var timeUntilNextPayment = dueDate.Subtract(today);
+ var dueDays = timeUntilNextPayment.Days;
+
+ if (dueDays == sub.NotificationDays)
+ {
+ SendNotification(sub.Id, $"{sub.Name} is due {GetDueDaysText(dueDays)} ({dueDate.DayOfWeek.Humanize(LetterCasing.LowerCase)})", notificationGroup);
+ }
+ });
+ }
+
+ private static string GetDueDaysText(int dueDays)
+ {
+ return dueDays switch
+ {
+ 0 => "today",
+ 1 => "tomorrow",
+ _ => $"in {dueDays} days"
+ };
+ }
}
diff --git a/subtrack.MAUI/Services/DateProvider.cs b/subtrack.MAUI/Services/DateProvider.cs
index 678d66e..0e236f3 100644
--- a/subtrack.MAUI/Services/DateProvider.cs
+++ b/subtrack.MAUI/Services/DateProvider.cs
@@ -5,5 +5,6 @@ namespace subtrack.MAUI.Services
public class DateProvider : IDateProvider
{
public DateTime Today => DateTime.Today;
+ public DateTime Now => DateTime.Now;
}
}
diff --git a/subtrack.MAUI/Services/SubscriptionService.cs b/subtrack.MAUI/Services/SubscriptionService.cs
index bf3dbad..91543d7 100644
--- a/subtrack.MAUI/Services/SubscriptionService.cs
+++ b/subtrack.MAUI/Services/SubscriptionService.cs
@@ -45,6 +45,7 @@ public async Task Update(Subscription subscriptionToUpdate)
sub.PrimaryColor = subscriptionToUpdate.PrimaryColor;
sub.SecondaryColor = subscriptionToUpdate.SecondaryColor;
sub.Icon = subscriptionToUpdate.Icon;
+ sub.NotificationDays = subscriptionToUpdate.NotificationDays;
AutoPay(sub);
sub.Cost = subscriptionToUpdate.Cost;
diff --git a/subtrack.MAUI/Utilities/NotificationsUtil.cs b/subtrack.MAUI/Utilities/NotificationsUtil.cs
new file mode 100644
index 0000000..007a4ac
--- /dev/null
+++ b/subtrack.MAUI/Utilities/NotificationsUtil.cs
@@ -0,0 +1,14 @@
+using Plugin.LocalNotification;
+
+namespace subtrack.MAUI.Utilities;
+public static class NotificationsUtil
+{
+ public static async Task EnsureNotificationsAreEnabled()
+ {
+ var hasEnabledNotifications = await LocalNotificationCenter.Current.AreNotificationsEnabled();
+ if (!hasEnabledNotifications)
+ {
+ await LocalNotificationCenter.Current.RequestNotificationPermission();
+ }
+ }
+}
diff --git a/subtrack.Tests/Integration/SubscriptionImporterTests.cs b/subtrack.Tests/Integration/SubscriptionImporterTests.cs
index ffbab8e..dd7de2d 100644
--- a/subtrack.Tests/Integration/SubscriptionImporterTests.cs
+++ b/subtrack.Tests/Integration/SubscriptionImporterTests.cs
@@ -15,8 +15,8 @@ public SubscriptionImporterTests()
[Fact]
public async Task ImportCsv_InvalidCsv_ShouldThrow()
{
- var csvText = @"Name,Description,IsAutoPaid,Cost,FirstPaymentDay,LastPayment,BillingOccurrence,BillingInterval,PrimaryColor,Icon,SecondaryColor
-Subscription1,Description1,True,29.99,1,2023-12-01,Monthly,1,,,,,,
+ var csvText = @"Name,Description,IsAutoPaid,Cost,FirstPaymentDay,LastPayment,BillingOccurrence,BillingInterval,PrimaryColor,Icon,SecondaryColor,NotificationDays
+Subscription1,Description1,True,29.99,1,2023-12-01,Monthly,1,,,,,,,,
";
var csv = new MemoryStream(Encoding.UTF8.GetBytes(csvText));
@@ -26,9 +26,9 @@ public async Task ImportCsv_InvalidCsv_ShouldThrow()
[Fact]
public async Task ImportCsv_EmptyValuesForOptionalProperties_ShouldReturnImportedItems()
{
- var csvText = @"Name,Description,IsAutoPaid,Cost,FirstPaymentDay,LastPayment,BillingOccurrence,BillingInterval,PrimaryColor,Icon,SecondaryColor
-Subscription1,Description1,True,29.99,1,2023-12-01,Month,1,,,,,,
-Subscription2,,True,29.99,1,2023-12-01,Month,1,,,,,,
+ var csvText = @"Name,Description,IsAutoPaid,Cost,FirstPaymentDay,LastPayment,BillingOccurrence,BillingInterval,PrimaryColor,Icon,SecondaryColor,NotificationDays
+Subscription1,Description1,True,29.99,1,2023-12-01,Month,1,,,,,,,,
+Subscription2,,True,29.99,1,2023-12-01,Month,1,,,,,,,,
";
var csv = new MemoryStream(Encoding.UTF8.GetBytes(csvText));
@@ -41,10 +41,10 @@ public async Task ImportCsv_EmptyValuesForOptionalProperties_ShouldReturnImporte
public async Task ImportCsv_WithSameDetailsAsExisting_ShouldReturnImportedSubs()
{
var existingSubscription = CreateSubscription(DateTime.Today, description: null);
- var csvText = $@"Name,Description,IsAutoPaid,Cost,FirstPaymentDay,LastPayment,BillingOccurrence,BillingInterval,PrimaryColor,Icon,SecondaryColor
+ var csvText = $@"Name,Description,IsAutoPaid,Cost,FirstPaymentDay,LastPayment,BillingOccurrence,BillingInterval,PrimaryColor,Icon,SecondaryColor,NotificationDays
{existingSubscription.Name},,{existingSubscription.IsAutoPaid},{existingSubscription.Cost},{existingSubscription.FirstPaymentDay},{existingSubscription.LastPayment},{existingSubscription.BillingOccurrence},{existingSubscription.BillingInterval},,,,,,
-Subscription1,,True,29.99,1,2023-12-01,Month,1,,,,,,
-Subscription2,,False,29.99,1,2022-12-01,Week,1,,,,,,
+Subscription1,,True,29.99,1,2023-12-01,Month,1,,,,,,,,
+Subscription2,,False,29.99,1,2022-12-01,Week,1,,,,,,,,
";
var csv = new MemoryStream(Encoding.UTF8.GetBytes(csvText));