Developer documentation for the Cambridge Beer Festival app - implementation details and completed features.
- Recently Implemented Features
- HTTP Request Timeouts
- Error Handling
- User-Friendly Error Messages
- Mobile UI Optimizations
- Reusable Screen Components
- Testing
This document tracks features that have been successfully implemented and how they work.
✅ Completed (5 items):
- HTTP request timeouts
- Error handling for URL launches
- User-friendly error messages
- SliverAppBar mobile optimization
- EntityDetailScreen reusable component
- Widget tests (StarRating only)
❌ Pending (29 items):
- See ../todos.md for complete list
Status: ✅ Completed
Location: lib/services/beer_api_service.dart
Implemented: 2025-11-30
The BeerApiService now includes configurable HTTP request timeouts to prevent indefinite hangs.
Implementation:
class BeerApiService {
final http.Client _client;
final Duration timeout;
BeerApiService({
http.Client? client,
this.timeout = const Duration(seconds: 30), // Default 30s
}) : _client = client ?? http.Client();
Future<List<Drink>> fetchDrinks(Festival festival, String beverageType) async {
final url = festival.getBeverageUrl(beverageType);
final response = await _client.get(Uri.parse(url))
.timeout(timeout); // Timeout applied here
// ...
}
}- Default timeout: 30 seconds (configurable)
- Throws TimeoutException when exceeded
- Can be customized via constructor for testing
- Applies to all HTTP requests in the service
// Use default 30s timeout
final service = BeerApiService();
// Custom timeout for testing
final testService = BeerApiService(
timeout: const Duration(milliseconds: 100),
);Tests exist in test/services_test.dart:
- Default timeout verification
- Custom timeout acceptance
- Timeout enforcement on HTTP requests
- Successful completion within timeout
Status: ✅ Completed
Location: lib/screens/festival_info_screen.dart
Implemented: 2025-11-30
URL launch operations (maps, website) now provide user feedback on failures.
Implementation:
void _openMaps(BuildContext context) async {
if (festival.latitude == null || festival.longitude == null) return;
final url = Uri.parse(
'https://www.google.com/maps/search/?api=1&query=${festival.latitude},${festival.longitude}',
);
try {
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not open maps')),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error opening maps')),
);
}
}
}- Try-catch blocks around async operations
- User-friendly SnackBar messages on failures
- Context.mounted checks to prevent errors after widget disposal
- Separate messages for "can't launch" vs "error launching"
The same pattern is used for:
_openWebsite()- Website URL launches- Both in
lib/screens/festival_info_screen.dart:225-270
Status: ✅ Completed
Location: lib/providers/beer_provider.dart
Implemented: 2025-11-30
Technical exceptions are now converted to user-friendly messages before display.
Implementation:
String _getUserFriendlyErrorMessage(Object error) {
if (error is BeerApiException) {
if (error.statusCode == 404) {
return 'Festival data not found. Please try a different festival.';
} else if (error.statusCode != null && error.statusCode! >= 500) {
return 'Server error. Please try again later.';
} else if (error.statusCode != null && error.statusCode! >= 400) {
return 'Could not load drinks. Please try again.';
} else {
return 'Could not load drinks. Please check your connection.';
}
} else if (error is FestivalServiceException) {
if (error.statusCode == 404) {
return 'Festival list not found. Please try again later.';
} else if (error.statusCode != null && error.statusCode! >= 500) {
return 'Server error. Please try again later.';
} else {
return 'Could not load festivals. Please check your connection.';
}
} else if (error is SocketException) {
return 'No internet connection. Please check your network.';
} else if (error is TimeoutException) {
return 'Request timed out. Please check your connection and try again.';
} else if (error is FormatException) {
return 'Invalid data received. Please try again later.';
} else {
return 'Something went wrong. Please try again.';
}
}- Exception type detection - Different messages for different errors
- HTTP status code handling - 404, 4xx, 5xx get specific messages
- Network errors - SocketException, TimeoutException handled
- Data errors - FormatException handled
- Fallback message - Generic message for unknown errors
try {
_allDrinks = await _apiService.fetchAllDrinks(currentFestival);
// ...
} catch (e) {
_error = _getUserFriendlyErrorMessage(e); // Translates error
// ...
}Before:
"BeerApiException: Failed to fetch beer: 500"
"SocketException: Connection refused"
After:
"Server error. Please try again later."
"No internet connection. Please check your network."
Status: ✅ SliverAppBar completed
Location: lib/screens/drinks_screen.dart
Implemented: 2025-11-30
The DrinksScreen now uses a collapsing app bar to save vertical space on mobile.
Implementation:
Widget build(BuildContext context) {
final provider = context.watch<BeerProvider>();
return Scaffold(
body: Column(
children: [
Expanded(
child: RefreshIndicator(
onRefresh: () => provider.loadDrinks(),
child: CustomScrollView(
slivers: [
SliverAppBar(
floating: true, // Appears on scroll up
snap: true, // Snaps in/out
title: _buildFestivalHeader(context, provider),
),
// ... rest of content
],
),
),
),
],
),
);
}floating: true- AppBar reappears when scrolling upsnap: true- Smooth snap-in/snap-out animation- Saves ~56px of vertical space when scrolled down
- CustomScrollView with slivers for optimal performance
- User scrolls down → AppBar hides, more content visible
- User scrolls up slightly → AppBar immediately reappears
- Smooth animations, no jarring transitions
See ../todos.md for remaining mobile UI items:
- #26: Collapsible festival info banner (40-50px savings)
- #27: Horizontal scrolling style chips (40-80px savings)
- #28: Reduced card density on mobile (20-30px per card)
Status: ✅ Completed
Location: lib/widgets/entity_detail_screen.dart
Implemented: 2025-12-14
The EntityDetailScreen widget provides a reusable layout pattern for detail screens that display a filtered list of drinks (e.g., BreweryScreen, StyleScreen). This refactoring extracted common code from similar screens, reducing duplication and making it easier to create new detail screens.
Implementation:
class EntityDetailScreen extends StatefulWidget {
final String title;
final String notFoundMessage;
final String notFoundTitle;
final double expandedHeight;
final List<Drink> Function(List<Drink> allDrinks) filterDrinks;
final Widget Function(BuildContext context, List<Drink> drinks) buildHeader;
final Future<void> Function()? logAnalytics;
const EntityDetailScreen({
required this.title,
required this.notFoundMessage,
required this.notFoundTitle,
required this.expandedHeight,
required this.filterDrinks,
required this.buildHeader,
this.logAnalytics,
});
}The widget provides the following shared functionality:
- Loading State - Shows loading indicator while data is being fetched
- Not Found State - Displays message when no drinks match the filter
- SliverAppBar Layout - Collapsible header with customizable expanded height
- Navigation - Home button when can't pop, back button otherwise
- Analytics - Optional analytics logging via callback
- Drink List - Filtered DrinkCard list with favorite toggle
- Theme Support - Uses primaryContainer color scheme
BreweryScreen:
EntityDetailScreen(
title: producer.name,
notFoundTitle: 'Brewery Not Found',
notFoundMessage: 'This brewery could not be found.',
expandedHeight: 244,
filterDrinks: (allDrinks) =>
allDrinks.where((d) => d.producer.id == breweryId).toList(),
buildHeader: (context, drinks) {
final producer = drinks.first.producer;
return _buildBreweryHeader(context, producer, drinks.length);
},
logAnalytics: () async {
await provider.analyticsService.logBreweryViewed(producer.name);
},
)StyleScreen:
EntityDetailScreen(
title: style,
notFoundTitle: 'Style Not Found',
notFoundMessage: 'No drinks found for this style.',
expandedHeight: 220,
filterDrinks: (allDrinks) =>
allDrinks.where((d) => d.style == style).toList(),
buildHeader: (context, drinks) {
final avgAbv = drinks.fold(0.0, (sum, d) => sum + d.abv) / drinks.length;
return _buildStyleHeader(context, style, drinks.length, avgAbv);
},
logAnalytics: () async {
await provider.analyticsService.logStyleViewed(style);
},
)- Code Reuse - Eliminated ~100 lines of duplicated code per screen
- Consistency - Ensures all detail screens follow the same UX pattern
- Maintainability - Bug fixes in EntityDetailScreen benefit all screens
- Flexibility - Builder pattern allows customization of header content
- Type Safety - Strong typing for filter and header builder functions
Each screen maintains its unique header design while sharing the common layout:
- BreweryScreen - Shows brewery initials, location, year founded, drink count
- StyleScreen - Shows style initial, drink count, average ABV statistics
Future detail screens (e.g., by category, region) can easily reuse this pattern.
Widget Tests:
- ✅
test/widgets_test.dart- StarRating widget (complete) - ✅
test/brewery_screen_test.dart- BreweryScreen (9 tests) - ✅
test/style_screen_test.dart- StyleScreen (8 tests)
Unit Tests:
- ✅
test/models_test.dart- Data models - ✅
test/services_test.dart- API service, storage - ✅
test/provider_test.dart- BeerProvider state management
Integration Tests:
- ❌ Not yet implemented
# Run all tests
flutter test
# Run with coverage
flutter test --coverage
# Run specific test file
flutter test test/services_test.dart
# Run tests with semantics enabled (for a11y)
flutter test --enable-semanticstest/
├── models_test.dart # Model JSON parsing, validation
├── services_test.dart # API calls, storage operations
├── provider_test.dart # State management, filtering
├── provider_test.mocks.dart # Generated mocks (Mockito)
├── widgets_test.dart # StarRating widget
└── beer_api_service_test.dart # API service detailed tests
See CLAUDE.md Testing Requirements section for:
- Test file location conventions
- How to add widget tests
- Integration test setup (when implemented)
- Read CLAUDE.md - Project instructions and code style
- Check todos.md - Verify task status
- Run tests - Ensure baseline passes
- Run analyzer -
flutter analyze --no-fatal-infos
- Run analyzer -
flutter analyze --no-fatal-infos - Run tests -
flutter test - Update documentation - If adding features
- Update todos.md - Mark items complete
- Commit with clear message
See CLAUDE.md Code Style Checklist for:
- Single quotes for strings
constconstructors where possiblefinalfor local variables- Accessibility requirements (Semantics widgets)
- Large text testing
Based on ../todos.md, prioritize in this order:
- ❌ Remove localhost from production CORS (#4)
⚠️ Complete widget tests for all screens (#2)- ❌ Implement accessibility (Semantics) (#6) - See ACCESSIBILITY.md
- ❌ Add retry logic for API calls (#8)
- ❌ Add keys to ListView items (#9)
- ❌ Add Firebase Crashlytics/Analytics (#10)
- ❌ Add integration tests (#5)
- ❌ Improve test coverage to 70%+ (#14)
See full implementation order in ../todos.md.
- Main Project Instructions - AI coding guidelines
- TODO List - All pending and completed tasks
- Accessibility Guide - How to implement a11y
- API Documentation - API schemas and endpoints
If you encounter issues with implemented features:
- Check this document for implementation details
- Review test files for usage examples
- Check ../todos.md for known issues
- Run tests to verify functionality
For new feature development, always check CLAUDE.md first for project guidelines.