diff --git a/source/Gnomeshade.Avalonia.Core/Converters.cs b/source/Gnomeshade.Avalonia.Core/Converters.cs
index d88a99a83..7ca1c33ed 100644
--- a/source/Gnomeshade.Avalonia.Core/Converters.cs
+++ b/source/Gnomeshade.Avalonia.Core/Converters.cs
@@ -18,6 +18,9 @@ public static class Converters
/// Gets a / converter.
public static LocalDateTimeConverter LocalDateTime { get; } = new();
+ /// Gets a / converter.
+ public static PeriodConverter Period { get; } = new();
+
/// Gets a converter that checks whether the collection is not empty.
public static IValueConverter NotEmpty { get; } =
new FuncValueConverter(collection => collection?.Count > 0);
diff --git a/source/Gnomeshade.Avalonia.Core/DesignTime/DesignTimeData.cs b/source/Gnomeshade.Avalonia.Core/DesignTime/DesignTimeData.cs
index 6f06f11ca..2b256dde8 100644
--- a/source/Gnomeshade.Avalonia.Core/DesignTime/DesignTimeData.cs
+++ b/source/Gnomeshade.Avalonia.Core/DesignTime/DesignTimeData.cs
@@ -131,7 +131,7 @@ public static class DesignTimeData
/// Gets an instance of for use during design time.
public static TransactionViewModel TransactionViewModel { get; } =
- InitializeViewModel(new(ActivityService, GnomeshadeClient, DialogService, Clock, DateTimeZoneProvider));
+ InitializeViewModel(new(ActivityService, GnomeshadeClient, DialogService, Clock, DateTimeZoneProvider));
/// Gets an instance of for use during design time.
public static TransactionFilter TransactionFilter { get; } = new(ActivityService, Clock, DateTimeZoneProvider);
@@ -148,7 +148,7 @@ public static class DesignTimeData
public static LinkViewModel LinkViewModel { get; } =
InitializeViewModel(new(ActivityService, GnomeshadeClient, Guid.Empty));
- /// Gets an instance of for use during design time.
+ /// Gets an instance of for use during design time.
public static LoanPaymentUpsertionViewModel LoanPaymentUpsertionViewModel { get; } =
InitializeViewModel(new LoanPaymentUpsertionViewModel(ActivityService, GnomeshadeClient, Guid.Empty, null));
@@ -240,6 +240,42 @@ public static class DesignTimeData
public static DashboardViewModel DashboardViewModel { get; } =
InitializeViewModel(new(ActivityService, GnomeshadeClient, Clock, DateTimeZoneProvider));
+ /// Gets an instance of for use during design time.
+ public static PlannedTransactionUpsertionViewModel PlannedTransactionUpsertionViewModel { get; } =
+ InitializeViewModel(new(ActivityService, GnomeshadeClient, DialogService, DateTimeZoneProvider, null));
+
+ /// Gets an instance of for use during design time.
+ public static TransactionScheduleUpsertionViewModel TransactionScheduleUpsertionViewModel { get; } =
+ InitializeViewModel(new(ActivityService, GnomeshadeClient, DialogService, DateTimeZoneProvider, null));
+
+ /// Gets an instance of for use during design time.
+ public static TransactionScheduleViewModel TransactionScheduleViewModel { get; } =
+ InitializeViewModel(new(ActivityService, GnomeshadeClient, DialogService, DateTimeZoneProvider));
+
+ /// Gets an instance of for use during design time.
+ public static PlannedTransferViewModel PlannedTransferViewModel { get; } =
+ InitializeViewModel(new(ActivityService, GnomeshadeClient, DialogService, DateTimeZoneProvider, Guid.Empty));
+
+ /// Gets an instance of for use during design time.
+ public static PlannedTransferUpsertionViewModel PlannedTransferUpsertionViewModel { get; } =
+ InitializeViewModel(new(ActivityService, GnomeshadeClient, DialogService, DateTimeZoneProvider, Guid.Empty, null));
+
+ /// Gets an instance of for use during design time.
+ public static PlannedPurchaseViewModel PlannedPurchaseViewModel { get; } =
+ InitializeViewModel(new(ActivityService, GnomeshadeClient, DialogService, DateTimeZoneProvider, Guid.Empty));
+
+ /// Gets an instance of for use during design time.
+ public static PlannedPurchaseUpsertionViewModel PlannedPurchaseUpsertionViewModel { get; } =
+ InitializeViewModel(new(ActivityService, GnomeshadeClient, DialogService, DateTimeZoneProvider, Guid.Empty, null));
+
+ /// Gets an instance of for use during design time.
+ public static PlannedLoanPaymentUpsertionViewModel PlannedLoanPaymentUpsertionViewModel { get; } =
+ InitializeViewModel(new(ActivityService, GnomeshadeClient, Guid.Empty, null));
+
+ /// Gets an instance of for use during design time.
+ public static PlannedLoanPaymentViewModel PlannedLoanPaymentViewModel { get; } =
+ InitializeViewModel(new(ActivityService, GnomeshadeClient, Guid.Empty));
+
[UnconditionalSuppressMessage(
"Trimming",
"IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code",
diff --git a/source/Gnomeshade.Avalonia.Core/DesignTime/DesignTimeGnomeshadeClient.cs b/source/Gnomeshade.Avalonia.Core/DesignTime/DesignTimeGnomeshadeClient.cs
index 71665d400..a69c9f054 100644
--- a/source/Gnomeshade.Avalonia.Core/DesignTime/DesignTimeGnomeshadeClient.cs
+++ b/source/Gnomeshade.Avalonia.Core/DesignTime/DesignTimeGnomeshadeClient.cs
@@ -47,6 +47,12 @@ public sealed class DesignTimeGnomeshadeClient : IGnomeshadeClient
private static readonly List _loanPayments;
private static readonly List _projects;
+ private static readonly List _transactionSchedules;
+ private static readonly List _plannedTransactions;
+ private static readonly List _plannedTransfers;
+ private static readonly List _plannedPurchases;
+ private static readonly List _plannedLoanPayments;
+
static DesignTimeGnomeshadeClient()
{
var euro = new Currency { Id = Guid.NewGuid(), Name = "Euro", AlphabeticCode = "EUR" };
@@ -55,7 +61,8 @@ static DesignTimeGnomeshadeClient()
var counterparty = new Counterparty { Id = Guid.Empty, Name = "John Doe" };
var otherCounterparty = new Counterparty { Id = Guid.NewGuid(), Name = "Jane Doe" };
- _counterparties = [counterparty, otherCounterparty];
+ var bankCounterparty = new Counterparty { Id = Guid.NewGuid(), Name = "Bank" };
+ _counterparties = [counterparty, otherCounterparty, bankCounterparty];
var cash = new Account
{
@@ -69,27 +76,39 @@ static DesignTimeGnomeshadeClient()
new() { Id = Guid.NewGuid(), CurrencyId = usd.Id, CurrencyAlphabeticCode = usd.AlphabeticCode }
],
};
+
var spending = new Account
{
Id = Guid.NewGuid(),
Name = "Spending",
CounterpartyId = counterparty.Id,
PreferredCurrencyId = euro.Id,
- Currencies =
- [new() { Id = Guid.NewGuid(), CurrencyId = euro.Id, CurrencyAlphabeticCode = euro.AlphabeticCode }],
+ Currencies = [new() { Id = Guid.NewGuid(), CurrencyId = euro.Id, CurrencyAlphabeticCode = euro.AlphabeticCode }],
+ };
+
+ var bankAccount = new Account
+ {
+ Id = Guid.NewGuid(),
+ Name = "Bank",
+ CounterpartyId = bankCounterparty.Id,
+ PreferredCurrencyId = euro.Id,
+ Currencies = [new() { Id = Guid.NewGuid(), CurrencyId = euro.Id, CurrencyAlphabeticCode = euro.AlphabeticCode }],
};
- _accounts = [cash, spending];
+
+ _accounts = [cash, spending, bankAccount];
var kilogram = new Unit { Id = Guid.NewGuid(), Name = "Kilogram" };
var gram = new Unit { Id = Guid.NewGuid(), Name = "Gram", ParentUnitId = kilogram.Id, Multiplier = 1000m };
_units = [kilogram, gram];
var food = new Category { Id = Guid.Empty, Name = "Food" };
- _categories = [food];
+ var liabilities = new Category { Id = Guid.NewGuid(), Name = "Liabilities" };
+ _categories = [food, liabilities];
var bread = new Product { Id = Guid.NewGuid(), Name = "Bread", CategoryId = food.Id, UnitId = kilogram.Id };
var milk = new Product { Id = Guid.NewGuid(), Name = "Milk", CategoryId = food.Id };
- _products = [bread, milk];
+ var loan = new Product { Id = Guid.NewGuid(), Name = "Loan", CategoryId = liabilities.Id };
+ _products = [bread, milk, loan];
var transaction = new Transaction
{
@@ -217,10 +236,94 @@ static DesignTimeGnomeshadeClient()
Name = "Home improvement",
},
];
+
+ var transactionSchedule = new TransactionSchedule
+ {
+ Id = Guid.Empty,
+ Name = "Monthly",
+ StartingAt = SystemClock.Instance.GetCurrentInstant() + Duration.FromDays(15),
+ Period = Period.FromMonths(1),
+ Count = 12,
+ };
+
+ _transactionSchedules = [transactionSchedule];
+ _plannedTransactions = [];
+ _plannedTransfers = [];
+ _plannedPurchases = [];
+ _plannedLoanPayments = [];
+
+ var timeZone = DateTimeZoneProviders.Tzdb.GetSystemDefault();
+ for (var i = 0; i < transactionSchedule.Count; i++)
+ {
+ var startingAt = transactionSchedule.StartingAt.InZone(timeZone).LocalDateTime;
+ for (var j = 0; j < i; j++)
+ {
+ startingAt += transactionSchedule.Period;
+ }
+
+ var bookedAt = startingAt.InZoneStrictly(timeZone).ToInstant();
+
+ var plannedTransaction = new PlannedTransaction
+ {
+ Id = i is 0 ? Guid.Empty : Guid.NewGuid(),
+ ScheduleId = transactionSchedule.Id,
+ };
+
+ var plannedPrincipalTransfer = new PlannedTransfer
+ {
+ Id = i is 0 ? Guid.Empty : Guid.NewGuid(),
+ TransactionId = plannedTransaction.Id,
+ SourceAmount = 500,
+ SourceAccountId = spending.Currencies.Single(account => account.CurrencyId == euro.Id).Id,
+ TargetAmount = 500,
+ TargetCounterpartyId = bankCounterparty.Id,
+ TargetCurrencyId = euro.Id,
+ BookedAt = bookedAt,
+ Order = 1,
+ };
+
+ var plannedInterestTransfer = new PlannedTransfer
+ {
+ Id = Guid.NewGuid(),
+ TransactionId = plannedTransaction.Id,
+ SourceAmount = 150,
+ SourceAccountId = spending.Currencies.Single(account => account.CurrencyId == euro.Id).Id,
+ TargetAmount = 150,
+ TargetCounterpartyId = bankCounterparty.Id,
+ TargetCurrencyId = euro.Id,
+ BookedAt = bookedAt,
+ Order = 2,
+ };
+
+ var plannedPurchase = new PlannedPurchase
+ {
+ Id = i is 0 ? Guid.Empty : Guid.NewGuid(),
+ TransactionId = plannedTransaction.Id,
+ Price = plannedPrincipalTransfer.SourceAmount + plannedInterestTransfer.SourceAmount,
+ CurrencyId = euro.Id,
+ ProductId = loan.Id,
+ Amount = 1,
+ ProjectIds = [],
+ };
+
+ var plannedLoanPayment = new PlannedLoanPayment
+ {
+ Id = i is 0 ? Guid.Empty : Guid.NewGuid(),
+ LoanId = _loans.Single().Id,
+ TransactionId = plannedTransaction.Id,
+ Amount = plannedPrincipalTransfer.SourceAmount,
+ Interest = plannedInterestTransfer.SourceAmount,
+ };
+
+ _plannedTransactions.Add(plannedTransaction);
+ _plannedTransfers.AddRange([plannedPrincipalTransfer, plannedInterestTransfer]);
+ _plannedPurchases.Add(plannedPurchase);
+ _plannedLoanPayments.Add(plannedLoanPayment);
+ }
}
///
- public Task LogInAsync(Login login) => throw new NotImplementedException();
+ public Task LogInAsync(Login login) => Task.FromResult(new SuccessfulLogin());
///
public Task SocialRegister() => throw new NotImplementedException();
@@ -486,6 +589,147 @@ public Task AddRelatedTransactionAsync(Guid id, Guid relatedId) =>
public Task RemoveRelatedTransactionAsync(Guid id, Guid relatedId) =>
throw new NotImplementedException();
+ ///
+ public Task> GetTransactionSchedules(CancellationToken cancellationToken = default) =>
+ Task.FromResult(_transactionSchedules.ToList());
+
+ ///
+ public Task GetTransactionSchedule(Guid id, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_transactionSchedules.Single(schedule => schedule.Id == id));
+
+ ///
+ public async Task CreateTransactionSchedule(TransactionScheduleCreation schedule)
+ {
+ var id = Guid.NewGuid();
+ await PutTransactionSchedule(id, schedule);
+ return id;
+ }
+
+ ///
+ public Task PutTransactionSchedule(Guid id, TransactionScheduleCreation schedule)
+ {
+ var existing = _transactionSchedules.SingleOrDefault(transactionSchedule => transactionSchedule.Id == id);
+ existing ??= new();
+
+ existing.Name = schedule.Name;
+ existing.StartingAt = schedule.StartingAt;
+ existing.Period = schedule.Period;
+ existing.Count = schedule.Count;
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async Task DeleteTransactionSchedule(Guid id)
+ {
+ var schedule = await GetTransactionSchedule(id);
+ _transactionSchedules.Remove(schedule);
+ }
+
+ ///
+ public Task> GetPlannedTransactions(Interval interval, CancellationToken cancellationToken = default)
+ {
+ var transactions = _plannedTransactions
+ .Where(transaction =>
+ {
+ var date = _plannedTransfers
+ .Where(transfer => transfer.TransactionId == transaction.Id)
+ .Select(transfer => transfer.BookedAt)
+ .Max();
+
+ return date is { } instant && interval.Contains(instant);
+ })
+ .ToList();
+
+ return Task.FromResult(transactions);
+ }
+
+ ///
+ public Task> GetPlannedTransactions(CancellationToken cancellationToken = default) =>
+ Task.FromResult(_plannedTransactions.ToList());
+
+ ///
+ public Task> GetPlannedTransactions(
+ Guid scheduleId,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(_plannedTransactions.Where(transaction => transaction.ScheduleId == scheduleId).ToList());
+
+ ///
+ public Task GetPlannedTransaction(Guid id, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_plannedTransactions.Single(transaction => transaction.Id == id));
+
+ ///
+ public Task CreatePlannedTransaction(PlannedTransactionCreation transaction) => throw new NotImplementedException();
+
+ ///
+ public Task PutPlannedTransaction(Guid id, PlannedTransactionCreation transaction) => throw new NotImplementedException();
+
+ ///
+ public Task DeletePlannedTransaction(Guid id) => throw new NotImplementedException();
+
+ ///
+ public Task> GetPlannedTransfers(CancellationToken cancellationToken = default) =>
+ Task.FromResult(_plannedTransfers.ToList());
+
+ ///
+ public Task> GetPlannedTransfers(Guid transactionId, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_plannedTransfers.Where(transfer => transfer.TransactionId == transactionId).ToList());
+
+ ///
+ public Task GetPlannedTransfer(Guid id, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_plannedTransfers.Single(transfer => transfer.Id == id));
+
+ ///
+ public Task CreatePlannedTransfer(PlannedTransferCreation transfer) => throw new NotImplementedException();
+
+ ///
+ public Task PutPlannedTransfer(Guid id, PlannedTransferCreation transfer) => throw new NotImplementedException();
+
+ ///
+ public Task DeletePlannedTransfer(Guid id) => throw new NotImplementedException();
+
+ ///
+ public Task> GetPlannedPurchases(CancellationToken cancellationToken = default) =>
+ Task.FromResult(_plannedPurchases.ToList());
+
+ ///
+ public Task> GetPlannedPurchases(Guid transactionId, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_plannedPurchases.Where(transfer => transfer.TransactionId == transactionId).ToList());
+
+ ///
+ public Task GetPlannedPurchase(Guid id, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_plannedPurchases.Single(purchase => purchase.Id == id));
+
+ ///
+ public Task CreatePlannedPurchase(PlannedPurchaseCreation purchase) => throw new NotImplementedException();
+
+ ///
+ public Task PutPlannedPurchase(Guid id, PlannedPurchaseCreation purchase) => throw new NotImplementedException();
+
+ ///
+ public Task DeletePlannedPurchase(Guid id) => throw new NotImplementedException();
+
+ ///
+ public Task> GetPlannedLoanPayments(CancellationToken cancellationToken = default) =>
+ Task.FromResult(_plannedLoanPayments.ToList());
+
+ ///
+ public Task> GetPlannedLoanPayments(Guid transactionId, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_plannedLoanPayments.Where(transfer => transfer.TransactionId == transactionId).ToList());
+
+ ///
+ public Task GetPlannedLoanPayment(Guid id, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_plannedLoanPayments.Single(payment => payment.Id == id));
+
+ ///
+ public Task CreatePlannedLoanPayment(LoanPaymentCreation loanPayment) => throw new NotImplementedException();
+
+ ///
+ public Task PutPlannedLoanPayment(Guid id, LoanPaymentCreation transfer) => throw new NotImplementedException();
+
+ ///
+ public Task DeletePlannedLoanPayment(Guid id) => throw new NotImplementedException();
+
///
[Obsolete]
public Task> GetLegacyLoans(CancellationToken cancellationToken = default) =>
diff --git a/source/Gnomeshade.Avalonia.Core/Loans/LoanPaymentRow.cs b/source/Gnomeshade.Avalonia.Core/Loans/LoanPaymentRow.cs
index cddb9cfa7..3fdbc43ab 100644
--- a/source/Gnomeshade.Avalonia.Core/Loans/LoanPaymentRow.cs
+++ b/source/Gnomeshade.Avalonia.Core/Loans/LoanPaymentRow.cs
@@ -18,7 +18,7 @@ public sealed class LoanPaymentRow : PropertyChangedBase
/// The payment which this row will represent.
/// All available loans.
/// All available currencies.
- public LoanPaymentRow(LoanPayment payment, IEnumerable loans, IEnumerable currencies)
+ public LoanPaymentRow(LoanPaymentBase payment, IEnumerable loans, IEnumerable currencies)
{
var loan = loans.Single(loan => loan.Id == payment.LoanId);
Loan = loan.Name;
diff --git a/source/Gnomeshade.Avalonia.Core/LocalDateConverter.cs b/source/Gnomeshade.Avalonia.Core/LocalDateConverter.cs
index 270e9fb02..cfe1d7312 100644
--- a/source/Gnomeshade.Avalonia.Core/LocalDateConverter.cs
+++ b/source/Gnomeshade.Avalonia.Core/LocalDateConverter.cs
@@ -10,7 +10,7 @@
namespace Gnomeshade.Avalonia.Core;
/// Converts a binding value of type .
-public sealed class LocalDateConverter : NodaTimeValueConverter
+public sealed class LocalDateConverter : NodaTimeValueStructConverter
{
///
protected override LocalDate TemplateValue { get; } = new(2000, 12, 31);
diff --git a/source/Gnomeshade.Avalonia.Core/LocalDateTimeConverter.cs b/source/Gnomeshade.Avalonia.Core/LocalDateTimeConverter.cs
index a205ce7c7..c207f1831 100644
--- a/source/Gnomeshade.Avalonia.Core/LocalDateTimeConverter.cs
+++ b/source/Gnomeshade.Avalonia.Core/LocalDateTimeConverter.cs
@@ -10,7 +10,7 @@
namespace Gnomeshade.Avalonia.Core;
/// Converts a binding value of type .
-public sealed class LocalDateTimeConverter : NodaTimeValueConverter
+public sealed class LocalDateTimeConverter : NodaTimeValueStructConverter
{
///
protected override LocalDateTime TemplateValue { get; } = new(2000, 12, 31, 13, 20);
diff --git a/source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs b/source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs
index 63f5ac9de..01fb78e66 100644
--- a/source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs
+++ b/source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs
@@ -79,6 +79,7 @@ public MainWindowViewModel(IServiceProvider serviceProvider)
About = activityService.Create(ShowAboutWindow, "Waiting for About window to be closed");
License = activityService.Create(ShowLicenseWindow, "Waiting for License window to be closed");
NavigateBack = activityService.Create(NavigateBackAsync, () => _navigationHistory.Count is not 0, "Navigating back");
+ TransactionSchedules = activityService.Create(SwitchTo, "Switching to transaction schedules");
PropertyChanging += OnPropertyChanging;
}
@@ -95,6 +96,9 @@ public MainWindowViewModel(IServiceProvider serviceProvider)
/// Gets a command for switching to the previous .
public CommandBase NavigateBack { get; }
+ /// Gets a command for switching the to .
+ public CommandBase TransactionSchedules { get; }
+
/// Gets a value indicating whether it's possible to log out.
public bool CanLogOut => ActiveView is not null and not LoginViewModel and not ConfigurationWizardViewModel;
@@ -241,6 +245,7 @@ private static Exception CurrentLifetimeIsNull()
private async Task InitializeActiveViewAsync()
{
// The first notification does not show up, and subsequent calls work after some delay
+ // todo if the app starts too fast the first notification still does not show up
ActivityService.ShowNotification(new(null, null, expiration: TimeSpan.FromMilliseconds(1)));
if (ActiveView is not null)
diff --git a/source/Gnomeshade.Avalonia.Core/NodaTimeValueClassConverter.cs b/source/Gnomeshade.Avalonia.Core/NodaTimeValueClassConverter.cs
new file mode 100644
index 000000000..710b6b442
--- /dev/null
+++ b/source/Gnomeshade.Avalonia.Core/NodaTimeValueClassConverter.cs
@@ -0,0 +1,77 @@
+// Copyright 2021 Valters Melnalksnis
+// Licensed under the GNU Affero General Public License v3.0 or later.
+// See LICENSE.txt file in the project root for full license information.
+
+using System;
+using System.Globalization;
+
+using Avalonia;
+using Avalonia.Data;
+
+using NodaTime.Text;
+
+namespace Gnomeshade.Avalonia.Core;
+
+///
+public abstract class NodaTimeValueClassConverter : NodaTimeValueConverterBase
+ where TTime : class
+{
+ /// Gets a value to use for displaying example value on validation error.
+ protected abstract TTime TemplateValue { get; }
+
+ ///
+ public override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is null)
+ {
+ return null;
+ }
+
+ if (!targetType.IsAssignableTo(typeof(string)) || value is not TTime time)
+ {
+ return InvalidCastNotification;
+ }
+
+ return GetPattern(culture).Format(time);
+ }
+
+ ///
+ public override object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is UnsetValueType or null)
+ {
+ return null;
+ }
+
+ if (value is not string text)
+ {
+ return InvalidCastNotification;
+ }
+
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return null;
+ }
+
+ if (!targetType.IsAssignableTo(typeof(TTime)))
+ {
+ return InvalidCastNotification;
+ }
+
+ var parseResult = GetPattern(culture).Parse(text);
+ if (parseResult.Success)
+ {
+ return parseResult.Value;
+ }
+
+ var exception = new DataValidationException($"Expected format is {GetPatternText(culture)}");
+ return new BindingNotification(exception, BindingErrorType.DataValidationError);
+ }
+
+ /// Gets the pattern using which will be convert to and from text.
+ /// The culture to use.
+ /// A pattern that can convert to and form text.
+ protected abstract IPattern GetPattern(CultureInfo culture);
+
+ private string GetPatternText(CultureInfo culture) => GetPattern(culture).Format(TemplateValue);
+}
diff --git a/source/Gnomeshade.Avalonia.Core/NodaTimeValueConverter.cs b/source/Gnomeshade.Avalonia.Core/NodaTimeValueStructConverter.cs
similarity index 96%
rename from source/Gnomeshade.Avalonia.Core/NodaTimeValueConverter.cs
rename to source/Gnomeshade.Avalonia.Core/NodaTimeValueStructConverter.cs
index 3f09a783b..65f0046e1 100644
--- a/source/Gnomeshade.Avalonia.Core/NodaTimeValueConverter.cs
+++ b/source/Gnomeshade.Avalonia.Core/NodaTimeValueStructConverter.cs
@@ -13,7 +13,7 @@
namespace Gnomeshade.Avalonia.Core;
///
-public abstract class NodaTimeValueConverter : NodaTimeValueConverterBase
+public abstract class NodaTimeValueStructConverter : NodaTimeValueConverterBase
where TTime : struct
{
/// Gets a value to use for displaying example value on validation error.
diff --git a/source/Gnomeshade.Avalonia.Core/PeriodConverter.cs b/source/Gnomeshade.Avalonia.Core/PeriodConverter.cs
new file mode 100644
index 000000000..0a095a3e9
--- /dev/null
+++ b/source/Gnomeshade.Avalonia.Core/PeriodConverter.cs
@@ -0,0 +1,22 @@
+// Copyright 2021 Valters Melnalksnis
+// Licensed under the GNU Affero General Public License v3.0 or later.
+// See LICENSE.txt file in the project root for full license information.
+
+using System.Globalization;
+
+using NodaTime;
+using NodaTime.Text;
+
+namespace Gnomeshade.Avalonia.Core;
+
+/// Converts a binding value of type .
+public sealed class PeriodConverter : NodaTimeValueClassConverter
+{
+ ///
+ protected override Period TemplateValue { get; } =
+ Period.FromYears(1) + Period.FromMonths(6) + Period.FromWeeks(2) + Period.FromDays(5);
+
+ ///
+ protected override PeriodPattern GetPattern(CultureInfo culture) =>
+ PeriodPattern.NormalizingIso;
+}
diff --git a/source/Gnomeshade.Avalonia.Core/Reports/BalanceReportViewModel.cs b/source/Gnomeshade.Avalonia.Core/Reports/BalanceReportViewModel.cs
index 7553b5410..261b17a67 100644
--- a/source/Gnomeshade.Avalonia.Core/Reports/BalanceReportViewModel.cs
+++ b/source/Gnomeshade.Avalonia.Core/Reports/BalanceReportViewModel.cs
@@ -7,11 +7,13 @@
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using Gnomeshade.Avalonia.Core.Reports.Splits;
using Gnomeshade.WebApi.Client;
using Gnomeshade.WebApi.Models.Accounts;
+using Gnomeshade.WebApi.Models.Transactions;
using LiveChartsCore.Defaults;
using LiveChartsCore.Kernel.Sketches;
@@ -54,6 +56,10 @@ public sealed partial class BalanceReportViewModel : ViewModelBase
[Notify]
private IReportSplit? _selectedSplit = SplitProvider.MonthlySplit;
+ /// Gets or sets a value indicating whether to include projections in the report.
+ [Notify]
+ private bool _includeProjections = true;
+
/// Gets the y axes for .
[Notify(Setter.Private)]
private List _yAxes;
@@ -103,11 +109,16 @@ public void ResetZoom()
///
protected override async Task Refresh()
{
- var transfersTask = _gnomeshadeClient.GetTransfersAsync();
+ var cancellation = new CancellationTokenSource();
+ var task = (
+ _gnomeshadeClient.GetTransfersAsync(cancellation.Token),
+ _gnomeshadeClient.GetPlannedTransfers(cancellation.Token))
+ .WhenAll();
+
var (counterparty, allAccounts, currencies) = await
- (_gnomeshadeClient.GetMyCounterpartyAsync(),
- _gnomeshadeClient.GetAccountsAsync(),
- _gnomeshadeClient.GetCurrenciesAsync())
+ (_gnomeshadeClient.GetMyCounterpartyAsync(cancellation.Token),
+ _gnomeshadeClient.GetAccountsAsync(cancellation.Token),
+ _gnomeshadeClient.GetCurrenciesAsync(cancellation.Token))
.WhenAll();
var selected = SelectedAccounts.Select(account => account.Id).ToArray();
@@ -123,6 +134,7 @@ protected override async Task Refresh()
if (SelectedSplit is not { } reportSplit)
{
+ await cancellation.CancelAsync();
return;
}
@@ -132,7 +144,22 @@ protected override async Task Refresh()
.SelectMany(account => account.Currencies.Where(aic => aic.CurrencyId == (SelectedCurrency?.Id ?? account.PreferredCurrencyId)).Select(aic => aic.Id))
.ToArray();
- var transfers = (await transfersTask)
+ var (actualTransfers, plannedTransfers) = await task;
+ var timeZone = _dateTimeZoneProvider.GetSystemDefault();
+
+ IEnumerable allTransfers = actualTransfers;
+ if (IncludeProjections)
+ {
+ var filtered = plannedTransfers
+ .Select(PlannedTransferSelector)
+ .Where(tuple =>
+ (tuple.Planned.SourceAccountId is { } sourceId && inCurrencyIds.Contains(sourceId)) ||
+ (tuple.Planned.TargetAccountId is { } targetId && inCurrencyIds.Contains(targetId)))
+ .Select(tuple => tuple.Transfer);
+ allTransfers = allTransfers.Concat(filtered);
+ }
+
+ var transfers = allTransfers
.Where(transfer =>
inCurrencyIds.Contains(transfer.SourceAccountId) ||
inCurrencyIds.Contains(transfer.TargetAccountId))
@@ -141,7 +168,6 @@ protected override async Task Refresh()
.ThenBy(transfer => transfer.ModifiedAt)
.ToArray();
- var timeZone = _dateTimeZoneProvider.GetSystemDefault();
var currentTime = _clock.GetCurrentInstant();
var dates = transfers
.Select(transfer => transfer.ValuedAt ?? transfer.BookedAt!.Value)
@@ -185,6 +211,32 @@ protected override async Task Refresh()
XAxes = [reportSplit.GetXAxis(startTime, endTime)];
}
+ private static (PlannedTransfer Planned, Transfer Transfer) PlannedTransferSelector(PlannedTransfer plannedTransfer)
+ {
+ var transfer = new Transfer
+ {
+ Id = plannedTransfer.Id,
+ CreatedAt = plannedTransfer.CreatedAt,
+ OwnerId = plannedTransfer.OwnerId,
+ CreatedByUserId = plannedTransfer.CreatedByUserId,
+ ModifiedAt = plannedTransfer.ModifiedAt,
+ ModifiedByUserId = plannedTransfer.ModifiedByUserId,
+ TransactionId = plannedTransfer.TransactionId,
+ SourceAmount = plannedTransfer.SourceAmount,
+ SourceAccountId = plannedTransfer.SourceAccountId ?? default,
+ TargetAmount = plannedTransfer.TargetAmount,
+ TargetAccountId = plannedTransfer.TargetAccountId ?? default,
+ BankReference = null,
+ ExternalReference = null,
+ InternalReference = null,
+ Order = plannedTransfer.Order,
+ BookedAt = plannedTransfer.BookedAt,
+ ValuedAt = null,
+ };
+
+ return (plannedTransfer, transfer);
+ }
+
private void OnPropertyChanging(object? sender, PropertyChangingEventArgs e)
{
if (e.PropertyName is nameof(SelectedAccounts))
@@ -205,6 +257,11 @@ private async void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
await RefreshAsync();
}
+ if (e.PropertyName is nameof(IncludeProjections))
+ {
+ await RefreshAsync();
+ }
+
if (!IsBusy && e.PropertyName is nameof(SelectedCurrency))
{
await RefreshAsync();
diff --git a/source/Gnomeshade.Avalonia.Core/Reports/CategoryReportViewModel.cs b/source/Gnomeshade.Avalonia.Core/Reports/CategoryReportViewModel.cs
index 9e3ed9cf3..70650a921 100644
--- a/source/Gnomeshade.Avalonia.Core/Reports/CategoryReportViewModel.cs
+++ b/source/Gnomeshade.Avalonia.Core/Reports/CategoryReportViewModel.cs
@@ -44,6 +44,10 @@ public sealed partial class CategoryReportViewModel : ViewModelBase
[Notify]
private IReportSplit? _selectedSplit = SplitProvider.MonthlySplit;
+ /// Gets or sets a value indicating whether to include projections in the report.
+ [Notify]
+ private bool _includeProjections = true;
+
/// Gets the data series of amount spent per month per category.
[Notify(Setter.Private)]
private List> _series = [];
@@ -86,9 +90,14 @@ protected override async Task Refresh()
return;
}
- var accounts = await _gnomeshadeClient.GetAccountsAsync();
+ var (accounts, allTransactions, plannedTransactions, products) = await
+ (_gnomeshadeClient.GetAccountsAsync(),
+ _gnomeshadeClient.GetDetailedTransactionsAsync(new(Instant.MinValue, Instant.MaxValue)),
+ _gnomeshadeClient.GetPlannedTransactions(new Interval(Instant.MinValue, Instant.MaxValue)),
+ _gnomeshadeClient.GetProductsAsync())
+ .WhenAll();
+
var accountsInCurrency = accounts.SelectMany(account => account.Currencies).ToArray();
- var allTransactions = await _gnomeshadeClient.GetDetailedTransactionsAsync(new(Instant.MinValue, Instant.MaxValue));
var displayableTransactions = allTransactions
.Select(transaction => transaction with { TransferBalance = -transaction.TransferBalance })
.Where(transaction => transaction.TransferBalance > 0)
@@ -96,6 +105,12 @@ protected override async Task Refresh()
var timeZone = _dateTimeZoneProvider.GetSystemDefault();
+ if (IncludeProjections)
+ {
+ var planned = await Task.WhenAll(plannedTransactions.Select(Selector));
+ displayableTransactions = displayableTransactions.Concat(planned).ToArray();
+ }
+
var transactions = new TransactionData[displayableTransactions.Length];
for (var i = 0; i < displayableTransactions.Length; i++)
{
@@ -133,11 +148,9 @@ protected override async Task Refresh()
var endTime = new ZonedDateTime(dates.Max(), timeZone);
var splits = reportSplit.GetSplits(startTime, endTime).ToArray();
- var products = await _gnomeshadeClient.GetProductsAsync();
- var categories = await _gnomeshadeClient.GetCategoriesAsync();
- var nodes = categories
+ var nodes = Categories
.Where(category => category.CategoryId == SelectedCategory?.Id || category.Id == SelectedCategory?.Id)
- .Select(category => CategoryNode.FromCategory(category, categories))
+ .Select(category => CategoryNode.FromCategory(category, Categories))
.ToList();
var uncategorizedTransfers = transactions
@@ -185,19 +198,15 @@ protected override async Task Refresh()
for (var purchaseIndex = 0; purchaseIndex < purchasesToSum.Length; purchaseIndex++)
{
var purchase = purchasesToSum[purchaseIndex];
- var sourceCurrencyIds = purchase.SourceCurrencyIds;
- var targetCurrencyIds = purchase.TargetCurrencyIds;
- if (sourceCurrencyIds.Length is not 1 || targetCurrencyIds.Length is not 1)
+ if (purchase.SourceCurrencyIds is not [var sourceCurrency] ||
+ purchase.TargetCurrencyIds is not [var targetCurrency])
{
// todo cannot handle multiple currencies (#686)
sum += purchase.Purchase.Price;
continue;
}
- var sourceCurrency = sourceCurrencyIds.Single();
- var targetCurrency = targetCurrencyIds.Single();
-
if (sourceCurrency == targetCurrency)
{
sum += purchase.Purchase.Price;
@@ -219,12 +228,85 @@ protected override async Task Refresh()
XAxes = [reportSplit.GetXAxis(startTime, endTime)];
}
+ private async Task Selector(PlannedTransaction transaction)
+ {
+ var plannedTransfers = await _gnomeshadeClient.GetPlannedTransfers(transaction.Id);
+ var plannedPurchases = await _gnomeshadeClient.GetPlannedPurchases(transaction.Id);
+ var startTime = plannedTransfers.Select(transfer => transfer.BookedAt).Max()!.Value;
+
+ var transfers = plannedTransfers.Select(plannedTransfer => new Transfer
+ {
+ Id = plannedTransfer.Id,
+ CreatedAt = plannedTransfer.CreatedAt,
+ OwnerId = plannedTransfer.OwnerId,
+ CreatedByUserId = plannedTransfer.CreatedByUserId,
+ ModifiedAt = plannedTransfer.ModifiedAt,
+ ModifiedByUserId = plannedTransfer.ModifiedByUserId,
+ TransactionId = plannedTransfer.TransactionId,
+ SourceAmount = plannedTransfer.SourceAmount,
+ SourceAccountId = plannedTransfer.SourceAccountId ?? default,
+ TargetAmount = plannedTransfer.TargetAmount,
+ TargetAccountId = plannedTransfer.TargetAccountId ?? default,
+ BankReference = null,
+ ExternalReference = null,
+ InternalReference = null,
+ Order = plannedTransfer.Order,
+ BookedAt = startTime,
+ ValuedAt = null,
+ }).ToList();
+
+ var purchases = plannedPurchases.Select(plannedPurchase => new Purchase
+ {
+ Id = plannedPurchase.Id,
+ CreatedAt = plannedPurchase.CreatedAt,
+ OwnerId = plannedPurchase.OwnerId,
+ CreatedByUserId = plannedPurchase.CreatedByUserId,
+ ModifiedAt = plannedPurchase.ModifiedAt,
+ ModifiedByUserId = plannedPurchase.ModifiedByUserId,
+ TransactionId = plannedPurchase.TransactionId,
+ Price = plannedPurchase.Price,
+ CurrencyId = plannedPurchase.CurrencyId,
+ ProductId = plannedPurchase.ProductId,
+ Amount = plannedPurchase.Amount,
+ DeliveryDate = null,
+ Order = plannedPurchase.Order,
+ }).ToList();
+
+ return new()
+ {
+ Id = transaction.Id,
+ OwnerId = transaction.OwnerId,
+ CreatedAt = transaction.CreatedAt,
+ CreatedByUserId = transaction.CreatedByUserId,
+ ModifiedAt = transaction.ModifiedAt,
+ ModifiedByUserId = transaction.ModifiedByUserId,
+ Description = null,
+ ImportedAt = null,
+ ReconciledAt = null,
+ RefundedBy = null,
+ BookedAt = startTime,
+ ValuedAt = null,
+ Transfers = transfers,
+ TransferBalance = transfers.Select(transfer => transfer.SourceAmount).Sum(), // todo
+ Purchases = purchases,
+ PurchaseTotal = purchases.Select(purchase => purchase.Price).Sum(),
+ LoanPayments = [],
+ LoanTotal = 0,
+ Links = [],
+ };
+ }
+
private async void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(SelectedSplit) && SelectedSplit is not null)
{
await RefreshAsync();
}
+
+ if (e.PropertyName is nameof(IncludeProjections))
+ {
+ await RefreshAsync();
+ }
}
internal readonly struct PurchaseData(Purchase purchase, CategoryNode? node, TransactionData transaction)
diff --git a/source/Gnomeshade.Avalonia.Core/ServiceCollectionExtensions.cs b/source/Gnomeshade.Avalonia.Core/ServiceCollectionExtensions.cs
index a3f26acd3..2dee3dcec 100644
--- a/source/Gnomeshade.Avalonia.Core/ServiceCollectionExtensions.cs
+++ b/source/Gnomeshade.Avalonia.Core/ServiceCollectionExtensions.cs
@@ -56,5 +56,6 @@ public static IServiceCollection AddViewModels(this IServiceCollection serviceCo
.AddSingleton()
.AddSingleton()
.AddSingleton()
- .AddSingleton();
+ .AddSingleton()
+ .AddSingleton();
}
diff --git a/source/Gnomeshade.Avalonia.Core/Transactions/Controls/TransactionFilter.cs b/source/Gnomeshade.Avalonia.Core/Transactions/Controls/TransactionFilter.cs
index 54bb93d1e..bd45a0adc 100644
--- a/source/Gnomeshade.Avalonia.Core/Transactions/Controls/TransactionFilter.cs
+++ b/source/Gnomeshade.Avalonia.Core/Transactions/Controls/TransactionFilter.cs
@@ -108,6 +108,10 @@ public sealed partial class TransactionFilter : FilterBase
[Notify]
private string? _transferReferenceFilter;
+ /// Gets or sets a value indicating whether to include planned transactions.
+ [Notify]
+ private bool _includeProjections = true;
+
/// Initializes a new instance of the class.
/// Service for indicating the activity of the application to the user.
/// Clock which can provide the current instant.
diff --git a/source/Gnomeshade.Avalonia.Core/Transactions/Loans/PlannedLoanPaymentUpsertionViewModel.cs b/source/Gnomeshade.Avalonia.Core/Transactions/Loans/PlannedLoanPaymentUpsertionViewModel.cs
new file mode 100644
index 000000000..5f48c89fd
--- /dev/null
+++ b/source/Gnomeshade.Avalonia.Core/Transactions/Loans/PlannedLoanPaymentUpsertionViewModel.cs
@@ -0,0 +1,173 @@
+// Copyright 2021 Valters Melnalksnis
+// Licensed under the GNU Affero General Public License v3.0 or later.
+// See LICENSE.txt file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Controls;
+
+using Gnomeshade.WebApi.Client;
+using Gnomeshade.WebApi.Models.Accounts;
+using Gnomeshade.WebApi.Models.Loans;
+
+using PropertyChanged.SourceGenerator;
+
+namespace Gnomeshade.Avalonia.Core.Transactions.Loans;
+
+/// Creates or updates a single loan payment.
+public sealed partial class PlannedLoanPaymentUpsertionViewModel : UpsertionViewModel
+{
+ private readonly Guid _transactionId;
+
+ /// Gets all available loans.
+ [Notify(Setter.Private)]
+ private List _loans = [];
+
+ /// Gets or sets the loan that this payment is a part of.
+ [Notify]
+ private Loan? _loan;
+
+ /// Gets or sets the amount of the loan payment.
+ ///
+ [Notify]
+ private decimal? _amount;
+
+ /// Gets or sets the interest amount of this loan payment.
+ [Notify]
+ private decimal? _interest;
+
+ /// Initializes a new instance of the class.
+ /// Service for indicating the activity of the application to the user.
+ /// A strongly typed API client.
+ /// The id of the transaction to which to add the loan.
+ /// The id of the loan payment to edit.
+ public PlannedLoanPaymentUpsertionViewModel(
+ IActivityService activityService,
+ IGnomeshadeClient gnomeshadeClient,
+ Guid transactionId,
+ Guid? id)
+ : base(activityService, gnomeshadeClient)
+ {
+ _transactionId = transactionId;
+ Id = id;
+
+ PropertyChanged += OnPropertyChanged;
+ }
+
+ ///
+ public AutoCompleteSelector