diff --git a/lib/model/store.dart b/lib/model/store.dart index a5d288fe02..528965059d 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -186,6 +186,13 @@ class PerAccountStore extends ChangeNotifier with StreamStore { /// to `globalStore.apiConnectionFromAccount(account)`. /// When present, it should be a connection that came from that method call, /// but it may have already been used for other requests. + bool isStale = true; // Flag indicating whether the data in the store is stale + void setIsStale(bool value) { + isStale = value; + notifyListeners(); + // Potentially notify other components about the staleness change + } + factory PerAccountStore.fromInitialSnapshot({ required GlobalStore globalStore, required int accountId, @@ -356,6 +363,7 @@ class PerAccountStore extends ChangeNotifier with StreamStore { void handleEvent(Event event) { if (event is HeartbeatEvent) { assert(debugLog("server event: heartbeat")); + setIsStale(false); // Data is no longer stale after receiving HeartbeatEvent } else if (event is RealmEmojiUpdateEvent) { assert(debugLog("server event: realm_emoji/update")); realmEmoji = event.realmEmoji; @@ -685,6 +693,7 @@ class UpdateMachine { switch (e) { case ZulipApiException(code: 'BAD_EVENT_QUEUE_ID'): assert(debugLog('Lost event queue for $store. Replacing…')); + store.setIsStale(true); // Set data as stale await store._globalStore._reloadPerAccount(store.accountId); dispose(); debugLog('… Event queue replaced.'); @@ -693,6 +702,7 @@ class UpdateMachine { case Server5xxException() || NetworkException(): assert(debugLog('Transient error polling event queue for $store: $e\n' 'Backing off, then will retry…')); + store.setIsStale(true); // Set data as stale in case of transient error // TODO tell user if transient polling errors persist // TODO reset to short backoff eventually await backoffMachine.wait(); @@ -702,6 +712,7 @@ class UpdateMachine { default: assert(debugLog('Error polling event queue for $store: $e\n' 'Backing off and retrying even though may be hopeless…')); + store.setIsStale(true); // Set data as stale in case of non-transient error // TODO tell user on non-transient error in polling await backoffMachine.wait(); assert(debugLog('… Backoff wait complete, retrying poll.')); diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 88fdcbe96f..3036eb8f15 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -16,6 +16,7 @@ import 'recent_dm_conversations.dart'; import 'store.dart'; import 'subscription_list.dart'; import 'theme.dart'; +import 'snackbar.dart'; class ZulipApp extends StatefulWidget { const ZulipApp({super.key, this.navigatorObservers}); @@ -295,6 +296,10 @@ class HomePage extends StatelessWidget { MessageListPage.buildRoute(context: context, narrow: StreamNarrow(testStreamId!))), child: const Text("#test here")), // scaffolding hack, see above + SizedBox( + height:40, + child:SnackBarPage(isStale:store.isStale), + ), ], ]))); } diff --git a/lib/widgets/snackbar.dart b/lib/widgets/snackbar.dart new file mode 100644 index 0000000000..8202838793 --- /dev/null +++ b/lib/widgets/snackbar.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +class SnackBarPage extends StatefulWidget { + final bool isStale; + const SnackBarPage({super.key, required this.isStale}); + + @override + SnackBarPageState createState() => SnackBarPageState(); +} + +class SnackBarPageState extends State { + @override + void initState() { + super.initState(); + // Call showSnackBar() after the build process is complete + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.isStale) { + showSnackBar(); + } + }); + } + + @override + void didUpdateWidget(covariant SnackBarPage oldWidget) { + super.didUpdateWidget(oldWidget); + // Check if isStale changed to true + if (widget.isStale && !oldWidget.isStale) { + WidgetsBinding.instance.addPostFrameCallback((_) { + showSnackBar(); + }); + } + } + + void showSnackBar() { + String snackBarText = 'Connecting'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon( + Icons.sync, + color: Colors.white, + ), + const SizedBox(width: 8), + Text( + snackBarText, + style: const TextStyle(color: Colors.white), + )]), + duration: const Duration(seconds: 20), + )); + } + + @override + Widget build(BuildContext context) { + return Container(); // Return an empty container or another widget here + } +} + + diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 0aadc42d0d..80799c417a 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -25,6 +25,8 @@ void main() { final account1 = eg.selfAccount.copyWith(id: 1); final account2 = eg.otherAccount.copyWith(id: 2); + + test('GlobalStore.perAccount sequential case', () async { final accounts = [account1, account2]; final globalStore = LoadingTestGlobalStore(accounts: accounts); @@ -117,6 +119,7 @@ void main() { group('PerAccountStore.sendMessage', () { test('smoke', () async { final store = eg.store(); + final connection = store.connection as FakeApiConnection; final stream = eg.stream(); connection.prepare(json: SendMessageResult(id: 12345).toJson()); @@ -136,6 +139,12 @@ void main() { }); }); + + final store = eg.store(); + check(store.isStale).isTrue(); + + + group('UpdateMachine.load', () { late TestGlobalStore globalStore; late FakeApiConnection connection; @@ -204,6 +213,8 @@ void main() { // TODO test UpdateMachine.load starts polling loop // TODO test UpdateMachine.load calls registerNotificationToken + + }); group('UpdateMachine.poll', () { @@ -240,7 +251,6 @@ void main() { test('loops on success', () => awaitFakeAsync((async) async { await prepareStore(lastEventId: 1); check(updateMachine.lastEventId).equals(1); - updateMachine.debugPauseLoop(); updateMachine.poll(); @@ -270,6 +280,8 @@ void main() { updateMachine.debugPauseLoop(); updateMachine.poll(); + + // Pick some arbitrary event and check it gets processed on the store. check(store.userSettings!.twentyFourHourTime).isFalse(); connection.prepare(json: GetEventsResult(events: [ @@ -280,6 +292,7 @@ void main() { async.flushMicrotasks(); await Future.delayed(Duration.zero); check(store.userSettings!.twentyFourHourTime).isTrue(); + check(store.isStale).isTrue(); })); test('handles expired queue', () => awaitFakeAsync((async) async { @@ -288,12 +301,16 @@ void main() { updateMachine.poll(); check(globalStore.perAccountSync(store.accountId)).identicalTo(store); + + + // Let the server expire the event queue. connection.prepare(httpStatus: 400, json: { 'result': 'error', 'code': 'BAD_EVENT_QUEUE_ID', 'queue_id': updateMachine.queueId, 'msg': 'Bad event queue ID: ${updateMachine.queueId}', }); + updateMachine.debugAdvanceLoop(); async.flushMicrotasks(); await Future.delayed(Duration.zero); @@ -306,6 +323,7 @@ void main() { updateMachine.debugPauseLoop(); updateMachine.poll(); check(store.userSettings!.twentyFourHourTime).isFalse(); + connection.prepare(json: GetEventsResult(events: [ UserSettingsUpdateEvent(id: 2, property: UserSettingName.twentyFourHourTime, value: true), @@ -314,6 +332,7 @@ void main() { async.flushMicrotasks(); await Future.delayed(Duration.zero); check(store.userSettings!.twentyFourHourTime).isTrue(); + check(store.isStale).isTrue(); })); void checkRetry(void Function() prepareError) { @@ -323,6 +342,8 @@ void main() { updateMachine.poll(); check(async.pendingTimers).length.equals(0); + + // Make the request, inducing an error in it. prepareError(); updateMachine.debugAdvanceLoop(); @@ -336,6 +357,8 @@ void main() { check(connection.lastRequest).isNull(); check(async.pendingTimers).length.equals(1); + + // Polling continues after a timer. connection.prepare(json: GetEventsResult(events: [ HeartbeatEvent(id: 2), @@ -343,7 +366,10 @@ void main() { async.flushTimers(); checkLastRequest(lastEventId: 1); check(updateMachine.lastEventId).equals(2); + check(store.isStale).isFalse(); + }); + } test('retries on Server5xxException', () { @@ -352,15 +378,18 @@ void main() { test('retries on NetworkException', () { checkRetry(() => connection.prepare(exception: Exception("failed"))); + }); test('retries on ZulipApiException', () { checkRetry(() => connection.prepare(httpStatus: 400, json: { 'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'})); + }); test('retries on MalformedServerResponseException', () { checkRetry(() => connection.prepare(httpStatus: 200, body: 'nonsense')); + }); }); diff --git a/test/widgets/snackbar_test.dart b/test/widgets/snackbar_test.dart new file mode 100644 index 0000000000..eee6d91956 --- /dev/null +++ b/test/widgets/snackbar_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/widgets/snackbar.dart'; + +void main() { + testWidgets('Test SnackBarPage', (WidgetTester tester) async { + /// SnackBarPage widget + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SnackBarPage(isStale: false), + ), + ), + ); + + /// noSnackBar is shown + await tester.pump(); + expect(find.byType(SnackBar), findsNothing); + + /// Change isStale to true + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SnackBarPage(isStale: true), + ), + ), + ); + + /// SnackBar is shown + await tester.pump(); + expect(find.text('Connecting'), findsOneWidget); + }); +}