Skip to content

Commit a8f384d

Browse files
Merge pull request #252 from richardthe3rd/copilot/add-vegan-data-feed-support
feat: Add vegan field support to drink model and detail UI
2 parents 6327c04 + bf46a25 commit a8f384d

7 files changed

Lines changed: 198 additions & 12 deletions

File tree

docs/code/api/beer-list-schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@
115115
"description": "Location/bar where the beverage is served",
116116
"examples": ["Main Bar", "Arctic", "Cider Bar", "Mead Bar", "International Bar", "Cask Bar", "Low/No Bar"]
117117
},
118+
"is_vegan": {
119+
"type": ["boolean", "integer", "string", "null"],
120+
"description": "Whether the drink is suitable for vegans. May be provided as boolean, integer (0/1), or string ('true'/'false'/'yes'/'no'/'1'/'0'). Legacy key 'vegan' may appear in older data and is treated as a fallback.",
121+
"examples": [true, false, 1, 0, "yes", "no", null]
122+
},
118123
"allergens": {
119124
"type": "object",
120125
"description": "Allergen information (keys are allergen names, values are truthy indicators)",

docs/code/api/data-api-reference.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ Each product (individual beverage) contains:
147147
| `notes` | string | No | Flavor description | "Crisp Heritage malts..." |
148148
| `status_text` | string | No | Availability status | "Plenty left", "Arrived" |
149149
| `bar` | string/boolean | No | Venue/location | "Arctic", "Main Bar", true/false |
150+
| `is_vegan` | boolean/integer/string | No | Vegan suitability | `true`, `false`, `1`, `0`, `"yes"` |
150151
| `allergens` | object | No | Allergen flags | `{"gluten": 1, "sulphites": 1}` |
151152

152153
### Dispense Methods
@@ -270,6 +271,7 @@ Product.fromJson(json) → {
270271
notes: json['notes'],
271272
statusText: json['status_text'],
272273
bar: json['bar'], // Handles String, int, or boolean
274+
vegan: json['is_vegan'] ?? json['vegan'], // Handles bool, int, or String; 'vegan' is legacy fallback
273275
allergens: parseAllergens(json['allergens']), // Handles int, bool, or num
274276
}
275277
```
@@ -280,7 +282,8 @@ Product.fromJson(json) → {
280282
2. **Allergens** values can be `int`, `bool`, or `num`
281283
3. **Year founded** can be `int` or `String`
282284
4. **Bar** can be `String`, `int`, or `boolean`
283-
5. Handle null values gracefully with `?.` and `??`
285+
5. **is_vegan** can be `bool`, `int`/`num`, or `String` (`"yes"`/`"no"`/`"true"`/`"false"`/`"1"`/`"0"`); legacy key `vegan` is also supported as a fallback
286+
6. Handle null values gracefully with `?.` and `??`
284287

285288
---
286289

@@ -404,3 +407,4 @@ Product.fromJson(json) → {
404407
| Version | Date | Changes |
405408
|---------|------|---------|
406409
| 1.0.0 | 2025-11-29 | Initial documentation for Flutter app |
410+
| 1.1.0 | 2026-05-11 | Add `is_vegan` field (with legacy `vegan` fallback) |

lib/models/drink.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class Product {
6262
final String? notes;
6363
final String? statusText;
6464
final String? bar;
65+
final bool? vegan;
6566
final Map<String, int> allergens;
6667

6768
const Product({
@@ -74,6 +75,7 @@ class Product {
7475
this.notes,
7576
this.statusText,
7677
this.bar,
78+
this.vegan,
7779
this.allergens = const {},
7880
});
7981

@@ -118,6 +120,22 @@ class Product {
118120
bar = barValue.toString();
119121
}
120122

123+
// Parse vegan field robustly - can be bool, int/num, or string.
124+
bool? parsedVegan;
125+
final veganValue = json['is_vegan'] ?? json['vegan'];
126+
if (veganValue is bool) {
127+
parsedVegan = veganValue;
128+
} else if (veganValue is num) {
129+
parsedVegan = veganValue != 0;
130+
} else if (veganValue is String) {
131+
final normalized = veganValue.toLowerCase();
132+
if (normalized == 'true' || normalized == '1' || normalized == 'yes') {
133+
parsedVegan = true;
134+
} else if (normalized == 'false' || normalized == '0' || normalized == 'no') {
135+
parsedVegan = false;
136+
}
137+
}
138+
121139
return Product(
122140
id: json['id'].toString(),
123141
name: json['name'].toString(),
@@ -128,6 +146,7 @@ class Product {
128146
notes: json['notes']?.toString(),
129147
statusText: json['status_text']?.toString(),
130148
bar: bar,
149+
vegan: parsedVegan,
131150
allergens: allergens,
132151
);
133152
}
@@ -143,6 +162,7 @@ class Product {
143162
if (notes != null) 'notes': notes,
144163
if (statusText != null) 'status_text': statusText,
145164
if (bar != null) 'bar': bar,
165+
if (vegan != null) 'is_vegan': vegan,
146166
'allergens': allergens,
147167
};
148168
}
@@ -232,6 +252,7 @@ class Drink {
232252
String? get notes => product.notes;
233253
String? get statusText => product.statusText;
234254
String? get bar => product.bar;
255+
bool? get vegan => product.vegan;
235256
Map<String, int> get allergens => product.allergens;
236257
AvailabilityStatus? get availabilityStatus => product.availabilityStatus;
237258
String? get allergenText => product.allergenText;

lib/screens/drink_detail_screen.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,14 @@ class _DrinkDetailScreenState extends State<DrinkDetailScreen> {
178178
? theme.colorScheme.error
179179
: theme.colorScheme.primary,
180180
),
181+
// Vegan indicator
182+
if (drink.vegan == true)
183+
HeroInfoRow(
184+
icon: Icons.eco,
185+
text: 'Vegan',
186+
iconColor: theme.colorScheme.primary,
187+
semanticLabel: 'This drink is vegan',
188+
),
181189
],
182190
);
183191
}

lib/widgets/hero_info_card.dart

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,15 @@ class HeroInfoRow extends StatelessWidget {
5656
/// Optional text style
5757
final TextStyle? textStyle;
5858

59+
/// Optional semantic label announced by screen readers
60+
final String? semanticLabel;
61+
5962
const HeroInfoRow({
6063
required this.icon,
6164
required this.text,
6265
this.iconColor,
6366
this.textStyle,
67+
this.semanticLabel,
6468
super.key,
6569
});
6670

@@ -72,7 +76,7 @@ class HeroInfoRow extends StatelessWidget {
7276
color: theme.colorScheme.onPrimaryContainer,
7377
);
7478

75-
return Row(
79+
final content = Row(
7680
children: [
7781
Icon(
7882
icon,
@@ -88,5 +92,14 @@ class HeroInfoRow extends StatelessWidget {
8892
),
8993
],
9094
);
95+
96+
if (semanticLabel == null) return content;
97+
98+
return Semantics(
99+
label: semanticLabel,
100+
// Prevent the row text from being announced in addition to semanticLabel.
101+
excludeSemantics: true,
102+
child: content,
103+
);
91104
}
92105
}

test/drink_detail_screen_test.dart

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ void main() {
4040
dispense: 'cask',
4141
style: 'IPA',
4242
bar: 'Main Bar',
43+
vegan: true,
4344
notes: 'A hoppy beer with citrus notes',
4445
allergens: {'gluten': 1, 'sulphites': 1},
4546
);
@@ -112,18 +113,25 @@ void main() {
112113

113114
testWidgets('displays drink details chips',
114115
(WidgetTester tester) async {
115-
when(mockDrinkRepository.getDrinks(any))
116-
.thenAnswer((_) async => [drink]);
117-
await provider.loadDrinks();
116+
final semanticsHandle = tester.ensureSemantics();
117+
try {
118+
when(mockDrinkRepository.getDrinks(any))
119+
.thenAnswer((_) async => [drink]);
120+
await provider.loadDrinks();
118121

119-
await tester.pumpWidget(createTestWidget('drink1'));
120-
await tester.pumpAndSettle();
122+
await tester.pumpWidget(createTestWidget('drink1'));
123+
await tester.pumpAndSettle();
121124

122-
// New layout shows combined information in HeroInfoCard
123-
expect(find.textContaining('5.0%'), findsOneWidget);
124-
expect(find.textContaining('IPA'), findsWidgets); // Appears in HeroInfoCard and style chip
125-
expect(find.textContaining('Cask'), findsOneWidget);
126-
expect(find.textContaining('Available at Main Bar'), findsOneWidget);
125+
// New layout shows combined information in HeroInfoCard
126+
expect(find.textContaining('5.0%'), findsOneWidget);
127+
expect(find.textContaining('IPA'), findsWidgets); // Appears in HeroInfoCard and style chip
128+
expect(find.textContaining('Cask'), findsOneWidget);
129+
expect(find.textContaining('Available at Main Bar'), findsOneWidget);
130+
expect(find.text('Vegan'), findsOneWidget);
131+
expect(find.bySemanticsLabel('This drink is vegan'), findsOneWidget);
132+
} finally {
133+
semanticsHandle.dispose();
134+
}
127135
});
128136

129137
testWidgets('displays status text in chips when available',

test/models_test.dart

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ void main() {
1414
'notes': 'A test beer',
1515
'status_text': 'Plenty left',
1616
'bar': 'Main Bar',
17+
'is_vegan': true,
1718
'allergens': {'gluten': 1},
1819
};
1920

@@ -28,6 +29,7 @@ void main() {
2829
expect(product.notes, 'A test beer');
2930
expect(product.statusText, 'Plenty left');
3031
expect(product.bar, 'Main Bar');
32+
expect(product.vegan, isTrue);
3133
expect(product.allergens, {'gluten': 1});
3234
});
3335

@@ -46,6 +48,7 @@ void main() {
4648
expect(product.notes, isNull);
4749
expect(product.statusText, isNull);
4850
expect(product.bar, isNull);
51+
expect(product.vegan, isNull);
4952
expect(product.allergens, isEmpty);
5053
});
5154

@@ -185,6 +188,126 @@ void main() {
185188
});
186189
});
187190

191+
group('vegan field parsing', () {
192+
test('parses legacy vegan key when is_vegan is absent', () {
193+
final product = Product.fromJson({
194+
'id': '1',
195+
'name': 'a',
196+
'category': 'beer',
197+
'dispense': 'cask',
198+
'abv': '4.0',
199+
'vegan': true,
200+
});
201+
expect(product.vegan, isTrue);
202+
});
203+
204+
test('prefers is_vegan when both keys are present', () {
205+
final product = Product.fromJson({
206+
'id': '1',
207+
'name': 'a',
208+
'category': 'beer',
209+
'dispense': 'cask',
210+
'abv': '4.0',
211+
'is_vegan': false,
212+
'vegan': true,
213+
});
214+
expect(product.vegan, isFalse);
215+
});
216+
217+
test('parses numeric one as true', () {
218+
final product = Product.fromJson({
219+
'id': '1',
220+
'name': 'a',
221+
'category': 'beer',
222+
'dispense': 'cask',
223+
'abv': '4.0',
224+
'is_vegan': 1,
225+
});
226+
expect(product.vegan, isTrue);
227+
});
228+
229+
test('parses numeric zero as false', () {
230+
final product = Product.fromJson({
231+
'id': '1',
232+
'name': 'a',
233+
'category': 'beer',
234+
'dispense': 'cask',
235+
'abv': '4.0',
236+
'is_vegan': 0,
237+
});
238+
expect(product.vegan, isFalse);
239+
});
240+
241+
test('parses supported string values', () {
242+
final yesProduct = Product.fromJson({
243+
'id': '1',
244+
'name': 'a',
245+
'category': 'beer',
246+
'dispense': 'cask',
247+
'abv': '4.0',
248+
'is_vegan': 'yes',
249+
});
250+
final noProduct = Product.fromJson({
251+
'id': '2',
252+
'name': 'b',
253+
'category': 'beer',
254+
'dispense': 'cask',
255+
'abv': '4.0',
256+
'is_vegan': 'no',
257+
});
258+
final trueProduct = Product.fromJson({
259+
'id': '3',
260+
'name': 'c',
261+
'category': 'beer',
262+
'dispense': 'cask',
263+
'abv': '4.0',
264+
'is_vegan': 'true',
265+
});
266+
final falseProduct = Product.fromJson({
267+
'id': '4',
268+
'name': 'd',
269+
'category': 'beer',
270+
'dispense': 'cask',
271+
'abv': '4.0',
272+
'is_vegan': 'false',
273+
});
274+
final oneProduct = Product.fromJson({
275+
'id': '5',
276+
'name': 'e',
277+
'category': 'beer',
278+
'dispense': 'cask',
279+
'abv': '4.0',
280+
'is_vegan': '1',
281+
});
282+
final zeroProduct = Product.fromJson({
283+
'id': '6',
284+
'name': 'f',
285+
'category': 'beer',
286+
'dispense': 'cask',
287+
'abv': '4.0',
288+
'is_vegan': '0',
289+
});
290+
expect(yesProduct.vegan, isTrue);
291+
expect(noProduct.vegan, isFalse);
292+
expect(trueProduct.vegan, isTrue);
293+
expect(falseProduct.vegan, isFalse);
294+
expect(oneProduct.vegan, isTrue);
295+
expect(zeroProduct.vegan, isFalse);
296+
});
297+
298+
test('returns null for unsupported string values', () {
299+
final product = Product.fromJson({
300+
'id': '1',
301+
'name': 'a',
302+
'category': 'beer',
303+
'dispense': 'cask',
304+
'abv': '4.0',
305+
'is_vegan': 'maybe',
306+
});
307+
expect(product.vegan, isNull);
308+
});
309+
});
310+
188311
group('availability status edge cases', () {
189312
test('returns plenty for "arrived" status', () {
190313
final product = Product.fromJson({
@@ -310,6 +433,7 @@ void main() {
310433
notes: 'A test beer',
311434
statusText: 'Plenty left',
312435
bar: 'Main Bar',
436+
vegan: true,
313437
allergens: {'gluten': 1},
314438
);
315439

@@ -324,6 +448,7 @@ void main() {
324448
expect(json['notes'], 'A test beer');
325449
expect(json['status_text'], 'Plenty left');
326450
expect(json['bar'], 'Main Bar');
451+
expect(json['is_vegan'], isTrue);
327452
expect(json['allergens'], {'gluten': 1});
328453
});
329454

@@ -597,6 +722,7 @@ void main() {
597722
'notes': 'Hoppy and bold',
598723
'status_text': 'Plenty left',
599724
'bar': 'Bar A',
725+
'is_vegan': true,
600726
'allergens': {'gluten': 1},
601727
});
602728

@@ -636,6 +762,7 @@ void main() {
636762
expect(drink.notes, 'Hoppy and bold');
637763
expect(drink.statusText, 'Plenty left');
638764
expect(drink.bar, 'Bar A');
765+
expect(drink.vegan, isTrue);
639766
expect(drink.allergens, {'gluten': 1});
640767
expect(drink.availabilityStatus, AvailabilityStatus.plenty);
641768
expect(drink.allergenText, 'Gluten');

0 commit comments

Comments
 (0)