Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/Design.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ The application automates mundane workflows like logo provisioning, smart catego
1. **Cross-Platform Mobile Component (.NET MAUI):**
* **Local State Caching:** Embedded `SQLite` context database providing sub-second runtime latency and offline access capabilities.
* **Renewal Reminders:** Local notifications scheduled on-device from the local mirror. The OS delivers them with the app closed, so no push service, vendor project or API key is involved.
* **Screens Load Once, Not Per Tab Switch:** Shell raises `OnAppearing` on every tab selection, so each page's `OnAppearing` calls `EnsureLoadedCommand` — a first-visit fetch, then nothing. Freshness comes from invalidation, not repetition: a write publishes `SubscriptionsChangedMessage`, signing out publishes `SessionEndedMessage`, and pull-to-refresh (`LoadCommand`) always fetches. A load that ended with nothing on screen is not "loaded", so an error screen still retries on the next visit.
2. **Microservice Backend API (ASP.NET Core):**
* **Authentication Matrix:** Secure stateless JWT (JSON Web Tokens) handling verification flows via industry-grade encryption frameworks.
* **Background Work:** Only what a client cannot do for itself — refreshing cached FX rates, syncing the provider catalog, and dispatching queued email. Nothing advances a billing date on a timer: that moves when the user marks a charge paid, so a date left in the past means the charge is genuinely outstanding.
Expand Down
49 changes: 48 additions & 1 deletion src/SubVora.Mobile/ViewModels/CategoriesViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Refit;
using SubVora.Mobile.Api;
using SubVora.Mobile.Api.Dtos;
using SubVora.Mobile.Messages;
using SubVora.Mobile.Services;

namespace SubVora.Mobile.ViewModels;
Expand All @@ -13,6 +15,15 @@ public partial class CategoriesViewModel : ObservableObject
private readonly ICategoriesApi _categoriesApi;
private readonly IConnectivityService _connectivity;
private readonly IUserPrompt _userPrompt;
private readonly IMessenger _messenger;

/// <summary>
/// Whether this screen already holds the category list. Shell raises OnAppearing on every tab
/// selection, so loading unconditionally there refetched on each tab tap - see
/// <c>SubscriptionListViewModel._isLoaded</c> for the full reasoning. Every mutation below
/// applies its own result locally, so nothing on this screen goes stale between visits.
/// </summary>
private bool _isLoaded;

[ObservableProperty]
public partial bool IsLoading { get; set; }
Expand All @@ -37,12 +48,38 @@ public partial class CategoriesViewModel : ObservableObject
/// </summary>
public ObservableCollection<CategoryGroup> Groups { get; } = [];

public CategoriesViewModel(ICategoriesApi categoriesApi, IConnectivityService connectivity, IUserPrompt userPrompt)
public CategoriesViewModel(
ICategoriesApi categoriesApi,
IConnectivityService connectivity,
IUserPrompt userPrompt,
IMessenger messenger)
{
_categoriesApi = categoriesApi;
_connectivity = connectivity;
_userPrompt = userPrompt;
_messenger = messenger;
IsOffline = !connectivity.IsConnected;

// Weak registration: the messenger is a singleton and this view model is not.
messenger.Register<SessionEndedMessage>(this, (_, _) => Reset());
}

/// <summary>
/// What OnAppearing calls: load on the first visit only. See <see cref="_isLoaded"/>.
/// </summary>
[RelayCommand]
private Task EnsureLoadedAsync() => _isLoaded ? Task.CompletedTask : LoadAsync();

/// <summary>
/// Drops the signed-out session's categories. Shell keeps the page it built for each tab, so
/// without this the next user would see the previous one's list and no fetch would follow.
/// </summary>
private void Reset()
{
_isLoaded = false;
Categories.Clear();
Groups.Clear();
ErrorMessage = null;
}

/// <summary>
Expand Down Expand Up @@ -78,9 +115,11 @@ private async Task LoadAsync()
}

RebuildGroups();
_isLoaded = true;
}
catch (Exception ex) when (ApiErrorMapper.IsApiFailure(ex))
{
// _isLoaded stays false: there is nothing on screen, so the next visit should retry.
ErrorMessage = ApiErrorMapper.ToDisplayMessage(ex);
}
finally
Expand Down Expand Up @@ -208,6 +247,10 @@ private async Task RenameAsync(CategoryDto category)
Categories[index] = renamed;
RebuildGroups();
}

// The dashboard names categories in its breakdown and the subscription list groups by
// them, and neither refetches on tab switch any more - so the rename has to say so.
_messenger.Send(new SubscriptionsChangedMessage());
}
catch (Exception ex) when (ApiErrorMapper.IsApiFailure(ex))
{
Expand Down Expand Up @@ -243,6 +286,10 @@ private async Task DeleteAsync(CategoryDto category)
Categories.Remove(category);
RebuildGroups();

// Subscriptions that used it are now uncategorised, which moves both the dashboard
// breakdown and the grouping on the list.
_messenger.Send(new SubscriptionsChangedMessage());

if (result.SubscriptionsUncategorized > 0)
{
var plural = result.SubscriptionsUncategorized == 1 ? "subscription is" : "subscriptions are";
Expand Down
23 changes: 23 additions & 0 deletions src/SubVora.Mobile/ViewModels/DashboardViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ public partial class DashboardViewModel : ObservableObject

public ObservableCollection<PaymentSourceBreakdownItem> ByPaymentSource { get; } = [];

/// <summary>
/// Whether the figures on screen came from a load. Shell raises OnAppearing on every tab
/// selection, so loading unconditionally there meant a fetch per tab tap - see
/// <c>SubscriptionListViewModel._isLoaded</c> for the full reasoning. Here the message handler
/// below refetches straight away rather than only marking it stale, because this view model is
/// a singleton whose totals are what a change to a subscription or the home currency moves.
/// </summary>
private bool _isLoaded;

public DashboardViewModel(IDashboardApi dashboardApi, ILocalCacheService localCacheService, IMessenger messenger)
{
_dashboardApi = dashboardApi;
Expand All @@ -85,12 +94,22 @@ public DashboardViewModel(IDashboardApi dashboardApi, ILocalCacheService localCa
messenger.Register<SessionEndedMessage>(this, (_, _) => Clear());
}

/// <summary>
/// What OnAppearing calls: load the first time the tab is opened, then leave the numbers alone
/// until a change message refetches them. See <see cref="_isLoaded"/>.
/// </summary>
[RelayCommand]
private Task EnsureLoadedAsync() => _isLoaded ? Task.CompletedTask : LoadAsync();

/// <summary>
/// Drops the figures so the banner cannot outlive the session that produced them - a signed-out
/// or expired user must not still see their spend on the login screen.
/// </summary>
public void Clear()
{
// Also makes the next appearance fetch again: this view model is a singleton, so without it
// the next user to sign in would open a dashboard that believes it is already loaded.
_isLoaded = false;
Weekly = 0;
Monthly = 0;
Yearly = 0;
Expand Down Expand Up @@ -130,6 +149,7 @@ private async Task LoadAsync()

ApplyBurnRate(snapshot);
IsShowingCachedData = false;
_isLoaded = true;

await _localCacheService.UpsertAsync(snapshot);
}
Expand All @@ -143,6 +163,9 @@ private async Task LoadAsync()
{
ApplyBurnRate(cached);
IsShowingCachedData = true;

// Cached totals still count as loaded - see the same call in the list view model.
_isLoaded = true;
}
else
{
Expand Down
49 changes: 48 additions & 1 deletion src/SubVora.Mobile/ViewModels/PaymentSourcesViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Refit;
using SubVora.Mobile.Api;
using SubVora.Mobile.Api.Dtos;
using SubVora.Mobile.Messages;
using SubVora.Mobile.Services;

namespace SubVora.Mobile.ViewModels;
Expand All @@ -13,6 +15,15 @@ public partial class PaymentSourcesViewModel : ObservableObject
private readonly IPaymentSourcesApi _paymentSourcesApi;
private readonly IUserPrompt _userPrompt;
private readonly IConnectivityService _connectivity;
private readonly IMessenger _messenger;

/// <summary>
/// Whether this screen already holds the payment sources. Shell raises OnAppearing on every tab
/// selection, so loading unconditionally there refetched on each tab tap - see
/// <c>SubscriptionListViewModel._isLoaded</c> for the full reasoning. Every mutation below
/// applies its own result locally, so the list cannot go stale between visits.
/// </summary>
private bool _isLoaded;

[ObservableProperty]
public partial bool IsLoading { get; set; }
Expand All @@ -30,12 +41,37 @@ public partial class PaymentSourcesViewModel : ObservableObject

public ObservableCollection<PaymentSourceDto> PaymentSources { get; } = [];

public PaymentSourcesViewModel(IPaymentSourcesApi paymentSourcesApi, IUserPrompt userPrompt, IConnectivityService connectivity)
public PaymentSourcesViewModel(
IPaymentSourcesApi paymentSourcesApi,
IUserPrompt userPrompt,
IConnectivityService connectivity,
IMessenger messenger)
{
_connectivity = connectivity;
IsOffline = !connectivity.IsConnected;
_paymentSourcesApi = paymentSourcesApi;
_userPrompt = userPrompt;
_messenger = messenger;

// Weak registration: the messenger is a singleton and this view model is not.
messenger.Register<SessionEndedMessage>(this, (_, _) => Reset());
}

/// <summary>
/// What OnAppearing calls: load on the first visit only. See <see cref="_isLoaded"/>.
/// </summary>
[RelayCommand]
private Task EnsureLoadedAsync() => _isLoaded ? Task.CompletedTask : LoadAsync();

/// <summary>
/// Drops the signed-out session's payment sources. Shell keeps the page it built for each tab,
/// so without this the next user would see the previous one's accounts and no fetch would run.
/// </summary>
private void Reset()
{
_isLoaded = false;
PaymentSources.Clear();
ErrorMessage = null;
}
/// <summary>
/// Whether the device has no network. Refreshed when the screen loads and after a failed write
Expand Down Expand Up @@ -69,9 +105,12 @@ private async Task LoadAsync()
{
PaymentSources.Add(paymentSource);
}

_isLoaded = true;
}
catch (Exception ex) when (ApiErrorMapper.IsApiFailure(ex))
{
// _isLoaded stays false: nothing is on screen, so the next visit should retry.
// A read, so the plain wording: nothing was lost, there is just nothing to show.
IsOffline = !_connectivity.IsConnected;
ErrorMessage = ApiErrorMapper.ToDisplayMessage(ex);
Expand Down Expand Up @@ -125,6 +164,10 @@ private async Task DeleteAsync(Guid id)
{
PaymentSources.Remove(toRemove);
}

// Subscriptions that billed to it are detached, which changes the dashboard's spend-by-
// account breakdown. Neither that screen nor the list refetches on tab switch now.
_messenger.Send(new SubscriptionsChangedMessage());
}
catch (Exception ex) when (ApiErrorMapper.IsApiFailure(ex))
{
Expand Down Expand Up @@ -167,6 +210,10 @@ private async Task RenameAsync(PaymentSourceDto paymentSource)
PaymentSources.RemoveAt(index);
PaymentSources.Insert(SortedIndexFor(updated.Label), updated);
}

// The dashboard names accounts in its spend-by-account summary, and it no longer
// refetches on tab switch - so the new label has to be announced.
_messenger.Send(new SubscriptionsChangedMessage());
}
catch (Exception ex) when (ApiErrorMapper.IsApiFailure(ex))
{
Expand Down
30 changes: 29 additions & 1 deletion src/SubVora.Mobile/ViewModels/SettingsViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Refit;
using CommunityToolkit.Mvvm.Messaging;
using Refit;
using SubVora.Mobile.Api;
using SubVora.Mobile.Api.Dtos;
using SubVora.Mobile.Formatting;
Expand Down Expand Up @@ -128,6 +128,32 @@ public SettingsViewModel(IUsersApi usersApi, IAuthApi authApi, ITokenStore token
// OnThemeChanged and re-applies the value it already has, which is a no-op - the theme was
// applied at startup, long before this page is built.
Theme = _themeService.Current;

// Weak registration: the messenger is a singleton and this view model is not. Signing out
// is published from this same view model, and the handler is what makes the next user's
// visit fetch their profile instead of showing the previous one's.
messenger.Register<SessionEndedMessage>(this, (_, _) => Reset());
}

/// <summary>
/// Whether the profile has already been fetched. Shell raises OnAppearing on every tab
/// selection, so loading unconditionally there meant a request per tab tap - see
/// <c>SubscriptionListViewModel._isLoaded</c> for the full reasoning. Save applies what the
/// server returns, so the fields cannot drift from it between visits.
/// </summary>
private bool _isLoaded;

/// <summary>What OnAppearing calls: fetch the profile on the first visit only.</summary>
[RelayCommand]
private Task EnsureLoadedAsync() => _isLoaded ? Task.CompletedTask : LoadAsync();

/// <summary>Drops the signed-out session's profile so the next one is fetched, not inherited.</summary>
private void Reset()
{
_isLoaded = false;
PreferredCurrency = string.Empty;
SelectedCurrency = null;
ErrorMessage = null;
}

/// <summary>
Expand Down Expand Up @@ -162,9 +188,11 @@ private async Task LoadAsync()
Currencies = SupportedCurrencies.Including(profile.PreferredCurrency);
PreferredCurrency = profile.PreferredCurrency;
DefaultAlertDaysAdvance = profile.DefaultAlertDaysAdvance;
_isLoaded = true;
}
catch (Exception ex) when (ApiErrorMapper.IsApiFailure(ex))
{
// _isLoaded stays false: the fields hold nothing, so the next visit should retry.
ErrorMessage = ApiErrorMapper.ToDisplayMessage(ex);
}
finally
Expand Down
49 changes: 49 additions & 0 deletions src/SubVora.Mobile/ViewModels/SubscriptionListViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,23 @@ public partial class SubscriptionListViewModel : ObservableObject
/// <summary>Raised by the Add toolbar button, to navigate to the detail screen in add mode.</summary>
public event EventHandler? AddRequested;

/// <summary>
/// Whether this screen already holds rows worth showing.
/// <para>
/// Shell raises OnAppearing on every tab selection, so a page that loads unconditionally there
/// refetches - clearing and repainting itself - on each tab tap. Against a slow or unreachable
/// API that is a spinner every time the user comes back, which is what it looked like the app
/// was stuck refreshing.
/// </para>
/// <para>
/// Invalidated by <see cref="SubscriptionsChangedMessage"/> rather than by a clock: this data
/// only moves when this app moves it, and every writer already publishes that message. Failing
/// with nothing to show leaves the flag false, so a retry still happens on the next visit, and
/// pull-to-refresh forces one through <see cref="LoadCommand"/> at any time.
/// </para>
/// </summary>
private bool _isLoaded;

public SubscriptionListViewModel(
ISubscriptionsApi subscriptionsApi,
ILocalCacheService localCacheService,
Expand All @@ -95,6 +112,33 @@ public SubscriptionListViewModel(
_connectivity = connectivity;

IsOffline = !connectivity.IsConnected;

// Weak registrations (WeakReferenceMessenger), so the singleton messenger does not keep a
// transient view model alive. A write made from the detail screen marks the list stale; the
// reload happens when the user is actually looking at it rather than behind their back.
messenger.Register<SubscriptionsChangedMessage>(this, (_, _) => _isLoaded = false);
messenger.Register<SessionEndedMessage>(this, (_, _) => Reset());
}

/// <summary>
/// What OnAppearing calls: load on the first visit, then leave the screen alone until something
/// says the data moved. See <see cref="_isLoaded"/>.
/// </summary>
[RelayCommand]
private Task EnsureLoadedAsync() => _isLoaded ? Task.CompletedTask : LoadAsync();

/// <summary>
/// Drops the signed-out session's rows. The tab pages outlive a sign-out - Shell keeps the page
/// it built for each ShellContent - so without this the next user would be handed the previous
/// one's list on the first appearance and no fetch, because the screen thinks it is loaded.
/// </summary>
private void Reset()
{
_isLoaded = false;
Subscriptions.Clear();
Groups.Clear();
ErrorMessage = null;
IsShowingCachedData = false;
}

[RelayCommand]
Expand All @@ -114,6 +158,7 @@ private async Task LoadAsync()
}

IsShowingCachedData = false;
_isLoaded = true;

await _localCacheService.ClearAsync<CachedSubscription>();
foreach (var subscription in result)
Expand All @@ -136,6 +181,10 @@ private async Task LoadAsync()
}

IsShowingCachedData = true;

// Cached rows count as loaded: showing the mirror and refetching on every tab tap
// is the same 30-second spinner, for a screen that already has something on it.
_isLoaded = true;
}
else
{
Expand Down
Loading
Loading