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": [ {