diff --git a/.claude/skills/ui-and-accessibility/SKILL.md b/.claude/skills/ui-and-accessibility/SKILL.md index 1cc638ac..fb0cc424 100644 --- a/.claude/skills/ui-and-accessibility/SKILL.md +++ b/.claude/skills/ui-and-accessibility/SKILL.md @@ -139,8 +139,6 @@ unless noted. | Divided strip of "key facts" (centred value over an uppercase label) inside a hero | `FactsStrip` + `FactCell` | `facts_strip.dart` | `FactsStrip` owns the top/bottom/left dividers; a `FactCell` becomes a navigation button when given `onTap` + `semanticLabel` | | Small metadata pill (style, dispense, bar location) | `InfoChip` | `info_chip.dart` | Optional `onTap` makes it a `Semantics(button: true)` link | | Section title with underline on a detail screen | `SectionHeader` | `section_header.dart` | `showSeparator` toggles the underline | -| Sticky bottom row of actions (tasting log, rate, favourite, share) | `BottomActionBar` + `ActionButton` | `bottom_action_bar.dart` | **Currently unused** — no screen wires it up (the drink detail screen uses a FAB + `YourTakeCard` instead). Available, but check it still fits before adopting. `ActionButton.isActive` drives colour + `FontWeight`; `semanticLabel` overrides the visible label for screen readers | -| Back-navigation header on a detail screen (drink/brewery/style) | `BreadcrumbBar` | `breadcrumb_bar.dart` | **Currently unused** — detail screens use `CollapsingDetailAppBar` + `buildHomeLeadingButton`. Only the `IconButton` gets `Semantics`, never the text row — see the "BAD" example in `docs/code/widget-standards.md`. 28px icon → 48×48 touch target. Text segments only become tappable/underlined when a callback is provided | | Three-dot menu for festival switch / settings / about | `buildOverflowMenu(context)` | `overflow_menu.dart` | A function, not a widget class — `docs/code/ui-components.md` documents where to include it (Drinks, My Festival screen) and where not to (detail screens, About, modals) | | Modal filter pickers (category, style, sort, visibility) | `showCategoryFilter` / `showStyleFilter` / `showSortOptions` / `showVisibilityFilter` | `drink_filter_sheets.dart` | All route through the private `_showSheet` helper (`isScrollControlled: true`) and share `_SheetHandle` — add a new filter type by adding a sheet class + show-function here, not a bespoke `showModalBottomSheet` call elsewhere | | Star rating display or picker | `StarRating` | `star_rating.dart` | `isEditable` toggles read-only vs tap-to-rate; semantic `value` is always `'$rating out of 5 stars'` | @@ -245,8 +243,8 @@ ExcludeSemantics(child: Icon(Icons.festival, color: menuContentColor)) `label` describing the *action*, not the icon (`'Add to favourites'`, not `'Heart icon'`). - Touch targets: 24×24 px minimum (WCAG AA); this project's icon buttons - generally exceed that (`BreadcrumbBar`'s back button is 28px icon → 48×48 - effective target). + generally exceed that (a typical back-navigation `IconButton` uses a 28px + icon → 48×48 effective target). - Colour contrast 4.5:1 for text; never rely on colour alone to convey state (relevant to My Festival badges — see Part 6). - Don't wrap a whole `Row` containing both a button and plain text in one @@ -331,10 +329,10 @@ navigateToRoute(context, buildBreweryPath(festivalId, breweryId)); navigateToRoute(context, buildStylePath(festivalId, style)); // lowercases + encodes ``` Available builders (`lib/utils/navigation_helpers.dart`): `buildFestivalPath`, -`buildFestivalHome`, `buildDrinksPath`, `buildFavoritesPath`, -`buildFestivalInfoPath`, `buildDrinkDetailPath`, `buildBreweryPath`, -`buildStylePath`, `buildCategoryPath`. Each asserts non-empty required -arguments in debug mode and URL-encodes user-provided segments. +`buildFestivalHome`, `buildFavoritesPath`, `buildFestivalInfoPath`, +`buildDrinkDetailPath`, `buildBreweryPath`, `buildStylePath`. Each asserts +non-empty required arguments in debug mode and URL-encodes user-provided +segments. ### 4. Post-frame analytics in `initState` diff --git a/docs/README.md b/docs/README.md index cb1a0113..77b8a90b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ How the codebase works -- implementation guides, architecture, and technical ref - **[routing.md](code/routing.md)** - URL routing (path-based with GoRouter) - **[navigation.md](code/navigation.md)** - Navigation helper API reference - **[widget-standards.md](code/widget-standards.md)** - Widget patterns and standards -- **[ui-components.md](code/ui-components.md)** - Shared UI components (OverflowMenu, BreadcrumbBar) +- **[ui-components.md](code/ui-components.md)** - Shared UI components (OverflowMenu) - **[network.md](code/network.md)** - Network security configuration and allowlist - **[api/](code/api/)** - API documentation - [README.md](code/api/README.md) - API overview diff --git a/docs/code/navigation.md b/docs/code/navigation.md index 390dfc77..bd1da3ee 100644 --- a/docs/code/navigation.md +++ b/docs/code/navigation.md @@ -16,12 +16,10 @@ All app URLs are scoped to a specific festival. This allows: ``` Examples: -- `/cbf2025` - Festival home -- `/cbf2025/drinks` - Drinks list -- `/cbf2025/drink/123` - Drink detail +- `/cbf2025` - Festival home (drinks list) +- `/cbf2025/drink/beer/123` - Drink detail - `/cbf2025/brewery/456` - Brewery detail - `/cbf2025/style/ipa` - Style detail (lowercase canonical) -- `/cbf2025/category/beer` - Category page ## URL Encoding @@ -43,38 +41,12 @@ import 'package:cambridge_beer_festival/utils/utils.dart'; // Build festival home URL final homeUrl = buildFestivalHome('cbf2025'); // '/cbf2025' -// Build drinks URL -final drinksUrl = buildDrinksPath('cbf2025'); // '/cbf2025/drinks' -final beerUrl = buildDrinksPath('cbf2025', category: 'beer'); // '/cbf2025/drinks?category=beer' - // Build detail URLs -final drinkUrl = buildDrinkDetailPath('cbf2025', drink.id); +final drinkUrl = buildDrinkDetailPath('cbf2025', drink.category, drink.id); final breweryUrl = buildBreweryPath('cbf2025', brewery.id); final styleUrl = buildStylePath('cbf2025', 'IPA'); // Returns: '/cbf2025/style/ipa' (lowercase) ``` -### Parsing URLs - -```dart -// Extract festival ID from path -final festivalId = extractFestivalId('/cbf2025/drinks'); // 'cbf2025' -final festivalId2 = extractFestivalId('/cbf2025'); // 'cbf2025' (festival home) -final festivalId3 = extractFestivalId('/'); // null -final festivalId4 = extractFestivalId(''); // null - -// Check if path is festival-scoped -if (isFestivalPath(path)) { - // Handle festival-scoped navigation -} -``` - -**Important Notes:** - -- `extractFestivalId()` returns the first path segment, but **cannot validate** if it's a real festival ID -- Single-segment paths like `/drinks` return `'drinks'` as the potential festival ID -- Actual validation against the festival registry happens in Phase 1 routing logic -- Empty paths and root path `/` return `null` - ## Input Validation All builder functions include assertions to prevent common errors: @@ -82,11 +54,7 @@ All builder functions include assertions to prevent common errors: ```dart // ❌ These will throw AssertionError in debug mode: buildFestivalPath('', '/drinks'); // Empty festival ID -buildDrinkDetailPath('cbf2025', ''); // Empty drink ID -buildCategoryPath('cbf2025', ''); // Empty category - -// ✅ These are handled gracefully: -buildDrinksPath('cbf2025', category: ''); // Returns '/cbf2025/drinks' (no query param) +buildDrinkDetailPath('cbf2025', 'beer', ''); // Empty drink ID ``` ## Testing @@ -94,6 +62,5 @@ buildDrinksPath('cbf2025', category: ''); // Returns '/cbf2025/drinks' (no quer All navigation helpers have comprehensive test coverage in `test/utils/navigation_helpers_test.dart`: - URL encoding edge cases (special characters, Unicode, etc.) - Input validation (assertions) -- Edge cases (long strings, multiple slashes, etc.) +- Edge cases (long strings, etc.) - All builder functions -- Path parsing and validation diff --git a/docs/code/ui-components.md b/docs/code/ui-components.md index 6cae7211..23e4ffd6 100644 --- a/docs/code/ui-components.md +++ b/docs/code/ui-components.md @@ -63,72 +63,3 @@ The overflow menu triggers these modal sheets: - `showSettingsSheet(context)` - Shows `SettingsSheet` Both sheets are defined in `lib/widgets/festival_menu_sheets.dart`. - ---- - -## BreadcrumbBar - -A navigation breadcrumb bar for detail screens. - -### Usage - -```dart -import 'package:cambridge_beer_festival/widgets/widgets.dart'; -import 'package:cambridge_beer_festival/utils/utils.dart'; - -// Breadcrumb for detail screens (back to festival home) -BreadcrumbBar( - backLabel: provider.currentFestival.id, // e.g., 'cbf2025' - contextLabel: 'Oakham Ales', - onBack: () { - if (context.canPop()) { - context.pop(); - } else { - context.go(buildFestivalHome(festivalId)); - } - }, - onBackLabelTap: () => context.go(buildFestivalHome(festivalId)), -) -``` - -**Current pattern (festival-scoped routing):** -- `backLabel`: Festival ID (e.g., `cbf2025`, `cbf2024`) -- `contextLabel`: Parent context (brewery name, style name, etc.) -- `onBack`: Pop if possible, otherwise navigate to festival home -- `onBackLabelTap`: Always navigate to festival home when clicking the festival ID - -### Accessibility - -- **Large touch target**: IconButton with 28px icon size (48x48 touch target) -- **Semantic labels**: Only the IconButton has `Semantics` (not the entire row) - - Label: "Back to {backLabel}" - - Marked as button for screen readers -- **Tooltip**: "Back to {backLabel}" on hover -- **Text overflow handling**: Single line with ellipsis for long text -- **Supports text scaling**: No overflow at 200% scale - -### Implementation Details - -- **Semantics structure**: Only the interactive IconButton is wrapped in `Semantics` -- **Text widget**: Non-interactive text is NOT marked as a button -- **Single-line constraint**: `maxLines: 1` with `TextOverflow.ellipsis` -- **No variable shadowing**: Uses `contextLabel` property (not `context`) to avoid Flutter BuildContext confusion - -### Design - -- Material Design back arrow icon -- Context text with separator (/) -- Ellipsis for long text -- Consistent padding (8px) - -### When to Use - -Use `BreadcrumbBar` on: -- Drink detail screens (back to drinks list) -- Brewery detail screens (back to drinks list) -- Style detail screens (back to drinks list) - -Do NOT use on: -- Home screen (no parent) -- Modal dialogs (use dialog close button) -- Settings screens (use AppBar back button) diff --git a/lib/utils/abv_strength_helper.dart b/lib/utils/abv_strength_helper.dart deleted file mode 100644 index 9a0374dd..00000000 --- a/lib/utils/abv_strength_helper.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Helper class for getting ABV strength-related information -/// -/// Provides consistent ABV strength indicators (colors and labels). -class ABVStrengthHelper { - ABVStrengthHelper._(); - - /// Get color for ABV strength indicator - /// - /// Returns theme-aware colors: - /// - Low ABV (< 4.0%): Blue - /// - Medium ABV (4.0% - 6.9%): Amber - /// - High ABV (>= 7.0%): Deep Orange - static Color getABVColor(BuildContext context, double abv) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final brightness = theme.brightness; - - if (abv < 4.0) { - // Low ABV: Blue-ish - return brightness == Brightness.dark - ? colorScheme.primary.withValues(alpha: 0.7) - : colorScheme.primary; - } else if (abv < 7.0) { - // Medium ABV: Amber/Secondary - return brightness == Brightness.dark - ? colorScheme.secondary.withValues(alpha: 0.8) - : colorScheme.secondary; - } else { - // High ABV: Deep Orange/Tertiary - return brightness == Brightness.dark - ? const Color(0xFFFF5722).withValues(alpha: 0.85) - : const Color(0xFFE64A19); - } - } - - /// Get human-readable label for ABV strength - /// - /// Returns: - /// - "(Low)" for ABV < 4.0% - /// - "(Medium)" for ABV 4.0% - 6.9% - /// - "(High)" for ABV >= 7.0% - static String getABVStrengthLabel(double abv) { - if (abv < 4.0) { - return '(Low)'; - } else if (abv < 7.0) { - return '(Medium)'; - } else { - return '(High)'; - } - } -} diff --git a/lib/utils/navigation_helpers.dart b/lib/utils/navigation_helpers.dart index d2d15865..898cc933 100644 --- a/lib/utils/navigation_helpers.dart +++ b/lib/utils/navigation_helpers.dart @@ -39,25 +39,6 @@ String buildFestivalHome(String festivalId) { return '/$festivalId'; } -/// Builds a drinks list URL for a festival. -/// -/// The optional [category] parameter is URL-encoded to handle special characters. -/// -/// Example: -/// ```dart -/// buildDrinksPath('cbf2025') // Returns: '/cbf2025/drinks' -/// buildDrinksPath('cbf2025', category: 'beer') // Returns: '/cbf2025/drinks?category=beer' -/// buildDrinksPath('cbf2025', category: 'cider & perry') // Returns: '/cbf2025/drinks?category=cider%20%26%20perry' -/// ``` -String buildDrinksPath(String festivalId, {String? category}) { - final base = buildFestivalPath(festivalId, '/drinks'); - if (category != null && category.isNotEmpty) { - final encodedCategory = Uri.encodeQueryComponent(category); - return '$base?category=$encodedCategory'; - } - return base; -} - /// Builds a favorites URL for a festival. /// /// Example: @@ -134,74 +115,6 @@ String buildStylePath(String festivalId, String style) { return buildFestivalPath(festivalId, '/style/$encodedStyle'); } -/// Builds a category URL. -/// -/// The [category] is URL-encoded to handle special characters safely. -/// -/// Example: -/// ```dart -/// buildCategoryPath('cbf2025', 'beer') // Returns: '/cbf2025/category/beer' -/// buildCategoryPath('cbf2025', 'low/no alcohol') // Returns: '/cbf2025/category/low%2Fno%20alcohol' -/// ``` -String buildCategoryPath(String festivalId, String category) { - assert(category.isNotEmpty, 'Category cannot be empty'); - final encodedCategory = Uri.encodeComponent(category); - return buildFestivalPath(festivalId, '/category/$encodedCategory'); -} - -/// Extracts festival ID from a festival-scoped path. -/// -/// Returns the festival ID if the path follows the pattern `/{festivalId}/...` -/// with at least one path segment after the festival ID. Returns `null` for -/// non-festival-scoped paths. -/// -/// A valid festival-scoped path must have at least 2 segments: -/// - First segment: festival ID -/// - Second+ segments: the actual route path -/// -/// Example: -/// ```dart -/// extractFestivalId('/cbf2025/drinks') // Returns: 'cbf2025' -/// extractFestivalId('/cbf2025/brewery/123') // Returns: 'cbf2025' -/// extractFestivalId('/cbf2025') // Returns: 'cbf2025' (festival home is valid) -/// extractFestivalId('/drinks') // Returns: null (not festival-scoped) -/// extractFestivalId('/') // Returns: null -/// extractFestivalId('') // Returns: null -/// ``` -String? extractFestivalId(String path) { - if (path.isEmpty) return null; - - final segments = path.split('/').where((s) => s.isNotEmpty).toList(); - - // Need at least 1 segment for festival ID - // Single segment like '/cbf2025' is valid (festival home) - // Multiple segments like '/cbf2025/drinks' is valid - if (segments.isEmpty) return null; - - return segments.first; -} - -/// Checks if a path is festival-scoped. -/// -/// A path is considered festival-scoped if it has at least one segment -/// (the festival ID). This includes both festival home pages (`/cbf2025`) -/// and nested routes (`/cbf2025/drinks`). -/// -/// Example: -/// ```dart -/// isFestivalPath('/cbf2025/drinks') // Returns: true -/// isFestivalPath('/cbf2025') // Returns: true -/// isFestivalPath('/drinks') // Returns: true (single segment treated as potential festival ID) -/// isFestivalPath('/') // Returns: false -/// isFestivalPath('') // Returns: false -/// ``` -/// -/// Note: This function cannot distinguish between a festival ID and a regular -/// route without additional context. Use with caution for validation. -bool isFestivalPath(String path) { - return extractFestivalId(path) != null; -} - /// Checks if navigation can pop in the current context. /// /// Safely handles contexts where GoRouter may not be available (e.g., in tests). diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 702eb663..26bb71d2 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -1,4 +1,3 @@ -export 'abv_strength_helper.dart'; export 'beverage_type_helper.dart'; export 'category_color_helper.dart'; export 'navigation_helpers.dart'; diff --git a/lib/widgets/availability_badge.dart b/lib/widgets/availability_badge.dart deleted file mode 100644 index 77a5b2be..00000000 --- a/lib/widgets/availability_badge.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:flutter/material.dart'; -import '../models/models.dart'; - -/// A badge indicating drink availability status -/// -/// Shows "Available" (green) or "Sold Out" (red) based on availability. -class AvailabilityBadge extends StatelessWidget { - /// Availability status to display - final AvailabilityStatus status; - - /// Optional custom text (overrides status-based text) - final String? customText; - - /// Whether to show as a compact chip or full-width banner - final bool compact; - - const AvailabilityBadge({ - required this.status, - this.customText, - this.compact = true, - super.key, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isSoldOut = status == AvailabilityStatus.out; - - final backgroundColor = isSoldOut - ? theme.colorScheme.errorContainer - : theme.colorScheme.primaryContainer; - - final foregroundColor = isSoldOut - ? theme.colorScheme.onErrorContainer - : theme.colorScheme.onPrimaryContainer; - - final icon = isSoldOut ? Icons.cancel : Icons.check_circle; - final text = customText ?? (isSoldOut ? 'Sold Out' : 'Available'); - - if (compact) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(4.0), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: foregroundColor), - const SizedBox(width: 4), - Text( - text, - style: theme.textTheme.labelSmall?.copyWith( - color: foregroundColor, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); - } - - // Full-width banner - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(8.0), - ), - child: Row( - children: [ - Icon(icon, size: 20, color: foregroundColor), - const SizedBox(width: 8), - Expanded( - child: Text( - text, - style: theme.textTheme.bodyMedium?.copyWith( - color: foregroundColor, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/bottom_action_bar.dart b/lib/widgets/bottom_action_bar.dart deleted file mode 100644 index 8d60fdf8..00000000 --- a/lib/widgets/bottom_action_bar.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:flutter/material.dart'; - -/// A sticky bottom action bar for detail screens -/// -/// Provides persistent access to important actions like tasting log, -/// rating, favorites, and sharing. -class BottomActionBar extends StatelessWidget { - /// List of action buttons to display - final List actions; - - /// Optional background color (defaults to surface with elevation) - final Color? backgroundColor; - - const BottomActionBar({ - required this.actions, - this.backgroundColor, - super.key, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Container( - decoration: BoxDecoration( - color: backgroundColor ?? theme.colorScheme.surface, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 8.0, - offset: const Offset(0, -2), - ), - ], - ), - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), - child: Row( - // Give each action an equal share of the width so long labels - // (e.g. "Want to Try", "Tasted 12×") wrap/ellipsize within their - // slot instead of overflowing the bar on narrow screens. - children: [for (final action in actions) Expanded(child: action)], - ), - ), - ), - ); - } -} - -/// A single action button for use in BottomActionBar -class ActionButton extends StatelessWidget { - /// Icon to display - final IconData icon; - - /// Label text - final String label; - - /// Callback when button is pressed - final VoidCallback? onPressed; - - /// Whether the action is active/selected - final bool isActive; - - /// Optional semantic label for accessibility - final String? semanticLabel; - - const ActionButton({ - required this.icon, - required this.label, - this.onPressed, - this.isActive = false, - this.semanticLabel, - super.key, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final color = isActive - ? theme.colorScheme.primary - : theme.colorScheme.onSurfaceVariant; - - return Semantics( - label: semanticLabel ?? label, - button: true, - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(8.0), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 24, color: color), - const SizedBox(height: 4), - Text( - label, - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelSmall?.copyWith( - color: color, - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/widgets/breadcrumb_bar.dart b/lib/widgets/breadcrumb_bar.dart deleted file mode 100644 index 275c9641..00000000 --- a/lib/widgets/breadcrumb_bar.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'package:flutter/material.dart'; - -/// A navigation breadcrumb bar for detail screens. -/// -/// Shows a back button with context text (e.g., "Beer / Oakham Ales"). -/// Text sections can be made clickable by providing navigation callbacks. -/// Optimized for mobile with large touch targets. -/// -/// Example usage: -/// ```dart -/// BreadcrumbBar( -/// backLabel: 'Beer', -/// contextLabel: 'Oakham Ales', -/// onBack: () => Navigator.pop(context), -/// onBackLabelTap: () => context.go('/beer'), -/// onContextLabelTap: () => context.go('/brewery/oakham'), -/// ) -/// ``` -class BreadcrumbBar extends StatelessWidget { - /// Creates a breadcrumb bar. - const BreadcrumbBar({ - required this.backLabel, - required this.onBack, - this.contextLabel, - this.onBackLabelTap, - this.onContextLabelTap, - super.key, - }); - - /// Label for the back button (e.g., "Beer", "Drinks"). - final String backLabel; - - /// Optional context text (e.g., brewery name, style name). - final String? contextLabel; - - /// Callback when back button is pressed. - final VoidCallback onBack; - - /// Optional callback when the back label text is tapped. - /// If provided, makes the back label clickable. - final VoidCallback? onBackLabelTap; - - /// Optional callback when the context label text is tapped. - /// If provided, makes the context label clickable. - final VoidCallback? onContextLabelTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final textStyle = theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface, - ); - final linkStyle = textStyle?.copyWith( - color: theme.colorScheme.primary, - decoration: TextDecoration.underline, - ); - - return Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - // Large back button with semantics - Semantics( - label: 'Back to $backLabel', - button: true, - child: IconButton( - icon: const Icon(Icons.arrow_back), - iconSize: 28, - tooltip: 'Back to $backLabel', - onPressed: onBack, - ), - ), - const SizedBox(width: 8), - // Breadcrumb text (can be interactive) - Expanded( - child: Row( - children: [ - // Back label (clickable if callback provided) - Flexible( - child: _buildTextSegment( - context, - backLabel, - onBackLabelTap, - onBackLabelTap != null ? linkStyle : textStyle, - ), - ), - // Separator and context label - if (contextLabel != null) ...[ - Text(' / ', style: textStyle), - Flexible( - child: _buildTextSegment( - context, - contextLabel!, - onContextLabelTap, - onContextLabelTap != null ? linkStyle : textStyle, - ), - ), - ], - ], - ), - ), - ], - ), - ); - } - - /// Builds a text segment that may be clickable. - Widget _buildTextSegment( - BuildContext context, - String text, - VoidCallback? onTap, - TextStyle? style, - ) { - final textWidget = Text( - text, - style: style, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ); - - if (onTap == null) { - return textWidget; - } - - return Semantics( - label: 'Navigate to $text', - button: true, - hint: 'Double tap to navigate', - child: InkWell(onTap: onTap, child: textWidget), - ); - } -} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 9269ebad..915c172a 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -1,6 +1,3 @@ -export 'availability_badge.dart'; -export 'bottom_action_bar.dart'; -export 'breadcrumb_bar.dart'; export 'brewery_hero_panel.dart'; export 'collapsing_detail_app_bar.dart'; export 'drink_card.dart'; diff --git a/test/abv_strength_helper_test.dart b/test/abv_strength_helper_test.dart deleted file mode 100644 index 5ff76c9a..00000000 --- a/test/abv_strength_helper_test.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:cambridge_beer_festival/utils/utils.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('ABVStrengthHelper', () { - group('getABVStrengthLabel', () { - test('returns (Low) below 4.0%', () { - expect(ABVStrengthHelper.getABVStrengthLabel(0.0), '(Low)'); - expect(ABVStrengthHelper.getABVStrengthLabel(3.9), '(Low)'); - }); - - test('returns (Medium) from 4.0% up to but not including 7.0%', () { - expect(ABVStrengthHelper.getABVStrengthLabel(4.0), '(Medium)'); - expect(ABVStrengthHelper.getABVStrengthLabel(6.9), '(Medium)'); - }); - - test('returns (High) at 7.0% and above', () { - expect(ABVStrengthHelper.getABVStrengthLabel(7.0), '(High)'); - expect(ABVStrengthHelper.getABVStrengthLabel(12.0), '(High)'); - }); - }); - - group('getABVColor', () { - /// Pumps a [Builder] under [brightness] and captures the colour the - /// helper returns for [abv] alongside the active colour scheme. - Future<(Color result, ColorScheme scheme)> resolve( - WidgetTester tester, - Brightness brightness, - double abv, - ) async { - late Color result; - late ColorScheme scheme; - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: brightness), - home: Builder( - builder: (context) { - scheme = Theme.of(context).colorScheme; - result = ABVStrengthHelper.getABVColor(context, abv); - return const SizedBox(); - }, - ), - ), - ); - await tester.pumpAndSettle(); - return (result, scheme); - } - - testWidgets('low ABV uses the primary colour (light theme)', ( - tester, - ) async { - final (result, scheme) = await resolve(tester, Brightness.light, 3.0); - expect(result, scheme.primary); - }); - - testWidgets('low ABV dims the primary colour (dark theme)', ( - tester, - ) async { - final (result, scheme) = await resolve(tester, Brightness.dark, 3.0); - expect(result, scheme.primary.withValues(alpha: 0.7)); - }); - - testWidgets('medium ABV uses the secondary colour (light theme)', ( - tester, - ) async { - final (result, scheme) = await resolve(tester, Brightness.light, 5.0); - expect(result, scheme.secondary); - }); - - testWidgets('medium ABV dims the secondary colour (dark theme)', ( - tester, - ) async { - final (result, scheme) = await resolve(tester, Brightness.dark, 5.0); - expect(result, scheme.secondary.withValues(alpha: 0.8)); - }); - - testWidgets('high ABV uses deep orange (light theme)', (tester) async { - final (result, _) = await resolve(tester, Brightness.light, 8.0); - expect(result, const Color(0xFFE64A19)); - }); - - testWidgets('high ABV uses translucent deep orange (dark theme)', ( - tester, - ) async { - final (result, _) = await resolve(tester, Brightness.dark, 8.0); - expect(result, const Color(0xFFFF5722).withValues(alpha: 0.85)); - }); - }); - }); -} diff --git a/test/utils/navigation_helpers_test.dart b/test/utils/navigation_helpers_test.dart index 0d11c3bc..8c708846 100644 --- a/test/utils/navigation_helpers_test.dart +++ b/test/utils/navigation_helpers_test.dart @@ -41,19 +41,6 @@ void main() { }); }); - group('buildDrinksPath', () { - test('builds drinks path without category', () { - expect(buildDrinksPath('cbf2025'), equals('/cbf2025/drinks')); - }); - - test('builds drinks path with category', () { - expect( - buildDrinksPath('cbf2025', category: 'beer'), - equals('/cbf2025/drinks?category=beer'), - ); - }); - }); - group('buildFavoritesPath', () { test('builds favorites path', () { expect(buildFavoritesPath('cbf2025'), equals('/cbf2025/favorites')); @@ -118,61 +105,6 @@ void main() { }); }); - group('buildCategoryPath', () { - test('builds category path', () { - expect( - buildCategoryPath('cbf2025', 'beer'), - equals('/cbf2025/category/beer'), - ); - }); - }); - - group('extractFestivalId', () { - test('extracts festival ID from simple path', () { - expect(extractFestivalId('/cbf2025/drinks'), equals('cbf2025')); - }); - - test('extracts festival ID from nested path', () { - expect(extractFestivalId('/cbf2025/brewery/123'), equals('cbf2025')); - }); - - test('extracts festival ID from home path', () { - expect(extractFestivalId('/cbf2025'), equals('cbf2025')); - }); - - test('returns null for root path', () { - expect(extractFestivalId('/'), isNull); - }); - - test('returns null for empty path', () { - expect(extractFestivalId(''), isNull); - }); - - test('handles paths without leading slash', () { - expect(extractFestivalId('cbf2025/drinks'), equals('cbf2025')); - }); - }); - - group('isFestivalPath', () { - test('returns true for festival-scoped paths', () { - expect(isFestivalPath('/cbf2025/drinks'), isTrue); - expect(isFestivalPath('/cbf2025/brewery/123'), isTrue); - expect(isFestivalPath('/cbf2025'), isTrue); - }); - - test('returns false for non-festival paths', () { - expect(isFestivalPath('/'), isFalse); - expect(isFestivalPath(''), isFalse); - }); - - test('returns true for single segment paths (ambiguous)', () { - // Note: Single segments are treated as potential festival IDs - // Validation against actual festival list happens in Phase 1 - expect(isFestivalPath('/drinks'), isTrue); - expect(isFestivalPath('/about'), isTrue); - }); - }); - group('URL encoding edge cases', () { test('encodes drink IDs with spaces', () { expect( @@ -195,22 +127,6 @@ void main() { ); }); - test('encodes categories with slashes', () { - expect( - buildCategoryPath('cbf2025', 'low/no alcohol'), - equals('/cbf2025/category/low%2Fno%20alcohol'), - ); - }); - - test('encodes query parameters in drinks path', () { - expect( - buildDrinksPath('cbf2025', category: 'cider & perry'), - equals( - '/cbf2025/drinks?category=cider+%26+perry', - ), // + is valid for spaces in query params - ); - }); - test( 'converts to lowercase and encodes Unicode characters in style names', () { @@ -241,17 +157,6 @@ void main() { test('buildBreweryPath asserts on empty brewery ID', () { expect(() => buildBreweryPath('cbf2025', ''), throwsAssertionError); }); - - test('buildCategoryPath asserts on empty category', () { - expect(() => buildCategoryPath('cbf2025', ''), throwsAssertionError); - }); - - test('buildDrinksPath handles empty category gracefully', () { - expect( - buildDrinksPath('cbf2025', category: ''), - equals('/cbf2025/drinks'), - ); - }); }); group('Edge cases', () { @@ -260,14 +165,6 @@ void main() { expect(buildFestivalHome(longId), equals('/$longId')); }); - test('handles paths with multiple slashes', () { - expect(extractFestivalId('/cbf2025//drinks'), equals('cbf2025')); - }); - - test('handles paths with trailing slashes', () { - expect(extractFestivalId('/cbf2025/'), equals('cbf2025')); - }); - test('handles URL-encoded characters in IDs', () { expect( buildDrinkDetailPath('cbf2025', 'beer', 'test%20drink'), diff --git a/test/utils_test.dart b/test/utils_test.dart index d138cddf..81c4d453 100644 --- a/test/utils_test.dart +++ b/test/utils_test.dart @@ -268,50 +268,6 @@ void main() { }); }); - group('ABVStrengthHelper', () { - test('getABVStrengthLabel returns Low for low ABV', () { - expect(ABVStrengthHelper.getABVStrengthLabel(3.5), '(Low)'); - expect(ABVStrengthHelper.getABVStrengthLabel(0.5), '(Low)'); - }); - - test('getABVStrengthLabel returns Medium for medium ABV', () { - expect(ABVStrengthHelper.getABVStrengthLabel(4.0), '(Medium)'); - expect(ABVStrengthHelper.getABVStrengthLabel(5.5), '(Medium)'); - expect(ABVStrengthHelper.getABVStrengthLabel(6.9), '(Medium)'); - }); - - test('getABVStrengthLabel returns High for high ABV', () { - expect(ABVStrengthHelper.getABVStrengthLabel(7.0), '(High)'); - expect(ABVStrengthHelper.getABVStrengthLabel(10.5), '(High)'); - }); - - testWidgets('getABVColor returns correct colors for different ABV ranges', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (context) { - final lowColor = ABVStrengthHelper.getABVColor(context, 3.5); - final mediumColor = ABVStrengthHelper.getABVColor(context, 5.0); - final highColor = ABVStrengthHelper.getABVColor(context, 8.0); - - expect(lowColor, isNotNull); - expect(mediumColor, isNotNull); - expect(highColor, isNotNull); - - // Colors should be different for different ranges - expect(lowColor, isNot(mediumColor)); - expect(mediumColor, isNot(highColor)); - - return Container(); - }, - ), - ), - ); - }); - }); - group('BeverageTypeHelper', () { test('formatBeverageType formats dash-separated strings', () { expect(BeverageTypeHelper.formatBeverageType('beer'), 'Beer'); diff --git a/test/widgets/availability_badge_test.dart b/test/widgets/availability_badge_test.dart deleted file mode 100644 index ce011dcc..00000000 --- a/test/widgets/availability_badge_test.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:cambridge_beer_festival/models/models.dart'; -import 'package:cambridge_beer_festival/widgets/availability_badge.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('AvailabilityBadge', () { - Widget buildBadge( - AvailabilityStatus status, { - bool compact = true, - String? customText, - }) { - return MaterialApp( - home: Scaffold( - body: AvailabilityBadge( - status: status, - compact: compact, - customText: customText, - ), - ), - ); - } - - group('compact mode (default)', () { - testWidgets('shows Available with check icon for plenty status', ( - tester, - ) async { - await tester.pumpWidget(buildBadge(AvailabilityStatus.plenty)); - expect(find.text('Available'), findsOneWidget); - expect(find.byIcon(Icons.check_circle), findsOneWidget); - expect(find.byIcon(Icons.cancel), findsNothing); - }); - - testWidgets('shows Available for low status', (tester) async { - await tester.pumpWidget(buildBadge(AvailabilityStatus.low)); - expect(find.text('Available'), findsOneWidget); - expect(find.byIcon(Icons.check_circle), findsOneWidget); - }); - - testWidgets('shows Sold Out with cancel icon for out status', ( - tester, - ) async { - await tester.pumpWidget(buildBadge(AvailabilityStatus.out)); - expect(find.text('Sold Out'), findsOneWidget); - expect(find.byIcon(Icons.cancel), findsOneWidget); - expect(find.byIcon(Icons.check_circle), findsNothing); - }); - - testWidgets('shows custom text instead of status-based text', ( - tester, - ) async { - await tester.pumpWidget( - buildBadge(AvailabilityStatus.plenty, customText: 'Just Tapped'), - ); - expect(find.text('Just Tapped'), findsOneWidget); - expect(find.text('Available'), findsNothing); - }); - - testWidgets('custom text overrides sold-out text', (tester) async { - await tester.pumpWidget( - buildBadge(AvailabilityStatus.out, customText: 'Gone'), - ); - expect(find.text('Gone'), findsOneWidget); - expect(find.text('Sold Out'), findsNothing); - }); - }); - - group('full-width banner mode', () { - testWidgets('renders full-width banner with available status', ( - tester, - ) async { - await tester.pumpWidget( - buildBadge(AvailabilityStatus.plenty, compact: false), - ); - expect(find.text('Available'), findsOneWidget); - expect(find.byIcon(Icons.check_circle), findsOneWidget); - }); - - testWidgets('renders full-width banner with sold-out status', ( - tester, - ) async { - await tester.pumpWidget( - buildBadge(AvailabilityStatus.out, compact: false), - ); - expect(find.text('Sold Out'), findsOneWidget); - expect(find.byIcon(Icons.cancel), findsOneWidget); - }); - - testWidgets('full-width banner uses different icon size than compact', ( - tester, - ) async { - await tester.pumpWidget( - buildBadge(AvailabilityStatus.plenty, compact: false), - ); - final icon = tester.widget(find.byIcon(Icons.check_circle)); - expect(icon.size, 20); - }); - - testWidgets('compact mode uses smaller icon', (tester) async { - await tester.pumpWidget(buildBadge(AvailabilityStatus.plenty)); - final icon = tester.widget(find.byIcon(Icons.check_circle)); - expect(icon.size, 14); - }); - }); - }); -} diff --git a/test/widgets/breadcrumb_bar_test.dart b/test/widgets/breadcrumb_bar_test.dart deleted file mode 100644 index f0d295b2..00000000 --- a/test/widgets/breadcrumb_bar_test.dart +++ /dev/null @@ -1,410 +0,0 @@ -import 'package:cambridge_beer_festival/widgets/widgets.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('BreadcrumbBar', () { - testWidgets('renders back button and label', (tester) async { - var backPressed = false; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar( - backLabel: 'Beer', - onBack: () => backPressed = true, - ), - ), - ), - ); - - // Find back button - expect(find.byIcon(Icons.arrow_back), findsOneWidget); - - // Find label text - expect(find.text('Beer'), findsOneWidget); - - // Tap back button - await tester.tap(find.byIcon(Icons.arrow_back)); - expect(backPressed, isTrue); - }); - - testWidgets('renders with context text', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar( - backLabel: 'Beer', - contextLabel: 'Oakham Ales', - onBack: () {}, - ), - ), - ), - ); - - // Find individual text segments - expect(find.text('Beer'), findsOneWidget); - expect(find.text(' / '), findsOneWidget); - expect(find.text('Oakham Ales'), findsOneWidget); - }); - - testWidgets('handles long text with ellipsis', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SizedBox( - width: 200, // Constrain width to force overflow - child: BreadcrumbBar( - backLabel: 'Beer', - contextLabel: 'Very Long Brewery Name That Should Overflow', - onBack: () {}, - ), - ), - ), - ), - ); - - // Find individual text widgets (text is now split into segments) - final backLabelText = tester.widget(find.text('Beer')); - final contextLabelText = tester.widget( - find.text('Very Long Brewery Name That Should Overflow'), - ); - - // Verify overflow behavior on both text segments - expect(backLabelText.overflow, equals(TextOverflow.ellipsis)); - expect(backLabelText.maxLines, equals(1)); - expect(contextLabelText.overflow, equals(TextOverflow.ellipsis)); - expect(contextLabelText.maxLines, equals(1)); - }); - - testWidgets('has correct semantic label', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar( - backLabel: 'Beer', - contextLabel: 'Oakham Ales', - onBack: () {}, - ), - ), - ), - ); - - // Find the Semantics widget with our custom label - final allSemantics = tester.widgetList( - find.descendant( - of: find.byType(BreadcrumbBar), - matching: find.byType(Semantics), - ), - ); - - // Filter to find our custom Semantics (has our label and button property) - final customSemantics = allSemantics.where( - (s) => - s.properties.label == 'Back to Beer' && s.properties.button == true, - ); - - // Should have exactly one - expect(customSemantics.length, equals(1)); - - // Verify semantic properties - final semantics = customSemantics.first; - expect(semantics.properties.label, 'Back to Beer'); - expect(semantics.properties.button, isTrue); - }); - - testWidgets('back button has tooltip', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar(backLabel: 'Beer', onBack: () {}), - ), - ), - ); - - // Find IconButton - final iconButton = tester.widget(find.byType(IconButton)); - - // Verify tooltip - expect(iconButton.tooltip, equals('Back to Beer')); - }); - - testWidgets('calls onBack when back button is pressed', (tester) async { - var callCount = 0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar(backLabel: 'Beer', onBack: () => callCount++), - ), - ), - ); - - // Tap back button - await tester.tap(find.byIcon(Icons.arrow_back)); - expect(callCount, equals(1)); - - // Tap again - await tester.tap(find.byIcon(Icons.arrow_back)); - expect(callCount, equals(2)); - }); - - testWidgets('handles very long backLabel', (tester) async { - final longLabel = 'x' * 100; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SizedBox( - width: 200, - child: BreadcrumbBar(backLabel: longLabel, onBack: () {}), - ), - ), - ), - ); - - // Widget should render without overflow errors - expect(find.byType(BreadcrumbBar), findsOneWidget); - }); - - testWidgets('handles Unicode characters', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar( - backLabel: 'Beer 🍺', - contextLabel: 'Oktoberfest Märzen', - onBack: () {}, - ), - ), - ), - ); - - // Text is now split into separate segments - expect(find.text('Beer 🍺'), findsOneWidget); - expect(find.text(' / '), findsOneWidget); - expect(find.text('Oktoberfest Märzen'), findsOneWidget); - }); - - testWidgets('semantics only wraps IconButton, not Text', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar( - backLabel: 'Beer', - contextLabel: 'Oakham Ales', - onBack: () {}, - ), - ), - ), - ); - - // Find the Semantics widget with our custom label - final allSemantics = tester.widgetList( - find.descendant( - of: find.byType(BreadcrumbBar), - matching: find.byType(Semantics), - ), - ); - - // Filter to find our custom Semantics (has our label and button property) - final customSemantics = allSemantics.where( - (s) => - s.properties.label == 'Back to Beer' && s.properties.button == true, - ); - - // Should have exactly one custom Semantics widget - expect(customSemantics.length, equals(1)); - }); - - testWidgets('maintains state across rebuilds', (tester) async { - var counter = 0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar(backLabel: 'Beer', onBack: () => counter++), - ), - ), - ); - - // First tap - await tester.tap(find.byIcon(Icons.arrow_back)); - expect(counter, equals(1)); - - // Rebuild widget - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar(backLabel: 'Beer', onBack: () => counter++), - ), - ), - ); - - // Second tap after rebuild - await tester.tap(find.byIcon(Icons.arrow_back)); - expect(counter, equals(2)); - }); - - testWidgets('calls onBackLabelTap when back label is tapped', ( - tester, - ) async { - var backLabelTapCount = 0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar( - backLabel: 'Drinks', - onBack: () {}, - onBackLabelTap: () => backLabelTapCount++, - ), - ), - ), - ); - - // Tap directly on the 'Drinks' text which should be wrapped in InkWell - await tester.tap(find.text('Drinks')); - expect(backLabelTapCount, equals(1)); - }); - - testWidgets('calls onContextLabelTap when context label is tapped', ( - tester, - ) async { - var contextLabelTapCount = 0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar( - backLabel: 'Drinks', - contextLabel: 'Oakham Ales', - onBack: () {}, - onContextLabelTap: () => contextLabelTapCount++, - ), - ), - ), - ); - - // Tap directly on the 'Oakham Ales' text which should be wrapped in InkWell - await tester.tap(find.text('Oakham Ales')); - expect(contextLabelTapCount, equals(1)); - }); - - testWidgets('both labels are clickable when both callbacks provided', ( - tester, - ) async { - var backLabelTaps = 0; - var contextLabelTaps = 0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar( - backLabel: 'Drinks', - contextLabel: 'Oakham Ales', - onBack: () {}, - onBackLabelTap: () => backLabelTaps++, - onContextLabelTap: () => contextLabelTaps++, - ), - ), - ), - ); - - // Tap the 'Drinks' text - await tester.tap(find.text('Drinks')); - expect(backLabelTaps, equals(1)); - expect(contextLabelTaps, equals(0)); - - // Tap the 'Oakham Ales' text - await tester.tap(find.text('Oakham Ales')); - expect(backLabelTaps, equals(1)); - expect(contextLabelTaps, equals(1)); - }); - - testWidgets('text is not clickable when callbacks not provided', ( - tester, - ) async { - var backTapCount = 0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar( - backLabel: 'Drinks', - contextLabel: 'Oakham Ales', - onBack: () => backTapCount++, - ), - ), - ), - ); - - // Tapping the text should not trigger any navigation - // (only the back button should work) - await tester.tap(find.text('Drinks')); - await tester.tap(find.text('Oakham Ales')); - - // Back button tap count should still be 0 - expect(backTapCount, equals(0)); - }); - - testWidgets('clickable labels have semantic properties', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar( - backLabel: 'Drinks', - contextLabel: 'Oakham Ales', - onBack: () {}, - onBackLabelTap: () {}, - onContextLabelTap: () {}, - ), - ), - ), - ); - - // Find all Semantics widgets - final allSemantics = tester.widgetList(find.byType(Semantics)); - - // Filter to find navigation semantics (button=true, label starts with 'Navigate to') - final navigationSemantics = allSemantics.where( - (s) => - s.properties.button == true && - s.properties.label?.startsWith('Navigate to') == true, - ); - - // Should have two navigation semantics (back label + context label) - expect(navigationSemantics.length, equals(2)); - - // Check labels - final labels = navigationSemantics - .map((s) => s.properties.label) - .toList(); - expect(labels, contains('Navigate to Drinks')); - expect(labels, contains('Navigate to Oakham Ales')); - }); - - testWidgets('separator text is not clickable', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: BreadcrumbBar( - backLabel: 'Drinks', - contextLabel: 'Oakham Ales', - onBack: () {}, - onBackLabelTap: () {}, - onContextLabelTap: () {}, - ), - ), - ), - ); - - // Find the separator text - expect(find.text(' / '), findsOneWidget); - - // Verify separator exists and is displayed - final separatorText = tester.widget(find.text(' / ')); - expect(separatorText.data, equals(' / ')); - }); - }); -}