Skip to content

Commit 4597ef0

Browse files
fix(drinks): move provider mutations out of setState (#538)
Both search-dismissal callbacks called provider.setSearchQuery('') from inside a setState closure. setSearchQuery calls notifyListeners(), which synchronously marks every watching element dirty, mixing two rebuild mechanisms in one block. setState now mutates widget-local fields only and the provider call runs after it. Adds test/drinks_screen_search_dismiss_test.dart: neither dismissal path had coverage. The tests pass against both the old and new implementations, pinning the refactor as behaviour-preserving. Fixes #526
1 parent 280bf29 commit 4597ef0

2 files changed

Lines changed: 202 additions & 4 deletions

File tree

lib/screens/drinks_screen.dart

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,16 @@ class _DrinksScreenState extends State<DrinksScreen> {
112112
icon: const Icon(Icons.close),
113113
onPressed: () {
114114
_searchDebounceTimer?.cancel();
115+
// setState mutates widget-local state only. The provider call
116+
// stays outside the closure: notifyListeners() marks watching
117+
// elements dirty synchronously, and mixing that with an
118+
// in-progress setState is what produces "setState() or
119+
// markNeedsBuild() called during build" (issue #526).
115120
setState(() {
116121
_showSearch = false;
117122
_searchController.clear();
118-
provider.setSearchQuery('');
119123
});
124+
provider.setSearchQuery('');
120125
},
121126
),
122127
),
@@ -207,14 +212,23 @@ class _DrinksScreenState extends State<DrinksScreen> {
207212
isActive: _showSearch,
208213
hasQuery: provider.searchQuery.isNotEmpty,
209214
onPressed: () {
215+
// Collapsing the search bar clears the query; expanding it does
216+
// not. As with the clear button, setState keeps only the
217+
// widget-local fields and the provider call runs after it
218+
// (issue #526).
219+
final isCollapsing = _showSearch;
220+
if (isCollapsing) {
221+
_searchDebounceTimer?.cancel();
222+
}
210223
setState(() {
211224
_showSearch = !_showSearch;
212-
if (!_showSearch) {
213-
_searchDebounceTimer?.cancel();
225+
if (isCollapsing) {
214226
_searchController.clear();
215-
provider.setSearchQuery('');
216227
}
217228
});
229+
if (isCollapsing) {
230+
provider.setSearchQuery('');
231+
}
218232
},
219233
),
220234
],
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import 'package:cambridge_beer_festival/models/models.dart';
2+
import 'package:cambridge_beer_festival/providers/providers.dart';
3+
import 'package:cambridge_beer_festival/screens/screens.dart';
4+
import 'package:cambridge_beer_festival/services/services.dart';
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter_test/flutter_test.dart';
7+
import 'package:mockito/mockito.dart';
8+
import 'package:provider/provider.dart';
9+
import 'package:shared_preferences/shared_preferences.dart';
10+
11+
import 'provider_test.mocks.dart';
12+
13+
/// Covers the two paths that dismiss the search bar. Both used to call
14+
/// `provider.setSearchQuery('')` from inside a `setState` closure, firing
15+
/// `notifyListeners()` while the element was being marked dirty (issue #526).
16+
/// These tests assert the user-visible outcome of each path — the bar closes
17+
/// and the full list comes back — so the behaviour is pinned regardless of how
18+
/// the rebuild is scheduled.
19+
void main() {
20+
group('DrinksScreen search dismissal', () {
21+
late MockDrinkRepository mockDrinkRepository;
22+
late MockFestivalRepository mockFestivalRepository;
23+
late MockAnalyticsService mockAnalyticsService;
24+
late BeerProvider provider;
25+
26+
final testDrinks = [
27+
Drink(
28+
product: const Product(
29+
id: 'drink1',
30+
name: 'Alpha IPA',
31+
abv: 5.5,
32+
category: 'beer',
33+
dispense: 'cask',
34+
style: 'IPA',
35+
),
36+
producer: const Producer(
37+
id: 'brewery1',
38+
name: 'Test Brewery',
39+
location: 'Cambridge',
40+
products: [],
41+
),
42+
festivalId: 'cbf2025',
43+
),
44+
Drink(
45+
product: const Product(
46+
id: 'drink2',
47+
name: 'Beta Bitter',
48+
abv: 4.2,
49+
category: 'beer',
50+
dispense: 'cask',
51+
style: 'Bitter',
52+
),
53+
producer: const Producer(
54+
id: 'brewery1',
55+
name: 'Test Brewery',
56+
location: 'Cambridge',
57+
products: [],
58+
),
59+
festivalId: 'cbf2025',
60+
),
61+
];
62+
63+
setUp(() async {
64+
SharedPreferences.setMockInitialValues({});
65+
mockDrinkRepository = MockDrinkRepository();
66+
mockFestivalRepository = MockFestivalRepository();
67+
mockAnalyticsService = MockAnalyticsService();
68+
69+
const testFestival = Festival(
70+
id: 'cbf2025',
71+
name: 'Cambridge Beer Festival 2025',
72+
dataBaseUrl: 'https://test.example.com/cbf2025',
73+
);
74+
final festivalsResponse = FestivalsResponse(
75+
festivals: [testFestival],
76+
defaultFestivalId: 'cbf2025',
77+
baseUrl: 'https://example.com',
78+
version: '1.0.0',
79+
);
80+
when(
81+
mockFestivalRepository.getFestivals(),
82+
).thenAnswer((_) async => festivalsResponse);
83+
when(
84+
mockFestivalRepository.getSelectedFestivalId(),
85+
).thenAnswer((_) async => null);
86+
when(
87+
mockDrinkRepository.getDrinks(any),
88+
).thenAnswer((_) async => testDrinks);
89+
90+
provider = BeerProvider(
91+
drinkRepository: mockDrinkRepository,
92+
festivalRepository: mockFestivalRepository,
93+
analyticsService: mockAnalyticsService,
94+
);
95+
await provider.initialize();
96+
await provider.loadDrinks();
97+
});
98+
99+
tearDown(() {
100+
provider.dispose();
101+
});
102+
103+
Widget createTestWidget() {
104+
return ChangeNotifierProvider<BeerProvider>.value(
105+
value: provider,
106+
child: const MaterialApp(home: DrinksScreen(festivalId: 'cbf2025')),
107+
);
108+
}
109+
110+
Future<void> tapBySemanticsLabel(WidgetTester tester, String label) async {
111+
final semantics = tester.ensureSemantics();
112+
await tester.tap(find.bySemanticsLabel(label));
113+
await tester.pumpAndSettle();
114+
semantics.dispose();
115+
}
116+
117+
/// Opens the search bar and applies [query], waiting out the 300ms debounce
118+
/// so the provider has actually filtered the list.
119+
Future<void> searchFor(WidgetTester tester, String query) async {
120+
await tapBySemanticsLabel(tester, 'Search drinks');
121+
await tester.enterText(find.byType(TextField).first, query);
122+
await tester.pump(const Duration(milliseconds: 400));
123+
await tester.pumpAndSettle();
124+
}
125+
126+
testWidgets('clear button closes the search bar and restores the list', (
127+
WidgetTester tester,
128+
) async {
129+
await tester.pumpWidget(createTestWidget());
130+
await tester.pumpAndSettle();
131+
132+
await searchFor(tester, 'Alpha');
133+
// The provider normalises the query to lower case.
134+
expect(provider.searchQuery, 'alpha');
135+
expect(find.text('Alpha IPA'), findsOneWidget);
136+
expect(find.text('Beta Bitter'), findsNothing);
137+
138+
await tapBySemanticsLabel(tester, 'Clear search');
139+
140+
// The search field is gone and the unfiltered list is back on screen.
141+
expect(find.byType(TextField), findsNothing);
142+
expect(provider.searchQuery, '');
143+
expect(find.text('Alpha IPA'), findsOneWidget);
144+
expect(find.text('Beta Bitter'), findsOneWidget);
145+
});
146+
147+
testWidgets('collapsing via the search button clears the query', (
148+
WidgetTester tester,
149+
) async {
150+
await tester.pumpWidget(createTestWidget());
151+
await tester.pumpAndSettle();
152+
153+
await searchFor(tester, 'Alpha');
154+
expect(find.text('Beta Bitter'), findsNothing);
155+
156+
// The search button relabels itself while the bar is open.
157+
await tapBySemanticsLabel(tester, 'Close search');
158+
159+
expect(find.byType(TextField), findsNothing);
160+
expect(provider.searchQuery, '');
161+
expect(find.text('Alpha IPA'), findsOneWidget);
162+
expect(find.text('Beta Bitter'), findsOneWidget);
163+
});
164+
165+
testWidgets('expanding the search bar leaves an existing query intact', (
166+
WidgetTester tester,
167+
) async {
168+
await tester.pumpWidget(createTestWidget());
169+
await tester.pumpAndSettle();
170+
171+
// A query set from elsewhere (e.g. a deep link) survives opening the bar:
172+
// only collapsing clears it.
173+
provider.setSearchQuery('Alpha');
174+
await tester.pumpAndSettle();
175+
176+
await tapBySemanticsLabel(tester, 'Search drinks');
177+
178+
expect(find.byType(TextField), findsOneWidget);
179+
expect(provider.searchQuery, 'alpha');
180+
expect(find.text('Alpha IPA'), findsOneWidget);
181+
expect(find.text('Beta Bitter'), findsNothing);
182+
});
183+
});
184+
}

0 commit comments

Comments
 (0)