Skip to content

Commit 336dc3b

Browse files
committed
Store:add bool isstale and snackbar widget
Previously, the absence of a dedicated mechanism to display connection-related messages resulted in users experiencing ambiguity regarding Data Staleness. By leveraging the initState and didUpdateWidget methods, the connecting SnackBar is displayed post-build and upon isStale updates. Fixes #465
1 parent d882f50 commit 336dc3b

File tree

5 files changed

+138
-1
lines changed

5 files changed

+138
-1
lines changed

lib/model/store.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,13 @@ class PerAccountStore extends ChangeNotifier with StreamStore {
186186
/// to `globalStore.apiConnectionFromAccount(account)`.
187187
/// When present, it should be a connection that came from that method call,
188188
/// but it may have already been used for other requests.
189+
bool isStale = true; // Flag indicating whether the data in the store is stale
190+
void setIsStale(bool value) {
191+
isStale = value;
192+
notifyListeners();
193+
// Potentially notify other components about the staleness change
194+
}
195+
189196
factory PerAccountStore.fromInitialSnapshot({
190197
required GlobalStore globalStore,
191198
required int accountId,
@@ -356,6 +363,7 @@ class PerAccountStore extends ChangeNotifier with StreamStore {
356363
void handleEvent(Event event) {
357364
if (event is HeartbeatEvent) {
358365
assert(debugLog("server event: heartbeat"));
366+
setIsStale(false); // Data is no longer stale after receiving HeartbeatEvent
359367
} else if (event is RealmEmojiUpdateEvent) {
360368
assert(debugLog("server event: realm_emoji/update"));
361369
realmEmoji = event.realmEmoji;
@@ -685,6 +693,7 @@ class UpdateMachine {
685693
switch (e) {
686694
case ZulipApiException(code: 'BAD_EVENT_QUEUE_ID'):
687695
assert(debugLog('Lost event queue for $store. Replacing…'));
696+
store.setIsStale(true); // Set data as stale
688697
await store._globalStore._reloadPerAccount(store.accountId);
689698
dispose();
690699
debugLog('… Event queue replaced.');
@@ -693,6 +702,7 @@ class UpdateMachine {
693702
case Server5xxException() || NetworkException():
694703
assert(debugLog('Transient error polling event queue for $store: $e\n'
695704
'Backing off, then will retry…'));
705+
store.setIsStale(true); // Set data as stale in case of transient error
696706
// TODO tell user if transient polling errors persist
697707
// TODO reset to short backoff eventually
698708
await backoffMachine.wait();
@@ -702,6 +712,7 @@ class UpdateMachine {
702712
default:
703713
assert(debugLog('Error polling event queue for $store: $e\n'
704714
'Backing off and retrying even though may be hopeless…'));
715+
store.setIsStale(true); // Set data as stale in case of non-transient error
705716
// TODO tell user on non-transient error in polling
706717
await backoffMachine.wait();
707718
assert(debugLog('… Backoff wait complete, retrying poll.'));

lib/widgets/app.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import 'recent_dm_conversations.dart';
1616
import 'store.dart';
1717
import 'subscription_list.dart';
1818
import 'theme.dart';
19+
import 'snackbar.dart';
1920

2021
class ZulipApp extends StatefulWidget {
2122
const ZulipApp({super.key, this.navigatorObservers});
@@ -295,6 +296,10 @@ class HomePage extends StatelessWidget {
295296
MessageListPage.buildRoute(context: context,
296297
narrow: StreamNarrow(testStreamId!))),
297298
child: const Text("#test here")), // scaffolding hack, see above
299+
SizedBox(
300+
height:40,
301+
child:SnackBarPage(isStale:store.isStale),
302+
),
298303
],
299304
])));
300305
}

lib/widgets/snackbar.dart

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import 'package:flutter/material.dart';
2+
3+
class SnackBarPage extends StatefulWidget {
4+
final bool isStale;
5+
const SnackBarPage({super.key, required this.isStale});
6+
7+
@override
8+
SnackBarPageState createState() => SnackBarPageState();
9+
}
10+
11+
class SnackBarPageState extends State<SnackBarPage> {
12+
@override
13+
void initState() {
14+
super.initState();
15+
// Call showSnackBar() after the build process is complete
16+
WidgetsBinding.instance.addPostFrameCallback((_) {
17+
if (widget.isStale) {
18+
showSnackBar();
19+
}
20+
});
21+
}
22+
23+
@override
24+
void didUpdateWidget(covariant SnackBarPage oldWidget) {
25+
super.didUpdateWidget(oldWidget);
26+
// Check if isStale changed to true
27+
if (widget.isStale && !oldWidget.isStale) {
28+
WidgetsBinding.instance.addPostFrameCallback((_) {
29+
showSnackBar();
30+
});
31+
}
32+
}
33+
34+
void showSnackBar() {
35+
String snackBarText = 'Connecting';
36+
ScaffoldMessenger.of(context).showSnackBar(
37+
SnackBar(
38+
content: Row(
39+
children: [
40+
const Icon(
41+
Icons.sync,
42+
color: Colors.white,
43+
),
44+
const SizedBox(width: 8),
45+
Text(
46+
snackBarText,
47+
style: const TextStyle(color: Colors.white),
48+
)]),
49+
duration: const Duration(seconds: 20),
50+
));
51+
}
52+
53+
@override
54+
Widget build(BuildContext context) {
55+
return Container(); // Return an empty container or another widget here
56+
}
57+
}
58+
59+

test/model/store_test.dart

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ void main() {
2525
final account1 = eg.selfAccount.copyWith(id: 1);
2626
final account2 = eg.otherAccount.copyWith(id: 2);
2727

28+
29+
2830
test('GlobalStore.perAccount sequential case', () async {
2931
final accounts = [account1, account2];
3032
final globalStore = LoadingTestGlobalStore(accounts: accounts);
@@ -117,6 +119,7 @@ void main() {
117119
group('PerAccountStore.sendMessage', () {
118120
test('smoke', () async {
119121
final store = eg.store();
122+
120123
final connection = store.connection as FakeApiConnection;
121124
final stream = eg.stream();
122125
connection.prepare(json: SendMessageResult(id: 12345).toJson());
@@ -136,6 +139,12 @@ void main() {
136139
});
137140
});
138141

142+
143+
final store = eg.store();
144+
check(store.isStale).isTrue();
145+
146+
147+
139148
group('UpdateMachine.load', () {
140149
late TestGlobalStore globalStore;
141150
late FakeApiConnection connection;
@@ -204,6 +213,8 @@ void main() {
204213

205214
// TODO test UpdateMachine.load starts polling loop
206215
// TODO test UpdateMachine.load calls registerNotificationToken
216+
217+
207218
});
208219

209220
group('UpdateMachine.poll', () {
@@ -240,7 +251,6 @@ void main() {
240251
test('loops on success', () => awaitFakeAsync((async) async {
241252
await prepareStore(lastEventId: 1);
242253
check(updateMachine.lastEventId).equals(1);
243-
244254
updateMachine.debugPauseLoop();
245255
updateMachine.poll();
246256

@@ -270,6 +280,8 @@ void main() {
270280
updateMachine.debugPauseLoop();
271281
updateMachine.poll();
272282

283+
284+
273285
// Pick some arbitrary event and check it gets processed on the store.
274286
check(store.userSettings!.twentyFourHourTime).isFalse();
275287
connection.prepare(json: GetEventsResult(events: [
@@ -280,6 +292,7 @@ void main() {
280292
async.flushMicrotasks();
281293
await Future.delayed(Duration.zero);
282294
check(store.userSettings!.twentyFourHourTime).isTrue();
295+
check(store.isStale).isTrue();
283296
}));
284297

285298
test('handles expired queue', () => awaitFakeAsync((async) async {
@@ -288,12 +301,16 @@ void main() {
288301
updateMachine.poll();
289302
check(globalStore.perAccountSync(store.accountId)).identicalTo(store);
290303

304+
305+
306+
291307
// Let the server expire the event queue.
292308
connection.prepare(httpStatus: 400, json: {
293309
'result': 'error', 'code': 'BAD_EVENT_QUEUE_ID',
294310
'queue_id': updateMachine.queueId,
295311
'msg': 'Bad event queue ID: ${updateMachine.queueId}',
296312
});
313+
297314
updateMachine.debugAdvanceLoop();
298315
async.flushMicrotasks();
299316
await Future.delayed(Duration.zero);
@@ -306,6 +323,7 @@ void main() {
306323
updateMachine.debugPauseLoop();
307324
updateMachine.poll();
308325
check(store.userSettings!.twentyFourHourTime).isFalse();
326+
309327
connection.prepare(json: GetEventsResult(events: [
310328
UserSettingsUpdateEvent(id: 2,
311329
property: UserSettingName.twentyFourHourTime, value: true),
@@ -314,6 +332,7 @@ void main() {
314332
async.flushMicrotasks();
315333
await Future.delayed(Duration.zero);
316334
check(store.userSettings!.twentyFourHourTime).isTrue();
335+
check(store.isStale).isTrue();
317336
}));
318337

319338
void checkRetry(void Function() prepareError) {
@@ -323,6 +342,8 @@ void main() {
323342
updateMachine.poll();
324343
check(async.pendingTimers).length.equals(0);
325344

345+
346+
326347
// Make the request, inducing an error in it.
327348
prepareError();
328349
updateMachine.debugAdvanceLoop();
@@ -336,14 +357,19 @@ void main() {
336357
check(connection.lastRequest).isNull();
337358
check(async.pendingTimers).length.equals(1);
338359

360+
361+
339362
// Polling continues after a timer.
340363
connection.prepare(json: GetEventsResult(events: [
341364
HeartbeatEvent(id: 2),
342365
], queueId: null).toJson());
343366
async.flushTimers();
344367
checkLastRequest(lastEventId: 1);
345368
check(updateMachine.lastEventId).equals(2);
369+
check(store.isStale).isFalse();
370+
346371
});
372+
347373
}
348374

349375
test('retries on Server5xxException', () {
@@ -352,15 +378,18 @@ void main() {
352378

353379
test('retries on NetworkException', () {
354380
checkRetry(() => connection.prepare(exception: Exception("failed")));
381+
355382
});
356383

357384
test('retries on ZulipApiException', () {
358385
checkRetry(() => connection.prepare(httpStatus: 400, json: {
359386
'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'}));
387+
360388
});
361389

362390
test('retries on MalformedServerResponseException', () {
363391
checkRetry(() => connection.prepare(httpStatus: 200, body: 'nonsense'));
392+
364393
});
365394
});
366395

test/widgets/snackbar_test.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:zulip/widgets/snackbar.dart';
4+
5+
void main() {
6+
testWidgets('Test SnackBarPage', (WidgetTester tester) async {
7+
/// SnackBarPage widget
8+
await tester.pumpWidget(
9+
const MaterialApp(
10+
home: Scaffold(
11+
body: SnackBarPage(isStale: false),
12+
),
13+
),
14+
);
15+
16+
/// noSnackBar is shown
17+
await tester.pump();
18+
expect(find.byType(SnackBar), findsNothing);
19+
20+
/// Change isStale to true
21+
await tester.pumpWidget(
22+
const MaterialApp(
23+
home: Scaffold(
24+
body: SnackBarPage(isStale: true),
25+
),
26+
),
27+
);
28+
29+
/// SnackBar is shown
30+
await tester.pump();
31+
expect(find.text('Connecting'), findsOneWidget);
32+
});
33+
}

0 commit comments

Comments
 (0)