From d908ac3b60bada211798ea272dbee0b7dd1906e8 Mon Sep 17 00:00:00 2001 From: Michael St Clair Date: Sun, 16 Aug 2026 11:20:30 -0600 Subject: [PATCH] Register the device for push and act on what arrives A small Swift channel rather than Firebase: `UNUserNotificationCenter` plus `didRegisterForRemoteNotificationsWithDeviceToken`, registered as a Flutter application life cycle delegate so plugins keep receiving the same callbacks. - The device token is PATCHed onto the API token once there is a session to hang it off. - A silent push invalidates the transactions, budget summary and banks providers, so a sync that finished on the server lands on screen without the user pulling to refresh. - A tapped notification opens the transactions tab. The shell's tab moved into its own provider to make that possible, which keeps it out of whatever the Cupertino port does to the shell. Needs the Push Notifications capability and the remote-notification background mode, both declared here. The sync screen's "pull to refresh" copy is now false, so it says what actually happens. Co-Authored-By: Claude Opus 5 --- .../api/controllers/bank_member_controller.ex | 4 +- mobile/README.md | 7 +- mobile/api/lib/src/api/banks_api.dart | 2 +- mobile/ios/Runner.xcodeproj/project.pbxproj | 4 + mobile/ios/Runner/AppDelegate.swift | 13 ++ mobile/ios/Runner/Info.plist | 5 + mobile/ios/Runner/PushChannel.swift | 86 +++++++++++ mobile/ios/Runner/Runner.entitlements | 4 + mobile/lib/app.dart | 5 +- mobile/lib/banks/banks_controller.dart | 3 +- mobile/lib/banks/banks_screen.dart | 6 +- mobile/lib/push/push_channel.dart | 72 +++++++++ mobile/lib/push/push_channel.g.dart | 85 +++++++++++ mobile/lib/push/push_controller.dart | 60 ++++++++ mobile/lib/push/push_controller.g.dart | 57 +++++++ mobile/lib/selected_tab.dart | 15 ++ mobile/lib/selected_tab.g.dart | 70 +++++++++ mobile/lib/shell.dart | 19 +-- mobile/test/banks/banks_screen_test.dart | 2 +- mobile/test/push/push_controller_test.dart | 140 ++++++++++++++++++ mobile/test/support/fakes.dart | 24 +++ priv/static/openapi.json | 2 +- 22 files changed, 662 insertions(+), 23 deletions(-) create mode 100644 mobile/ios/Runner/PushChannel.swift create mode 100644 mobile/lib/push/push_channel.dart create mode 100644 mobile/lib/push/push_channel.g.dart create mode 100644 mobile/lib/push/push_controller.dart create mode 100644 mobile/lib/push/push_controller.g.dart create mode 100644 mobile/lib/selected_tab.dart create mode 100644 mobile/lib/selected_tab.g.dart create mode 100644 mobile/test/push/push_controller_test.dart diff --git a/lib/spendable_web/api/controllers/bank_member_controller.ex b/lib/spendable_web/api/controllers/bank_member_controller.ex index 3bcdfec4..b4767f4a 100644 --- a/lib/spendable_web/api/controllers/bank_member_controller.ex +++ b/lib/spendable_web/api/controllers/bank_member_controller.ex @@ -87,8 +87,8 @@ defmodule SpendableWeb.Api.BankMemberController do operation_id: "syncBank", summary: "Pull two years of history", description: """ - Queues the work and returns immediately. There is no completion signal - refresh the lists to - pick up whatever has landed. + Queues the work and returns immediately. A silent push goes out when the run finishes, which + is the signal to re-read whatever the client is showing. """, parameters: [id: [in: :path, type: :string, required: true]], responses: [ diff --git a/mobile/README.md b/mobile/README.md index 025dfcdc..2e0b4d36 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -36,7 +36,8 @@ or every regeneration will show up as a diff. ## Setup this repo cannot do for you -Both are external to the codebase and sign-in fails on device without them. +All external to the codebase. The first two fail sign-in on device; the third silently sends no +pushes. - **Sign in with Apple** needs the capability enabled on `fiftysevenmedia.Spendable` in the Apple Developer portal. `ios/Runner/Runner.entitlements` already declares it. @@ -44,3 +45,7 @@ Both are external to the codebase and sign-in fails on device without them. and its reversed form as a URL scheme. The **server** needs the same value in its `GOOGLE_IOS_CLIENT_ID` env var, or it rejects the app's ID tokens on audience - `scripts/install.sh` prompts for it. +- **Push notifications** need the capability enabled on the same App ID and an APNs key (a `.p8`) + created under Keys. The **server** reads it, its key id and the team id from `.secrets/` - + `scripts/install.sh` prompts for all three. There is no push on the simulator: APNs issues no + device token there, and registration fails as a matter of course. diff --git a/mobile/api/lib/src/api/banks_api.dart b/mobile/api/lib/src/api/banks_api.dart index 7299070e..195c7138 100644 --- a/mobile/api/lib/src/api/banks_api.dart +++ b/mobile/api/lib/src/api/banks_api.dart @@ -453,7 +453,7 @@ class BanksApi { } /// Pull two years of history - /// Queues the work and returns immediately. There is no completion signal - refresh the lists to pick up whatever has landed. + /// Queues the work and returns immediately. A silent push goes out when the run finishes, which is the signal to re-read whatever the client is showing. /// /// Parameters: /// * [id] - diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index c0ceea89..a6c8914d 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 7A19C4022F00000100C636F2 /* PushChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A19C4012F00000100C636F2 /* PushChannel.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -50,6 +51,7 @@ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7A19C4012F00000100C636F2 /* PushChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushChannel.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -123,6 +125,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 7A19C4012F00000100C636F2 /* PushChannel.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -284,6 +287,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + 7A19C4022F00000100C636F2 /* PushChannel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index c30b367e..850d37e8 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,16 +1,29 @@ import Flutter import UIKit +import UserNotifications @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + private var push: PushChannel? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + // Flutter forwards notification centre events to its life cycle delegates only while the app + // delegate is the centre's delegate. + UNUserNotificationCenter.current().delegate = self + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + + let push = PushChannel(messenger: engineBridge.applicationRegistrar.messenger()) + + addApplicationLifeCycleDelegate(push) + + self.push = push } } diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 44f334e2..b726c389 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -42,6 +42,11 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + + UIBackgroundModes + + remote-notification + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/mobile/ios/Runner/PushChannel.swift b/mobile/ios/Runner/PushChannel.swift new file mode 100644 index 00000000..05e741ba --- /dev/null +++ b/mobile/ios/Runner/PushChannel.swift @@ -0,0 +1,86 @@ +import Flutter +import UIKit +import UserNotifications + +/// The device end of push, kept to plumbing: what a notification means is Dart's business. +/// +/// Registered as a Flutter application life cycle delegate rather than written onto the app +/// delegate, so plugins keep receiving the same callbacks. +class PushChannel: NSObject, FlutterApplicationLifeCycleDelegate { + private let channel: FlutterMethodChannel + + init(messenger: FlutterBinaryMessenger) { + channel = FlutterMethodChannel(name: "spendable/push", binaryMessenger: messenger) + + super.init() + + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "register" else { + result(FlutterMethodNotImplemented) + + return + } + + self?.register(result) + } + } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + channel.invokeMethod("token", arguments: deviceToken.map { String(format: "%02x", $0) }.joined()) + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + // The simulator has no APNs, so this is the ordinary path there and not worth surfacing. + NSLog("push registration failed: %@", error.localizedDescription) + } + + /// A silent push. iOS gives the app seconds rather than a callback to wait on, so the fetch + /// result is reported as soon as Dart has been told. + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) -> Bool { + channel.invokeMethod("refresh", arguments: nil) + completionHandler(.newData) + + return true + } + + /// An alert that arrived while the app was open: show it, and refresh behind it. + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + channel.invokeMethod("refresh", arguments: nil) + completionHandler([.banner, .sound]) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + channel.invokeMethod("opened", arguments: nil) + completionHandler() + } + + private func register(_ result: @escaping FlutterResult) { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in + DispatchQueue.main.async { + // Registered whatever the answer: a silent push needs no permission, and it is what + // refreshes the app in the background. + UIApplication.shared.registerForRemoteNotifications() + + result(granted) + } + } + } +} diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements index 54eed416..5365816f 100644 --- a/mobile/ios/Runner/Runner.entitlements +++ b/mobile/ios/Runner/Runner.entitlements @@ -2,6 +2,10 @@ + + aps-environment + development com.apple.developer.applesignin Default diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index b4ae1476..052a7f40 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'auth/auth_controller.dart'; import 'auth/sign_in_screen.dart'; import 'banks/plaid_oauth_links.dart'; +import 'push/push_controller.dart'; import 'shell.dart'; import 'theme.dart'; @@ -14,8 +15,10 @@ class SpendableApp extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final auth = ref.watch(authStateProvider); - // Nothing renders it; it just has to be alive to catch a bank's OAuth redirect on launch. + // Nothing renders these; they just have to be alive to catch a bank's OAuth redirect on + // launch, and to answer what APNs sends. ref.watch(plaidOAuthResumeProvider); + ref.watch(pushControllerProvider); return MaterialApp( title: 'Spendable', diff --git a/mobile/lib/banks/banks_controller.dart b/mobile/lib/banks/banks_controller.dart index f42be0ad..273db797 100644 --- a/mobile/lib/banks/banks_controller.dart +++ b/mobile/lib/banks/banks_controller.dart @@ -62,7 +62,8 @@ class BanksController extends _$BanksController { Future assignBudget(BankAccount account, String? budgetId) => _updateAccount(account.id, BankAccountRequest((builder) => builder.budgetId = budgetId)); - /// Two years of history, queued. There is no completion signal, so the user pulls to refresh. + /// Two years of history, queued. The silent push at the end of the run is what refreshes the + /// lists, so there is nothing to wait on here. Future syncHistory(String memberId) => _write(() => _api.syncBank(id: memberId).orApiError()); BanksApi get _api => ref.read(apiProvider).getBanksApi(); diff --git a/mobile/lib/banks/banks_screen.dart b/mobile/lib/banks/banks_screen.dart index 5e8deedb..28f2f986 100644 --- a/mobile/lib/banks/banks_screen.dart +++ b/mobile/lib/banks/banks_screen.dart @@ -93,12 +93,10 @@ class _Member extends ConsumerWidget { if (!queued || !context.mounted) return; - // The job answers nothing when it finishes, so say so rather than implying a wait. + // The request only queues the job; the silent push at the end of the run is what refreshes. ScaffoldMessenger.of(context) ..clearSnackBars() - ..showSnackBar( - const SnackBar(content: Text('Syncing history. Pull to refresh to see what has landed.')), - ); + ..showSnackBar(const SnackBar(content: Text('Syncing history. The lists update when it finishes.'))); } } diff --git a/mobile/lib/push/push_channel.dart b/mobile/lib/push/push_channel.dart new file mode 100644 index 00000000..fb0f776a --- /dev/null +++ b/mobile/lib/push/push_channel.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'push_channel.g.dart'; + +/// What the device has to say about push. +sealed class PushEvent { + const PushEvent(); +} + +/// iOS issued a device token. It arrives after [PushChannel.register] and again whenever iOS +/// reissues one, so it is an event rather than the return of the call that asked for it. +class PushToken extends PushEvent { + const PushToken(this.token); + + final String token; +} + +/// A silent push: a sync finished and whatever is on screen is now behind. +class PushRefresh extends PushEvent { + const PushRefresh(); +} + +/// The user tapped a notification. +class PushOpened extends PushEvent { + const PushOpened(); +} + +/// Permission, registration, and the events that follow. An interface so tests do not need a +/// platform channel. +abstract interface class PushChannel { + Stream get events; + + /// Asks for permission and registers with APNs either way - a silent push needs no permission. + /// Answers whether the user allowed alerts. + Future register(); +} + +class PlatformPushChannel implements PushChannel { + PlatformPushChannel([MethodChannel? channel]) + : _channel = channel ?? const MethodChannel('spendable/push') { + _channel.setMethodCallHandler(_receive); + } + + final MethodChannel _channel; + final _events = StreamController.broadcast(); + + @override + Stream get events => _events.stream; + + @override + Future register() async => await _channel.invokeMethod('register') ?? false; + + Future _receive(MethodCall call) async { + switch (call.method) { + case 'token': + _events.add(PushToken(call.arguments as String)); + case 'refresh': + _events.add(const PushRefresh()); + case 'opened': + _events.add(const PushOpened()); + } + } +} + +@Riverpod(keepAlive: true) +PushChannel pushChannel(Ref ref) => PlatformPushChannel(); + +@Riverpod(keepAlive: true) +Stream pushEvents(Ref ref) => ref.watch(pushChannelProvider).events; diff --git a/mobile/lib/push/push_channel.g.dart b/mobile/lib/push/push_channel.g.dart new file mode 100644 index 00000000..96e40b6f --- /dev/null +++ b/mobile/lib/push/push_channel.g.dart @@ -0,0 +1,85 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'push_channel.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(pushChannel) +final pushChannelProvider = PushChannelProvider._(); + +final class PushChannelProvider + extends $FunctionalProvider + with $Provider { + PushChannelProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pushChannelProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pushChannelHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + PushChannel create(Ref ref) { + return pushChannel(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PushChannel value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$pushChannelHash() => r'c177d822753fafe7f8119849877e1362fb222133'; + +@ProviderFor(pushEvents) +final pushEventsProvider = PushEventsProvider._(); + +final class PushEventsProvider + extends + $FunctionalProvider, PushEvent, Stream> + with $FutureModifier, $StreamProvider { + PushEventsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pushEventsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pushEventsHash(); + + @$internal + @override + $StreamProviderElement $createElement($ProviderPointer pointer) => + $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + return pushEvents(ref); + } +} + +String _$pushEventsHash() => r'21daab61587463f4d6b1f16d8a9edcad723d0803'; diff --git a/mobile/lib/push/push_controller.dart b/mobile/lib/push/push_controller.dart new file mode 100644 index 00000000..d45fb0eb --- /dev/null +++ b/mobile/lib/push/push_controller.dart @@ -0,0 +1,60 @@ +import 'package:dio/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:spendable_api/spendable_api.dart'; + +import '../api/api_client.dart'; +import '../auth/auth_controller.dart'; +import '../banks/banks_providers.dart'; +import '../budgets/budgets_providers.dart'; +import '../selected_tab.dart'; +import '../transactions/transactions_providers.dart'; +import 'push_channel.dart'; + +part 'push_controller.g.dart'; + +/// Registers the device once there is a session to attach it to, and acts on what arrives. +/// Nothing renders it; it is watched from the root so it is alive whichever screen the user is on. +@Riverpod(keepAlive: true) +void pushController(Ref ref) { + // Registering only while signed in: the device token is held against the API token, so there is + // nowhere to put it until sign-in has produced one. + ref.listen(authStateProvider, (_, next) { + if (next case AsyncData(value: true)) ref.read(pushChannelProvider).register(); + }, fireImmediately: true); + + ref.listen(pushEventsProvider, (_, next) { + if (next case AsyncData(value: final event)) { + switch (event) { + case PushToken(:final token): + _register(ref, token); + case PushRefresh(): + _refresh(ref); + case PushOpened(): + ref.read(selectedTabProvider.notifier).select(AppTab.transactions); + _refresh(ref); + } + } + }); +} + +/// A failure here is not worth surfacing: the user asked for nothing, and the next launch tries +/// again with whatever token iOS hands over then. +Future _register(Ref ref, String token) async { + if (ref.read(authStateProvider).value != true) return; + + final request = SessionUpdateRequest((builder) => builder.apnsToken = token); + + try { + await ref.read(apiProvider).getSessionApi().updateSession(sessionUpdateRequest: request); + } on DioException { + // Nothing to tell the user: they asked for nothing, and the next launch registers again. + } +} + +/// A sync landed on the server, so everything read from it is now behind. Invalidating rather +/// than patching keeps to the rule that the server is the only account of what things look like. +void _refresh(Ref ref) { + ref.invalidate(transactionsProvider); + ref.invalidate(budgetSummaryProvider); + ref.invalidate(bankMembersProvider); +} diff --git a/mobile/lib/push/push_controller.g.dart b/mobile/lib/push/push_controller.g.dart new file mode 100644 index 00000000..4d6d2369 --- /dev/null +++ b/mobile/lib/push/push_controller.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'push_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Registers the device once there is a session to attach it to, and acts on what arrives. +/// Nothing renders it; it is watched from the root so it is alive whichever screen the user is on. + +@ProviderFor(pushController) +final pushControllerProvider = PushControllerProvider._(); + +/// Registers the device once there is a session to attach it to, and acts on what arrives. +/// Nothing renders it; it is watched from the root so it is alive whichever screen the user is on. + +final class PushControllerProvider extends $FunctionalProvider + with $Provider { + /// Registers the device once there is a session to attach it to, and acts on what arrives. + /// Nothing renders it; it is watched from the root so it is alive whichever screen the user is on. + PushControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pushControllerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pushControllerHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + void create(Ref ref) { + return pushController(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$pushControllerHash() => r'c46cc5070ec2f21da058d1ede37880a365834de1'; diff --git a/mobile/lib/selected_tab.dart b/mobile/lib/selected_tab.dart new file mode 100644 index 00000000..eab9be85 --- /dev/null +++ b/mobile/lib/selected_tab.dart @@ -0,0 +1,15 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'selected_tab.g.dart'; + +enum AppTab { budgets, transactions, splits, banks } + +/// Which tab the shell is showing. A provider rather than the shell's own state because a tapped +/// notification has to be able to move it. +@Riverpod(keepAlive: true) +class SelectedTab extends _$SelectedTab { + @override + AppTab build() => AppTab.budgets; + + void select(AppTab tab) => state = tab; +} diff --git a/mobile/lib/selected_tab.g.dart b/mobile/lib/selected_tab.g.dart new file mode 100644 index 00000000..9d7f5a64 --- /dev/null +++ b/mobile/lib/selected_tab.g.dart @@ -0,0 +1,70 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'selected_tab.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Which tab the shell is showing. A provider rather than the shell's own state because a tapped +/// notification has to be able to move it. + +@ProviderFor(SelectedTab) +final selectedTabProvider = SelectedTabProvider._(); + +/// Which tab the shell is showing. A provider rather than the shell's own state because a tapped +/// notification has to be able to move it. +final class SelectedTabProvider extends $NotifierProvider { + /// Which tab the shell is showing. A provider rather than the shell's own state because a tapped + /// notification has to be able to move it. + SelectedTabProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'selectedTabProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$selectedTabHash(); + + @$internal + @override + SelectedTab create() => SelectedTab(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AppTab value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$selectedTabHash() => r'7e06ca85e5b9ca9306cd25dba26520a14962b0ba'; + +/// Which tab the shell is showing. A provider rather than the shell's own state because a tapped +/// notification has to be able to move it. + +abstract class _$SelectedTab extends $Notifier { + AppTab build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + AppTab, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/mobile/lib/shell.dart b/mobile/lib/shell.dart index 1bc33b4d..002e8258 100644 --- a/mobile/lib/shell.dart +++ b/mobile/lib/shell.dart @@ -1,32 +1,29 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'banks/banks_screen.dart'; import 'budgets/budgets_screen.dart'; +import 'selected_tab.dart'; import 'splits/splits_screen.dart'; import 'transactions/transactions_screen.dart'; /// IndexedStack rather than a swapped child, so switching tabs keeps each screen's scroll /// position and its loaded pages. -class Shell extends StatefulWidget { +class Shell extends ConsumerWidget { const Shell({super.key}); @override - State createState() => _ShellState(); -} - -class _ShellState extends State { - var _tab = 0; + Widget build(BuildContext context, WidgetRef ref) { + final tab = ref.watch(selectedTabProvider); - @override - Widget build(BuildContext context) { return Scaffold( body: IndexedStack( - index: _tab, + index: tab.index, children: const [BudgetsScreen(), TransactionsScreen(), SplitsScreen(), BanksScreen()], ), bottomNavigationBar: NavigationBar( - selectedIndex: _tab, - onDestinationSelected: (index) => setState(() => _tab = index), + selectedIndex: tab.index, + onDestinationSelected: (index) => ref.read(selectedTabProvider.notifier).select(AppTab.values[index]), destinations: const [ NavigationDestination( key: Key('tab-budgets'), diff --git a/mobile/test/banks/banks_screen_test.dart b/mobile/test/banks/banks_screen_test.dart index c06231f1..f4c72f0c 100644 --- a/mobile/test/banks/banks_screen_test.dart +++ b/mobile/test/banks/banks_screen_test.dart @@ -263,6 +263,6 @@ void main() { await tester.pumpAndSettle(); expect(api.requests.map((request) => request.path), contains('/api/banks/bkm_1/sync')); - expect(find.textContaining('Pull to refresh'), findsOneWidget); + expect(find.textContaining('when it finishes'), findsOneWidget); }); } diff --git a/mobile/test/push/push_controller_test.dart b/mobile/test/push/push_controller_test.dart new file mode 100644 index 00000000..c386f842 --- /dev/null +++ b/mobile/test/push/push_controller_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spendable/api/api_client.dart'; +import 'package:spendable/auth/auth_controller.dart'; +import 'package:spendable/auth/token_storage.dart'; +import 'package:spendable/push/push_channel.dart'; +import 'package:spendable/push/push_controller.dart'; +import 'package:spendable/selected_tab.dart'; +import 'package:spendable/transactions/transactions_providers.dart'; + +import '../support/fakes.dart'; + +const _transaction = { + 'id': 'trn_1', + 'name': 'Coffee', + 'amount': '-4.50', + 'date': '2026-08-16', + 'reviewed': false, + 'excluded': false, + 'budget_allocations': [], +}; + +ProviderContainer _container({ + required FakePushChannel push, + required FakeApi api, + String? token = 'apt_stored', +}) { + final container = ProviderContainer( + overrides: [ + pushChannelProvider.overrideWithValue(push), + tokenStorageProvider.overrideWithValue(FakeTokenStorage(token)), + apiProvider.overrideWithValue(api.build()), + ], + ); + + addTearDown(container.dispose); + + return container; +} + +void main() { + test('a signed-in launch asks for permission', () async { + final push = FakePushChannel(); + final container = _container(push: push, api: FakeApi({})); + + container.listen(pushControllerProvider, (previous, next) {}); + + // The session is read from the Keychain, so the ask cannot happen on the first frame. + await container.read(authStateProvider.future); + await pumpEventQueue(); + + expect(push.registered, 1); + }); + + test('a signed-out launch asks for nothing', () async { + final push = FakePushChannel(); + final container = _container(push: push, api: FakeApi({}), token: null); + + container.listen(pushControllerProvider, (previous, next) {}); + + await container.read(authStateProvider.future); + await pumpEventQueue(); + + expect(push.registered, 0); + }); + + test('a device token is registered against the session', () async { + final push = FakePushChannel(); + final api = FakeApi({'PATCH /api/session': (status: 204, body: null)}); + final container = _container(push: push, api: api); + + container.listen(pushControllerProvider, (previous, next) {}); + + await container.read(authStateProvider.future); + + push.send(const PushToken('abcdef')); + + await pumpEventQueue(); + + expect(api.requests.single.method, 'PATCH'); + expect(api.requests.single.data, containsPair('apns_token', 'abcdef')); + }); + + // The API token the device token would hang off does not exist yet. + test('a device token that arrives signed out is not sent', () async { + final push = FakePushChannel(); + final api = FakeApi({}); + final container = _container(push: push, api: api, token: null); + + container.listen(pushControllerProvider, (previous, next) {}); + + await container.read(authStateProvider.future); + + push.send(const PushToken('abcdef')); + + await pumpEventQueue(); + + expect(api.requests, isEmpty); + }); + + // A silent push says a sync finished, which is the only signal the app gets that what it is + // showing is behind. + test('a silent push refetches the lists', () async { + final push = FakePushChannel(); + final api = FakeApi({ + 'GET /api/transactions': (status: 200, body: [_transaction]), + }); + final container = _container(push: push, api: api); + + container.listen(pushControllerProvider, (previous, next) {}); + container.listen(transactionsProvider, (previous, next) {}); + + await container.read(transactionsProvider.future); + + push.send(const PushRefresh()); + + await pumpEventQueue(); + await container.read(transactionsProvider.future); + + expect(api.requests.where((request) => request.path == '/api/transactions'), hasLength(2)); + }); + + test('a tapped notification opens the transactions tab', () async { + final push = FakePushChannel(); + final api = FakeApi({ + 'GET /api/transactions': (status: 200, body: [_transaction]), + }); + final container = _container(push: push, api: api); + + container.listen(pushControllerProvider, (previous, next) {}); + + expect(container.read(selectedTabProvider), AppTab.budgets); + + push.send(const PushOpened()); + + await pumpEventQueue(); + + expect(container.read(selectedTabProvider), AppTab.transactions); + }); +} diff --git a/mobile/test/support/fakes.dart b/mobile/test/support/fakes.dart index 2b019f0f..98c01d63 100644 --- a/mobile/test/support/fakes.dart +++ b/mobile/test/support/fakes.dart @@ -1,9 +1,11 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:spendable/auth/identity_tokens.dart'; import 'package:spendable/auth/token_storage.dart'; +import 'package:spendable/push/push_channel.dart'; import 'package:spendable_api/spendable_api.dart'; class FakeTokenStorage implements TokenStorage { @@ -40,6 +42,28 @@ class FakeIdentityTokens implements IdentityTokens { } } +class FakePushChannel implements PushChannel { + FakePushChannel({this.granted = true}); + + final bool granted; + final _events = StreamController.broadcast(); + + var registered = 0; + + @override + Stream get events => _events.stream; + + @override + Future register() async { + registered += 1; + + return granted; + } + + /// Stands in for the device: what iOS would have sent up the channel. + void send(PushEvent event) => _events.add(event); +} + /// One canned reply per `METHOD /path`, so a test states only the calls it cares about. class FakeApi { FakeApi(this.replies); diff --git a/priv/static/openapi.json b/priv/static/openapi.json index 04eff22c..da8d6d87 100644 --- a/priv/static/openapi.json +++ b/priv/static/openapi.json @@ -1412,7 +1412,7 @@ "/api/banks/{id}/sync": { "post": { "callbacks": {}, - "description": "Queues the work and returns immediately. There is no completion signal - refresh the lists to\npick up whatever has landed.\n", + "description": "Queues the work and returns immediately. A silent push goes out when the run finishes, which\nis the signal to re-read whatever the client is showing.\n", "operationId": "syncBank", "parameters": [ {