diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ea6ecb3..c8d6b28 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "layrz_theme", "description": "Claude Code skills for layrz_theme Flutter widget library", - "version": "7.5.22" + "version": "7.5.23" } diff --git a/.claude-plugin/skills/conventional-commits/SKILL.md b/.claude-plugin/skills/conventional-commits/SKILL.md new file mode 100644 index 0000000..489e986 --- /dev/null +++ b/.claude-plugin/skills/conventional-commits/SKILL.md @@ -0,0 +1,393 @@ +--- +name: conventional-commits +description: Create semantic commit messages following Conventional Commits specification with proper formatting and changelog impact +--- + +## Overview + +Conventional Commits provides a specification for adding human and machine readable meaning to commit messages. This skill helps create commits with proper formatting, types, scopes, and breaking change declarations. + +| Element | Purpose | Impact | +|---|---|---| +| Type | Classifies the change (feat, fix, docs, etc.) | Determines changelog category | +| Scope | Optional context (component, module name) | Narrows change scope in changelog | +| Description | Concise change summary in imperative mood | Primary changelog entry | +| Body | Detailed explanation (optional) | PR review and code history | +| Breaking Change | Signals incompatible API/behavior changes | Major version bump required | +| Co-authors | Attribution for human/AI collaborators | Credit in commit history | + +**When to use:** Every commit. The specification ensures semantic versioning, automatic changelog generation, and clear git history. + +--- + +## Format Specification + +### Basic Format + +``` +: + +[optional body] +[extra : ] + +[BREAKING CHANGE: ] + +[optional co-author(s)] +``` + +### Subject Line Rules + +- **Type MUST be lowercase**: `feat`, `fix`, `docs`, `perf`, `test`, `refactor`, `sec`, `lab`, `exp`, `deps`, `revert`, `chore`, `style` +- **Colon and space MUST follow type**: `: ` (required separator) +- **Description MUST be present**: Concise, actionable, and in imperative mood +- **Description MUST NOT end with period**: "Add OAuth support" NOT "Add OAuth support." +- **Scope is optional**: `feat(auth): Add OAuth2 support` — narrows context in changelog +- **Keep under 72 characters**: Optimal for git log display + +--- + +## Required Types + +| Type | Changelog | Purpose | Example | +|---|---|---|---| +| `feat` | ✅ Features | New feature addition | `feat: Add passkey authentication` | +| `fix` | ✅ Bug Fixes | Bug fix | `fix: Resolve MFA validation timing issue` | +| `docs` | ✅ Documentation | Documentation changes | `docs: Update OAuth2 setup instructions` | +| `perf` | ✅ Performance | Performance improvement | `perf: Optimize GraphQL query caching` | +| `test` | ❌ Hidden | Adding/updating tests | `test: Add login flow integration tests` | +| `refactor` | ❌ Hidden | Code refactoring (no functional change) | `refactor: Simplify auth middleware` | +| `sec` | ✅ Security | Security patch/CVE fix | `sec: Patch CVE-2026-12345 in jwt` | +| `lab` | ✅ Labs | Labs/exploratory work | `lab: Prototype Redis caching layer` | +| `exp` | ✅ Experimental | Experimental features | `exp: Test WebAuthn alternative flow` | +| `deps` | ✅ Dependencies | Dependency updates | `deps: Bump axios from 1.6.0 to 1.7.2` | +| `revert` | ✅ Reverts | Revert previous commit | `revert: Revert OAuth2 flow changes` | +| `chore` | ❌ Hidden | Maintenance tasks | `chore: Update build config` | +| `style` | ❌ Hidden | Code style changes | `style: Format TypeScript files` | + +--- + +## Single-Type Commits + +### Simple Feature + +``` +feat: Add OAuth2 authentication support +``` + +### Bug Fix with Scope + +``` +fix(tabs): Resolve arrow button state not updating during navigation +``` + +### Security Fix + +``` +sec: Upgrade jwt library to patch CVE-2026-12345 +``` + +### Documentation + +``` +docs: Add OAuth2 setup guide for web platform +``` + +### Performance Improvement + +``` +perf: Optimize GraphQL query caching layer +``` + +--- + +## Multi-Type Commits + +A single commit can declare **multiple changes** in the body. The subject line is the primary type, and additional `: ` lines in the body are each categorized independently in the changelog. + +### Example: Feature with Security, Docs, and Fixes + +``` +feat: Add OAuth2 authentication + +sec: Upgrade jwt library to fix CVE-2026-12345 +docs: Add OAuth2 setup instructions +fix: Resolve token refresh race condition + +Co-authored-by: Kenny Mochizuki +Co-authored-by: AI Assistant +``` + +**Result:** 4 separate changelog entries: +1. ✅ Features: "Add OAuth2 authentication" +2. ✅ Security: "Upgrade jwt library to fix CVE-2026-12345" +3. ✅ Documentation: "Add OAuth2 setup instructions" +4. ✅ Bug Fixes: "Resolve token refresh race condition" + +### Example: Widget Enhancement with Tests and Docs + +``` +feat: Enhance ThemedTabView with wrap navigation + +test: Add 4 comprehensive widget tests for arrow navigation +docs: Update ThemedTabView skill documentation + +BREAKING CHANGE: initialPosition now clamped instead of rejected for invalid indices +``` + +**Result:** 3 visible entries + 1 breaking change notice: +1. ✅ Features: "Enhance ThemedTabView with wrap navigation" +2. ✅ Tests (hidden): "Add 4 comprehensive widget tests for arrow navigation" +3. ✅ Documentation: "Update ThemedTabView skill documentation" +4. ⚠️ BREAKING CHANGE announced + +--- + +## Breaking Changes + +### Declaration Methods + +#### Method 1: As Subject Line + +``` +BREAKING CHANGE: Replace session-based auth with JWT +``` + +#### Method 2: As Footer + +``` +feat: Replace session-based auth with JWT + +All endpoints now require Bearer token authentication instead of session cookies. + +BREAKING CHANGE: Session-based authentication removed +``` + +#### Method 3: With Scope (Recommended) + +``` +feat(auth)!: Replace session-based auth with JWT + +Migration path: +- Clients must obtain Bearer token via /auth/token endpoint +- Old session cookies no longer accepted +- Legacy endpoints deprecated (see docs) + +BREAKING CHANGE: All endpoints now require Bearer token authentication + +Co-authored-by: Security Team +``` + +The `!` before the colon signals a breaking change at a glance. + +--- + +## Scope Guidelines + +Optional scope clarifies **which component/module/system** the change affects. + +``` +feat(auth): Add passkey support ← Scope: auth module +feat(tabs): Add wrap navigation ← Scope: tabs widget +fix(api): Resolve timeout on bulk upload ← Scope: api layer +perf(cache): Optimize query results ← Scope: cache system +docs(setup): Update installation steps ← Scope: setup instructions +``` + +**Common scopes:** +- Component names: `(tabs)`, `(table)`, `(input)`, `(avatar)` +- Feature areas: `(auth)`, `(api)`, `(ui)`, `(db)` +- Layers: `(frontend)`, `(backend)`, `(schema)` +- Systems: `(cache)`, `(logging)`, `(monitoring)` + +--- + +## Co-Authors + +Attribute multiple contributors (humans or AIs) to a single commit. + +``` +feat: Add OAuth2 authentication + +sec: Upgrade jwt library to fix CVE-2026-12345 +docs: Add OAuth2 setup instructions +fix: Resolve token refresh race condition + +Co-authored-by: Kenny Mochizuki +Co-authored-by: AI Assistant +``` + +**Format rules:** +- One `Co-authored-by:` per line +- Include name and email in angle brackets +- Placed at end of commit message +- Email must be valid format (can be noreply) + +**AI Attribution Examples:** +``` +Co-authored-by: Claude AI +Co-authored-by: GitHub Copilot +Co-authored-by: Claude Haiku +``` + +--- + +## Real-World Examples + +### Example 1: ThemedTabView Enhancement (Multi-type) + +``` +feat(tabs): Add wrapArrowNavigation for circular tab navigation + +test: Add 4 comprehensive widget tests for arrow navigation + - Arrow button state updates reactively during navigation (regression) + - wrapArrowNavigation wraps from last tab to first tab + - wrapArrowNavigation wraps from first tab to last tab + - wrapArrowNavigation keeps arrows enabled at boundaries + +fix(tabs): Resolve arrow button state not updating on tab change + +docs: Update ThemedTabView skill documentation + +All 358 tests passing, flutter analyze clean. + +Co-authored-by: Claude AI +``` + +**Changelog result:** +- ✅ Features: "Add wrapArrowNavigation for circular tab navigation" +- ✅ Bug Fixes: "Resolve arrow button state not updating on tab change" +- ✅ Documentation: "Update ThemedTabView skill documentation" +- ❌ Hidden: Test coverage details + +### Example 2: Security Patch + +``` +sec: Patch CVE-2026-12345 in jwt dependency + +Upgraded jwt library from 8.2.0 to 8.2.1. + +Vulnerability: Timing attack on signature verification +Impact: Low - affects only applications validating tokens with high frequency +Mitigation: Update immediately for defense-in-depth + +Co-authored-by: Security Team +``` + +### Example 3: Simple Bug Fix + +``` +fix(auth): Resolve session timeout not refreshing token +``` + +### Example 4: Breaking Change + +``` +feat(auth)!: Replace session-based auth with JWT + +Session cookies are no longer accepted. All clients must: +1. Call POST /auth/token to obtain Bearer token +2. Include token in Authorization header: "Bearer " +3. Handle 401 responses with token refresh logic + +Deprecation timeline: +- v8.0: Bearer token required (breaking) +- v7.5: Both session and Bearer supported (this release) +- v7.4 and earlier: Session-only (legacy) + +BREAKING CHANGE: Session-based authentication removed in favor of JWT + +Co-authored-by: API Team +``` + +--- + +## Commit Message Structure + +### Minimal Commit + +``` +type: description +``` + +### Full Commit with Details + +``` +type(scope): description + +Detailed explanation of what was changed and why. This is where you explain +the motivation for the change and any relevant context. + +BREAKING CHANGE: description of the breaking change +Co-authored-by: Name +``` + +### Multi-Type Commit + +``` +type(scope): primary description + +secondary_type: secondary description +tertiary_type: tertiary description + +Additional context or motivation here. + +BREAKING CHANGE: optional breaking change notice +Co-authored-by: Name +``` + +--- + +## Validation Checklist + +Before creating a commit, verify: + +- ✅ Type is lowercase (`feat`, `fix`, `docs`, etc.) +- ✅ Type followed by colon and space (`: `) +- ✅ Description is in imperative mood ("Add", "Fix", "Update" NOT "Added", "Fixed") +- ✅ Description does NOT end with period +- ✅ Subject line under 72 characters +- ✅ Scope in parentheses if used: `feat(component): description` +- ✅ Body explains "why" not just "what" +- ✅ Breaking changes declared with `BREAKING CHANGE:` footer +- ✅ Co-authors formatted as `Co-authored-by: Name ` +- ✅ No extra blank lines between header and body +- ✅ One blank line before BREAKING CHANGE or Co-authored-by + +--- + +## Common Mistakes to Avoid + +❌ **Wrong:** `feat: Adds OAuth support` (past tense, ends with period) +✅ **Correct:** `feat: Add OAuth support` + +❌ **Wrong:** `FIX: resolve token issue` (type not lowercase) +✅ **Correct:** `fix: Resolve token issue` + +❌ **Wrong:** `feat(tabs) Add navigation` (missing colon) +✅ **Correct:** `feat(tabs): Add navigation` + +❌ **Wrong:** Mixing descriptions without types in body +✅ **Correct:** Each line in body starts with `type: description` + +❌ **Wrong:** `Co-authored by: Name` (incorrect keyword) +✅ **Correct:** `Co-authored-by: Name ` + +--- + +## Benefits + +1. **Semantic Versioning:** Commit types drive major/minor/patch version bumps +2. **Automated Changelog:** Tools parse commits and generate organized changelogs by type +3. **Searchable History:** `git log --grep="^feat"` finds all features +4. **Code Review:** Clear intent in subject line speeds review +5. **Release Notes:** Types automatically categorize changes for users +6. **Team Alignment:** Consistent format across all contributors + +--- + +## Reference + +- Full Specification: https://www.conventionalcommits.org +- Changelog Generation: commitizen/cz-cli automates commits and changelog +- Type Definitions: See "Required Types" table above +- Scope Best Practices: Keep scopes consistent with your codebase structure diff --git a/.claude-plugin/skills/responsive-row/SKILL.md b/.claude-plugin/skills/responsive-row/SKILL.md new file mode 100644 index 0000000..e6b25e6 --- /dev/null +++ b/.claude-plugin/skills/responsive-row/SKILL.md @@ -0,0 +1,332 @@ +--- +name: responsive-row +description: ResponsiveRow and ResponsiveCol — Material 3 responsive grid layouts with breakpoint-based column sizing +--- + +## Overview + +`ResponsiveRow` and `ResponsiveCol` provide a responsive 12-column grid system for layrz_theme. ResponsiveRow acts as a flex container using Wrap layout, while ResponsiveCol defines column width at different breakpoints (xs, sm, md, lg, xl). Together they create layouts that adapt to screen size without manual media queries. + +| Feature | Details | +|---|---| +| Responsive Breakpoints | xs (0-600), sm (600-960), md (960-1264), lg (1264-1904), xl (1904+) | +| 12-Column Grid | Sizes enum with col1-col12 for flexible width specification | +| Wrap Layout | ResponsiveRow uses Wrap for automatic line breaking when space insufficient | +| Builder Pattern | ResponsiveRow.builder for dynamic child generation | +| Spacing Control | Configurable gap between columns | +| Alignment Options | Main axis and cross axis alignment control | +| Full-Width Enforcement | ResponsiveRow always spans full parent width | +| Fallback Chain | Missing breakpoints cascade to larger breakpoints automatically | + +**When to use:** Responsive dashboards, card grids, sidebar + content layouts, responsive forms. Prefer when you need width-based column sizing without MediaQuery or custom LayoutDelegate. + +## ResponsiveRow + +Horizontal container that arranges ResponsiveCol children using a Wrap widget. All children are laid out horizontally with configurable spacing and alignment. Wraps to next line when space is insufficient. + +### Key Parameters + +| Parameter | Type | Default | Notes | +|---|---|---|---| +| `children` | `List` | required (OR `builder`) | List of ResponsiveCol widgets. Type-safe at compile time | +| `builder` | `IndexedWidgetBuilder?` | required (OR `children`) | Build function for dynamic children. Use with `itemCount` | +| `itemCount` | `int?` | `null` | Number of items to build when using `builder` | +| `spacing` | `double` | `0` | Horizontal gap between columns (in pixels) | +| `mainAxisAlignment` | `WrapAlignment` | `.start` | Horizontal alignment of columns | +| `crossAxisAlignment` | `WrapCrossAlignment` | `.start` | Vertical alignment of columns | + +### Minimal responsive row + +```dart +ResponsiveRow( + children: [ + ResponsiveCol(xs: Sizes.col12, sm: Sizes.col6, md: Sizes.col4, child: CardWidget()), + ResponsiveCol(xs: Sizes.col12, sm: Sizes.col6, md: Sizes.col4, child: CardWidget()), + ResponsiveCol(xs: Sizes.col12, sm: Sizes.col6, md: Sizes.col4, child: CardWidget()), + ], +) +``` + +### Row with spacing and alignment + +```dart +ResponsiveRow( + spacing: 16, + mainAxisAlignment: WrapAlignment.spaceEvenly, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ResponsiveCol(xs: Sizes.col12, md: Sizes.col6, child: SidebarWidget()), + ResponsiveCol(xs: Sizes.col12, md: Sizes.col6, child: ContentWidget()), + ], +) +``` + +### Row with builder for dynamic children + +```dart +ResponsiveRow.builder( + spacing: 12, + itemCount: 12, + itemBuilder: (context, index) { + return ResponsiveCol( + xs: Sizes.col12, + sm: Sizes.col6, + md: Sizes.col4, + lg: Sizes.col3, + child: ProductCard(product: products[index]), + ); + }, +) +``` + +## ResponsiveCol + +Defines a column's width at different breakpoints. Must be a child of ResponsiveRow. Uses LayoutBuilder internally to detect current breakpoint and apply responsive sizing. + +### Key Parameters + +| Parameter | Type | Default | Notes | +|---|---|---|---| +| `child` | `Widget` | required | Content widget displayed in this column | +| `xs` | `Sizes` | `col12` | Width at extra small breakpoint (0-600px) | +| `sm` | `Sizes?` | `null` | Width at small breakpoint (600-960px). Falls back to xs if null | +| `md` | `Sizes?` | `null` | Width at medium breakpoint (960-1264px). Falls back to sm→xs if null | +| `lg` | `Sizes?` | `null` | Width at large breakpoint (1264-1904px). Falls back to md→sm→xs if null | +| `xl` | `Sizes?` | `null` | Width at extra large breakpoint (1904px+). Falls back to lg→md→sm→xs if null | + +### Sizes Enum + +Column widths specified using Sizes enum with 12-column system: +- `Sizes.col1` through `Sizes.col12` — represents 1 to 12 columns +- Width = `(containerWidth / 12) * columnCount` + +### Minimal column + +```dart +ResponsiveCol( + xs: Sizes.col12, + md: Sizes.col6, + child: CardWidget(), +) +``` + +### Column with all breakpoints + +```dart +ResponsiveCol( + xs: Sizes.col12, // Mobile: full width + sm: Sizes.col6, // Tablet: half width + md: Sizes.col4, // Desktop: one-third + lg: Sizes.col3, // Large: one-quarter + xl: Sizes.col2, // Extra large: one-sixth + child: ProductCard(), +) +``` + +### Column with fallback chain (lazy specification) + +```dart +ResponsiveCol( + xs: Sizes.col12, // Mobile: full width (explicit) + md: Sizes.col8, // Desktop: two-thirds (sm and lg fall back to this) + // sm → falls back to md (col8) + // lg → falls back to xl (col12) → defaults to col12 + child: ArticleView(), +) +``` + +## Sizes Enum & Extension + +### Sizes Enum +```dart +enum Sizes { col1, col2, col3, col4, col5, col6, col7, col8, col9, col10, col11, col12 } +``` + +### SizesExt Extension +```dart +extension SizesExt on Sizes { + double boxWidth(double width) // Calculate actual width in pixels + int get gridSize // Get column count (1-12) +} +``` + +**Usage:** +```dart +Sizes.col6.gridSize // Returns: 6 +Sizes.col6.boxWidth(1200) // Returns: 600 (6 out of 12 = 50%) +Sizes.col3.boxWidth(600) // Returns: 150 (3 out of 12 = 25%) +``` + +## Responsive Behavior + +### Breakpoint Logic + +ResponsiveCol evaluates width and selects the best Sizes: + +``` +width < 600px + → use xs + +600px ≤ width < 960px + → use sm (or xs if sm is null) + +960px ≤ width < 1264px + → use md (fallback: sm → xs) + +1264px ≤ width < 1904px + → use lg (fallback: md → sm → xs) + +width ≥ 1904px + → use xl (fallback: lg → md → sm → xs) +``` + +### Example: 3-Column Grid + +```dart +ResponsiveRow( + spacing: 16, + children: [ + ResponsiveCol( + xs: .col12, // 1 column on mobile + sm: .col6, // 2 columns on tablet portrait + md: .col4, // 3 columns on tablet landscape + child: Card(), + ), + // ... 2 more cols with identical responsive behavior + ], +) + +// Renders as: +// Mobile (< 600px): [full-width] [full-width] [full-width] +// Tablet Portrait (600-960): [50%] [50%] [50%] (wraps to 2 rows + 1) +// Tablet Land (960-1264): [33%] [33%] [33%] +// Desktop (1264px+): [33%] [33%] [33%] +``` + +## Testing + +Widget tests are located in `test/widgets/responsive_row_test.dart`. + +**Test coverage:** +- Basic rendering with children (empty, single, multiple) +- Spacing parameter (0, custom values) +- Alignment parameters (mainAxis, crossAxis) +- Full width enforcement +- builder() with various itemCounts +- ResponsiveCol rendering and child preservation +- Sizes enum calculations (boxWidth, gridSize) +- Integration: ResponsiveRow + ResponsiveCol together + +**Running tests:** +```bash +flutter test test/widgets/responsive_row_test.dart +``` + +## Best Practices + +1. **Specify xs explicitly** — Always define `xs` since it's the fallback for all breakpoints + ```dart + ResponsiveCol(xs: .col12, md: .col6) // ✓ Good + ResponsiveCol(md: .col6) // ✗ Compile error (xs required) + ``` + +2. **Use lazy specification for efficiency** — Only specify breakpoints that differ from cascading + ```dart + ResponsiveCol( + xs: .col12, // Mobile + lg: .col6, // Desktop (tablet will inherit xs) + child: Widget(), + ) + ``` + +3. **Combine with LayoutBuilder for complex layouts** — ResponsiveCol handles sizing, but layout decisions may need LayoutBuilder: + ```dart + LayoutBuilder( + builder: (ctx, constraints) { + bool isMobile = constraints.maxWidth < 600; + return ResponsiveRow( + children: [ + ResponsiveCol(xs: isMobile ? .col12 : .col6, child: Widget()), + ], + ); + }, + ) + ``` + +4. **Remember wrap behavior** — ResponsiveRow wraps children to next line when space is insufficient. Plan layouts accordingly. + +5. **Spacing and alignment matter** — Use `spacing` and alignment parameters for proper visual hierarchy + ```dart + ResponsiveRow( + spacing: 16, + mainAxisAlignment: WrapAlignment.spaceEvenly, + crossAxisAlignment: WrapCrossAlignment.center, + children: [...], + ) + ``` + +## Common Patterns + +### Full-Width Sidebar + Content +```dart +ResponsiveRow( + children: [ + ResponsiveCol( + xs: .col12, + lg: .col3, + child: Sidebar(), + ), + ResponsiveCol( + xs: .col12, + lg: .col9, + child: MainContent(), + ), + ], +) +``` + +### Card Grid with Even Spacing +```dart +ResponsiveRow.builder( + itemCount: cards.length, + spacing: 16, + mainAxisAlignment: WrapAlignment.center, + itemBuilder: (index) => ResponsiveCol( + xs: .col12, + sm: .col6, + lg: .col4, + child: CardWidget(data: cards[index]), + ), +) +``` + +### Responsive Form Layout +```dart +ResponsiveRow( + spacing: 12, + children: [ + ResponsiveCol( + xs: .col12, + md: .col6, + child: TextInput(label: 'First Name'), + ), + ResponsiveCol( + xs: .col12, + md: .col6, + child: TextInput(label: 'Last Name'), + ), + ResponsiveCol( + xs: .col12, + child: TextInput(label: 'Email'), + ), + ], +) +``` + +## Implementation Notes + +- **Wrap direction is always Axis.horizontal** — Children are laid out left-to-right and wrap to next line +- **ResponsiveCol uses LayoutBuilder** — Breakpoint evaluation is dynamic and responds to constraint changes +- **No state management** — Both components are StatelessWidget (stateless, efficient) +- **No custom LayoutDelegate** — Uses Wrap's built-in logic for simplicity and predictability +- **Type-safe children** — Dart's type system ensures only ResponsiveCol can be children of ResponsiveRow diff --git a/.claude-plugin/skills/themed-tabview/SKILL.md b/.claude-plugin/skills/themed-tabview/SKILL.md new file mode 100644 index 0000000..97117c1 --- /dev/null +++ b/.claude-plugin/skills/themed-tabview/SKILL.md @@ -0,0 +1,577 @@ +--- +name: themed-tabview +description: ThemedTabView and ThemedTab — Material 3 styled tab navigation with flexible layouts and callbacks +--- + +## Overview + +`ThemedTabView` is the layrz_theme tab navigation widget. It combines a horizontal tab bar with a content view that switches based on the selected tab. All tabs are built upfront (not lazy-loaded), making it suitable for a small to medium number of tabs. + +| Feature | Details | +|---|---| +| Tab Styles | Two styles: `filledTonal` (default, Material 3 filled buttons) and `underline` (text-based with underline) | +| Scrollable TabBar | Horizontal scroll for many tabs | +| Optional Arrows | Left/right navigation buttons for non-scrollable contexts | +| Additional Widgets | Extra widgets (filters, buttons) can be placed next to the tab bar | +| Callbacks | `onTabIndex` fires when tab selection changes | +| State Persistence | `persistTabPosition` controls whether selected tab is preserved on widget rebuild | +| Custom Alignment | Control padding and alignment of the tab bar and content area | +| Icons & Labels | Tabs support leading/trailing icons alongside text labels | + +**When to use:** Any horizontal navigation scenario — settings tabs, data views, dialogs with multiple sections. Keep tab count reasonable (< 10 recommended). + +--- + +## ThemedTab + +Defines a single tab's appearance and content. + +### Key parameters + +| Parameter | Type | Default | Notes | +|---|---|---|---| +| `labelText` | `String?` | required (OR `label`) | Tab label text. Use for simple text labels. **Either `labelText` or `label` must be provided** | +| `label` | `Widget?` | required (OR `labelText`) | Custom label widget. Use for complex label layouts (e.g., with badges) | +| `child` | `Widget` | `SizedBox()` | Content widget displayed when this tab is active. Required in practice | +| `leadingIcon` | `IconData?` | `null` | Icon displayed before the label. Use `leading` for custom widgets | +| `leading` | `Widget?` | `null` | Custom widget displayed before the label. Prefer `leadingIcon` for simplicity | +| `trailingIcon` | `IconData?` | `null` | Icon displayed after the label. Use `trailing` for custom widgets | +| `trailing` | `Widget?` | `null` | Custom widget displayed after the label. Prefer `trailingIcon` for simplicity | +| `iconSize` | `double` | `30` | Size for leading/trailing icons | +| `padding` | `EdgeInsets` | `all(10)` | Padding around the tab's entire label area | +| `color` | `Color?` | `null` | Override the tab's color (used for active state detection). If not provided, uses the theme's primary color | +| `style` | `ThemedTabStyle` | `.filledTonal` | Visual style. Inherited from parent `ThemedTabView.style` via `overrideStyle()` | + +### Minimal tab + +```dart +ThemedTab( + labelText: 'Settings', + child: SettingsPanel(), +) +``` + +### Tab with leading icon + +```dart +ThemedTab( + labelText: 'Dashboard', + leadingIcon: Icons.dashboard, + child: DashboardView(), +) +``` + +### Tab with custom label widget + +```dart +ThemedTab( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.notifications), + const SizedBox(width: 8), + const Text('Alerts'), + // Custom badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(10), + ), + child: const Text('3', style: TextStyle(color: Colors.white, fontSize: 10)), + ), + ], + ), + child: AlertsView(), +) +``` + +--- + +## ThemedTabView + +The container widget that manages tab selection and displays both the tab bar and content area. + +### Key parameters + +| Parameter | Type | Default | Notes | +|---|---|---|---| +| `tabs` | `List` | required | List of tabs to display. All tabs are built upfront (not lazy). Minimum 1 tab | +| `style` | `ThemedTabStyle` | `.filledTonal` | Visual style for all tabs. Options: `.filledTonal`, `.underline` | +| `initialPosition` | `int` | `0` | Starting tab index. **Clamped to valid range** — invalid indices don't crash | +| `onTabIndex` | `Function(int)?` | `null` | Callback fired when user taps a different tab. Receives the new tab index (0-based) | +| `persistTabPosition` | `bool` | `true` | If `true`, the selected tab is remembered when the widget list changes. If `false`, resets to tab 0 when `tabs.length` changes | +| `animationDuration` | `Duration` | `250ms` | Duration of the tab switch animation | +| `showArrows` | `bool` | `false` | If `true`, left/right arrow buttons appear for manual tab navigation | +| `wrapArrowNavigation` | `bool` | `false` | If `true`, arrows wrap around: left arrow on first tab goes to last, right arrow on last tab goes to first. If `false`, arrows are disabled at boundaries. Only applies when `showArrows: true` | +| `additionalWidgets` | `List` | `[]` | Extra widgets (filters, search, etc.) placed next to the tab bar on the right | +| `padding` | `EdgeInsetsGeometry` | `all(10)` | Padding around the entire tab view (bar + content) | +| `separatorPadding` | `EdgeInsetsGeometry` | `only(top: 10)` | Padding between the tab bar and content area | +| `crossAxisAlignment` | `CrossAxisAlignment` | `.start` | Vertical alignment of the content area | +| `mainAxisAlignment` | `MainAxisAlignment` | `.start` | Horizontal alignment of the content area (rarely used) | +| `physics` | `ScrollPhysics?` | `null` | Scroll physics for the `TabBarView` content area | + +### Minimal tab view + +```dart +ThemedTabView( + tabs: [ + ThemedTab( + labelText: 'Home', + child: HomePage(), + ), + ThemedTab( + labelText: 'Settings', + child: SettingsPage(), + ), + ], +) +``` + +### With arrows and custom styling + +```dart +ThemedTabView( + showArrows: true, + style: .underline, + initialPosition: 1, + tabs: [ + ThemedTab(labelText: 'Tab A', child: ContentA()), + ThemedTab(labelText: 'Tab B', child: ContentB()), + ThemedTab(labelText: 'Tab C', child: ContentC()), + ], +) +``` + +### With circular arrow navigation + +```dart +ThemedTabView( + showArrows: true, + wrapArrowNavigation: true, // Wrap around at boundaries + tabs: [ + ThemedTab(labelText: 'Tab 1', child: Content1()), + ThemedTab(labelText: 'Tab 2', child: Content2()), + ThemedTab(labelText: 'Tab 3', child: Content3()), + ], +) +// Left arrow on Tab 1 → Tab 3 +// Right arrow on Tab 3 → Tab 1 +``` + +### With callbacks and additional widgets + +```dart +ThemedTabView( + onTabIndex: (index) { + print('User switched to tab $index'); + // Update analytics, state, etc. + }, + additionalWidgets: [ + SearchBar(), + FilterButton(), + ], + tabs: [ + ThemedTab(labelText: 'All', child: AllItemsView()), + ThemedTab(labelText: 'Favorites', child: FavoritesView()), + ], +) +``` + +### With icons and custom padding + +```dart +ThemedTabView( + padding: const EdgeInsets.all(20), + separatorPadding: const EdgeInsets.symmetric(vertical: 16), + tabs: [ + ThemedTab( + labelText: 'Profile', + leadingIcon: Icons.person, + child: ProfileView(), + ), + ThemedTab( + labelText: 'Orders', + leadingIcon: Icons.shopping_bag, + child: OrdersView(), + ), + ], +) +``` + +--- + +## Styling + +### ThemedTabStyle enum + +```dart +enum ThemedTabStyle { + filledTonal, // Material 3 filled tonal button (default) + underline, // Text-based with bottom underline when active +} +``` + +### filledTonal style + +- Active tab: filled background with 20% opacity of the tab color +- Inactive tab: transparent background +- Animation: smooth 200ms fade when tab changes +- Best for: Dashboard-style multi-tab interfaces + +### underline style + +- Active tab: bottom underline (via TabBar indicator) +- Inactive tab: no underline +- Animation: smooth 250ms scroll animation +- Best for: Document-style tabs, minimal design + +--- + +## Gotchas & Edge Cases + +### 1. **All Tabs Are Built Upfront** + +`ThemedTabView` builds all tab content widgets immediately, not lazily. This is fine for 2–5 tabs, but for 10+ tabs or expensive content widgets, consider: +- Lazy loading within each tab's `child` widget +- Using `visibility`-aware widgets to defer expensive work + +```dart +// Good: Each tab's content is lightweight, or loads data on first interaction +ThemedTab( + labelText: 'Analytics', + child: Builder( + builder: (context) { + // This builder only runs when the widget is first mounted + // Content loading happens inside this widget via FutureBuilder, StreamBuilder, etc. + return AnalyticsPanel(); + }, + ), +) +``` + +### 2. **Invalid initialPosition is Clamped, Not Rejected** + +If `initialPosition >= tabs.length`, it's clamped to the last valid index. No exception is thrown. + +```dart +// initialPosition: 10, but only 3 tabs → starts at tab 2 (not crash) +ThemedTabView( + initialPosition: 10, + tabs: [tab1, tab2, tab3], +) +``` + +### 3. **persistTabPosition Requires tabs.length Change** + +`persistTabPosition: false` only resets to tab 0 if the **number of tabs changes**. If the widget rebuilds with the same `tabs.length`, the tab index is preserved. + +```dart +// Scenario 1: persistTabPosition: true + tabs.length changes → remembers old index +// Scenario 2: persistTabPosition: false + tabs.length changes → resets to tab 0 +// Scenario 3: Widget rebuilds but tabs.length unchanged → index is preserved (regardless of persistTabPosition) +``` + +### 4. **onTabIndex Doesn't Fire on Initial Load** + +The `onTabIndex` callback is only called when the user **changes** the tab, not when the widget first mounts. + +```dart +// This callback won't fire initially; you need to handle initialPosition separately +onTabIndex: (index) { + print('User switched to $index'); + // NOT called if initialPosition was 0 and no user interaction yet +} +``` + +### 5. **Arrow Button State Updates Reactively During Navigation** + +When `showArrows: true` and `wrapArrowNavigation: false` (default), arrow buttons automatically enable/disable at tab boundaries. This is reactive — the button state updates every time the tab index changes, even without an `onTabIndex` callback. The widget rebuilds internally to reflect the current position. + +```dart +// Scenario: Currently on first tab, right arrow enabled, left arrow disabled +// User taps right arrow → navigates to second tab → both arrows become enabled (automatically) +// User taps right arrow → navigates to third tab (last) → left arrow enabled, right arrow disabled + +ThemedTabView( + showArrows: true, + wrapArrowNavigation: false, // Default: arrows disabled at boundaries + tabs: [tab1, tab2, tab3], +) +// The arrow disabled/enabled state is always correct, no manual state management needed +``` + +If you need `wrapArrowNavigation: true` for circular navigation, both arrows stay enabled at all times. + +### 6. **Color Detection is RGB-Based** + +The active tab highlight color is determined by comparing the tab's `color` to the theme's primary color (RGB + Alpha match required). If your tabs don't have explicit colors, they use `DefaultTextStyle.of(context).style.color`, which might be `null` and default to primary. + +```dart +// Custom colors for different tabs +ThemedTab( + labelText: 'Custom', + color: Colors.purple, // Will show as active if this matches the primary color + child: CustomView(), +) +``` + +### 7. **Tab Bar Text is RichText** + +Tab labels are rendered with `RichText` and `TextSpan`, not plain `Text`. This means: +- Custom styling per part of the label (e.g., icon + text) is easy +- Finding tabs by text in widget tests requires custom finders (see Testing below) + +--- + +## Best Practices + +### 1. **Use `persistTabPosition: true` for Settings/Navigation Tabs** + +If users might apply settings or make selections in a tab, remember their position when they return. + +```dart +ThemedTabView( + persistTabPosition: true, // Preserve tab selection across rebuilds + tabs: [ + ThemedTab(labelText: 'General', child: GeneralSettings()), + ThemedTab(labelText: 'Advanced', child: AdvancedSettings()), + ], +) +``` + +### 2. **Use `persistTabPosition: false` for Filtered/Dynamic Tab Lists** + +If the tab list changes dynamically (e.g., filtering by category), reset to the first tab. + +```dart +ThemedTabView( + persistTabPosition: false, // Reset when categories change + tabs: activeCategories.map((cat) => + ThemedTab(labelText: cat.name, child: CategoryView(cat)) + ).toList(), +) +``` + +### 3. **Keep Tabs Lightweight; Defer Expensive Work** + +Don't do heavy computation in tab content build methods. Use `FutureBuilder` or `StreamBuilder` inside each tab's `child`. + +```dart +// Bad: Expensive work in the child widget's build method +ThemedTab( + labelText: 'Reports', + child: ReportGenerator(), // ← This runs upfront for ALL tabs +) + +// Good: Lazy loading inside the tab +ThemedTab( + labelText: 'Reports', + child: FutureBuilder( + future: _loadReports(), + builder: (context, snapshot) { + if (!snapshot.hasData) return const CircularProgressIndicator(); + return ReportView(snapshot.data!); + }, + ), +) +``` + +### 4. **Use Arrows Only When Tab Bar Doesn't Scroll** + +The `showArrows` buttons are for non-scrollable tab bar scenarios. If your tabs fit on screen or the `TabBar` is already scrollable, skip arrows. + +```dart +// With arrows: controlled, button-based navigation +ThemedTabView( + showArrows: true, + tabs: many_tabs, +) + +// Without arrows: natural scrolling (TabBar is isScrollable: true) +ThemedTabView( + showArrows: false, // Let TabBar handle horizontal scroll + tabs: many_tabs, +) +``` + +### 4b. **Choose Between Linear and Wrap Arrow Navigation** + +When using `showArrows: true`, decide if arrows should disable at boundaries or wrap around: + +```dart +// Linear navigation (default): arrows disabled at first/last tabs +ThemedTabView( + showArrows: true, + wrapArrowNavigation: false, + tabs: [tab1, tab2, tab3], +) +// On first tab: left arrow disabled, right arrow enabled +// On last tab: left arrow enabled, right arrow disabled + +// Circular navigation: arrows wrap around +ThemedTabView( + showArrows: true, + wrapArrowNavigation: true, + tabs: [tab1, tab2, tab3], +) +// On first tab: left arrow goes to last tab, right arrow goes to second tab +// On last tab: left arrow goes to previous tab, right arrow goes to first tab +// Both arrows always enabled +``` + +### 5. **Pass Constants for Theme Consistency** + +Use inherited theme values for spacing and animation durations rather than magic numbers. + +```dart +ThemedTabView( + animationDuration: Theme.of(context).pageTransitionsTheme.buildTransitions == null + ? const Duration(milliseconds: 250) + : const Duration(milliseconds: 300), + padding: const EdgeInsets.all(16), // Match Material 3 spacing + tabs: tabs, +) +``` + +### 6. **Combine with LayoutBuilder for Responsive Tabs** + +If tab list or additional widgets should change based on screen size, wrap in `LayoutBuilder`. + +```dart +LayoutBuilder( + builder: (context, constraints) { + return ThemedTabView( + showArrows: constraints.maxWidth < 600, // Show arrows on mobile + additionalWidgets: constraints.maxWidth > 960 + ? [SearchBar(), FilterButton()] + : [], + tabs: tabs, + ); + }, +) +``` + +--- + +## Testing + +When writing widget tests for tabs: + +1. **Find tabs by RichText content**, not plain text: + ```dart + Finder findTabByText(String text) { + return find.byWidgetPredicate((widget) { + if (widget is RichText) { + return widget.text.toPlainText().contains(text); + } + return false; + }); + } + + await tester.tap(findTabByText('Settings')); + await tester.pumpAndSettle(); + ``` + +2. **Verify tab content changes** after tapping: + ```dart + expect(find.text('Settings Content'), findsOneWidget); + ``` + +3. **Test callbacks**: + ```dart + int? lastIndex; + await tester.pumpWidget( + ThemedTabView( + onTabIndex: (index) { lastIndex = index; }, + tabs: tabs, + ), + ); + await tester.tap(findTabByText('Tab 2')); + await tester.pumpAndSettle(); + expect(lastIndex, equals(1)); + ``` + +--- + +## Common Patterns + +### Pattern: Analytics Tracking + +```dart +ThemedTabView( + onTabIndex: (index) { + analytics.logEvent( + name: 'tab_switched', + parameters: {'tab_index': index, 'tab_name': tabs[index].labelText}, + ); + }, + tabs: tabs, +) +``` + +### Pattern: Save User Preference + +```dart +ThemedTabView( + initialPosition: savedTabIndex, // Load from SharedPreferences + onTabIndex: (index) { + prefs.setInt('last_tab', index); // Save on change + }, + tabs: tabs, +) +``` + +### Pattern: Conditional Tab Visibility + +```dart +ThemedTabView( + tabs: [ + if (user.isAdmin) ...[ + ThemedTab(labelText: 'Admin Panel', child: AdminView()), + ], + if (showDebugTabs) ...[ + ThemedTab(labelText: 'Debug', child: DebugView()), + ], + ThemedTab(labelText: 'Profile', child: ProfileView()), + ], +) +``` + +### Pattern: Dynamic Tab Title with Badge + +```dart +ThemedTab( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Notifications'), + if (notificationCount > 0) ...[ + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$notificationCount', + style: const TextStyle(color: Colors.white, fontSize: 10), + ), + ), + ], + ], + ), + child: NotificationsView(), +) +``` + +--- + +## Summary + +- **Use `ThemedTabView` for horizontal tab navigation** with Material 3 styling +- **Keep tabs lightweight**; defer expensive work to each tab's content widget +- **Use `persistTabPosition: true` for settings**, `false` for dynamic tab lists +- **Test with custom RichText finders**, not plain text finds +- **Combine with `LayoutBuilder` for responsive designs** diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ac0a48..d1e887c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 7.5.23 + +- Fixed `ThemedTabView` debugPrint statement left in production code. +- Fixed `ThemedTabView` initialPosition validation: out-of-bounds indices are now clamped to valid range instead of potentially crashing `TabController`. +- Fixed `ThemedTabView` unnecessary `setState()` calls in arrow button handlers: `TabController.animateTo()` already notifies listeners. +- Fixed `ThemedTabView` arrow button disabling state not updating during navigation: `setState()` now called unconditionally when tab index changes, ensuring arrow disabled state reflects current position even without `onTabIndex` callback. +- Improved `ThemedTabView` tab active-state detection: color comparison now includes alpha channel for more accurate active state handling. +- Improved `ThemedTabView` code quality: extracted magic numbers to named constants (`_kTabBorderRadius`, `_kArrowButtonHeight`, `_kAdditionalWidgetsSpacing`, `_kTabAnimationDuration`). +- Added `wrapArrowNavigation` parameter to `ThemedTabView` for circular tab navigation: when `true`, left arrow from first tab goes to last tab and right arrow from last tab goes to first tab; when `false` (default), arrows are disabled at boundaries. +- Added comprehensive widget tests for `ThemedTabView` covering: rendering, tab switching via tap, `onTabIndex` callback firing, arrow button navigation, `initialPosition` clamping, `persistTabPosition` behavior, additional widgets, different tab styles, leading/trailing icons, custom padding and alignment, arrow button state reactivity during navigation (regression test), and `wrapArrowNavigation` behavior (wrap first→last, wrap last→first, arrows always enabled at boundaries). +- Added `ThemedTabView` skill documentation with parameter reference, usage examples, gotchas, best practices, and testing patterns. +- Added comprehensive widget tests for `ResponsiveRow` covering: basic rendering, empty children, single child, spacing parameter (0 and custom values), main/cross axis alignment, full-width enforcement, `ResponsiveRow.builder` with various item counts, and integration tests with `ResponsiveCol`. +- Added comprehensive widget tests for `ResponsiveCol` covering: child rendering, breakpoint fallback logic (xs default, sm/md/lg/xl optional), LayoutBuilder usage, and child widget preservation. +- Added widget tests for `Sizes` enum covering: gridSize calculations for all column variants, boxWidth calculations at various container widths, and validation of size constraints. +- Added `ResponsiveRow` and `ResponsiveCol` skill documentation with parameter reference, breakpoint logic, responsive behavior examples, gotchas (fallback chain, LayoutBuilder dynamics, width calculations), best practices, and common layout patterns (sidebar + content, card grid, responsive forms). + ## 7.5.22 - Fixed `ThemedTable2` column key collision in `_itemsStrings`: inner key now uses column index instead of `col.hashCode`, preventing silent data corruption when two columns share the same `headerText`. diff --git a/README.md b/README.md index 36722d3..6cacba3 100644 --- a/README.md +++ b/README.md @@ -49,3 +49,15 @@ This project is maintained by [Golden M](https://goldenm.com) with authorization ## Who are you? / Want to work with us? **Golden M** is a software and hardware development company what is working on a new, innovative and disruptive technologies. For more information, contact us at [sales@goldenm.com](mailto:sales@goldenm.com) or via WhatsApp at [+(507)-6979-3073](https://wa.me/50769793073?text="From%20layrz_theme%20flutter%20library.%20Hello"). + + + +test: + - Add widget tests for ResponsiveRow and ResponsiveCol + - Add comprehensive tests covering rendering, spacing, alignment, builder functionality, and integration between ResponsiveRow and ResponsiveCol. + +docs: + - Add ResponsiveRow and ResponsiveCol skill documentation + - Document parameter reference, breakpoint logic, responsive behavior examples, gotchas, best practices, and common layout patterns. + + Co-authored-by: Claude AI \ No newline at end of file diff --git a/example/lib/router.dart b/example/lib/router.dart index 312acfc..17d3744 100644 --- a/example/lib/router.dart +++ b/example/lib/router.dart @@ -14,6 +14,7 @@ import 'package:layrz_theme_example/views/not_found.dart'; import 'package:layrz_theme_example/views/table/table.dart'; import 'package:layrz_theme_example/views/theme_generation.dart'; import 'package:layrz_theme_example/views/snackbars/snackbars.dart'; +import 'package:layrz_theme_example/views/tabs/tabs.dart'; Page customTransitionBuilder(BuildContext context, GoRouterState state, Widget child) { return CustomTransitionPage( @@ -127,6 +128,20 @@ final goRoutes = [ pageBuilder: (context, state) => customTransitionBuilder(context, state, const BasicSnackbarView()), ), + // Tabs + GoRoute( + path: '/tabs', + redirect: (context, state) => '/tabs/basic', + ), + GoRoute( + path: '/tabs/basic', + pageBuilder: (context, state) => customTransitionBuilder(context, state, const BasicTabsView()), + ), + GoRoute( + path: '/tabs/advanced', + pageBuilder: (context, state) => customTransitionBuilder(context, state, const AdvancedTabsView()), + ), + // Map GoRoute( path: '/map', diff --git a/example/lib/store/wrapper.dart b/example/lib/store/wrapper.dart index 8cd548c..d30aebf 100644 --- a/example/lib/store/wrapper.dart +++ b/example/lib/store/wrapper.dart @@ -185,6 +185,24 @@ class _LayoutState extends State { ), ], ), + ThemedNavigatorPage( + labelText: 'Tabs', + path: '/tabs', + useDefaultRedirect: false, + icon: LayrzIcons.solarOutlineAlbum, + children: [ + ThemedNavigatorPage( + labelText: 'Basic tabs', + path: '/tabs/basic', + icon: LayrzIcons.solarOutlineAlbum, + ), + ThemedNavigatorPage( + labelText: 'Advanced tabs', + path: '/tabs/advanced', + icon: LayrzIcons.solarOutlineAlbum, + ), + ], + ), ThemedNavigatorPage( labelText: 'Snackbars', path: '/snackbar/basic', diff --git a/example/lib/views/home.dart b/example/lib/views/home.dart index e467328..ec990d3 100644 --- a/example/lib/views/home.dart +++ b/example/lib/views/home.dart @@ -87,7 +87,7 @@ class _HomeViewState extends State { const Divider(), ListTile( leading: ThemedAvatar( - icon: LayrzIcons.mdiTable, + icon: LayrzIcons.solarOutlineAlbum, color: color, size: iconSize, ), @@ -134,6 +134,30 @@ class _HomeViewState extends State { ), ), const Divider(), + ListTile( + leading: ThemedAvatar( + icon: LayrzIcons.solarOutlineAlbum, + color: color, + size: iconSize, + ), + title: Text( + "Tabs", + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold), + ), + subtitle: Text( + "ThemedTabView provides a customizable tab widget with multiple styles, navigation arrows, " + "and support for icons and additional widgets. Perfect for organizing content into tabs.", + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 5, + ), + trailing: ThemedButton( + labelText: "Go!", + icon: LayrzIcons.mdiRocketLaunch, + color: Colors.green, + onTap: () => context.go('/tabs'), + ), + ), + const Divider(), ListTile( leading: Layo( size: iconSize, diff --git a/example/lib/views/layo.dart b/example/lib/views/layo.dart index f3019e9..0717c70 100644 --- a/example/lib/views/layo.dart +++ b/example/lib/views/layo.dart @@ -82,14 +82,14 @@ class _LayoViewState extends State { ThemedButton( style: ThemedButtonStyle.filledTonalFab, tooltipPosition: ThemedTooltipPosition.left, - icon: LayrzIcons.mdiContentCopy, + icon: LayrzIcons.solarOutlineCopy, labelText: "Copy this emotion as example", onTap: () { Clipboard.setData(ClipboardData(text: "Layo(size: 30, emotion: ${emotion.toString()})")); ThemedSnackbarMessenger.of(context).showSnackbar( ThemedSnackbar( message: "Copied to clipboard", - icon: LayrzIcons.mdiClipboardCheckOutline, + icon: LayrzIcons.solarOutlineClipboardCheck, color: Colors.green, ), ); diff --git a/example/lib/views/tabs/src/advanced.dart b/example/lib/views/tabs/src/advanced.dart new file mode 100644 index 0000000..d3c219f --- /dev/null +++ b/example/lib/views/tabs/src/advanced.dart @@ -0,0 +1,509 @@ +part of '../tabs.dart'; + +class AdvancedTabsView extends StatefulWidget { + const AdvancedTabsView({super.key}); + + @override + State createState() => _AdvancedTabsViewState(); +} + +class _AdvancedTabsViewState extends State { + bool _showArrows = false; + int _selectedIndex = 0; + + @override + Widget build(BuildContext context) { + return Layout( + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + "Advanced Tabs Features", + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Text( + "Explore advanced features like arrows, additional widgets, and custom styling.", + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 20), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 30, + children: [ + // Tabs with arrows - title + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Tabs with Navigation Arrows", + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + SizedBox( + width: 150, + height: 80, + child: ThemedCheckboxInput( + value: _showArrows, + onChanged: (value) { + setState(() => _showArrows = value); + }, + labelText: 'Show arrows', + ), + ), + ], + ), + + // Tabs with arrows - with wrap (circular navigation) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + 'With Wrap Navigation (wraps around)', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + SizedBox( + height: 280, + child: ThemedTabView( + showArrows: _showArrows, + wrapArrowNavigation: true, + style: ThemedTabStyle.filledTonal, + tabs: List.generate( + 5, + (index) => ThemedTab( + labelText: 'Tab ${index + 1}', + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LayrzIcons.solarOutlineDocumentText, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 10), + Text( + 'Tab ${index + 1}', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Text( + 'Left arrow from tab 1 goes to tab 5, right from tab 5 goes to tab 1', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + + // Tabs with arrows - without wrap (disabled at limits) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + 'Without Wrap Navigation (arrows disabled at limits)', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + SizedBox( + height: 280, + child: ThemedTabView( + showArrows: _showArrows, + wrapArrowNavigation: false, + style: ThemedTabStyle.filledTonal, + tabs: List.generate( + 5, + (index) => ThemedTab( + labelText: 'Tab ${index + 1}', + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LayrzIcons.solarOutlineDocumentText, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 10), + Text( + 'Tab ${index + 1}', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Text( + 'Arrows are disabled when at first or last tab', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + + // Tabs with additional widgets - title + Text( + "Tabs with Additional Widgets", + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + + // Tabs with additional widgets - content + SizedBox( + height: 300, + child: ThemedTabView( + style: ThemedTabStyle.filledTonal, + additionalWidgets: [ + SizedBox( + width: 80, + child: ThemedButton( + labelText: 'Add', + style: ThemedButtonStyle.filledTonal, + color: Theme.of(context).colorScheme.primary, + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Add button pressed')), + ); + }, + ), + ), + SizedBox( + width: 100, + child: ThemedButton( + labelText: 'Settings', + style: ThemedButtonStyle.filledTonal, + color: Theme.of(context).colorScheme.primary, + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Settings button pressed')), + ); + }, + ), + ), + ], + tabs: [ + ThemedTab( + labelText: 'All Items', + child: _buildItemsList(context, 'All Items'), + ), + ThemedTab( + labelText: 'Active', + child: _buildItemsList(context, 'Active Items'), + ), + ThemedTab( + labelText: 'Archived', + child: _buildItemsList(context, 'Archived Items'), + ), + ], + ), + ), + + // Tabs with custom content - title + Text( + "Tabs with Form Content", + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + + // Tabs with custom content - content + SizedBox( + height: 400, + child: ThemedTabView( + style: ThemedTabStyle.filledTonal, + onTabIndex: (index) { + setState(() => _selectedIndex = index); + }, + tabs: [ + ThemedTab( + labelText: 'Personal Info', + leadingIcon: LayrzIcons.solarOutlineUser, + child: Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: Column( + spacing: 12, + children: [ + ThemedTextInput( + labelText: 'First Name', + placeholder: 'Enter your first name', + ), + ThemedTextInput( + labelText: 'Last Name', + placeholder: 'Enter your last name', + ), + ThemedTextInput( + labelText: 'Email', + placeholder: 'Enter your email', + ), + ], + ), + ), + ), + ), + ThemedTab( + labelText: 'Contact', + leadingIcon: LayrzIcons.solarOutlinePhone, + child: Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: Column( + spacing: 12, + children: [ + ThemedTextInput( + labelText: 'Phone', + placeholder: '+1 (555) 000-0000', + ), + ThemedTextInput( + labelText: 'Address', + placeholder: 'Enter your address', + ), + ThemedTextInput( + labelText: 'City', + placeholder: 'Enter your city', + ), + ], + ), + ), + ), + ), + ThemedTab( + labelText: 'Preferences', + leadingIcon: LayrzIcons.solarOutlineSettings, + child: Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + 'Notifications', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + SizedBox( + width: 60, + child: ThemedCheckboxInput( + value: true, + onChanged: (_) {}, + hideDetails: true, + ), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + 'Dark Mode', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + SizedBox( + width: 60, + child: ThemedCheckboxInput( + value: false, + onChanged: (_) {}, + hideDetails: true, + ), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + 'Analytics', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + SizedBox( + width: 60, + child: ThemedCheckboxInput( + value: true, + onChanged: (_) {}, + hideDetails: true, + ), + ), + ], + ), + Divider(), + Text( + 'Currently on tab: Preferences (index $_selectedIndex)', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ), + ], + ), + ), + + // Tabs with trailing elements - title + Text( + "Tabs with Trailing Elements", + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + + // Tabs with trailing elements - content + SizedBox( + height: 250, + child: ThemedTabView( + style: ThemedTabStyle.filledTonal, + tabs: [ + ThemedTab( + labelText: 'Messages', + leadingIcon: LayrzIcons.solarOutlineInboxIn, + trailingIcon: LayrzIcons.solarOutlineAltArrowRight, + child: Center( + child: Text( + 'Messages Tab', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ThemedTab( + labelText: 'Notifications', + leadingIcon: LayrzIcons.solarOutlineBell, + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '5', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.white, + ), + ), + ), + child: Center( + child: Text( + 'Notifications Tab', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ThemedTab( + labelText: 'Search', + leadingIcon: LayrzIcons.solarOutlineMagnifier, + child: Center( + child: Text( + 'Search Tab', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildItemsList(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ...List.generate( + 5, + (index) => Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + LayrzIcons.solarOutlineDocumentText, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + Text( + 'Item ${index + 1}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Description for item ${index + 1}', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/views/tabs/src/basic.dart b/example/lib/views/tabs/src/basic.dart new file mode 100644 index 0000000..0088d28 --- /dev/null +++ b/example/lib/views/tabs/src/basic.dart @@ -0,0 +1,321 @@ +part of '../tabs.dart'; + +class BasicTabsView extends StatefulWidget { + const BasicTabsView({super.key}); + + @override + State createState() => _BasicTabsViewState(); +} + +class _BasicTabsViewState extends State { + int _selectedTabIndex = 0; + final List _settingsValues = [true, false, true, false, true]; + + @override + Widget build(BuildContext context) { + return Layout( + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + "Tabs", + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Text( + "ThemedTabView provides a customizable tab widget with multiple styles and features.", + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 20), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Basic tabs with filledTonal style + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Basic Tabs (filledTonal)", + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + SizedBox( + height: 300, + child: ThemedTabView( + style: ThemedTabStyle.filledTonal, + onTabIndex: (index) { + setState(() => _selectedTabIndex = index); + }, + tabs: [ + ThemedTab( + labelText: 'Overview', + child: Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Overview Tab', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Text( + 'This is the overview tab. Currently on tab index: $_selectedTabIndex', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withAlpha(30), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'ThemedTabView is a stateful widget that manages tab switching with smooth animations.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ), + ), + ThemedTab( + labelText: 'Settings', + child: Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Settings Tab', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + ...List.generate( + 5, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: ThemedCheckboxInput( + value: _settingsValues[index], + labelText: 'Setting ${index + 1}', + onChanged: (value) { + setState(() => _settingsValues[index] = value); + }, + ), + ), + ), + ], + ), + ), + ), + ), + ThemedTab( + labelText: 'Details', + child: Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Details Tab', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Text( + 'Features:', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + ...[ + 'Smooth tab transitions with customizable animation duration', + 'Multiple tab styles (filledTonal, underline)', + 'Support for icons, leading/trailing widgets', + 'Tab position persistence across resizes', + 'Arrow navigation for scrollable tabs', + 'Custom callbacks on tab change', + ].map( + (feature) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon( + LayrzIcons.solarOutlineCheckCircle, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + feature, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 30), + + // Tabs with underline style + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Tabs with Underline Style", + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + SizedBox( + height: 250, + child: ThemedTabView( + style: ThemedTabStyle.underline, + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: Center( + child: Text( + 'Content for Tab 1', + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ), + ThemedTab( + labelText: 'Tab 2', + child: Center( + child: Text( + 'Content for Tab 2', + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 30), + + // Tabs with icons + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Tabs with Icons", + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + SizedBox( + height: 250, + child: ThemedTabView( + style: ThemedTabStyle.filledTonal, + tabs: [ + ThemedTab( + labelText: 'Dashboard', + leadingIcon: LayrzIcons.solarOutlineHome, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LayrzIcons.solarOutlineHome, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 10), + Text( + 'Dashboard', + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ), + ThemedTab( + labelText: 'Users', + leadingIcon: LayrzIcons.solarOutlineUsersGroupRounded, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LayrzIcons.solarOutlineUsersGroupRounded, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 10), + Text( + 'Users', + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ), + ThemedTab( + labelText: 'Settings', + leadingIcon: LayrzIcons.solarOutlineSettings, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LayrzIcons.solarOutlineSettings, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 10), + Text( + 'Settings', + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/views/tabs/tabs.dart b/example/lib/views/tabs/tabs.dart new file mode 100644 index 0000000..34f793b --- /dev/null +++ b/example/lib/views/tabs/tabs.dart @@ -0,0 +1,9 @@ +library; + +import 'package:flutter/material.dart'; +import 'package:layrz_icons/layrz_icons.dart'; +import 'package:layrz_theme/layrz_theme.dart'; +import 'package:layrz_theme_example/store/store.dart'; + +part 'src/basic.dart'; +part 'src/advanced.dart'; diff --git a/example/pubspec.lock b/example/pubspec.lock index 9a06dd5..978c273 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -390,7 +390,7 @@ packages: path: ".." relative: true source: path - version: "7.5.22" + version: "7.5.23" leak_tracker: dependency: transitive description: diff --git a/lib/src/tabs/src/tab.dart b/lib/src/tabs/src/tab.dart index 67aa872..1552a5b 100644 --- a/lib/src/tabs/src/tab.dart +++ b/lib/src/tabs/src/tab.dart @@ -1,5 +1,11 @@ part of '../tabs.dart'; +// Constants for ThemedTab styling +const double _kTabContainerBorderRadius = 8.0; +const double _kTabActiveAlpha = 0.2; +const double _kTabInternalPadding = 10.0; +const Duration _kTabAnimationDuration = Duration(milliseconds: 200); + class ThemedTab extends StatelessWidget { /// [labelText] is the label text of the input. /// Avoid submit [label] and [labelText] at the same time. @@ -74,27 +80,32 @@ class ThemedTab extends StatelessWidget { ); } + /// Compares two colors for equality (RGB + Alpha) + static bool _colorsAreEqual(Color a, Color b) { + return a.r == b.r && a.g == b.g && a.b == b.b && a.a == b.a; + } + @override Widget build(BuildContext context) { final primary = Theme.of(context).colorScheme.primary; - Color backgroundColor = color ?? DefaultTextStyle.of(context).style.color ?? primary; - final bool redMatch = primary.r == backgroundColor.r; - final bool greenMatch = primary.g == backgroundColor.g; - final bool blueMatch = primary.b == backgroundColor.b; + final fallbackColor = DefaultTextStyle.of(context).style.color ?? primary; + final backgroundColor = color ?? fallbackColor; - final isActive = redMatch && greenMatch && blueMatch; + // Determine if active by comparing colors + // Note: This is a heuristic since the tab doesn't have direct access to TabController.index + final isActive = _colorsAreEqual(backgroundColor, primary); return AnimatedContainer( - duration: const Duration(milliseconds: 200), + duration: _kTabAnimationDuration, padding: padding, decoration: style == .filledTonal ? BoxDecoration( - color: isActive ? backgroundColor.withAlpha((255 * 0.2).toInt()) : Colors.transparent, - borderRadius: .circular(8), + color: isActive ? backgroundColor.withAlpha((255 * _kTabActiveAlpha).toInt()) : Colors.transparent, + borderRadius: BorderRadius.circular(_kTabContainerBorderRadius), ) : null, child: Padding( - padding: const .symmetric(horizontal: 10), + padding: EdgeInsets.symmetric(horizontal: _kTabInternalPadding), child: RichText( text: TextSpan( children: [ @@ -103,7 +114,7 @@ class ThemedTab extends StatelessWidget { alignment: .middle, child: leading ?? Icon(leadingIcon!, size: iconSize, color: backgroundColor), ), - const WidgetSpan(child: SizedBox(width: 10)), + WidgetSpan(child: SizedBox(width: _kTabInternalPadding)), ], if (label != null) ...[ WidgetSpan(child: label!), @@ -114,7 +125,7 @@ class ThemedTab extends StatelessWidget { ), ], if (trailing != null || trailingIcon != null) ...[ - const WidgetSpan(child: SizedBox(width: 10)), + WidgetSpan(child: SizedBox(width: _kTabInternalPadding)), WidgetSpan( alignment: .middle, child: trailing ?? Icon(trailingIcon!, size: iconSize, color: backgroundColor), diff --git a/lib/src/tabs/src/view.dart b/lib/src/tabs/src/view.dart index a393249..49f4842 100644 --- a/lib/src/tabs/src/view.dart +++ b/lib/src/tabs/src/view.dart @@ -1,5 +1,11 @@ part of '../tabs.dart'; +// Constants for ThemedTabView styling and behavior +const double _kTabBorderRadius = 8.0; +const double _kArrowButtonHeight = 40.0; +const double _kArrowButtonFontSize = 30.0; +const double _kAdditionalWidgetsSpacing = 10.0; + class ThemedTabView extends StatefulWidget { /// [tabs] is the list of tabs to display final List tabs; @@ -42,6 +48,11 @@ class ThemedTabView extends StatefulWidget { /// [style] is the style of the tab view final ThemedTabStyle style; + /// [wrapArrowNavigation] is whether to wrap navigation when using arrows + /// When true: left arrow from first tab goes to last, right arrow from last tab goes to first + /// When false: arrows are disabled at boundaries + final bool wrapArrowNavigation; + /// [ThemedTabView] is a tab for the [TabBar] widget /// /// Be careful! @@ -61,6 +72,7 @@ class ThemedTabView extends StatefulWidget { this.onTabIndex, this.additionalWidgets = const [], this.style = .filledTonal, + this.wrapArrowNavigation = false, }); @override @@ -76,20 +88,21 @@ class _ThemedTabViewState extends State with TickerProviderStateM void initState() { super.initState(); + final validInitialPosition = widget.initialPosition.clamp(0, widget.tabs.length - 1); _tabController = TabController( - initialIndex: widget.initialPosition, + initialIndex: validInitialPosition, length: widget.tabs.length, vsync: this, animationDuration: widget.animationDuration, ); - if (widget.onTabIndex != null) { - _tabController.addListener(() { - if (_tabController.indexIsChanging) { + _tabController.addListener(() { + if (_tabController.indexIsChanging) { + setState(() {}); + if (widget.onTabIndex != null) { widget.onTabIndex!(_tabController.index); - debugPrint("tab change: ${_tabController.index}"); } - }); - } + } + }); } @override @@ -101,11 +114,12 @@ class _ThemedTabViewState extends State with TickerProviderStateM if (!widget.persistTabPosition) lastIndex = 0; _tabController.dispose(); + final validIndex = lastIndex.clamp(0, (widget.tabs.length - 1).clamp(0, double.infinity).toInt()); _tabController = TabController( length: widget.tabs.length, vsync: this, animationDuration: widget.animationDuration, - initialIndex: lastIndex < widget.tabs.length ? lastIndex : 0, + initialIndex: validIndex, ); } } @@ -138,15 +152,19 @@ class _ThemedTabViewState extends State with TickerProviderStateM style: .filledTonalFab, labelText: '', tooltipEnabled: false, - height: 40, - fontSize: 30, + height: _kArrowButtonHeight, + fontSize: _kArrowButtonFontSize, color: color, icon: LayrzIcons.solarOutlineAltArrowLeft, - isDisabled: _tabController.index == 0, + isDisabled: !widget.wrapArrowNavigation && _tabController.index == 0, onTap: () { - if (_tabController.index == 0) return; - _tabController.animateTo(_tabController.index - 1); - setState(() {}); + if (widget.wrapArrowNavigation) { + final nextIndex = _tabController.index == 0 ? widget.tabs.length - 1 : _tabController.index - 1; + _tabController.animateTo(nextIndex); + } else { + if (_tabController.index == 0) return; + _tabController.animateTo(_tabController.index - 1); + } }, ), ], @@ -155,13 +173,13 @@ class _ThemedTabViewState extends State with TickerProviderStateM isScrollable: true, tabs: widget.tabs.map((e) => e.overrideStyle(widget.style)).toList(), labelPadding: .zero, - splashBorderRadius: widget.style == .filledTonal ? BorderRadius.circular(8) : null, + splashBorderRadius: widget.style == .filledTonal ? BorderRadius.circular(_kTabBorderRadius) : null, controller: _tabController, ), ), if (widget.additionalWidgets.isNotEmpty) ...[ Row( - spacing: 10, + spacing: _kAdditionalWidgetsSpacing, children: widget.additionalWidgets, ), ], @@ -170,15 +188,19 @@ class _ThemedTabViewState extends State with TickerProviderStateM style: .filledTonalFab, labelText: '', tooltipEnabled: false, - height: 40, - fontSize: 30, + height: _kArrowButtonHeight, + fontSize: _kArrowButtonFontSize, color: color, icon: LayrzIcons.solarOutlineAltArrowRight, - isDisabled: _tabController.index == widget.tabs.length - 1, + isDisabled: !widget.wrapArrowNavigation && _tabController.index == widget.tabs.length - 1, onTap: () { - if (_tabController.index == widget.tabs.length - 1) return; - _tabController.animateTo(_tabController.index + 1); - setState(() {}); + if (widget.wrapArrowNavigation) { + final nextIndex = _tabController.index == widget.tabs.length - 1 ? 0 : _tabController.index + 1; + _tabController.animateTo(nextIndex); + } else { + if (_tabController.index == widget.tabs.length - 1) return; + _tabController.animateTo(_tabController.index + 1); + } }, ), ], diff --git a/pubspec.yaml b/pubspec.yaml index f6d8b8a..c508888 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: layrz_theme description: Layrz standard styling library for Flutter. Widget library following the Material Design 3 guidelines, with a focus on reliavility and functionality. -version: "7.5.22" +version: "7.5.23" homepage: https://theme.layrz.com repository: https://github.com/goldenm-software/layrz_theme diff --git a/test/widgets/responsive_row_test.dart b/test/widgets/responsive_row_test.dart new file mode 100644 index 0000000..ad8c933 --- /dev/null +++ b/test/widgets/responsive_row_test.dart @@ -0,0 +1,540 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:layrz_theme/layrz_theme.dart'; + +void main() { + group('ResponsiveRow', () { + testWidgets('Renders basic ResponsiveRow with children', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow( + children: [ + ResponsiveCol(xs: .col6, child: Container(color: Colors.red, height: 100)), + ResponsiveCol(xs: .col6, child: Container(color: Colors.blue, height: 100)), + ], + ), + ), + ), + ); + + expect(find.byType(Wrap), findsOneWidget); + expect(find.byType(ResponsiveCol), findsNWidgets(2)); + expect(find.byType(Container), findsNWidgets(2)); + }); + + testWidgets('ResponsiveRow with empty children renders empty Wrap', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ResponsiveRow( + children: [], + ), + ), + ), + ); + + final wrap = find.byType(Wrap); + expect(wrap, findsOneWidget); + expect(find.byType(ResponsiveCol), findsNothing); + }); + + testWidgets('ResponsiveRow with single child', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow( + children: [ + ResponsiveCol(xs: .col12, child: Container(color: Colors.green, height: 50)), + ], + ), + ), + ), + ); + + expect(find.byType(ResponsiveCol), findsOneWidget); + expect(find.byType(Container), findsOneWidget); + }); + + testWidgets('ResponsiveRow respects spacing parameter', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow( + spacing: 16, + children: [ + ResponsiveCol(xs: .col6, child: Container(color: Colors.red, height: 100)), + ResponsiveCol(xs: .col6, child: Container(color: Colors.blue, height: 100)), + ], + ), + ), + ), + ); + + final wrap = find.byType(Wrap).evaluate().first.widget as Wrap; + expect(wrap.spacing, 16); + }); + + testWidgets('ResponsiveRow respects spacing = 0', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow( + spacing: 0, + children: [ + ResponsiveCol(xs: .col6, child: Container(color: Colors.red, height: 100)), + ResponsiveCol(xs: .col6, child: Container(color: Colors.blue, height: 100)), + ], + ), + ), + ), + ); + + final wrap = find.byType(Wrap).evaluate().first.widget as Wrap; + expect(wrap.spacing, 0); + }); + + testWidgets('ResponsiveRow default spacing is 0', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow( + children: [ + ResponsiveCol(xs: .col6, child: Container(height: 100)), + ResponsiveCol(xs: .col6, child: Container(height: 100)), + ], + ), + ), + ), + ); + + final wrap = find.byType(Wrap).evaluate().first.widget as Wrap; + expect(wrap.spacing, 0); // Default spacing should be 0 + }); + + testWidgets('ResponsiveRow respects mainAxisAlignment', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow( + mainAxisAlignment: WrapAlignment.center, + children: [ + ResponsiveCol(xs: .col6, child: Container(color: Colors.red, height: 100)), + ], + ), + ), + ), + ); + + final wrap = find.byType(Wrap).evaluate().first.widget as Wrap; + expect(wrap.alignment, WrapAlignment.center); + }); + + testWidgets('ResponsiveRow respects crossAxisAlignment', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ResponsiveCol(xs: .col6, child: Container(color: Colors.red, height: 100)), + ], + ), + ), + ), + ); + + final wrap = find.byType(Wrap).evaluate().first.widget as Wrap; + expect(wrap.crossAxisAlignment, WrapCrossAlignment.center); + }); + + testWidgets('ResponsiveRow has full width (width: double.infinity)', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow( + children: [ + ResponsiveCol(xs: .col6, child: Container(color: Colors.red, height: 100)), + ], + ), + ), + ), + ); + + final sizedBox = find.byType(SizedBox).first.evaluate().first.widget as SizedBox; + expect(sizedBox.width, double.infinity); + }); + + testWidgets('ResponsiveRow.builder creates correct number of children', (WidgetTester tester) async { + int buildCount = 0; + ResponsiveCol itemBuilder(int index) { + buildCount++; + return ResponsiveCol( + xs: .col6, + child: Container(color: Colors.red, height: 50), + ); + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow.builder( + itemCount: 5, + itemBuilder: itemBuilder, + ), + ), + ), + ); + + expect(buildCount, 5); + expect(find.byType(ResponsiveCol), findsNWidgets(5)); + }); + + testWidgets('ResponsiveRow.builder with itemCount=0', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow.builder( + itemCount: 0, + itemBuilder: (index) => ResponsiveCol(xs: .col6, child: Container()), + ), + ), + ), + ); + + expect(find.byType(ResponsiveCol), findsNothing); + }); + + testWidgets('ResponsiveRow.builder respects mainAxisAlignment', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow.builder( + itemCount: 2, + mainAxisAlignment: WrapAlignment.spaceEvenly, + itemBuilder: (index) => ResponsiveCol(xs: .col4, child: Container()), + ), + ), + ), + ); + + final wrap = find.byType(Wrap).evaluate().first.widget as Wrap; + expect(wrap.alignment, WrapAlignment.spaceEvenly); + }); + + testWidgets('ResponsiveRow.builder respects spacing', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow.builder( + itemCount: 3, + spacing: 20, + itemBuilder: (index) => ResponsiveCol(xs: .col4, child: Container()), + ), + ), + ), + ); + + final wrap = find.byType(Wrap).evaluate().first.widget as Wrap; + expect(wrap.spacing, 20); + }); + + testWidgets('ResponsiveRow uses Wrap as layout widget', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow( + children: [ + ResponsiveCol(xs: .col6, child: Container()), + ResponsiveCol(xs: .col6, child: Container()), + ], + ), + ), + ), + ); + + expect(find.byType(Wrap), findsOneWidget); + }); + + testWidgets('ResponsiveRow.builder with large itemCount', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow.builder( + itemCount: 20, + itemBuilder: (index) => ResponsiveCol( + xs: .col6, + child: Container(height: 50), + ), + ), + ), + ), + ); + + expect(find.byType(ResponsiveCol), findsNWidgets(20)); + }); + }); + + group('ResponsiveCol', () { + testWidgets('ResponsiveCol renders child', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveCol( + xs: .col6, + child: Container(color: Colors.red, height: 100, key: const Key('test-child')), + ), + ), + ), + ); + + expect(find.byKey(const Key('test-child')), findsOneWidget); + }); + + testWidgets('ResponsiveCol uses xs by default', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveCol( + xs: .col12, + child: Container(color: Colors.red, height: 100, key: const Key('test')), + ), + ), + ), + ); + + expect(find.byKey(const Key('test')), findsOneWidget); + }); + + testWidgets('ResponsiveCol falls back to xs if sm is null', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveCol( + xs: .col12, + sm: null, + child: Container(color: Colors.red, height: 100, key: const Key('test')), + ), + ), + ), + ); + + expect(find.byKey(const Key('test')), findsOneWidget); + }); + + testWidgets('ResponsiveCol with all breakpoints specified', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveCol( + xs: .col12, + sm: .col6, + md: .col4, + lg: .col3, + xl: .col2, + child: Container(color: Colors.green, height: 100, key: const Key('test')), + ), + ), + ), + ); + + expect(find.byKey(const Key('test')), findsOneWidget); + }); + + testWidgets('ResponsiveCol uses LayoutBuilder for responsive sizing', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveCol( + xs: .col12, + child: Container(color: Colors.red, height: 100), + ), + ), + ), + ); + + expect(find.byType(LayoutBuilder), findsOneWidget); + }); + + testWidgets('ResponsiveCol preserves child widget type', (WidgetTester tester) async { + const testKey = Key('test-container'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveCol( + xs: .col6, + child: Container(key: testKey, color: Colors.red, height: 100), + ), + ), + ), + ); + + expect(find.byType(Container), findsWidgets); + expect(find.byKey(testKey), findsOneWidget); + }); + + testWidgets('ResponsiveCol with different size values', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveCol( + xs: .col1, + sm: .col2, + md: .col4, + lg: .col6, + xl: .col8, + child: Container(height: 50, key: const Key('sized-col')), + ), + ), + ), + ); + + expect(find.byKey(const Key('sized-col')), findsOneWidget); + }); + }); + + group('Sizes enum', () { + test('Sizes.col1 returns gridSize 1', () { + expect(Sizes.col1.gridSize, 1); + }); + + test('Sizes.col6 returns gridSize 6', () { + expect(Sizes.col6.gridSize, 6); + }); + + test('Sizes.col12 returns gridSize 12', () { + expect(Sizes.col12.gridSize, 12); + }); + + test('boxWidth calculates correct width for col6 at 1200px', () { + final width = Sizes.col6.boxWidth(1200); + expect(width, 600); // (1200 / 12) * 6 = 600 + }); + + test('boxWidth calculates correct width for col12 at 1200px', () { + final width = Sizes.col12.boxWidth(1200); + expect(width, 1200); // (1200 / 12) * 12 = 1200 + }); + + test('boxWidth calculates correct width for col3 at 600px', () { + final width = Sizes.col3.boxWidth(600); + expect(width, 150); // (600 / 12) * 3 = 150 + }); + + test('boxWidth calculates correct width for col1 at 1200px', () { + final width = Sizes.col1.boxWidth(1200); + expect(width, 100); // (1200 / 12) * 1 = 100 + }); + + test('boxWidth with col2 at 1200px equals 200', () { + final width = Sizes.col2.boxWidth(1200); + expect(width, 200); + }); + + test('boxWidth with col4 at 800px equals 266.67', () { + final width = Sizes.col4.boxWidth(800); + expect(width, closeTo(266.67, 0.01)); + }); + + test('All sizes have valid gridSize', () { + expect(Sizes.col1.gridSize, greaterThan(0)); + expect(Sizes.col12.gridSize, lessThanOrEqualTo(12)); + }); + }); + + group('ResponsiveRow and ResponsiveCol integration', () { + testWidgets('ResponsiveRow with multiple ResponsiveCol children renders correctly', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow( + children: [ + ResponsiveCol(xs: .col6, md: .col4, child: Container(key: const Key('col1'), height: 100)), + ResponsiveCol(xs: .col6, md: .col4, child: Container(key: const Key('col2'), height: 100)), + ResponsiveCol(xs: .col12, md: .col4, child: Container(key: const Key('col3'), height: 100)), + ], + ), + ), + ), + ); + + expect(find.byKey(const Key('col1')), findsOneWidget); + expect(find.byKey(const Key('col2')), findsOneWidget); + expect(find.byKey(const Key('col3')), findsOneWidget); + }); + + testWidgets('ResponsiveRow with many children', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow.builder( + itemCount: 12, + itemBuilder: (index) => ResponsiveCol( + xs: .col12, + md: .col6, + lg: .col4, + child: Container(height: 50), + ), + ), + ), + ), + ); + + expect(find.byType(ResponsiveCol), findsNWidgets(12)); + }); + + testWidgets('ResponsiveRow.builder with all parameters', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow.builder( + itemCount: 4, + spacing: 10, + mainAxisAlignment: WrapAlignment.spaceEvenly, + crossAxisAlignment: WrapCrossAlignment.center, + itemBuilder: (index) => ResponsiveCol( + xs: .col6, + lg: .col3, + child: Container(height: 100), + ), + ), + ), + ), + ); + + expect(find.byType(ResponsiveCol), findsNWidgets(4)); + + final wrap = find.byType(Wrap).evaluate().first.widget as Wrap; + expect(wrap.spacing, 10); + expect(wrap.alignment, WrapAlignment.spaceEvenly); + expect(wrap.crossAxisAlignment, WrapCrossAlignment.center); + }); + + testWidgets('ResponsiveCol with ResponsiveRow preserves layout structure', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveRow( + children: [ + ResponsiveCol( + xs: .col12, + md: .col6, + child: Container( + key: const Key('responsive-item'), + height: 100, + ), + ), + ], + ), + ), + ), + ); + + expect(find.byKey(const Key('responsive-item')), findsOneWidget); + expect(find.byType(Wrap), findsOneWidget); + }); + }); +} diff --git a/test/widgets/tabs_test.dart b/test/widgets/tabs_test.dart new file mode 100644 index 0000000..5700d0e --- /dev/null +++ b/test/widgets/tabs_test.dart @@ -0,0 +1,884 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:layrz_theme/layrz_theme.dart'; + +void main() { + group('ThemedTabView widget', () { + // Helper function to find text in RichText + Finder findRichText(String text) { + return find.byWidgetPredicate((widget) { + if (widget is RichText) { + return widget.text.toPlainText().contains(text); + } + return false; + }); + } + + testWidgets('renders with multiple tabs', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.byType(ThemedTabView), findsOneWidget); + expect(find.byType(TabBar), findsOneWidget); + expect(find.byType(TabBarView), findsOneWidget); + expect(findRichText('Tab 1'), findsWidgets); + expect(findRichText('Tab 2'), findsWidgets); + expect(find.text('Content 1'), findsOneWidget); + }); + + testWidgets('displays initial tab content', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + initialPosition: 0, + tabs: [ + ThemedTab( + labelText: 'First', + child: const Text('First Content'), + ), + ThemedTab( + labelText: 'Second', + child: const Text('Second Content'), + ), + ], + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.text('First Content'), findsOneWidget); + expect(find.text('Second Content'), findsNothing); + }); + + testWidgets('switches tabs on tap', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + tabs: [ + ThemedTab( + labelText: 'Tab A', + child: const Text('Content A'), + ), + ThemedTab( + labelText: 'Tab B', + child: const Text('Content B'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Content A'), findsOneWidget); + expect(find.text('Content B'), findsNothing); + + // Tap on Tab B - find by RichText + await tester.tap(findRichText('Tab B')); + await tester.pumpAndSettle(); + + expect(find.text('Content A'), findsNothing); + expect(find.text('Content B'), findsOneWidget); + }); + + testWidgets('calls onTabIndex callback when tab changes', (WidgetTester tester) async { + int? lastIndex; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ThemedTab( + labelText: 'Tab 3', + child: const Text('Content 3'), + ), + ], + onTabIndex: (index) { + lastIndex = index; + }, + ), + ), + ), + ), + ); + + // Initial tab doesn't trigger callback because it's not a change + expect(lastIndex, isNull); + + // Switch to second tab + await tester.tap(findRichText('Tab 2')); + await tester.pumpAndSettle(); + expect(lastIndex, equals(1)); + + // Switch to third tab + await tester.tap(findRichText('Tab 3')); + await tester.pumpAndSettle(); + expect(lastIndex, equals(2)); + + // Switch back to first tab + await tester.tap(findRichText('Tab 1')); + await tester.pumpAndSettle(); + expect(lastIndex, equals(0)); + }); + + testWidgets('renders arrow buttons when showArrows is true', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + showArrows: true, + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ], + ), + ), + ), + ), + ); + + // Should have two arrow buttons + expect(find.byType(ThemedButton), findsWidgets); + }); + + testWidgets('arrow buttons exist and are functional', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + showArrows: true, + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Content 1'), findsOneWidget); + expect(find.byType(ThemedButton), findsWidgets); + }); + + testWidgets('left arrow is disabled on first tab', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + showArrows: true, + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ], + ), + ), + ), + ), + ); + + // Left arrow should be disabled on first tab + final buttons = find.byType(ThemedButton); + final leftButton = tester.widget(buttons.at(0)); + expect(leftButton.isDisabled, isTrue); + }); + + testWidgets('right arrow is disabled on last tab', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + showArrows: true, + initialPosition: 1, + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ], + ), + ), + ), + ), + ); + + // Right arrow should be disabled on last tab + final buttons = find.byType(ThemedButton); + final rightButton = tester.widget(buttons.at(1)); + expect(rightButton.isDisabled, isTrue); + }); + + testWidgets('handles initialPosition correctly', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + initialPosition: 1, + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ThemedTab( + labelText: 'Tab 3', + child: const Text('Content 3'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Content 2'), findsOneWidget); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 3'), findsNothing); + }); + + testWidgets('clamps invalid initialPosition to valid range', (WidgetTester tester) async { + // Test with initialPosition larger than tabs length + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + initialPosition: 10, // Out of bounds + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ], + ), + ), + ), + ), + ); + + // Should show last tab content instead of crashing + expect(find.text('Content 2'), findsOneWidget); + expect(find.text('Content 1'), findsNothing); + }); + + testWidgets('persists tab position on widget rebuild', (WidgetTester tester) async { + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + return MaterialApp( + home: Scaffold( + body: Column( + children: [ + ElevatedButton( + onPressed: () { + setState(() {}); + }, + child: const Text('Rebuild'), + ), + Expanded( + child: SizedBox( + width: 800, + child: ThemedTabView( + persistTabPosition: true, + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); + + // Switch to second tab + await tester.tap(findRichText('Tab 2')); + await tester.pumpAndSettle(); + expect(find.text('Content 2'), findsOneWidget); + + // Trigger rebuild + await tester.tap(find.text('Rebuild')); + await tester.pumpAndSettle(); + + // Tab position should be preserved + expect(find.text('Content 2'), findsOneWidget); + }); + + testWidgets('resets tab position when tabs list changes and persistTabPosition is false', + (WidgetTester tester) async { + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + return MaterialApp( + home: Scaffold( + body: Column( + children: [ + ElevatedButton( + onPressed: () { + // This will change the tabs length, triggering didUpdateWidget + setState(() {}); + }, + child: const Text('ToggleTabs'), + ), + Expanded( + child: SizedBox( + width: 800, + child: Builder( + builder: (context) { + // Generate a different tab list on each rebuild + final tabsList = [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ThemedTab( + labelText: 'Tab 3', + child: const Text('Content 3'), + ), + ]; + + return ThemedTabView( + persistTabPosition: false, + tabs: tabsList, + ); + }, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); + + // Switch to third tab + await tester.tap(findRichText('Tab 3')); + await tester.pumpAndSettle(); + expect(find.text('Content 3'), findsOneWidget); + + // Now change tabs (by adding one more) + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + return MaterialApp( + home: Scaffold( + body: Column( + children: [ + ElevatedButton( + onPressed: () { + setState(() {}); + }, + child: const Text('ToggleTabs'), + ), + Expanded( + child: SizedBox( + width: 800, + child: Builder( + builder: (context) { + final tabsList = [ + ThemedTab( + labelText: 'Tab A', + child: const Text('Content A'), + ), + ThemedTab( + labelText: 'Tab B', + child: const Text('Content B'), + ), + ThemedTab( + labelText: 'Tab C', + child: const Text('Content C'), + ), + ThemedTab( + labelText: 'Tab D', + child: const Text('Content D'), + ), + ]; + + return ThemedTabView( + persistTabPosition: false, + tabs: tabsList, + ); + }, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); + + await tester.pumpAndSettle(); + // Tab position should be reset to first tab since persistTabPosition: false + expect(find.text('Content A'), findsOneWidget); + }); + + testWidgets('renders additionalWidgets', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + additionalWidgets: [ + const Chip(label: Text('Filter 1')), + const Chip(label: Text('Filter 2')), + ], + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Filter 1'), findsOneWidget); + expect(find.text('Filter 2'), findsOneWidget); + expect(find.byType(Chip), findsWidgets); + }); + + testWidgets('renders with different tab styles', (WidgetTester tester) async { + for (final style in ThemedTabStyle.values) { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + style: style, + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ], + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.byType(ThemedTabView), findsOneWidget); + expect(findRichText('Tab 1'), findsWidgets); + expect(findRichText('Tab 2'), findsWidgets); + } + }); + + testWidgets('ThemedTab renders with leading icon', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + tabs: [ + ThemedTab( + labelText: 'Home', + leadingIcon: Icons.home, + child: const Text('Home Content'), + ), + ThemedTab( + labelText: 'Settings', + leadingIcon: Icons.settings, + child: const Text('Settings Content'), + ), + ], + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.home), findsOneWidget); + expect(find.byIcon(Icons.settings), findsOneWidget); + expect(findRichText('Home'), findsWidgets); + }); + + testWidgets('ThemedTab renders with trailing icon', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + tabs: [ + ThemedTab( + labelText: 'Notifications', + trailingIcon: Icons.notifications, + child: const Text('Notifications Content'), + ), + ], + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.notifications), findsOneWidget); + expect(findRichText('Notifications'), findsWidgets); + }); + + testWidgets('renders with custom padding and alignment', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + padding: const EdgeInsets.all(20), + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.byType(ThemedTabView), findsOneWidget); + expect(find.byType(Padding), findsWidgets); + }); + + testWidgets('arrow button state updates when navigating between tabs (bug regression)', (WidgetTester tester) async { + // This test verifies the fix: setState() must be called on tab index changes + // even without onTabIndex callback, so arrow disabled state updates correctly + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + showArrows: true, + initialPosition: 0, + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ThemedTab( + labelText: 'Tab 3', + child: const Text('Content 3'), + ), + ], + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + var buttons = find.byType(ThemedButton); + var leftButton = tester.widget(buttons.at(0)); + var rightButton = tester.widget(buttons.at(1)); + + // Initially on first tab: left disabled, right enabled + expect(leftButton.isDisabled, isTrue, reason: 'Left arrow should be disabled on first tab'); + expect(rightButton.isDisabled, isFalse, reason: 'Right arrow should be enabled on first tab'); + + // Navigate to second tab via right arrow tap + await tester.tap(find.byType(ThemedButton).at(1)); + await tester.pumpAndSettle(); + + // Re-fetch buttons after navigation + buttons = find.byType(ThemedButton); + leftButton = tester.widget(buttons.at(0)); + rightButton = tester.widget(buttons.at(1)); + + // After navigation to middle tab: neither should be disabled + expect(leftButton.isDisabled, isFalse, reason: 'Left arrow should be enabled on middle tab'); + expect(rightButton.isDisabled, isFalse, reason: 'Right arrow should be enabled on middle tab'); + + // Navigate to last tab + await tester.tap(find.byType(ThemedButton).at(1)); + await tester.pumpAndSettle(); + + // Re-fetch buttons after navigation + buttons = find.byType(ThemedButton); + leftButton = tester.widget(buttons.at(0)); + rightButton = tester.widget(buttons.at(1)); + + // On last tab: left enabled, right disabled + expect(leftButton.isDisabled, isFalse, reason: 'Left arrow should be enabled on last tab'); + expect(rightButton.isDisabled, isTrue, reason: 'Right arrow should be disabled on last tab'); + + // Navigate back to middle tab + await tester.tap(find.byType(ThemedButton).at(0)); + await tester.pumpAndSettle(); + + // Re-fetch buttons after navigation + buttons = find.byType(ThemedButton); + leftButton = tester.widget(buttons.at(0)); + rightButton = tester.widget(buttons.at(1)); + + expect(leftButton.isDisabled, isFalse, reason: 'Left arrow should be enabled on middle tab'); + expect(rightButton.isDisabled, isFalse, reason: 'Right arrow should be enabled on middle tab'); + }); + + testWidgets('wrapArrowNavigation wraps from last tab to first tab', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + showArrows: true, + wrapArrowNavigation: true, + initialPosition: 2, // Last tab + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ThemedTab( + labelText: 'Tab 3', + child: const Text('Content 3'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Content 3'), findsOneWidget); + + // Right arrow on last tab should wrap to first tab + final buttons = find.byType(ThemedButton); + await tester.tap(buttons.at(1)); // Right arrow + await tester.pumpAndSettle(); + + expect(find.text('Content 1'), findsOneWidget); + expect(find.text('Content 3'), findsNothing); + }); + + testWidgets('wrapArrowNavigation wraps from first tab to last tab', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + showArrows: true, + wrapArrowNavigation: true, + initialPosition: 0, // First tab + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ThemedTab( + labelText: 'Tab 3', + child: const Text('Content 3'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Content 1'), findsOneWidget); + + // Left arrow on first tab should wrap to last tab + final buttons = find.byType(ThemedButton); + await tester.tap(buttons.at(0)); // Left arrow + await tester.pumpAndSettle(); + + expect(find.text('Content 3'), findsOneWidget); + expect(find.text('Content 1'), findsNothing); + }); + + testWidgets('wrapArrowNavigation keeps arrows enabled at boundaries', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: ThemedTabView( + showArrows: true, + wrapArrowNavigation: true, + initialPosition: 0, // First tab + tabs: [ + ThemedTab( + labelText: 'Tab 1', + child: const Text('Content 1'), + ), + ThemedTab( + labelText: 'Tab 2', + child: const Text('Content 2'), + ), + ], + ), + ), + ), + ), + ); + + var buttons = find.byType(ThemedButton); + var leftButton = tester.widget(buttons.at(0)); + var rightButton = tester.widget(buttons.at(1)); + + // With wrap enabled, both arrows should be enabled even at boundaries + expect(leftButton.isDisabled, isFalse); + expect(rightButton.isDisabled, isFalse); + + // Navigate to last tab + await tester.tap(buttons.at(1)); + await tester.pumpAndSettle(); + + // Both arrows should still be enabled at last tab + buttons = find.byType(ThemedButton); + leftButton = tester.widget(buttons.at(0)); + rightButton = tester.widget(buttons.at(1)); + expect(leftButton.isDisabled, isFalse); + expect(rightButton.isDisabled, isFalse); + }); + }); +}