Skip to content

Commit 839dede

Browse files
fix(models): make Drink user-state fields immutable with copyWith (#366)
* fix(models): make Drink user-state fields immutable with copyWith isFavorite, rating, and isTasted are now final. BeerProvider replaces list elements via copyWith rather than mutating shared objects, enabling correct widget diffing and unblocking == / hashCode (see #323). Fixes #322 * fix(provider): replace by stable id in _replaceDrink; await setRating in test _replaceDrink now finds drinks by id+festivalId rather than object identity, preventing missed updates when a caller holds a stale reference after copyWith. Also awaits provider.setRating() in drink_detail_screen_test to eliminate a potential flaky assertion. Addresses review comments on #366.
1 parent 337bb4c commit 839dede

11 files changed

Lines changed: 101 additions & 72 deletions

lib/domain/repositories/api_drink_repository.dart

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,13 @@ class ApiDrinkRepository implements DrinkRepository {
8787
/// Populate favorite status, ratings, and tasted status in a single pass.
8888
void _applyUserState(List<Drink> drinks, String festivalId) {
8989
final favorites = _favoritesService.getFavorites(festivalId);
90-
for (final drink in drinks) {
91-
drink.isFavorite = favorites.contains(drink.id);
92-
drink.rating = _ratingsService.getRating(festivalId, drink.id);
93-
drink.isTasted = _tastingLogService.hasTasted(festivalId, drink.id);
90+
for (var i = 0; i < drinks.length; i++) {
91+
final drink = drinks[i];
92+
drinks[i] = drink.copyWith(
93+
isFavorite: favorites.contains(drink.id),
94+
rating: _ratingsService.getRating(festivalId, drink.id),
95+
isTasted: _tastingLogService.hasTasted(festivalId, drink.id),
96+
);
9497
}
9598
}
9699

lib/models/drink.dart

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,11 @@ class Drink {
223223
final Product product;
224224
final Producer producer;
225225
final String festivalId;
226-
bool isFavorite;
227-
int? rating;
228-
bool isTasted;
226+
final bool isFavorite;
227+
final int? rating;
228+
final bool isTasted;
229+
230+
static const _absent = Object();
229231

230232
Drink({
231233
required this.product,
@@ -236,6 +238,17 @@ class Drink {
236238
this.isTasted = false,
237239
});
238240

241+
Drink copyWith({bool? isFavorite, Object? rating = _absent, bool? isTasted}) {
242+
return Drink(
243+
product: product,
244+
producer: producer,
245+
festivalId: festivalId,
246+
isFavorite: isFavorite ?? this.isFavorite,
247+
rating: identical(rating, _absent) ? this.rating : rating as int?,
248+
isTasted: isTasted ?? this.isTasted,
249+
);
250+
}
251+
239252
String get id => product.id;
240253
String get name => product.name;
241254
String get breweryName => producer.name;

lib/providers/beer_provider.dart

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,20 @@ class BeerProvider extends ChangeNotifier {
680680
await prefs.setInt(PreferenceKeys.themeMode, mode.index);
681681
}
682682

683+
/// Replace a drink in [_allDrinks] with [updated] and recompute filters.
684+
///
685+
/// Returns [updated] for convenience.
686+
Drink _replaceDrink(Drink old, Drink updated) {
687+
final idx = _allDrinks.indexWhere(
688+
(d) => d.id == old.id && d.festivalId == old.festivalId,
689+
);
690+
if (idx != -1) {
691+
_allDrinks[idx] = updated;
692+
}
693+
_filter.recompute();
694+
return updated;
695+
}
696+
683697
/// Toggle favorite status for a drink
684698
Future<void> toggleFavorite(Drink drink) async {
685699
if (_drinkRepository == null) return;
@@ -688,11 +702,7 @@ class BeerProvider extends ChangeNotifier {
688702
currentFestival.id,
689703
drink.id,
690704
);
691-
drink.isFavorite = newStatus;
692-
693-
if (_filter.showFavoritesOnly) {
694-
_filter.recompute();
695-
}
705+
_replaceDrink(drink, drink.copyWith(isFavorite: newStatus));
696706

697707
notifyListeners();
698708

@@ -710,13 +720,12 @@ class BeerProvider extends ChangeNotifier {
710720

711721
if (rating == null) {
712722
await _drinkRepository!.removeRating(currentFestival.id, drink.id);
713-
drink.rating = null;
714723
} else {
715724
await _drinkRepository!.setRating(currentFestival.id, drink.id, rating);
716-
drink.rating = rating;
717725
// Log analytics event for rating (fire and forget)
718726
unawaited(_analyticsService.logRatingGiven(drink, rating));
719727
}
728+
_replaceDrink(drink, drink.copyWith(rating: rating));
720729
notifyListeners();
721730
}
722731

@@ -728,13 +737,7 @@ class BeerProvider extends ChangeNotifier {
728737
currentFestival.id,
729738
drink.id,
730739
);
731-
drink.isTasted = newStatus;
732-
733-
// Re-filter so the drink appears/disappears immediately when the
734-
// not-tasted visibility filter is active.
735-
if (_filter.visibilityFilters.contains(DrinkVisibilityFilter.notTasted)) {
736-
_filter.recompute();
737-
}
740+
_replaceDrink(drink, drink.copyWith(isTasted: newStatus));
738741

739742
notifyListeners();
740743

test/beer_provider_test.dart

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,7 +1034,7 @@ void main() {
10341034
await provider.initialize();
10351035

10361036
final sampleDrinks = createSampleDrinks();
1037-
sampleDrinks[0].isTasted = true;
1037+
sampleDrinks[0] = sampleDrinks[0].copyWith(isTasted: true);
10381038
when(
10391039
mockDrinkRepository.getDrinks(any),
10401040
).thenAnswer((_) async => sampleDrinks);
@@ -2337,15 +2337,16 @@ void main() {
23372337
mockDrinkRepository.toggleTasted(any, any),
23382338
).thenAnswer((_) async => true);
23392339
await provider.toggleTasted(drink);
2340-
expect(drink.isTasted, isTrue);
2340+
expect(provider.getDrinkById(drink.id)!.isTasted, isTrue);
23412341
verify(mockAnalyticsService.logTastedAdded(drink)).called(1);
23422342

23432343
when(
23442344
mockDrinkRepository.toggleTasted(any, any),
23452345
).thenAnswer((_) async => false);
2346-
await provider.toggleTasted(drink);
2347-
expect(drink.isTasted, isFalse);
2348-
verify(mockAnalyticsService.logTastedRemoved(drink)).called(1);
2346+
final drink2 = provider.getDrinkById(drink.id)!;
2347+
await provider.toggleTasted(drink2);
2348+
expect(provider.getDrinkById(drink.id)!.isTasted, isFalse);
2349+
verify(mockAnalyticsService.logTastedRemoved(drink2)).called(1);
23492350
});
23502351

23512352
test(
@@ -2502,8 +2503,8 @@ void main() {
25022503
).thenAnswer((_) async => true);
25032504
await provider.toggleFavorite(drink);
25042505

2505-
// The favourites-only list is refreshed in place by toggleFavorite.
2506-
expect(provider.drinks, contains(drink));
2506+
// The favourites-only list is refreshed by toggleFavorite.
2507+
expect(provider.drinks.any((d) => d.id == drink.id), isTrue);
25072508
},
25082509
);
25092510

test/brewery_screen_test.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ void main() {
200200
await tester.pumpWidget(createTestWidget('brewery1'));
201201
await tester.pumpAndSettle();
202202

203-
expect(drink1.isFavorite, false);
203+
expect(provider.getDrinkById(drink1.id)!.isFavorite, false);
204204

205205
// Mock toggleFavorite to properly toggle state
206206
final favorites = <String>{};
@@ -225,7 +225,7 @@ void main() {
225225
await tester.tap(favoriteButton);
226226
await tester.pumpAndSettle();
227227

228-
expect(drink1.isFavorite, true);
228+
expect(provider.getDrinkById(drink1.id)!.isFavorite, true);
229229
});
230230

231231
testWidgets('displays correct count of drinks', (

test/domain/controllers/drink_filter_controller_test.dart

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ void main() {
181181
group('favorites filter', () {
182182
test('shows only favourites when enabled', () {
183183
final drinks = _sampleDrinks();
184-
drinks[0].isFavorite = true;
184+
drinks[0] = drinks[0].copyWith(isFavorite: true);
185185
controller.setSource(drinks);
186186

187187
controller.setShowFavoritesOnly(true);
@@ -221,7 +221,7 @@ void main() {
221221

222222
test('notTasted filter hides tasted drinks', () {
223223
final drinks = _sampleDrinks();
224-
drinks[0].isTasted = true;
224+
drinks[0] = drinks[0].copyWith(isTasted: true);
225225
controller.setSource(drinks);
226226
controller.setVisibilityFilter(DrinkVisibilityFilter.notTasted, true);
227227
expect(
@@ -327,18 +327,21 @@ void main() {
327327
});
328328

329329
group('recompute', () {
330-
test('reflects in-place favourite mutation when favourites-only', () {
331-
final drinks = _sampleDrinks();
332-
controller.setSource(drinks);
333-
controller.setShowFavoritesOnly(true);
334-
expect(controller.filteredDrinks, isEmpty);
335-
336-
// Simulate BeerProvider mutating the drink in place, then asking the
337-
// controller to re-run the pipeline.
338-
drinks[1].isFavorite = true;
339-
controller.recompute();
340-
expect(controller.filteredDrinks.map((d) => d.name), ['Beta Bitter']);
341-
});
330+
test(
331+
'reflects favourite change via list replacement when favourites-only',
332+
() {
333+
final drinks = _sampleDrinks();
334+
controller.setSource(drinks);
335+
controller.setShowFavoritesOnly(true);
336+
expect(controller.filteredDrinks, isEmpty);
337+
338+
// Simulate BeerProvider replacing a list element via copyWith, then
339+
// asking the controller to re-run the pipeline.
340+
drinks[1] = drinks[1].copyWith(isFavorite: true);
341+
controller.setSource(drinks);
342+
expect(controller.filteredDrinks.map((d) => d.name), ['Beta Bitter']);
343+
},
344+
);
342345
});
343346

344347
group('clearCategoryStyleSearch', () {

test/domain/services/drink_filter_service_test.dart

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -137,16 +137,16 @@ void main() {
137137

138138
group('filterByFavorites', () {
139139
test('filters to show only favorites', () {
140-
testDrinks[0].isFavorite = true;
141-
testDrinks[2].isFavorite = true;
140+
testDrinks[0] = testDrinks[0].copyWith(isFavorite: true);
141+
testDrinks[2] = testDrinks[2].copyWith(isFavorite: true);
142142

143143
final result = service.filterByFavorites(testDrinks, true).toList();
144144
expect(result, hasLength(2));
145145
expect(result.every((d) => d.isFavorite), isTrue);
146146
});
147147

148148
test('returns all drinks when favoritesOnly is false', () {
149-
testDrinks[0].isFavorite = true;
149+
testDrinks[0] = testDrinks[0].copyWith(isFavorite: true);
150150

151151
final result = service.filterByFavorites(testDrinks, false).toList();
152152
expect(result, hasLength(5));
@@ -190,16 +190,16 @@ void main() {
190190

191191
group('filterByNotTasted', () {
192192
test('hides drinks already tasted', () {
193-
testDrinks[0].isTasted = true;
194-
testDrinks[1].isTasted = true;
193+
testDrinks[0] = testDrinks[0].copyWith(isTasted: true);
194+
testDrinks[1] = testDrinks[1].copyWith(isTasted: true);
195195

196196
final result = service.filterByNotTasted(testDrinks, true).toList();
197197
expect(result, hasLength(3));
198198
expect(result.every((d) => !d.isTasted), isTrue);
199199
});
200200

201201
test('returns all drinks when notTastedOnly is false', () {
202-
testDrinks[0].isTasted = true;
202+
testDrinks[0] = testDrinks[0].copyWith(isTasted: true);
203203

204204
final result = service.filterByNotTasted(testDrinks, false).toList();
205205
expect(result, hasLength(5));
@@ -390,7 +390,7 @@ void main() {
390390

391391
group('filterDrinks', () {
392392
test('applies all filters in combination', () {
393-
testDrinks[0].isFavorite = true; // Hoppy IPA
393+
testDrinks[0] = testDrinks[0].copyWith(isFavorite: true); // Hoppy IPA
394394

395395
final result = service.filterDrinks(
396396
testDrinks,

test/drink_card_test.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ void main() {
115115
testWidgets('shows favorite icon as outlined when not favorite', (
116116
WidgetTester tester,
117117
) async {
118-
testDrink.isFavorite = false;
118+
testDrink = testDrink.copyWith(isFavorite: false);
119119
await tester.pumpWidget(createTestWidget(drink: testDrink));
120120

121121
expect(find.byIcon(Icons.favorite_border), findsOneWidget);
@@ -125,7 +125,7 @@ void main() {
125125
testWidgets('shows favorite icon as filled when favorite', (
126126
WidgetTester tester,
127127
) async {
128-
testDrink.isFavorite = true;
128+
testDrink = testDrink.copyWith(isFavorite: true);
129129
await tester.pumpWidget(createTestWidget(drink: testDrink));
130130

131131
expect(find.byIcon(Icons.favorite), findsOneWidget);

test/drink_detail_screen_test.dart

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ void main() {
319319
await tester.pumpWidget(createTestWidget('drink1'));
320320
await tester.pumpAndSettle();
321321

322-
expect(drink.isFavorite, false);
322+
expect(provider.getDrinkById('drink1')!.isFavorite, false);
323323
expect(find.byIcon(Icons.favorite_border), findsOneWidget);
324324

325325
// Mock toggleFavorite to properly toggle state
@@ -341,7 +341,7 @@ void main() {
341341
await tester.tap(find.byIcon(Icons.favorite_border));
342342
await tester.pumpAndSettle();
343343

344-
expect(drink.isFavorite, true);
344+
expect(provider.getDrinkById('drink1')!.isFavorite, true);
345345
expect(find.byIcon(Icons.favorite), findsOneWidget);
346346
});
347347

@@ -417,11 +417,12 @@ void main() {
417417
testWidgets('displays rating value when drink has rating', (
418418
WidgetTester tester,
419419
) async {
420-
when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]);
420+
final ratedDrink = drink.copyWith(rating: 4);
421+
when(
422+
mockDrinkRepository.getDrinks(any),
423+
).thenAnswer((_) async => [ratedDrink]);
421424
await provider.loadDrinks();
422425

423-
drink.rating = 4;
424-
425426
await tester.pumpWidget(createTestWidget('drink1'));
426427
await tester.pumpAndSettle();
427428

@@ -434,8 +435,6 @@ void main() {
434435
when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]);
435436
await provider.loadDrinks();
436437

437-
drink.rating = null;
438-
439438
await tester.pumpWidget(createTestWidget('drink1'));
440439
await tester.pumpAndSettle();
441440

@@ -451,13 +450,16 @@ void main() {
451450
await tester.pumpWidget(createTestWidget('drink1'));
452451
await tester.pumpAndSettle();
453452

454-
expect(drink.rating, null);
453+
expect(provider.getDrinkById('drink1')!.rating, null);
455454

456455
// Simulate rating change through provider
457-
provider.setRating(drink, 5);
456+
when(
457+
mockDrinkRepository.setRating(any, any, any),
458+
).thenAnswer((_) async {});
459+
await provider.setRating(provider.getDrinkById('drink1')!, 5);
458460
await tester.pumpAndSettle();
459461

460-
expect(drink.rating, 5);
462+
expect(provider.getDrinkById('drink1')!.rating, 5);
461463
expect(find.text('5/5'), findsOneWidget);
462464
});
463465

test/models_test.dart

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -905,32 +905,36 @@ void main() {
905905
expect(drink.rating, isNull);
906906
});
907907

908-
test('can set isFavorite', () {
908+
test('copyWith changes isFavorite', () {
909909
final drink = Drink(
910910
product: testProduct,
911911
producer: testProducer,
912912
festivalId: 'cbf2025',
913913
isFavorite: true,
914914
);
915915

916+
final updated = drink.copyWith(isFavorite: false);
917+
expect(updated.isFavorite, isFalse);
918+
// Original is unchanged
916919
expect(drink.isFavorite, isTrue);
917-
918-
drink.isFavorite = false;
919-
expect(drink.isFavorite, isFalse);
920920
});
921921

922-
test('can set rating', () {
922+
test('copyWith changes rating', () {
923923
final drink = Drink(
924924
product: testProduct,
925925
producer: testProducer,
926926
festivalId: 'cbf2025',
927927
rating: 4,
928928
);
929929

930+
final updated = drink.copyWith(rating: 5);
931+
expect(updated.rating, 5);
932+
// Original is unchanged
930933
expect(drink.rating, 4);
931934

932-
drink.rating = 5;
933-
expect(drink.rating, 5);
935+
// Clearing the rating to null
936+
final cleared = drink.copyWith(rating: null);
937+
expect(cleared.rating, isNull);
934938
});
935939

936940
group('getShareMessage', () {

0 commit comments

Comments
 (0)