Skip to content

Commit 1b51201

Browse files
committed
store [nfc]: Add and use PerAccountStoreAwareStateMixin
1 parent 38ed6c8 commit 1b51201

File tree

4 files changed

+148
-12
lines changed

4 files changed

+148
-12
lines changed

lib/widgets/message_list.dart

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,20 +107,13 @@ class MessageList extends StatefulWidget {
107107
State<StatefulWidget> createState() => _MessageListState();
108108
}
109109

110-
class _MessageListState extends State<MessageList> {
110+
class _MessageListState extends State<MessageList> with PerAccountStoreAwareStateMixin<MessageList> {
111111
MessageListView? model;
112112

113113
@override
114-
void didChangeDependencies() {
115-
super.didChangeDependencies();
116-
final store = PerAccountStoreWidget.of(context);
117-
if (model != null && model!.store == store) {
118-
// We already have a model, and it's for the right store.
119-
return;
120-
}
121-
// Otherwise, set up the model. Dispose of any old model.
114+
void onNewStore() {
122115
model?.dispose();
123-
_initModel(store);
116+
_initModel(PerAccountStoreWidget.of(context));
124117
}
125118

126119
@override

lib/widgets/store.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ class PerAccountStoreWidget extends StatefulWidget {
140140
/// * [GlobalStoreWidget.of], for the app's data beyond that of a
141141
/// particular account.
142142
/// * [InheritedNotifier], which provides the "dependency" mechanism.
143+
// TODO(#185): Explain in dartdoc that the returned [PerAccountStore] might
144+
// differ from one call to the next, and to handle that with
145+
// [PerAccountStoreAwareStateMixin].
143146
static PerAccountStore of(BuildContext context) {
144147
final widget = context.dependOnInheritedWidgetOfExactType<_PerAccountStoreInheritedWidget>();
145148
assert(widget != null, 'No PerAccountStoreWidget ancestor');
@@ -246,3 +249,41 @@ class LoadingPage extends StatelessWidget {
246249
return const Center(child: CircularProgressIndicator());
247250
}
248251
}
252+
253+
/// A [State] that uses the ambient [PerAccountStore].
254+
///
255+
/// The ambient [PerAccountStore] can be replaced in some circumstances,
256+
/// such as when an event queue expires. See [PerAccountStoreWidget.of].
257+
/// When that happens, stateful widgets should
258+
/// - remove listeners on the old [PerAccountStore], and
259+
/// - add listeners on the new one.
260+
///
261+
/// Use this mixin, overriding [onNewStore], to do that concisely.
262+
// TODO(#185): Until #185, I think [PerAccountStoreWidget.of] never actually
263+
// returns a different [PerAccountStore] from one call to the next.
264+
// But it will, and when it does, we want our [StatefulWidgets] to handle it.
265+
mixin PerAccountStoreAwareStateMixin<T extends StatefulWidget> on State<T> {
266+
PerAccountStore? _store;
267+
268+
/// Called when there is a new ambient [PerAccountStore].
269+
///
270+
/// Specifically this is called when this element is first inserted into the tree
271+
/// (so that it has an ambient [PerAccountStore] for the first time),
272+
/// and again whenever dependencies change so that [PerAccountStoreWidget.of]
273+
/// would return a different store from previously.
274+
///
275+
/// In this, remove any listeners on the old store
276+
/// and add them on the new store.
277+
void onNewStore();
278+
279+
@override
280+
void didChangeDependencies() {
281+
super.didChangeDependencies();
282+
283+
final storeNow = PerAccountStoreWidget.of(context);
284+
if (_store != storeNow) {
285+
_store = storeNow;
286+
onNewStore();
287+
}
288+
}
289+
}

test/flutter_checks.dart

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
import 'dart:ui';
33

44
import 'package:checks/checks.dart';
5-
import 'package:flutter/foundation.dart';
6-
import 'package:flutter/painting.dart';
5+
import 'package:flutter/widgets.dart';
76

7+
extension GlobalKeyChecks<T extends State<StatefulWidget>> on Subject<GlobalKey<T>> {
8+
Subject<BuildContext?> get currentContext => has((k) => k.currentContext, 'currentContext');
9+
Subject<Widget?> get currentWidget => has((k) => k.currentWidget, 'currentWidget');
10+
Subject<T?> get currentState => has((k) => k.currentState, 'currentState');
11+
}
812

913
extension ValueNotifierChecks<T> on Subject<ValueNotifier<T>> {
1014
Subject<T> get value => has((c) => c.value, 'value');

test/widgets/store_test.dart

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,47 @@ import 'package:flutter_test/flutter_test.dart';
44
import 'package:zulip/model/store.dart';
55
import 'package:zulip/widgets/store.dart';
66

7+
import '../flutter_checks.dart';
78
import '../model/binding.dart';
89
import '../example_data.dart' as eg;
910
import '../model/store_checks.dart';
1011

12+
/// A widget whose state uses [PerAccountStoreAwareStateMixin].
13+
class MyWidgetWithMixin extends StatefulWidget {
14+
const MyWidgetWithMixin({super.key});
15+
16+
@override
17+
State<MyWidgetWithMixin> createState() => MyWidgetWithMixinState();
18+
}
19+
20+
class MyWidgetWithMixinState extends State<MyWidgetWithMixin> with PerAccountStoreAwareStateMixin<MyWidgetWithMixin> {
21+
int anyDepChangeCounter = 0;
22+
int storeChangeCounter = 0;
23+
24+
@override
25+
void onNewStore() {
26+
storeChangeCounter++;
27+
}
28+
29+
@override
30+
void didChangeDependencies() {
31+
super.didChangeDependencies();
32+
anyDepChangeCounter++;
33+
}
34+
35+
@override
36+
Widget build(BuildContext context) {
37+
final brightness = Theme.of(context).brightness;
38+
final accountId = PerAccountStoreWidget.of(context).account.id;
39+
return Text('brightness: $brightness; accountId: $accountId');
40+
}
41+
}
42+
43+
extension MyWidgetWithMixinStateChecks on Subject<MyWidgetWithMixinState> {
44+
Subject<int> get anyDepChangeCounter => has((w) => w.anyDepChangeCounter, 'anyDepChangeCounter');
45+
Subject<int> get storeChangeCounter => has((w) => w.storeChangeCounter, 'storeChangeCounter');
46+
}
47+
1148
void main() {
1249
TestZulipBinding.ensureInitialized();
1350

@@ -113,4 +150,65 @@ void main() {
113150
check(tester.any(find.textContaining('found store'))).isTrue();
114151
tester.widget(find.text('found store, account: ${eg.selfAccount.id}'));
115152
});
153+
154+
testWidgets('PerAccountStoreAwareStateMixin', (tester) async {
155+
final widgetWithMixinKey = GlobalKey<MyWidgetWithMixinState>();
156+
final accountId = eg.selfAccount.id;
157+
158+
await TestZulipBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot());
159+
160+
Future<void> pumpWithParams({required bool light, required int accountId}) async {
161+
await tester.pumpWidget(
162+
MaterialApp(
163+
theme: light ? ThemeData.light() : ThemeData.dark(),
164+
home: GlobalStoreWidget(
165+
child: PerAccountStoreWidget(
166+
accountId: accountId,
167+
child: MyWidgetWithMixin(key: widgetWithMixinKey)))));
168+
}
169+
170+
// [onNewStore] called initially
171+
await pumpWithParams(light: true, accountId: accountId);
172+
await tester.pump(); // global store
173+
await tester.pump(); // per-account store
174+
check(widgetWithMixinKey).currentState.isNotNull()
175+
..anyDepChangeCounter.equals(1)
176+
..storeChangeCounter.equals(1);
177+
178+
// [onNewStore] not called on unrelated dependency change
179+
await pumpWithParams(light: false, accountId: accountId);
180+
await tester.pumpAndSettle(kThemeAnimationDuration);
181+
check(widgetWithMixinKey).currentState.isNotNull()
182+
..anyDepChangeCounter.equals(2)
183+
..storeChangeCounter.equals(1);
184+
185+
// [onNewStore] called when store changes
186+
//
187+
// TODO: Trigger `store` change by simulating an event-queue renewal,
188+
// instead of with this hack where we change the UI's backing Account
189+
// out from under it. (That account-swapping would be suspicious in
190+
// production code, where we could reasonably add an assert against it.
191+
// If forced, we could let this test code proceed despite such an assert…)
192+
// hack; the snapshot probably corresponds to selfAccount, not otherAccount.
193+
await TestZulipBinding.instance.globalStore.add(eg.otherAccount, eg.initialSnapshot());
194+
await pumpWithParams(light: false, accountId: eg.otherAccount.id);
195+
// Nudge PerAccountStoreWidget to send its updated store to MyWidgetWithMixin.
196+
//
197+
// A change in PerAccountStoreWidget's [accountId] field doesn't by itself
198+
// prompt dependent widgets (those using PerAccountStoreWidget.of) to update,
199+
// even though it holds a new store. (See its state's didUpdateWidget,
200+
// or lack thereof.) That's reasonable, since such a change is never expected;
201+
// see TODO above.
202+
//
203+
// But when PerAccountStoreWidget gets a notification from the GlobalStore,
204+
// it checks at that time whether it has a new PerAccountStore to distribute
205+
// (as it will when widget.accountId has changed), and if so,
206+
// it will notify dependent widgets. (See its state's didChangeDependencies.)
207+
// So, take advantage of that.
208+
TestZulipBinding.instance.globalStore.notifyListeners();
209+
await tester.pumpAndSettle();
210+
check(widgetWithMixinKey).currentState.isNotNull()
211+
..anyDepChangeCounter.equals(3)
212+
..storeChangeCounter.equals(2);
213+
});
116214
}

0 commit comments

Comments
 (0)