Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions .claude/skills/ui-and-accessibility/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'` |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 2 additions & 35 deletions docs/code/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,10 @@ All app URLs are scoped to a specific festival. This allows:
```

Examples:
- `/cbf2025` - Festival home
- `/cbf2025/drinks` - Drinks list
- `/cbf2025` - Festival home (drinks list)
- `/cbf2025/drink/123` - Drink detail
- `/cbf2025/brewery/456` - Brewery detail
- `/cbf2025/style/ipa` - Style detail (lowercase canonical)
Comment thread
richardthe3rd marked this conversation as resolved.
- `/cbf2025/category/beer` - Category page

## URL Encoding

Expand All @@ -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 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:
Expand All @@ -83,17 +55,12 @@ All builder functions include assertions to prevent common errors:
// ❌ 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)
```

## Testing

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
69 changes: 0 additions & 69 deletions docs/code/ui-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
53 changes: 0 additions & 53 deletions lib/utils/abv_strength_helper.dart

This file was deleted.

87 changes: 0 additions & 87 deletions lib/utils/navigation_helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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).
Expand Down
1 change: 0 additions & 1 deletion lib/utils/utils.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
export 'abv_strength_helper.dart';
export 'beverage_type_helper.dart';
export 'category_color_helper.dart';
export 'navigation_helpers.dart';
Expand Down
Loading
Loading