diff --git a/lib/providers/beer_provider.dart b/lib/providers/beer_provider.dart index 51c52705..dc7c221e 100644 --- a/lib/providers/beer_provider.dart +++ b/lib/providers/beer_provider.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../models/models.dart'; import '../services/services.dart'; @@ -361,7 +361,7 @@ class BeerProvider extends ChangeNotifier { } else { return 'Could not load festivals. Please check your connection.'; } - } else if (error is SocketException) { + } else if (error is http.ClientException) { return 'No internet connection. Please check your network.'; } else if (error is TimeoutException) { return 'Request timed out. Please check your connection and try again.'; diff --git a/lib/services/beer_api_service.dart b/lib/services/beer_api_service.dart index 967b1ab6..a19586a5 100644 --- a/lib/services/beer_api_service.dart +++ b/lib/services/beer_api_service.dart @@ -37,7 +37,8 @@ class BeerApiService { } /// Fetches all available drinks from a festival (all beverage types) - /// + /// + /// Fetches all beverage types in parallel for faster loading. /// Throws [BeerApiException] if ALL beverage types fail to load or return /// no drinks. Individual failures are tracked and reported in the exception /// message to help diagnose issues like CORS or network problems. @@ -45,14 +46,21 @@ class BeerApiService { final allDrinks = []; final errors = {}; - for (final beverageType in festival.availableBeverageTypes) { - try { - final drinks = await fetchDrinks(festival, beverageType); - allDrinks.addAll(drinks); - } catch (e) { - // Track the error for this beverage type - errors[beverageType] = e.toString(); - } + // Fetch all beverage types in parallel for faster loading + final results = await Future.wait( + festival.availableBeverageTypes.map((beverageType) async { + try { + return await fetchDrinks(festival, beverageType); + } catch (e) { + // Track the error for this beverage type + errors[beverageType] = e.toString(); + return []; + } + }), + ); + + for (final drinks in results) { + allDrinks.addAll(drinks); } // If we got no drinks at all and there were errors, throw with details diff --git a/lib/widgets/festival_menu_sheets.dart b/lib/widgets/festival_menu_sheets.dart index 5f470eb5..246fb3a3 100644 --- a/lib/widgets/festival_menu_sheets.dart +++ b/lib/widgets/festival_menu_sheets.dart @@ -185,8 +185,24 @@ class FestivalSelectorSheet extends StatelessWidget { sortedFestivals: festivals, isSelected: isSelected, onTap: () { + // Capture current path first, before any state changes + // Default to festival home; override if currently on favorites + String targetPath = buildFestivalHome(festival.id); + try { + final currentPath = GoRouterState.of(context).uri.path; + // Preserve user's tab: if on favorites, stay on favorites + if (currentPath.endsWith('/favorites')) { + targetPath = buildFavoritesPath(festival.id); + } + } catch (e) { + // GoRouterState unavailable (e.g., in tests), keep default path + debugPrint('Festival selector: Unable to get current route, using default: $e'); + } + + final router = GoRouter.maybeOf(context); provider.setFestival(festival); Navigator.pop(context); + router?.go(targetPath); }, onInfoTap: () { Navigator.pop(context); diff --git a/test/provider_test.dart b/test/provider_test.dart index 79c9f14b..98f70831 100644 --- a/test/provider_test.dart +++ b/test/provider_test.dart @@ -1,8 +1,8 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:cambridge_beer_festival/providers/beer_provider.dart'; import 'package:cambridge_beer_festival/services/services.dart'; +import 'package:http/http.dart' as http; import 'package:cambridge_beer_festival/models/models.dart'; import 'package:cambridge_beer_festival/domain/models/models.dart'; import 'package:cambridge_beer_festival/domain/repositories/repositories.dart'; @@ -153,16 +153,16 @@ void main() { ); await provider.initialize(); - // Mock SocketException (no internet) + // http.ClientException is thrown on network failures across all platforms when(mockDrinkRepository.getDrinks(any)) - .thenThrow(const SocketException('Failed host lookup')); + .thenThrow(http.ClientException('Failed host lookup')); await provider.loadDrinks(); expect(provider.error, isNotNull); expect(provider.error, contains('No internet connection')); expect(provider.error, contains('check your network')); - expect(provider.error, isNot(contains('SocketException'))); + expect(provider.error, isNot(contains('ClientException'))); expect(provider.error, isNot(contains('Failed host lookup'))); }); @@ -315,15 +315,15 @@ void main() { analyticsService: mockAnalyticsService, ); - // Mock SocketException + // http.ClientException is thrown on network failures across all platforms when(mockFestivalRepository.getFestivals()) - .thenThrow(const SocketException('Network unreachable')); + .thenThrow(http.ClientException('Network unreachable')); await provider.loadFestivals(); expect(provider.festivalsError, isNotNull); expect(provider.festivalsError, contains('No internet connection')); - expect(provider.festivalsError, isNot(contains('SocketException'))); + expect(provider.festivalsError, isNot(contains('ClientException'))); }); test('shows connection message for FestivalServiceException without status', diff --git a/test/utf8_encoding_test.mocks.dart b/test/utf8_encoding_test.mocks.dart new file mode 100644 index 00000000..95a316ed --- /dev/null +++ b/test/utf8_encoding_test.mocks.dart @@ -0,0 +1,284 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in cambridge_beer_festival/test/utf8_encoding_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; +import 'dart:convert' as _i4; +import 'dart:typed_data' as _i6; + +import 'package:http/http.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i5; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { + _FakeResponse_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeStreamedResponse_1 extends _i1.SmartFake + implements _i2.StreamedResponse { + _FakeStreamedResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [Client]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockClient extends _i1.Mock implements _i2.Client { + MockClient() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future<_i2.Response> head( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> get( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> post( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> put( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> patch( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> delete( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future read( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future.value(_i5.dummyValue( + this, + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + )), + ) as _i3.Future); + + @override + _i3.Future<_i6.Uint8List> readBytes( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #readBytes, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), + ) as _i3.Future<_i6.Uint8List>); + + @override + _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => + (super.noSuchMethod( + Invocation.method( + #send, + [request], + ), + returnValue: + _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( + this, + Invocation.method( + #send, + [request], + ), + )), + ) as _i3.Future<_i2.StreamedResponse>); + + @override + void close() => super.noSuchMethod( + Invocation.method( + #close, + [], + ), + returnValueForMissingStub: null, + ); +}