diff --git a/docs/Design.md b/docs/Design.md
index b2dec95..99fb861 100644
--- a/docs/Design.md
+++ b/docs/Design.md
@@ -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.
diff --git a/src/SubVora.Mobile/ViewModels/CategoriesViewModel.cs b/src/SubVora.Mobile/ViewModels/CategoriesViewModel.cs
index 7dd7ee6..01b335a 100644
--- a/src/SubVora.Mobile/ViewModels/CategoriesViewModel.cs
+++ b/src/SubVora.Mobile/ViewModels/CategoriesViewModel.cs
@@ -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;
@@ -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;
+
+ ///
+ /// 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
+ /// SubscriptionListViewModel._isLoaded for the full reasoning. Every mutation below
+ /// applies its own result locally, so nothing on this screen goes stale between visits.
+ ///
+ private bool _isLoaded;
[ObservableProperty]
public partial bool IsLoading { get; set; }
@@ -37,12 +48,38 @@ public partial class CategoriesViewModel : ObservableObject
///
public ObservableCollection 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(this, (_, _) => Reset());
+ }
+
+ ///
+ /// What OnAppearing calls: load on the first visit only. See .
+ ///
+ [RelayCommand]
+ private Task EnsureLoadedAsync() => _isLoaded ? Task.CompletedTask : LoadAsync();
+
+ ///
+ /// 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.
+ ///
+ private void Reset()
+ {
+ _isLoaded = false;
+ Categories.Clear();
+ Groups.Clear();
+ ErrorMessage = null;
}
///
@@ -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
@@ -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))
{
@@ -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";
diff --git a/src/SubVora.Mobile/ViewModels/DashboardViewModel.cs b/src/SubVora.Mobile/ViewModels/DashboardViewModel.cs
index 5222c06..cf24742 100644
--- a/src/SubVora.Mobile/ViewModels/DashboardViewModel.cs
+++ b/src/SubVora.Mobile/ViewModels/DashboardViewModel.cs
@@ -74,6 +74,15 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection ByPaymentSource { get; } = [];
+ ///
+ /// 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
+ /// SubscriptionListViewModel._isLoaded 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.
+ ///
+ private bool _isLoaded;
+
public DashboardViewModel(IDashboardApi dashboardApi, ILocalCacheService localCacheService, IMessenger messenger)
{
_dashboardApi = dashboardApi;
@@ -85,12 +94,22 @@ public DashboardViewModel(IDashboardApi dashboardApi, ILocalCacheService localCa
messenger.Register(this, (_, _) => Clear());
}
+ ///
+ /// What OnAppearing calls: load the first time the tab is opened, then leave the numbers alone
+ /// until a change message refetches them. See .
+ ///
+ [RelayCommand]
+ private Task EnsureLoadedAsync() => _isLoaded ? Task.CompletedTask : LoadAsync();
+
///
/// 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.
///
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;
@@ -130,6 +149,7 @@ private async Task LoadAsync()
ApplyBurnRate(snapshot);
IsShowingCachedData = false;
+ _isLoaded = true;
await _localCacheService.UpsertAsync(snapshot);
}
@@ -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
{
diff --git a/src/SubVora.Mobile/ViewModels/PaymentSourcesViewModel.cs b/src/SubVora.Mobile/ViewModels/PaymentSourcesViewModel.cs
index 133a5fd..e721c19 100644
--- a/src/SubVora.Mobile/ViewModels/PaymentSourcesViewModel.cs
+++ b/src/SubVora.Mobile/ViewModels/PaymentSourcesViewModel.cs
@@ -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;
@@ -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;
+
+ ///
+ /// 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
+ /// SubscriptionListViewModel._isLoaded for the full reasoning. Every mutation below
+ /// applies its own result locally, so the list cannot go stale between visits.
+ ///
+ private bool _isLoaded;
[ObservableProperty]
public partial bool IsLoading { get; set; }
@@ -30,12 +41,37 @@ public partial class PaymentSourcesViewModel : ObservableObject
public ObservableCollection 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(this, (_, _) => Reset());
+ }
+
+ ///
+ /// What OnAppearing calls: load on the first visit only. See .
+ ///
+ [RelayCommand]
+ private Task EnsureLoadedAsync() => _isLoaded ? Task.CompletedTask : LoadAsync();
+
+ ///
+ /// 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.
+ ///
+ private void Reset()
+ {
+ _isLoaded = false;
+ PaymentSources.Clear();
+ ErrorMessage = null;
}
///
/// Whether the device has no network. Refreshed when the screen loads and after a failed write
@@ -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);
@@ -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))
{
@@ -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))
{
diff --git a/src/SubVora.Mobile/ViewModels/SettingsViewModel.cs b/src/SubVora.Mobile/ViewModels/SettingsViewModel.cs
index 740ca8c..0bfe3a4 100644
--- a/src/SubVora.Mobile/ViewModels/SettingsViewModel.cs
+++ b/src/SubVora.Mobile/ViewModels/SettingsViewModel.cs
@@ -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;
@@ -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(this, (_, _) => Reset());
+ }
+
+ ///
+ /// 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
+ /// SubscriptionListViewModel._isLoaded for the full reasoning. Save applies what the
+ /// server returns, so the fields cannot drift from it between visits.
+ ///
+ private bool _isLoaded;
+
+ /// What OnAppearing calls: fetch the profile on the first visit only.
+ [RelayCommand]
+ private Task EnsureLoadedAsync() => _isLoaded ? Task.CompletedTask : LoadAsync();
+
+ /// Drops the signed-out session's profile so the next one is fetched, not inherited.
+ private void Reset()
+ {
+ _isLoaded = false;
+ PreferredCurrency = string.Empty;
+ SelectedCurrency = null;
+ ErrorMessage = null;
}
///
@@ -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
diff --git a/src/SubVora.Mobile/ViewModels/SubscriptionListViewModel.cs b/src/SubVora.Mobile/ViewModels/SubscriptionListViewModel.cs
index ee0a2c9..b74928c 100644
--- a/src/SubVora.Mobile/ViewModels/SubscriptionListViewModel.cs
+++ b/src/SubVora.Mobile/ViewModels/SubscriptionListViewModel.cs
@@ -79,6 +79,23 @@ public partial class SubscriptionListViewModel : ObservableObject
/// Raised by the Add toolbar button, to navigate to the detail screen in add mode.
public event EventHandler? AddRequested;
+ ///
+ /// Whether this screen already holds rows worth showing.
+ ///
+ /// 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.
+ ///
+ ///
+ /// Invalidated by 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 at any time.
+ ///
+ ///
+ private bool _isLoaded;
+
public SubscriptionListViewModel(
ISubscriptionsApi subscriptionsApi,
ILocalCacheService localCacheService,
@@ -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(this, (_, _) => _isLoaded = false);
+ messenger.Register(this, (_, _) => Reset());
+ }
+
+ ///
+ /// What OnAppearing calls: load on the first visit, then leave the screen alone until something
+ /// says the data moved. See .
+ ///
+ [RelayCommand]
+ private Task EnsureLoadedAsync() => _isLoaded ? Task.CompletedTask : LoadAsync();
+
+ ///
+ /// 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.
+ ///
+ private void Reset()
+ {
+ _isLoaded = false;
+ Subscriptions.Clear();
+ Groups.Clear();
+ ErrorMessage = null;
+ IsShowingCachedData = false;
}
[RelayCommand]
@@ -114,6 +158,7 @@ private async Task LoadAsync()
}
IsShowingCachedData = false;
+ _isLoaded = true;
await _localCacheService.ClearAsync();
foreach (var subscription in result)
@@ -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
{
diff --git a/src/SubVora.Mobile/Views/CategoriesPage.xaml.cs b/src/SubVora.Mobile/Views/CategoriesPage.xaml.cs
index d6195f3..087bf6d 100644
--- a/src/SubVora.Mobile/Views/CategoriesPage.xaml.cs
+++ b/src/SubVora.Mobile/Views/CategoriesPage.xaml.cs
@@ -20,7 +20,9 @@ public CategoriesPage(CategoriesViewModel viewModel, IUserPrompt userPrompt)
protected override void OnAppearing()
{
base.OnAppearing();
- _viewModel.LoadCommand.Execute(null);
+ // EnsureLoaded, not Load: Shell raises OnAppearing on every tab selection, and a
+ // refetch per tab tap is what made the app look like it was permanently refreshing.
+ _viewModel.EnsureLoadedCommand.Execute(null);
}
///
diff --git a/src/SubVora.Mobile/Views/DashboardPage.xaml.cs b/src/SubVora.Mobile/Views/DashboardPage.xaml.cs
index 1dd9c1a..3c94ccc 100644
--- a/src/SubVora.Mobile/Views/DashboardPage.xaml.cs
+++ b/src/SubVora.Mobile/Views/DashboardPage.xaml.cs
@@ -16,6 +16,8 @@ public DashboardPage(DashboardViewModel viewModel)
protected override void OnAppearing()
{
base.OnAppearing();
- _viewModel.LoadCommand.Execute(null);
+ // EnsureLoaded, not Load: Shell raises OnAppearing on every tab selection, and a
+ // refetch per tab tap is what made the app look like it was permanently refreshing.
+ _viewModel.EnsureLoadedCommand.Execute(null);
}
}
diff --git a/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml b/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml
index 922c73c..794d589 100644
--- a/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml
+++ b/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml
@@ -52,55 +52,44 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml.cs b/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml.cs
index 8dae0fe..2a98e7e 100644
--- a/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml.cs
+++ b/src/SubVora.Mobile/Views/PaymentSourcesPage.xaml.cs
@@ -20,16 +20,18 @@ public PaymentSourcesPage(PaymentSourcesViewModel viewModel, IUserPrompt userPro
protected override void OnAppearing()
{
base.OnAppearing();
- _viewModel.LoadCommand.Execute(null);
+ // EnsureLoaded, not Load: Shell raises OnAppearing on every tab selection, and a
+ // refetch per tab tap is what made the app look like it was permanently refreshing.
+ _viewModel.EnsureLoadedCommand.Execute(null);
}
///
/// Opens the row menu anchored to the button tapped - see CategoriesPage.OnManageTapped for why
/// this goes through the page rather than binding the command directly.
///
- private void OnManageClicked(object? sender, EventArgs e)
+ private void OnManageTapped(object? sender, TappedEventArgs e)
{
- if (sender is not Button button || button.CommandParameter is not PaymentSourceDto paymentSource)
+ if (sender is not VisualElement button || e.Parameter is not PaymentSourceDto paymentSource)
{
return;
}
diff --git a/src/SubVora.Mobile/Views/SettingsPage.xaml.cs b/src/SubVora.Mobile/Views/SettingsPage.xaml.cs
index 3b01fd8..4cf591a 100644
--- a/src/SubVora.Mobile/Views/SettingsPage.xaml.cs
+++ b/src/SubVora.Mobile/Views/SettingsPage.xaml.cs
@@ -46,7 +46,9 @@ private void ShowAppVersion()
protected override void OnAppearing()
{
base.OnAppearing();
- _viewModel.LoadCommand.Execute(null);
+ // EnsureLoaded, not Load: Shell raises OnAppearing on every tab selection, and a
+ // refetch per tab tap is what made the app look like it was permanently refreshing.
+ _viewModel.EnsureLoadedCommand.Execute(null);
}
private async void OnSignedOut(object? sender, EventArgs e)
diff --git a/src/SubVora.Mobile/Views/SubscriptionListPage.xaml.cs b/src/SubVora.Mobile/Views/SubscriptionListPage.xaml.cs
index 0786d21..d7baed6 100644
--- a/src/SubVora.Mobile/Views/SubscriptionListPage.xaml.cs
+++ b/src/SubVora.Mobile/Views/SubscriptionListPage.xaml.cs
@@ -18,7 +18,9 @@ public SubscriptionListPage(SubscriptionListViewModel viewModel)
protected override void OnAppearing()
{
base.OnAppearing();
- _viewModel.LoadCommand.Execute(null);
+ // EnsureLoaded, not Load: Shell raises OnAppearing on every tab selection, and a
+ // refetch per tab tap is what made the app look like it was permanently refreshing.
+ _viewModel.EnsureLoadedCommand.Execute(null);
}
private async void OnAddRequested(object? sender, EventArgs e)
diff --git a/tests/SubVora.Mobile.Tests/CategoriesViewModelTests.cs b/tests/SubVora.Mobile.Tests/CategoriesViewModelTests.cs
index f3e6350..10bea05 100644
--- a/tests/SubVora.Mobile.Tests/CategoriesViewModelTests.cs
+++ b/tests/SubVora.Mobile.Tests/CategoriesViewModelTests.cs
@@ -1,4 +1,5 @@
using System.Net;
+using CommunityToolkit.Mvvm.Messaging;
using SubVora.Mobile.Api.Dtos;
using SubVora.Mobile.Tests.Fakes;
using SubVora.Mobile.ViewModels;
@@ -18,7 +19,7 @@ public async Task LoadAsync_PopulatesListDistinguishingSystemFromUserOwned()
new CategoryDto { Id = Guid.NewGuid(), Name = "My Custom Category", IsSystemDefault = false },
]),
};
- var viewModel = new CategoriesViewModel(api, new FakeConnectivityService(), new FakeUserPrompt());
+ var viewModel = new CategoriesViewModel(api, new FakeConnectivityService(), new FakeUserPrompt(), new WeakReferenceMessenger());
await viewModel.LoadCommand.ExecuteAsync(null);
@@ -31,7 +32,7 @@ public async Task LoadAsync_PopulatesListDistinguishingSystemFromUserOwned()
public async Task AddAsync_WithValidName_CallsCreateAndAppendsToList()
{
var api = new FakeCategoriesApi();
- var viewModel = new CategoriesViewModel(api, new FakeConnectivityService(), new FakeUserPrompt()) { NewCategoryName = "Streaming" };
+ var viewModel = new CategoriesViewModel(api, new FakeConnectivityService(), new FakeUserPrompt(), new WeakReferenceMessenger()) { NewCategoryName = "Streaming" };
await viewModel.AddCommand.ExecuteAsync(null);
@@ -48,7 +49,7 @@ public async Task AddAsync_WithDuplicateName_SurfacesApiError()
{
CreateHandler = _ => throw TestApiExceptions.Create(HttpStatusCode.Conflict),
};
- var viewModel = new CategoriesViewModel(api, new FakeConnectivityService(), new FakeUserPrompt()) { NewCategoryName = "Utilities" };
+ var viewModel = new CategoriesViewModel(api, new FakeConnectivityService(), new FakeUserPrompt(), new WeakReferenceMessenger()) { NewCategoryName = "Utilities" };
await viewModel.AddCommand.ExecuteAsync(null);
@@ -65,7 +66,7 @@ public async Task AddAsync_WithEmptyName_SurfacesApiValidationError()
HttpStatusCode.BadRequest,
"""{"errors":{"Name":["'Name' must not be empty."]}}"""),
};
- var viewModel = new CategoriesViewModel(api, new FakeConnectivityService(), new FakeUserPrompt()) { NewCategoryName = "" };
+ var viewModel = new CategoriesViewModel(api, new FakeConnectivityService(), new FakeUserPrompt(), new WeakReferenceMessenger()) { NewCategoryName = "" };
await viewModel.AddCommand.ExecuteAsync(null);
diff --git a/tests/SubVora.Mobile.Tests/CategoryEditingViewModelTests.cs b/tests/SubVora.Mobile.Tests/CategoryEditingViewModelTests.cs
index cfae2be..3798ea9 100644
--- a/tests/SubVora.Mobile.Tests/CategoryEditingViewModelTests.cs
+++ b/tests/SubVora.Mobile.Tests/CategoryEditingViewModelTests.cs
@@ -1,4 +1,5 @@
using System.Net;
+using CommunityToolkit.Mvvm.Messaging;
using SubVora.Mobile.Api.Dtos;
using SubVora.Mobile.Tests.Fakes;
using SubVora.Mobile.ViewModels;
@@ -13,7 +14,7 @@ private static CategoryDto Category(string name, bool isSystemDefault = false) =
private static async Task LoadedAsync(FakeCategoriesApi api, FakeUserPrompt prompt, params CategoryDto[] categories)
{
api.GetAllHandler = () => Task.FromResult>(categories);
- var viewModel = new CategoriesViewModel(api, new FakeConnectivityService(), prompt);
+ var viewModel = new CategoriesViewModel(api, new FakeConnectivityService(), prompt, new WeakReferenceMessenger());
await viewModel.LoadCommand.ExecuteAsync(null);
return viewModel;
}
@@ -180,7 +181,7 @@ public async Task RenamePaymentSource_KeepsItsTypeAndReplacesTheRow()
[new PaymentSourceDto { Id = Guid.NewGuid(), Label = "HDFC 4021", SourceType = PaymentSourceType.Card }]),
};
var prompt = new FakeUserPrompt { PromptResult = "HDFC Credit ••4021" };
- var viewModel = new PaymentSourcesViewModel(api, prompt, new FakeConnectivityService());
+ var viewModel = new PaymentSourcesViewModel(api, prompt, new FakeConnectivityService(), new WeakReferenceMessenger());
await viewModel.LoadCommand.ExecuteAsync(null);
await viewModel.RenameCommand.ExecuteAsync(viewModel.PaymentSources.Single());
diff --git a/tests/SubVora.Mobile.Tests/CategoryGroupingTests.cs b/tests/SubVora.Mobile.Tests/CategoryGroupingTests.cs
index 2f9fa07..d139c4b 100644
--- a/tests/SubVora.Mobile.Tests/CategoryGroupingTests.cs
+++ b/tests/SubVora.Mobile.Tests/CategoryGroupingTests.cs
@@ -1,4 +1,5 @@
-using SubVora.Mobile.Api.Dtos;
+using CommunityToolkit.Mvvm.Messaging;
+using SubVora.Mobile.Api.Dtos;
using SubVora.Mobile.Tests.Fakes;
using SubVora.Mobile.ViewModels;
@@ -16,7 +17,7 @@ public class CategoryGroupingTests
private static CategoryDto User(string name) => new() { Id = Guid.NewGuid(), Name = name, IsSystemDefault = false };
private static CategoriesViewModel CreateViewModel(FakeCategoriesApi api, FakeUserPrompt? prompt = null) =>
- new(api, new FakeConnectivityService(), prompt ?? new FakeUserPrompt());
+ new(api, new FakeConnectivityService(), prompt ?? new FakeUserPrompt(), new WeakReferenceMessenger());
private static FakeCategoriesApi ApiReturning(params CategoryDto[] categories) => new()
{
diff --git a/tests/SubVora.Mobile.Tests/Fakes/FakeSubscriptionsApi.cs b/tests/SubVora.Mobile.Tests/Fakes/FakeSubscriptionsApi.cs
index 8f5903b..44ffe4d 100644
--- a/tests/SubVora.Mobile.Tests/Fakes/FakeSubscriptionsApi.cs
+++ b/tests/SubVora.Mobile.Tests/Fakes/FakeSubscriptionsApi.cs
@@ -36,7 +36,14 @@ public class FakeSubscriptionsApi : ISubscriptionsApi
public List MarkPaidCalls { get; } = [];
public List ResolveCalls { get; } = [];
- public Task> GetAllAsync(CancellationToken cancellationToken = default) => GetAllHandler();
+ /// How many times the list has been fetched - what the tab-switch reload tests count.
+ public int GetAllCallCount { get; private set; }
+
+ public Task> GetAllAsync(CancellationToken cancellationToken = default)
+ {
+ GetAllCallCount++;
+ return GetAllHandler();
+ }
public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => GetByIdHandler(id);
diff --git a/tests/SubVora.Mobile.Tests/ManageActionTests.cs b/tests/SubVora.Mobile.Tests/ManageActionTests.cs
index c823fcc..e3bad27 100644
--- a/tests/SubVora.Mobile.Tests/ManageActionTests.cs
+++ b/tests/SubVora.Mobile.Tests/ManageActionTests.cs
@@ -1,4 +1,5 @@
-using SubVora.Mobile.Api.Dtos;
+using CommunityToolkit.Mvvm.Messaging;
+using SubVora.Mobile.Api.Dtos;
using SubVora.Mobile.Tests.Fakes;
using SubVora.Mobile.ViewModels;
@@ -15,7 +16,7 @@ private static CategoryDto UserCategory(string name = "Music") =>
new() { Id = Guid.NewGuid(), Name = name, IsSystemDefault = false };
private static CategoriesViewModel Categories(FakeCategoriesApi api, FakeUserPrompt prompt) =>
- new(api, new FakeConnectivityService(), prompt);
+ new(api, new FakeConnectivityService(), prompt, new WeakReferenceMessenger());
[Fact]
public async Task Manage_ChoosingRename_OpensTheRenamePrompt()
@@ -98,7 +99,7 @@ public async Task PaymentSource_Manage_ChoosingDelete_DeletesAfterConfirming()
GetAllHandler = () => Task.FromResult>([source]),
DeleteHandler = id => { deleted.Add(id); return Task.CompletedTask; },
};
- var viewModel = new PaymentSourcesViewModel(api, prompt, new FakeConnectivityService());
+ var viewModel = new PaymentSourcesViewModel(api, prompt, new FakeConnectivityService(), new WeakReferenceMessenger());
await viewModel.LoadCommand.ExecuteAsync(null);
await viewModel.ManageCommand.ExecuteAsync(source);
diff --git a/tests/SubVora.Mobile.Tests/OfflineFailureTests.cs b/tests/SubVora.Mobile.Tests/OfflineFailureTests.cs
index 93dba7b..8d57eca 100644
--- a/tests/SubVora.Mobile.Tests/OfflineFailureTests.cs
+++ b/tests/SubVora.Mobile.Tests/OfflineFailureTests.cs
@@ -81,7 +81,7 @@ public async Task Register_WhenApiIsUnreachable_ShowsOfflineMessageInsteadOfCras
public async Task Categories_WhenApiIsUnreachable_ShowsOfflineMessageInsteadOfCrashing()
{
var categoriesApi = new FakeCategoriesApi { GetAllHandler = () => throw Unreachable() };
- var viewModel = new CategoriesViewModel(categoriesApi, new FakeConnectivityService(), new FakeUserPrompt());
+ var viewModel = new CategoriesViewModel(categoriesApi, new FakeConnectivityService(), new FakeUserPrompt(), new WeakReferenceMessenger());
await viewModel.LoadCommand.ExecuteAsync(null);
@@ -95,7 +95,7 @@ public async Task AddCategory_WhenApiIsUnreachable_ShowsOfflineMessageNotTheDupl
// The 409 special-case in this catch block used to read ex.StatusCode directly, which only
// exists on ApiException - the transport failure has no status at all.
var categoriesApi = new FakeCategoriesApi { CreateHandler = _ => throw Unreachable() };
- var viewModel = new CategoriesViewModel(categoriesApi, new FakeConnectivityService(), new FakeUserPrompt()) { NewCategoryName = "Streaming" };
+ var viewModel = new CategoriesViewModel(categoriesApi, new FakeConnectivityService(), new FakeUserPrompt(), new WeakReferenceMessenger()) { NewCategoryName = "Streaming" };
await viewModel.AddCommand.ExecuteAsync(null);
@@ -107,7 +107,7 @@ public async Task AddCategory_WhenApiIsUnreachable_ShowsOfflineMessageNotTheDupl
public async Task PaymentSources_WhenApiIsUnreachable_ShowsOfflineMessageInsteadOfCrashing()
{
var paymentSourcesApi = new FakePaymentSourcesApi { GetAllHandler = () => throw Unreachable() };
- var viewModel = new PaymentSourcesViewModel(paymentSourcesApi, new FakeUserPrompt(), new FakeConnectivityService());
+ var viewModel = new PaymentSourcesViewModel(paymentSourcesApi, new FakeUserPrompt(), new FakeConnectivityService(), new WeakReferenceMessenger());
await viewModel.LoadCommand.ExecuteAsync(null);
diff --git a/tests/SubVora.Mobile.Tests/OfflineWriteGuardTests.cs b/tests/SubVora.Mobile.Tests/OfflineWriteGuardTests.cs
index 5c6c640..9c857d6 100644
--- a/tests/SubVora.Mobile.Tests/OfflineWriteGuardTests.cs
+++ b/tests/SubVora.Mobile.Tests/OfflineWriteGuardTests.cs
@@ -53,8 +53,8 @@ public void WithNoNetwork_TheWriteButtonIsNotOffered()
// The list serves itself from the SQLite mirror, so it is the screen most likely to be open
// with no network - and it carries two writes, swipe-to-delete and mark-as-paid.
Assert.False(List(offline).CanSubmit);
- Assert.False(new CategoriesViewModel(new FakeCategoriesApi(), offline, new FakeUserPrompt()).CanSubmit);
- Assert.False(new PaymentSourcesViewModel(new FakePaymentSourcesApi(), new FakeUserPrompt(), offline).CanSubmit);
+ Assert.False(new CategoriesViewModel(new FakeCategoriesApi(), offline, new FakeUserPrompt(), new WeakReferenceMessenger()).CanSubmit);
+ Assert.False(new PaymentSourcesViewModel(new FakePaymentSourcesApi(), new FakeUserPrompt(), offline, new WeakReferenceMessenger()).CanSubmit);
}
[Fact]
@@ -65,8 +65,8 @@ public void WithNetwork_TheWriteButtonIsOffered()
Assert.True(Settings(online).CanSubmit);
Assert.True(Detail(online).CanSubmit);
Assert.True(List(online).CanSubmit);
- Assert.True(new CategoriesViewModel(new FakeCategoriesApi(), online, new FakeUserPrompt()).CanSubmit);
- Assert.True(new PaymentSourcesViewModel(new FakePaymentSourcesApi(), new FakeUserPrompt(), online).CanSubmit);
+ Assert.True(new CategoriesViewModel(new FakeCategoriesApi(), online, new FakeUserPrompt(), new WeakReferenceMessenger()).CanSubmit);
+ Assert.True(new PaymentSourcesViewModel(new FakePaymentSourcesApi(), new FakeUserPrompt(), online, new WeakReferenceMessenger()).CanSubmit);
}
[Fact]
diff --git a/tests/SubVora.Mobile.Tests/PaymentSourcesViewModelTests.cs b/tests/SubVora.Mobile.Tests/PaymentSourcesViewModelTests.cs
index 5bd0484..d290d9e 100644
--- a/tests/SubVora.Mobile.Tests/PaymentSourcesViewModelTests.cs
+++ b/tests/SubVora.Mobile.Tests/PaymentSourcesViewModelTests.cs
@@ -1,4 +1,5 @@
using System.Net;
+using CommunityToolkit.Mvvm.Messaging;
using SubVora.Mobile.Api.Dtos;
using SubVora.Mobile.Tests.Fakes;
using SubVora.Mobile.ViewModels;
@@ -10,7 +11,7 @@ public class PaymentSourcesViewModelTests
private static PaymentSourcesViewModel CreateViewModel(
FakePaymentSourcesApi? api = null,
FakeUserPrompt? userPrompt = null) =>
- new(api ?? new FakePaymentSourcesApi(), userPrompt ?? new FakeUserPrompt(), new FakeConnectivityService());
+ new(api ?? new FakePaymentSourcesApi(), userPrompt ?? new FakeUserPrompt(), new FakeConnectivityService(), new WeakReferenceMessenger());
[Fact]
public async Task LoadAsync_PopulatesList()
diff --git a/tests/SubVora.Mobile.Tests/TabSwitchReloadTests.cs b/tests/SubVora.Mobile.Tests/TabSwitchReloadTests.cs
new file mode 100644
index 0000000..75c7b0a
--- /dev/null
+++ b/tests/SubVora.Mobile.Tests/TabSwitchReloadTests.cs
@@ -0,0 +1,346 @@
+using CommunityToolkit.Mvvm.Messaging;
+using SubVora.Mobile.Api.Dtos;
+using SubVora.Mobile.Messages;
+using SubVora.Mobile.Models;
+using SubVora.Mobile.Tests.Fakes;
+using SubVora.Mobile.ViewModels;
+
+namespace SubVora.Mobile.Tests;
+
+///
+/// Shell raises OnAppearing on every tab selection, so a page that loads unconditionally there
+/// refetches on each tab tap - which against a slow or unreachable API is a spinner every time the
+/// user comes back to a screen, and reads as an app stuck refreshing itself.
+///
+/// Each page's OnAppearing calls EnsureLoadedCommand instead. These pin the two halves of that:
+/// a repeat appearance costs nothing, and the things that genuinely invalidate a screen - a write
+/// somewhere else, a sign-out, an empty failed load - still make the next visit fetch.
+///
+///
+public class TabSwitchReloadTests
+{
+ private static SubscriptionDto Subscription(string name = "Netflix") => new()
+ {
+ Id = Guid.NewGuid(),
+ CustomName = name,
+ CostAmount = 15.99m,
+ Currency = "USD",
+ CycleCadence = BillingCycleType.Monthly,
+ PurchaseDate = new DateOnly(2026, 1, 1),
+ NextBillingDate = new DateOnly(2026, 8, 1),
+ AlertDaysAdvance = 3,
+ CategoryName = "Streaming",
+ IsActive = true,
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+
+ private static (FakeSubscriptionsApi Api, SubscriptionListViewModel ViewModel) ListWith(IMessenger messenger)
+ {
+ var api = new FakeSubscriptionsApi
+ {
+ GetAllHandler = () => Task.FromResult>([Subscription()]),
+ };
+
+ return (api, new SubscriptionListViewModel(
+ api,
+ new FakeLocalCacheService(),
+ new FakeUserPrompt(),
+ messenger,
+ new FakeRenewalNotificationScheduler(),
+ new FakeConnectivityService()));
+ }
+
+ private static int GetAllCalls(FakeSubscriptionsApi api) => api.GetAllCallCount;
+
+ [Fact]
+ public async Task SubscriptionList_SecondAppearance_DoesNotRefetch()
+ {
+ var (api, viewModel) = ListWith(new WeakReferenceMessenger());
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+
+ Assert.Equal(1, GetAllCalls(api));
+ Assert.Single(viewModel.Subscriptions);
+ }
+
+ [Fact]
+ public async Task SubscriptionList_PullToRefresh_StillFetchesEveryTime()
+ {
+ // The gate is on appearing, not on the user asking. LoadCommand is what RefreshView binds.
+ var (api, viewModel) = ListWith(new WeakReferenceMessenger());
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ await viewModel.LoadCommand.ExecuteAsync(null);
+
+ Assert.Equal(2, GetAllCalls(api));
+ }
+
+ [Fact]
+ public async Task SubscriptionList_AfterAChangeElsewhere_RefetchesOnTheNextAppearance()
+ {
+ var messenger = new WeakReferenceMessenger();
+ var (api, viewModel) = ListWith(messenger);
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+
+ // What the detail screen publishes after a save or a delete.
+ messenger.Send(new SubscriptionsChangedMessage());
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+
+ Assert.Equal(2, GetAllCalls(api));
+ }
+
+ [Fact]
+ public async Task SubscriptionList_AfterSignOut_ForgetsTheRowsAndFetchesAgain()
+ {
+ // Shell keeps the page it built for each tab, so without this the next user to sign in
+ // would be shown the previous one's subscriptions and no request would follow.
+ var messenger = new WeakReferenceMessenger();
+ var (api, viewModel) = ListWith(messenger);
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ messenger.Send(new SessionEndedMessage());
+
+ Assert.Empty(viewModel.Subscriptions);
+ Assert.Empty(viewModel.Groups);
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ Assert.Equal(2, GetAllCalls(api));
+ }
+
+ [Fact]
+ public async Task SubscriptionList_ServedFromCache_CountsAsLoaded()
+ {
+ // Offline with a mirror to show is exactly the case the refetch made painful: every tab tap
+ // spent the full request timeout to end up showing the same cached rows again.
+ var cache = new FakeLocalCacheService();
+ await cache.UpsertAsync(CachedSubscription.FromDto(Subscription()));
+
+ var api = new FakeSubscriptionsApi { GetAllHandler = () => throw TestApiExceptions.ConnectionFailure() };
+ var viewModel = new SubscriptionListViewModel(
+ api,
+ cache,
+ new FakeUserPrompt(),
+ new WeakReferenceMessenger(),
+ new FakeRenewalNotificationScheduler(),
+ new FakeConnectivityService());
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+
+ Assert.True(viewModel.IsShowingCachedData);
+ Assert.Single(viewModel.Subscriptions);
+ Assert.Equal(1, GetAllCalls(api));
+ }
+
+ [Fact]
+ public async Task SubscriptionList_FailedWithNothingToShow_RetriesOnTheNextAppearance()
+ {
+ var api = new FakeSubscriptionsApi { GetAllHandler = () => throw TestApiExceptions.ConnectionFailure() };
+ var viewModel = new SubscriptionListViewModel(
+ api,
+ new FakeLocalCacheService(),
+ new FakeUserPrompt(),
+ new WeakReferenceMessenger(),
+ new FakeRenewalNotificationScheduler(),
+ new FakeConnectivityService());
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+
+ // An empty error screen is not a loaded screen - coming back to the tab is the retry.
+ Assert.Equal(2, GetAllCalls(api));
+ }
+
+ [Fact]
+ public async Task Dashboard_SecondAppearance_DoesNotRefetch()
+ {
+ var calls = 0;
+ var api = new FakeDashboardApi
+ {
+ Handler = () =>
+ {
+ calls++;
+ return Task.FromResult(new BurnRateResult { Weekly = 1m, Monthly = 4m, Yearly = 50m, HomeCurrency = "USD" });
+ },
+ };
+ var viewModel = new DashboardViewModel(api, new FakeLocalCacheService(), new WeakReferenceMessenger());
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+
+ Assert.Equal(1, calls);
+ }
+
+ [Fact]
+ public async Task Dashboard_AfterSignOut_FetchesAgainForTheNextSession()
+ {
+ var calls = 0;
+ var api = new FakeDashboardApi
+ {
+ Handler = () =>
+ {
+ calls++;
+ return Task.FromResult(new BurnRateResult { Weekly = 1m, Monthly = 4m, Yearly = 50m, HomeCurrency = "USD" });
+ },
+ };
+ var messenger = new WeakReferenceMessenger();
+
+ // Singleton for the whole app, so it is the one view model that survives sign-out for sure.
+ var viewModel = new DashboardViewModel(api, new FakeLocalCacheService(), messenger);
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ messenger.Send(new SessionEndedMessage());
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+
+ Assert.Equal(2, calls);
+ }
+
+ [Fact]
+ public async Task Categories_SecondAppearance_DoesNotRefetch()
+ {
+ var calls = 0;
+ var api = new FakeCategoriesApi
+ {
+ GetAllHandler = () =>
+ {
+ calls++;
+ return Task.FromResult>(
+ [new CategoryDto { Id = Guid.NewGuid(), Name = "Music", IsSystemDefault = false }]);
+ },
+ };
+ var messenger = new WeakReferenceMessenger();
+ var viewModel = new CategoriesViewModel(api, new FakeConnectivityService(), new FakeUserPrompt(), messenger);
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ Assert.Equal(1, calls);
+
+ messenger.Send(new SessionEndedMessage());
+ Assert.Empty(viewModel.Categories);
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ Assert.Equal(2, calls);
+ }
+
+ [Fact]
+ public async Task PaymentSources_SecondAppearance_DoesNotRefetch()
+ {
+ var calls = 0;
+ var api = new FakePaymentSourcesApi
+ {
+ GetAllHandler = () =>
+ {
+ calls++;
+ return Task.FromResult>(
+ [new PaymentSourceDto { Id = Guid.NewGuid(), Label = "HDFC Card", SourceType = PaymentSourceType.Card }]);
+ },
+ };
+ var messenger = new WeakReferenceMessenger();
+ var viewModel = new PaymentSourcesViewModel(api, new FakeUserPrompt(), new FakeConnectivityService(), messenger);
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ Assert.Equal(1, calls);
+
+ messenger.Send(new SessionEndedMessage());
+ Assert.Empty(viewModel.PaymentSources);
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ Assert.Equal(2, calls);
+ }
+
+ [Fact]
+ public async Task Settings_SecondAppearance_DoesNotRefetchTheProfile()
+ {
+ var calls = 0;
+ var usersApi = new FakeUsersApi
+ {
+ GetMeHandler = () =>
+ {
+ calls++;
+ return Task.FromResult(new UserProfileDto
+ {
+ Id = Guid.NewGuid(),
+ Email = "user@example.com",
+ PreferredCurrency = "INR",
+ DefaultAlertDaysAdvance = 3,
+ });
+ },
+ };
+ var messenger = new WeakReferenceMessenger();
+ var viewModel = new SettingsViewModel(
+ usersApi,
+ new FakeAuthApi(),
+ new FakeTokenStore(),
+ new FakeLocalCacheService(),
+ new FakeUserPrompt(),
+ messenger,
+ new FakeThemeService(),
+ new FakeConnectivityService());
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ Assert.Equal(1, calls);
+
+ messenger.Send(new SessionEndedMessage());
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ Assert.Equal(2, calls);
+ }
+
+ [Fact]
+ public async Task RenamingACategory_TellsTheOtherScreens()
+ {
+ // The dashboard names categories in its breakdown and the list groups by them. Neither
+ // refetches on tab switch any more, so the rename has to publish or they show the old name.
+ var category = new CategoryDto { Id = Guid.NewGuid(), Name = "Music", IsSystemDefault = false };
+ var api = new FakeCategoriesApi
+ {
+ GetAllHandler = () => Task.FromResult>([category]),
+ };
+ var messenger = new WeakReferenceMessenger();
+ var viewModel = new CategoriesViewModel(
+ api,
+ new FakeConnectivityService(),
+ new FakeUserPrompt { PromptResult = "Podcasts" },
+ messenger);
+
+ var announced = 0;
+ var listener = new object();
+ messenger.Register(listener, (_, _) => announced++);
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ await viewModel.RenameCommand.ExecuteAsync(viewModel.Categories[0]);
+
+ Assert.Equal(1, announced);
+ GC.KeepAlive(listener);
+ }
+
+ [Fact]
+ public async Task RenamingAPaymentSource_TellsTheOtherScreens()
+ {
+ var source = new PaymentSourceDto { Id = Guid.NewGuid(), Label = "HDFC Card", SourceType = PaymentSourceType.Card };
+ var api = new FakePaymentSourcesApi
+ {
+ GetAllHandler = () => Task.FromResult>([source]),
+ };
+ var messenger = new WeakReferenceMessenger();
+ var viewModel = new PaymentSourcesViewModel(
+ api,
+ new FakeUserPrompt { PromptResult = "ICICI Card" },
+ new FakeConnectivityService(),
+ messenger);
+
+ var announced = 0;
+ var listener = new object();
+ messenger.Register(listener, (_, _) => announced++);
+
+ await viewModel.EnsureLoadedCommand.ExecuteAsync(null);
+ await viewModel.RenameCommand.ExecuteAsync(viewModel.PaymentSources[0]);
+
+ Assert.Equal(1, announced);
+ GC.KeepAlive(listener);
+ }
+}