Skip to content

snackbar: show connecting snackbar depending on bool isstale #619

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.');
Expand All @@ -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();
Expand All @@ -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.'));
Expand Down
5 changes: 5 additions & 0 deletions lib/widgets/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down Expand Up @@ -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),
),
],
])));
}
Expand Down
59 changes: 59 additions & 0 deletions lib/widgets/snackbar.dart
Original file line number Diff line number Diff line change
@@ -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<SnackBarPage> {
@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
}
}


31 changes: 30 additions & 1 deletion test/model/store_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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());
Expand All @@ -136,6 +139,12 @@ void main() {
});
});


final store = eg.store();
check(store.isStale).isTrue();



group('UpdateMachine.load', () {
late TestGlobalStore globalStore;
late FakeApiConnection connection;
Expand Down Expand Up @@ -204,6 +213,8 @@ void main() {

// TODO test UpdateMachine.load starts polling loop
// TODO test UpdateMachine.load calls registerNotificationToken


});

group('UpdateMachine.poll', () {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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: [
Expand All @@ -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 {
Expand All @@ -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);
Expand All @@ -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),
Expand All @@ -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) {
Expand All @@ -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();
Expand All @@ -336,14 +357,19 @@ 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),
], queueId: null).toJson());
async.flushTimers();
checkLastRequest(lastEventId: 1);
check(updateMachine.lastEventId).equals(2);
check(store.isStale).isFalse();

});

}

test('retries on Server5xxException', () {
Expand All @@ -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'));

});
});

Expand Down
33 changes: 33 additions & 0 deletions test/widgets/snackbar_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading