diff --git a/Docs/Images/MMExNotifier-MainWindow.png b/Docs/Images/MMExNotifier-MainWindow.png new file mode 100644 index 0000000..4762161 Binary files /dev/null and b/Docs/Images/MMExNotifier-MainWindow.png differ diff --git a/MMExNotifier.Tests/MMExNotifier.Tests.csproj b/MMExNotifier.Tests/MMExNotifier.Tests.csproj new file mode 100644 index 0000000..4bbbc7b --- /dev/null +++ b/MMExNotifier.Tests/MMExNotifier.Tests.csproj @@ -0,0 +1,29 @@ + + + + net6.0-windows10.0.22000.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/MMExNotifier.Tests/MainViewModelTests.cs b/MMExNotifier.Tests/MainViewModelTests.cs new file mode 100644 index 0000000..9fc9b09 --- /dev/null +++ b/MMExNotifier.Tests/MainViewModelTests.cs @@ -0,0 +1,124 @@ + +using MMExNotifier.Database; +using MMExNotifier.DataModel; +using MMExNotifier.Helpers; +using MMExNotifier.ViewModels; +using Moq; + +namespace MMExNotifier.Tests +{ + public class MainViewModelTests + { + private Mock mockAppConfiguration; + private Mock mockDatabaseService; + private Mock mockNotificationService; + + [SetUp] + public void Setup() + { + mockAppConfiguration = new Mock(); + mockAppConfiguration.Setup(x => x.MMExDatabasePath).Returns("C:\\mydatabase.mmb"); + mockAppConfiguration.Setup(x => x.RunAtLogon).Returns(false); + mockAppConfiguration.Setup(x => x.DaysAhead).Returns(7); + + mockDatabaseService = new Mock(); + + mockNotificationService = new Mock(); + } + + [Test] + public void WhenDatabasePathIsNotConfigured_ShouldNotAttemptOpenDatabase() + { + mockAppConfiguration.Setup(x => x.MMExDatabasePath).Returns(string.Empty); + mockDatabaseService.Setup(x => x.ExpiringBills).Returns(() => null); + + var mainViewModel = new MainViewModel(mockAppConfiguration.Object, mockNotificationService.Object, mockDatabaseService.Object); + mainViewModel.Activate(); + + Assert.That(mockDatabaseService.Invocations, Is.Empty); + } + + [Test] + public void WhenNoExpiringBills_ShouldRaiseCloseEvent() + { + mockDatabaseService.Setup(x => x.ExpiringBills).Returns(() => null); + + var mainViewModel = new MainViewModel(mockAppConfiguration.Object, mockNotificationService.Object, mockDatabaseService.Object); + var closeInvoked = false; + mainViewModel.OnClose += (s, e) => { closeInvoked = true; }; + mainViewModel.Activate(); + + Assert.That(closeInvoked, Is.True); + } + + [Test] + public void WhenAtLeastOneExpiringBill_ShouldShowToastNotification() + { + mockDatabaseService.Setup(x => x.ExpiringBills).Returns( + new List + { + new() + { + BillId=1, + CategoryName="testCategory", + PayeeName="TestPayee", + NextOccurrenceDate=new DateTime(2024,6,1) + } + }); + + mockNotificationService.Setup( + x => x.ShowToastNotification("viewTransactions", It.IsAny(), "MMExNotifier", "One ore more recurring transaction are about to expire.", It.IsAny()) + ); + + var mainViewModel = new MainViewModel(mockAppConfiguration.Object, mockNotificationService.Object, mockDatabaseService.Object); + mainViewModel.Activate(); + + mockNotificationService.Verify(); + } + + [Test] + public void WhenNotificationActivated_ShouldRaiseOpen() + { + mockDatabaseService.Setup(x => x.ExpiringBills).Returns( + new List + { + new() + { + BillId=1, + CategoryName="testCategory", + PayeeName="TestPayee", + NextOccurrenceDate=new DateTime(2024,6,1) + } + }); + + var mockToastNotification = new Mock(); + mockToastNotification.Setup( + x => x.Show(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()) + ); + + var mainViewModel = new MainViewModel(mockAppConfiguration.Object, new NotificationService(mockToastNotification.Object), mockDatabaseService.Object); + var openInvoked = false; + mainViewModel.OnOpen += (s, e) => { openInvoked = true; }; + mainViewModel.Activate(); + + mockToastNotification.Raise(x => x.OnActivated += null, new EventArgs()); + + Assert.That(openInvoked, Is.True); + } + + [Test] + public void OnDatabaseError_ShouldShowErrorMessage() + { + mockDatabaseService.Setup(x => x.ExpiringBills).Throws(); + + mockNotificationService.Setup( + x => x.ShowErrorNotification(It.IsAny()) + ); + + var mainViewModel = new MainViewModel(mockAppConfiguration.Object, mockNotificationService.Object, mockDatabaseService.Object); + mainViewModel.Activate(); + + mockNotificationService.Verify(); + } + } +} \ No newline at end of file diff --git a/MMExNotifier.sln b/MMExNotifier.sln index 4a3dbfc..faf5e82 100644 --- a/MMExNotifier.sln +++ b/MMExNotifier.sln @@ -3,7 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.1.32228.430 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MMExNotifier", "MMExNotifier\MMExNotifier.csproj", "{91E2037D-BD50-4051-AA06-C8CC19D00131}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MMExNotifier", "MMExNotifier\MMExNotifier.csproj", "{91E2037D-BD50-4051-AA06-C8CC19D00131}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MMExNotifier.Tests", "MMExNotifier.Tests\MMExNotifier.Tests.csproj", "{8E1F80AF-5A10-4790-8367-A15EC5A0C2B2}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -15,6 +17,10 @@ Global {91E2037D-BD50-4051-AA06-C8CC19D00131}.Debug|Any CPU.Build.0 = Debug|Any CPU {91E2037D-BD50-4051-AA06-C8CC19D00131}.Release|Any CPU.ActiveCfg = Release|Any CPU {91E2037D-BD50-4051-AA06-C8CC19D00131}.Release|Any CPU.Build.0 = Release|Any CPU + {8E1F80AF-5A10-4790-8367-A15EC5A0C2B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E1F80AF-5A10-4790-8367-A15EC5A0C2B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E1F80AF-5A10-4790-8367-A15EC5A0C2B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E1F80AF-5A10-4790-8367-A15EC5A0C2B2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MMExNotifier/App.xaml.cs b/MMExNotifier/App.xaml.cs index 8bb51a3..78738d9 100644 --- a/MMExNotifier/App.xaml.cs +++ b/MMExNotifier/App.xaml.cs @@ -1,7 +1,8 @@ -using Microsoft.Toolkit.Uwp.Notifications; -using System.Linq; +using MMExNotifier.Database; +using MMExNotifier.DataModel; +using MMExNotifier.Helpers; +using MMExNotifier.ViewModels; using System.Windows; -using Windows.Foundation.Collections; namespace MMExNotifier { @@ -14,43 +15,15 @@ protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); - var dbPath = MMExNotifier.Properties.Settings.Default.MMExDatabasePath; + var appConfiguration = new AppConfiguration(); - if (string.IsNullOrEmpty(dbPath)) - { - var mainWindow = new MainWindow(); - return; - } + var view = new MainWindow(); + var viewModel = new MainViewModel(appConfiguration, new NotificationService(new ToastNotification()), new DatabaseService(appConfiguration)); - var daysAhead = MMExNotifier.Properties.Settings.Default.DaysAhead; - var expiringTransactions = DbHelper.LoadRecurringTransactions(dbPath, daysAhead); - - if ((expiringTransactions != null) && (!expiringTransactions.Any())) - { - App.Current.Shutdown(0); - } - else - { - new ToastContentBuilder() - .AddArgument("action", "viewTransactions") - .AddArgument("conversationId", 9813) - .AddText($"MMExNotifier", AdaptiveTextStyle.Header) - .AddText($"One ore more recurring transaction are about to expire.") - .SetToastScenario(ToastScenario.Reminder) - .Show(); - - // Listen to notification activation - ToastNotificationManagerCompat.OnActivated += toastArgs => - { - ToastArguments args = ToastArguments.Parse(toastArgs.Argument); - ValueSet userInput = toastArgs.UserInput; - Application.Current.Dispatcher.Invoke(delegate - { - var mainWindow = new MainWindow(); - mainWindow.ShowDialog(); - }); - }; - } + view.DataContext = viewModel; + viewModel.OnClose += (s, e) => Application.Current.Dispatcher.Invoke(() => view.Close()); + viewModel.OnOpen += (s, e) => Application.Current.Dispatcher.Invoke(() => { if (view.Visibility != Visibility.Visible) view.ShowDialog(); }); + viewModel.Activate(); } } } diff --git a/MMExNotifier/AssemblyInfo.cs b/MMExNotifier/AssemblyInfo.cs index 8b5504e..7b452a4 100644 --- a/MMExNotifier/AssemblyInfo.cs +++ b/MMExNotifier/AssemblyInfo.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using System.Windows; [assembly: ThemeInfo( @@ -8,3 +9,6 @@ //(used if a resource is not found in the page, // app, or any theme specific resource dictionaries) )] + +[assembly: InternalsVisibleTo("MMExNotifier.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/MMExNotifier/Connection.cs b/MMExNotifier/Connection.cs deleted file mode 100644 index 51b194a..0000000 --- a/MMExNotifier/Connection.cs +++ /dev/null @@ -1,13 +0,0 @@ -using LinqToDB.Data; - -namespace MMExNotifier -{ - internal class Connection : DataConnection - { - public Connection() - :base() - { - - } - } -} diff --git a/MMExNotifier/Entities/Account.cs b/MMExNotifier/DataModel/Account.cs similarity index 97% rename from MMExNotifier/Entities/Account.cs rename to MMExNotifier/DataModel/Account.cs index 52fef71..59ff585 100644 --- a/MMExNotifier/Entities/Account.cs +++ b/MMExNotifier/DataModel/Account.cs @@ -1,7 +1,7 @@ using LinqToDB.Mapping; using System; -namespace MMExNotifier.Entities +namespace MMExNotifier.DataModel { [Table(Name = "ACCOUNTLIST_V1")] internal class Account diff --git a/MMExNotifier/DataModel/AppConfiguration.cs b/MMExNotifier/DataModel/AppConfiguration.cs new file mode 100644 index 0000000..3de207a --- /dev/null +++ b/MMExNotifier/DataModel/AppConfiguration.cs @@ -0,0 +1,71 @@ +using Microsoft.Win32.TaskScheduler; +using System; +using System.Linq; +using System.Security.Principal; + +namespace MMExNotifier.DataModel +{ + internal class AppConfiguration : IAppConfiguration + { + public string? MMExDatabasePath { get; set; } + public int DaysAhead { get; set; } + public bool RunAtLogon { get; set; } + + public AppConfiguration() + { + MMExDatabasePath = Properties.Settings.Default.MMExDatabasePath; + DaysAhead = Properties.Settings.Default.DaysAhead; + + using TaskService taskService = new(); + RunAtLogon = taskService.RootFolder.Tasks.Any(t => t.Name == "MMExNotifier"); + } + + public void Save() + { + Properties.Settings.Default.DaysAhead = DaysAhead; + Properties.Settings.Default.MMExDatabasePath = MMExDatabasePath; + + if (RunAtLogon) + { + EnableSchedulerTask(); + } + else + { + DisableSchedulerTask(); + } + + Properties.Settings.Default.Save(); + } + + private static void EnableSchedulerTask() + { + using TaskService taskService = new(); + + if (taskService.RootFolder.Tasks.Any(t => t.Name == "MMExNotifier")) + return; + + TaskDefinition taskDefinition = taskService.NewTask(); + + // Set the task settings + taskDefinition.RegistrationInfo.Description = "MMExNotifier"; + var userId = WindowsIdentity.GetCurrent().Name; + + // Set the trigger to run on logon + LogonTrigger logonTrigger = new() { UserId = userId }; + taskDefinition.Triggers.Add(logonTrigger); + + // Set the action to run the executable that creates the task + string executablePath = Environment.ProcessPath ?? ""; + taskDefinition.Actions.Add(new ExecAction(executablePath)); + + // Register the task in the Windows Task Scheduler + taskService.RootFolder.RegisterTaskDefinition("MMExNotifier", taskDefinition); + } + + private static void DisableSchedulerTask() + { + using TaskService taskService = new(); + taskService.RootFolder.DeleteTask("MMExNotifier", false); + } + } +} diff --git a/MMExNotifier/Entities/BillDeposit.cs b/MMExNotifier/DataModel/BillDeposit.cs similarity index 97% rename from MMExNotifier/Entities/BillDeposit.cs rename to MMExNotifier/DataModel/BillDeposit.cs index 1042a30..962c961 100644 --- a/MMExNotifier/Entities/BillDeposit.cs +++ b/MMExNotifier/DataModel/BillDeposit.cs @@ -1,7 +1,7 @@ using LinqToDB.Mapping; using System; -namespace MMExNotifier.Entities +namespace MMExNotifier.DataModel { [Table(Name = "BILLSDEPOSITS_V1")] internal class BillDeposit diff --git a/MMExNotifier/Entities/Category.cs b/MMExNotifier/DataModel/Category.cs similarity index 82% rename from MMExNotifier/Entities/Category.cs rename to MMExNotifier/DataModel/Category.cs index f7bdbfc..a323a46 100644 --- a/MMExNotifier/Entities/Category.cs +++ b/MMExNotifier/DataModel/Category.cs @@ -1,8 +1,8 @@ using LinqToDB.Mapping; -namespace MMExNotifier.Entities +namespace MMExNotifier.DataModel { - [Table(Name="CATEGORY_V1")] + [Table(Name = "CATEGORY_V1")] internal class Category { [PrimaryKey] diff --git a/MMExNotifier/Entities/ExpiringBill.cs b/MMExNotifier/DataModel/ExpiringBill.cs similarity index 93% rename from MMExNotifier/Entities/ExpiringBill.cs rename to MMExNotifier/DataModel/ExpiringBill.cs index 5ea4944..8fbf123 100644 --- a/MMExNotifier/Entities/ExpiringBill.cs +++ b/MMExNotifier/DataModel/ExpiringBill.cs @@ -1,6 +1,6 @@ using System; -namespace MMExNotifier.Entities +namespace MMExNotifier.DataModel { public class ExpiringBill { diff --git a/MMExNotifier/DataModel/IAppConfiguration.cs b/MMExNotifier/DataModel/IAppConfiguration.cs new file mode 100644 index 0000000..11fffb6 --- /dev/null +++ b/MMExNotifier/DataModel/IAppConfiguration.cs @@ -0,0 +1,11 @@ +namespace MMExNotifier.DataModel +{ + internal interface IAppConfiguration + { + int DaysAhead { get; set; } + string? MMExDatabasePath { get; set; } + bool RunAtLogon { get; set; } + + void Save(); + } +} \ No newline at end of file diff --git a/MMExNotifier/Entities/Payee.cs b/MMExNotifier/DataModel/Payee.cs similarity index 83% rename from MMExNotifier/Entities/Payee.cs rename to MMExNotifier/DataModel/Payee.cs index a5a7aaf..b20ef1e 100644 --- a/MMExNotifier/Entities/Payee.cs +++ b/MMExNotifier/DataModel/Payee.cs @@ -1,8 +1,8 @@ using LinqToDB.Mapping; -namespace MMExNotifier.Entities +namespace MMExNotifier.DataModel { - [Table(Name="PAYEE_V1")] + [Table(Name = "PAYEE_V1")] internal class Payee { [PrimaryKey] diff --git a/MMExNotifier/Entities/Transaction.cs b/MMExNotifier/DataModel/Transaction.cs similarity index 97% rename from MMExNotifier/Entities/Transaction.cs rename to MMExNotifier/DataModel/Transaction.cs index 79632f2..378479b 100644 --- a/MMExNotifier/Entities/Transaction.cs +++ b/MMExNotifier/DataModel/Transaction.cs @@ -1,7 +1,7 @@ using LinqToDB.Mapping; using System; -namespace MMExNotifier.Entities +namespace MMExNotifier.DataModel { [Table(Name = "CHECKINGACCOUNT_V1")] internal class Transaction diff --git a/MMExNotifier/Database/DatabaseService.cs b/MMExNotifier/Database/DatabaseService.cs new file mode 100644 index 0000000..ceb3773 --- /dev/null +++ b/MMExNotifier/Database/DatabaseService.cs @@ -0,0 +1,58 @@ +using LinqToDB; +using MMExNotifier.DataModel; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MMExNotifier.Database +{ + internal class DatabaseService : IDatabaseService + { + private readonly IAppConfiguration _appConfiguration; + + public DatabaseService(IAppConfiguration appConfiguration) + { + _appConfiguration = appConfiguration; + } + + public List? ExpiringBills + { + get + { + try + { + var db = new LinqToDB.Data.DataConnection( + ProviderName.SQLite, + $"Data Source={_appConfiguration.MMExDatabasePath}"); + + var billDeposits = db.GetTable(); + var categories = db.GetTable(); + var payees = db.GetTable(); + var transactions = db.GetTable(); + var accounts = db.GetTable(); + + return (from b in billDeposits + from s in categories.Where(s => s.CATEGID == b.CATEGID).DefaultIfEmpty() + from c in categories.Where(c => c.CATEGID == s.PARENTID).DefaultIfEmpty() + from p in payees.Where(p => p.PAYEEID == b.PAYEEID).DefaultIfEmpty() + where b.NEXTOCCURRENCEDATE < DateTime.Now.AddDays(_appConfiguration.DaysAhead) + orderby b.NEXTOCCURRENCEDATE + select new ExpiringBill + { + BillId = b.BDID, + NextOccurrenceDate = b.NEXTOCCURRENCEDATE.Value, + PayeeName = p.PAYEENAME!, + CategoryName = c.CATEGNAME!, + SubCategoryName = s.CATEGNAME!, + Notes = b.NOTES! + }).ToList(); + } + catch (Exception) + { + throw; + } + } + } + + } +} diff --git a/MMExNotifier/Database/IDatabaseService.cs b/MMExNotifier/Database/IDatabaseService.cs new file mode 100644 index 0000000..2aefdd9 --- /dev/null +++ b/MMExNotifier/Database/IDatabaseService.cs @@ -0,0 +1,10 @@ +using MMExNotifier.DataModel; +using System.Collections.Generic; + +namespace MMExNotifier.Database +{ + internal interface IDatabaseService + { + List? ExpiringBills { get; } + } +} \ No newline at end of file diff --git a/MMExNotifier/DbHelper.cs b/MMExNotifier/DbHelper.cs deleted file mode 100644 index 3979158..0000000 --- a/MMExNotifier/DbHelper.cs +++ /dev/null @@ -1,47 +0,0 @@ -using LinqToDB; -using MMExNotifier.Entities; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace MMExNotifier -{ - internal static class DbHelper - { - public static List? LoadRecurringTransactions(string mmexDatabasePath, int daysAhead) - { - try - { - var db = new LinqToDB.Data.DataConnection( - ProviderName.SQLite, - $"Data Source={mmexDatabasePath}"); - - var billDeposits = db.GetTable(); - var categories = db.GetTable(); - var payees = db.GetTable(); - var transactions = db.GetTable(); - var accounts = db.GetTable(); - - return (from b in billDeposits - from s in categories.Where(s => s.CATEGID == b.CATEGID).DefaultIfEmpty() - from c in categories.Where(c => c.CATEGID == s.PARENTID).DefaultIfEmpty() - from p in payees.Where(p => p.PAYEEID == b.PAYEEID).DefaultIfEmpty() - where b.NEXTOCCURRENCEDATE < DateTime.Now.AddDays(daysAhead) - orderby b.NEXTOCCURRENCEDATE - select new ExpiringBill - { - BillId = b.BDID, - NextOccurrenceDate = b.NEXTOCCURRENCEDATE.Value, - PayeeName = p.PAYEENAME!, - CategoryName = c.CATEGNAME!, - SubCategoryName = s.CATEGNAME!, - Notes = b.NOTES! - }).ToList(); - } - catch (Exception) - { - throw; - } - } - } -} diff --git a/MMExNotifier/Helpers/INotificationService.cs b/MMExNotifier/Helpers/INotificationService.cs new file mode 100644 index 0000000..6a7ab2c --- /dev/null +++ b/MMExNotifier/Helpers/INotificationService.cs @@ -0,0 +1,10 @@ +using System; + +namespace MMExNotifier.Helpers +{ + internal interface INotificationService + { + public void ShowToastNotification(string actionName, int conversationId, string headerText, string message, Action onToastClickAction); + public void ShowErrorNotification(string message); + } +} diff --git a/MMExNotifier/Helpers/IToastNotification.cs b/MMExNotifier/Helpers/IToastNotification.cs new file mode 100644 index 0000000..a39c368 --- /dev/null +++ b/MMExNotifier/Helpers/IToastNotification.cs @@ -0,0 +1,10 @@ +using System; + +namespace MMExNotifier.Helpers +{ + internal interface IToastNotification + { + event EventHandler? OnActivated; + void Show(string actionName, int conversationId, string headerText, string message); + } +} \ No newline at end of file diff --git a/MMExNotifier/Helpers/NotificationService.cs b/MMExNotifier/Helpers/NotificationService.cs new file mode 100644 index 0000000..e6fd80f --- /dev/null +++ b/MMExNotifier/Helpers/NotificationService.cs @@ -0,0 +1,26 @@ +using System; +using System.Windows; + +namespace MMExNotifier.Helpers +{ + internal class NotificationService : INotificationService + { + private IToastNotification _toastNotification; + + public NotificationService(IToastNotification toastNotification) + { + _toastNotification = toastNotification; + } + + public void ShowErrorNotification(string message) + { + MessageBox.Show(message, "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + + public void ShowToastNotification(string actionName, int conversationId, string headerText, string message, Action onToastClickAction) + { + _toastNotification.OnActivated += (s, e) => onToastClickAction(); + _toastNotification.Show(actionName, conversationId, headerText, message); + } + } +} diff --git a/MMExNotifier/Helpers/ToastNotification.cs b/MMExNotifier/Helpers/ToastNotification.cs new file mode 100644 index 0000000..bb81719 --- /dev/null +++ b/MMExNotifier/Helpers/ToastNotification.cs @@ -0,0 +1,30 @@ +using Microsoft.Toolkit.Uwp.Notifications; +using System; +using Windows.Foundation.Collections; + +namespace MMExNotifier.Helpers +{ + internal class ToastNotification : IToastNotification + { + public event EventHandler? OnActivated; + + public void Show(string actionName, int conversationId, string headerText, string message) + { + new ToastContentBuilder() + .AddArgument("action", actionName) + .AddArgument("conversationId", conversationId) + .AddText(headerText, AdaptiveTextStyle.Header) + .AddText(message) + .SetToastScenario(ToastScenario.Reminder) + .Show(); + + // Listen to notification activation + ToastNotificationManagerCompat.OnActivated += toastArgs => + { + ToastArguments args = ToastArguments.Parse(toastArgs.Argument); + ValueSet userInput = toastArgs.UserInput; + OnActivated?.Invoke(this, new EventArgs()); + }; + } + } +} diff --git a/MMExNotifier/IsLessThanConverter.cs b/MMExNotifier/MVVM/IsLessThanConverter.cs similarity index 95% rename from MMExNotifier/IsLessThanConverter.cs rename to MMExNotifier/MVVM/IsLessThanConverter.cs index d5d3fb9..e4c5e72 100644 --- a/MMExNotifier/IsLessThanConverter.cs +++ b/MMExNotifier/MVVM/IsLessThanConverter.cs @@ -2,7 +2,7 @@ using System.Globalization; using System.Windows.Data; -namespace MMExNotifier +namespace MMExNotifier.MVVM { internal class IsLessThanConverter : IValueConverter { diff --git a/MMExNotifier/MVVM/RangeObservableCollection.cs b/MMExNotifier/MVVM/RangeObservableCollection.cs new file mode 100644 index 0000000..2e566ce --- /dev/null +++ b/MMExNotifier/MVVM/RangeObservableCollection.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; + +namespace MMExNotifier.MVVM +{ + internal class RangeObservableCollection : ObservableCollection + { + public void AddRange(IEnumerable items) + { + using (BlockReentrancy()) + { + foreach (T item in items) + { + Items.Add(item); + } + + OnPropertyChanged(new PropertyChangedEventArgs("Count")); + OnPropertyChanged(new PropertyChangedEventArgs("Item[]")); + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + } + } +} diff --git a/MMExNotifier/MVVM/RelayCommand.cs b/MMExNotifier/MVVM/RelayCommand.cs new file mode 100644 index 0000000..6709c4e --- /dev/null +++ b/MMExNotifier/MVVM/RelayCommand.cs @@ -0,0 +1,80 @@ +using System; +using System.Windows.Input; + +namespace MMExNotifier.MVVM +{ + internal class RelayCommand : ICommand + { + private readonly Action? _execute = null; + private readonly Func? _canExecute = null; + + public event EventHandler? CanExecuteChanged + { + add { CommandManager.RequerySuggested += value; } + remove { CommandManager.RequerySuggested -= value; } + } + + public RelayCommand(Action actionToExecute) + { + _execute = actionToExecute ?? throw new ArgumentNullException(nameof(actionToExecute)); + } + + public RelayCommand(Action actionToExecute, Func executionEvaluator) + : this(actionToExecute) + { + _canExecute = executionEvaluator ?? throw new ArgumentNullException(nameof(executionEvaluator)); + } + + public bool CanExecute(object? parameter) + { + return _canExecute == null ? true : _canExecute.Invoke(); + } + + public void Execute(object? parameter) + { + _execute?.Invoke(); + } + } + + internal class RelayCommand : ICommand + { + private readonly Action? _execute = null; + private readonly Predicate? _canExecute = null; + + public event EventHandler? CanExecuteChanged + { + add { CommandManager.RequerySuggested += value; } + remove { CommandManager.RequerySuggested -= value; } + } + + public RelayCommand(Action actionToExecute) + { + _execute = actionToExecute ?? throw new ArgumentNullException(nameof(actionToExecute)); + } + + public RelayCommand(Action actionToExecute, Predicate executionEvaluator) + : this(actionToExecute) + { + _canExecute = executionEvaluator ?? throw new ArgumentNullException(nameof(executionEvaluator)); + } + + public bool CanExecute(object? parameter) + { + if (_canExecute == null) + return true; + + if (parameter == null && typeof(T?).IsValueType) + return _canExecute.Invoke(default); + + if (parameter == null || parameter is T?) + return _canExecute.Invoke((T?)parameter); + + return false; + } + + public void Execute(object? parameter) + { + _execute?.Invoke((T?)parameter); + } + } +} diff --git a/MMExNotifier/MVVM/ViewModelBase.cs b/MMExNotifier/MVVM/ViewModelBase.cs new file mode 100644 index 0000000..0c2b581 --- /dev/null +++ b/MMExNotifier/MVVM/ViewModelBase.cs @@ -0,0 +1,30 @@ +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace MMExNotifier.MVVM +{ + internal abstract class ViewModelBase : INotifyPropertyChanged + { + public event EventHandler? OnClose; + public event EventHandler? OnOpen; + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + protected virtual void Close() + { + OnClose?.Invoke(this, EventArgs.Empty); + } + + protected virtual void Open() + { + OnOpen?.Invoke(this, EventArgs.Empty); + } + + public abstract void Activate(); + } +} diff --git a/MMExNotifier/ViewModels/MainViewModel.cs b/MMExNotifier/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..01b0235 --- /dev/null +++ b/MMExNotifier/ViewModels/MainViewModel.cs @@ -0,0 +1,71 @@ +using MMExNotifier.Database; +using MMExNotifier.DataModel; +using MMExNotifier.Helpers; +using MMExNotifier.MVVM; +using System; + +namespace MMExNotifier.ViewModels +{ + internal class MainViewModel : ViewModelBase + { + private readonly INotificationService _notificationService; + private readonly IDatabaseService _database; + + public RangeObservableCollection ExpiringBills { get; set; } = new RangeObservableCollection(); + public IAppConfiguration AppSettings { get; private set; } + public RelayCommand SaveSettingsCommand { get; private set; } + + public MainViewModel(IAppConfiguration appSettings, INotificationService notificationService, IDatabaseService databaseService) + { + _notificationService = notificationService; + _database = databaseService; + + AppSettings = appSettings; + OnPropertyChanged(nameof(AppSettings)); + + SaveSettingsCommand = new(() => SaveSettings()); + } + + public override void Activate() + { + if (string.IsNullOrEmpty(AppSettings.MMExDatabasePath)) + return; + + LoadExpiringBills(); + + if (ExpiringBills.Count == 0) + { + Close(); + } + + const int ConversationId = 9813; + _notificationService.ShowToastNotification("viewTransactions", ConversationId, "MMExNotifier", "One ore more recurring transaction are about to expire.", () => Open()); + } + + private void LoadExpiringBills() + { + try + { + ExpiringBills.Clear(); + var expiringBills = _database.ExpiringBills; + if (expiringBills != null) + { + ExpiringBills.AddRange(expiringBills); + } + } + catch (Exception) + { + _notificationService.ShowErrorNotification("An error has occurred while loading the recurring transactions.\nMake sure that the database version matches the supported version."); + } + } + + private void SaveSettings() + { + AppSettings.Save(); + + LoadExpiringBills(); + } + + } +} + diff --git a/MMExNotifier/MainWindow.xaml b/MMExNotifier/Views/MainWindow.xaml similarity index 88% rename from MMExNotifier/MainWindow.xaml rename to MMExNotifier/Views/MainWindow.xaml index 9c001ba..d57e9f0 100644 --- a/MMExNotifier/MainWindow.xaml +++ b/MMExNotifier/Views/MainWindow.xaml @@ -4,9 +4,13 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:MMExNotifier" + xmlns:mvvm="clr-namespace:MMExNotifier.MVVM" + xmlns:viewmodels="clr-namespace:MMExNotifier.ViewModels" + d:DataContext="{d:DesignInstance Type=viewmodels:MainViewModel}" mc:Ignorable="d" WindowStartupLocation="Manual" - Visibility="Visible" + Visibility="Hidden" + d:Visibility="Visible" Title="MoneyManagerEx Notifier" Height="450" Width="800" AllowsTransparency="True" @@ -14,7 +18,7 @@ WindowStyle="None"> - + + + @@ -168,7 +183,7 @@