Skip to content

Commit ab79716

Browse files
Merge pull request #174 from richardthe3rd/copilot/fix-non-english-character-handling
Fix UTF-8 mojibake and sorting for non-ASCII characters in style names
2 parents 019791f + 9af2940 commit ab79716

8 files changed

Lines changed: 579 additions & 9 deletions

File tree

cloudflare-worker/worker.js

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export default {
4242
if (url.pathname === '/health') {
4343
return new Response(JSON.stringify({ status: 'ok' }), {
4444
headers: {
45-
'Content-Type': 'application/json',
45+
'Content-Type': 'application/json; charset=utf-8',
4646
...getCorsHeaders(request),
4747
},
4848
});
@@ -53,7 +53,7 @@ export default {
5353
return new Response(JSON.stringify(festivalsData), {
5454
status: 200,
5555
headers: {
56-
'Content-Type': 'application/json',
56+
'Content-Type': 'application/json; charset=utf-8',
5757
'Cache-Control': FESTIVALS_CACHE_CONTROL,
5858
...getCorsHeaders(request),
5959
},
@@ -81,6 +81,13 @@ export default {
8181
// Clone the response and add CORS headers
8282
const newHeaders = new Headers(response.headers);
8383
setCorsHeaders(newHeaders, request);
84+
85+
// Ensure JSON responses explicitly declare UTF-8 encoding
86+
// This prevents mojibake when non-ASCII characters (é, ö, ä, ñ) are present
87+
const contentType = newHeaders.get('Content-Type');
88+
if (contentType && contentType.includes('application/json') && !contentType.includes('charset')) {
89+
newHeaders.set('Content-Type', 'application/json; charset=utf-8');
90+
}
8491

8592
return new Response(response.body, {
8693
status: response.status,
@@ -91,7 +98,7 @@ export default {
9198
return new Response(JSON.stringify({ error: 'Proxy error', message: error.message }), {
9299
status: 502,
93100
headers: {
94-
'Content-Type': 'application/json',
101+
'Content-Type': 'application/json; charset=utf-8',
95102
...getCorsHeaders(request),
96103
},
97104
});
@@ -124,7 +131,7 @@ async function handleAvailableBeverageTypes(festivalId, request) {
124131
}), {
125132
status: 404,
126133
headers: {
127-
'Content-Type': 'application/json',
134+
'Content-Type': 'application/json; charset=utf-8',
128135
...getCorsHeaders(request),
129136
},
130137
});
@@ -142,7 +149,7 @@ async function handleAvailableBeverageTypes(festivalId, request) {
142149
}), {
143150
status: 200,
144151
headers: {
145-
'Content-Type': 'application/json',
152+
'Content-Type': 'application/json; charset=utf-8',
146153
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
147154
...getCorsHeaders(request),
148155
},
@@ -154,7 +161,7 @@ async function handleAvailableBeverageTypes(festivalId, request) {
154161
}), {
155162
status: 500,
156163
headers: {
157-
'Content-Type': 'application/json',
164+
'Content-Type': 'application/json; charset=utf-8',
158165
...getCorsHeaders(request),
159166
},
160167
});

lib/screens/drinks_screen.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -845,9 +845,10 @@ class _StyleFilterSheet extends StatelessWidget {
845845
final styleCounts = beerProvider.styleCountsMap;
846846
final selectedStyles = beerProvider.selectedStyles;
847847

848-
// Sort styles alphabetically (don't move selected to top to avoid jumping)
848+
// Sort styles alphabetically using locale-aware comparison
849+
// This ensures non-ASCII characters (é, ñ, etc.) sort correctly
849850
final sortedStyles = List<String>.from(styles);
850-
sortedStyles.sort((a, b) => a.compareTo(b));
851+
sortedStyles.sort(StringComparisonHelper.compareLocaleAware);
851852

852853
return Container(
853854
padding: const EdgeInsets.all(16),

lib/services/beer_api_service.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ class BeerApiService {
1919
.timeout(timeout);
2020

2121
if (response.statusCode == 200) {
22-
final data = json.decode(response.body) as Map<String, dynamic>;
22+
// Decode as UTF-8 to handle non-ASCII characters properly (é, ñ, etc.)
23+
// Using response.body defaults to Latin-1 if no charset in Content-Type,
24+
// which causes "Rosé" to display as "Rosé" (mojibake)
25+
final jsonString = utf8.decode(response.bodyBytes);
26+
final data = json.decode(jsonString) as Map<String, dynamic>;
2327
return _parseDrinks(data, festival.id);
2428
} else if (response.statusCode == 404) {
2529
// Beverage type not available for this festival
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/// Helper class for locale-aware string comparisons
2+
///
3+
/// Provides methods to properly sort and compare strings containing
4+
/// non-ASCII characters (e.g., "rosé", "café") in a human-friendly way.
5+
class StringComparisonHelper {
6+
// Private constructor to prevent instantiation
7+
StringComparisonHelper._();
8+
9+
/// Locale-aware case-insensitive string comparison
10+
///
11+
/// This ensures that strings with accented characters (é, ñ, ü, etc.)
12+
/// are sorted in a reasonable alphabetical order. While not perfect for
13+
/// all locales, this approach handles common European accented characters
14+
/// properly for beer/wine/cider style names.
15+
///
16+
/// The comparison is case-insensitive, so "IPA", "Ipa", and "ipa" are
17+
/// treated as equal.
18+
///
19+
/// Examples:
20+
/// - "Café" comes right after "Cafe"
21+
/// - "Rosé" comes right after "Rose"
22+
/// - "IPA" and "ipa" are treated as equal
23+
///
24+
/// For sorting lists:
25+
/// ```dart
26+
/// styles.sort(StringComparisonHelper.compareLocaleAware);
27+
/// ```
28+
static int compareLocaleAware(String a, String b) {
29+
// Use case-insensitive comparison
30+
// This handles accented characters reasonably well for European languages
31+
// by comparing the lowercase versions
32+
return a.toLowerCase().compareTo(b.toLowerCase());
33+
}
34+
}

lib/utils/utils.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export 'abv_strength_helper.dart';
22
export 'beverage_type_helper.dart';
33
export 'category_color_helper.dart';
4+
export 'string_comparison_helper.dart';
45
export 'string_formatting_helper.dart';
56
export 'style_description_helper.dart';
67
export 'url_launcher_helper.dart';

test/drinks_screen_style_filter_test.dart

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,5 +342,131 @@ void main() {
342342
// Verify Stout is selected but stays in alphabetical position
343343
expect(thirdCheckbox.value, true);
344344
});
345+
346+
testWidgets('styles with non-ASCII characters sort correctly',
347+
(WidgetTester tester) async {
348+
// Override the test drinks to include non-ASCII characters
349+
final drinksWithAccents = [
350+
Drink(
351+
product: const Product(
352+
id: 'drink1',
353+
name: 'Rose Cider',
354+
abv: 5.0,
355+
category: 'cider',
356+
dispense: 'keg',
357+
style: 'Rose',
358+
),
359+
producer: const Producer(
360+
id: 'cidery1',
361+
name: 'Test Cidery',
362+
location: 'France',
363+
products: [],
364+
),
365+
festivalId: 'cbf2025',
366+
),
367+
Drink(
368+
product: const Product(
369+
id: 'drink2',
370+
name: 'Rosé Cider',
371+
abv: 5.2,
372+
category: 'cider',
373+
dispense: 'keg',
374+
style: 'Rosé',
375+
),
376+
producer: const Producer(
377+
id: 'cidery1',
378+
name: 'Test Cidery',
379+
location: 'France',
380+
products: [],
381+
),
382+
festivalId: 'cbf2025',
383+
),
384+
Drink(
385+
product: const Product(
386+
id: 'drink3',
387+
name: 'Cafe Stout',
388+
abv: 6.0,
389+
category: 'beer',
390+
dispense: 'cask',
391+
style: 'Cafe',
392+
),
393+
producer: const Producer(
394+
id: 'brewery1',
395+
name: 'Test Brewery',
396+
location: 'UK',
397+
products: [],
398+
),
399+
festivalId: 'cbf2025',
400+
),
401+
Drink(
402+
product: const Product(
403+
id: 'drink4',
404+
name: 'Café Stout',
405+
abv: 6.2,
406+
category: 'beer',
407+
dispense: 'cask',
408+
style: 'Café',
409+
),
410+
producer: const Producer(
411+
id: 'brewery1',
412+
name: 'Test Brewery',
413+
location: 'UK',
414+
products: [],
415+
),
416+
festivalId: 'cbf2025',
417+
),
418+
];
419+
420+
// Create new provider with accented test data
421+
final accentProvider = BeerProvider(
422+
apiService: mockApiService,
423+
festivalService: mockFestivalService,
424+
analyticsService: mockAnalyticsService,
425+
);
426+
427+
when(mockApiService.fetchAllDrinks(any))
428+
.thenAnswer((_) async => drinksWithAccents);
429+
430+
await accentProvider.initialize();
431+
await accentProvider.loadDrinks();
432+
433+
await tester.pumpWidget(
434+
ChangeNotifierProvider<BeerProvider>.value(
435+
value: accentProvider,
436+
child: const MaterialApp(
437+
home: DrinksScreen(),
438+
),
439+
),
440+
);
441+
await tester.pumpAndSettle();
442+
443+
// Open style filter
444+
await tester.tap(find.text('Style'));
445+
await tester.pumpAndSettle();
446+
447+
// Find all CheckboxListTiles
448+
final checkboxes = find.byType(CheckboxListTile);
449+
expect(checkboxes, findsNWidgets(4));
450+
451+
// Verify locale-aware alphabetical order:
452+
// Cafe, Café, Rose, Rosé
453+
final firstCheckbox = tester.widget<CheckboxListTile>(checkboxes.at(0));
454+
final secondCheckbox = tester.widget<CheckboxListTile>(checkboxes.at(1));
455+
final thirdCheckbox = tester.widget<CheckboxListTile>(checkboxes.at(2));
456+
final fourthCheckbox = tester.widget<CheckboxListTile>(checkboxes.at(3));
457+
458+
expect((firstCheckbox.title as Text).data, 'Cafe (1)');
459+
expect((secondCheckbox.title as Text).data, 'Café (1)');
460+
expect((thirdCheckbox.title as Text).data, 'Rose (1)');
461+
expect((fourthCheckbox.title as Text).data, 'Rosé (1)');
462+
463+
// Verify the accented characters display correctly (not garbled)
464+
expect((secondCheckbox.title as Text).data?.contains('é'), true,
465+
reason: 'Café should display the é character correctly');
466+
expect((fourthCheckbox.title as Text).data?.contains('é'), true,
467+
reason: 'Rosé should display the é character correctly');
468+
469+
accentProvider.dispose();
470+
});
345471
});
346472
}

0 commit comments

Comments
 (0)