diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 4f9dbbc..291d3d4 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -3,10 +3,10 @@ github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username -ko_fi: ukieTux # Replace with a single Ko-fi username +ko_fi: lzyct # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username -custom: ['https://www.buymeacoffee.com/ukieTux','https://paypal.me/ukieTux']# Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] +custom: ['https://www.buymeacoffee.com/lzyct','https://paypal.me/ukieTux']# Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/merge-ci.yml b/.github/merge-ci.yml new file mode 100644 index 0000000..d7d04be --- /dev/null +++ b/.github/merge-ci.yml @@ -0,0 +1,85 @@ +name: Merge CI + +# This workflow is triggered on pull request to the repository. + +# Run this script when PR and status is closed +on: + pull_request: + types: [ closed ] + +jobs: + build: + # This job will run on ubuntu virtual machine + runs-on: ubuntu-latest + steps: + + # Setup Java environment in order to build the Android app. + - uses: actions/checkout@v4.2.1 + - uses: actions/setup-java@v4.3.0 + with: + distribution: 'zulu' # See 'Supported distributions' for available options + java-version: '17' + + # Setup the flutter environment. + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' # 'dev', 'alpha', default to: 'stable' + # flutter-version: '1.12.x' # you can also specify exact version of flutter + + # Get flutter dependencies. + - name: Run initialize flutter project + run: flutter pub get ;flutter gen-l10n;flutter pub run build_runner build --delete-conflicting-outputs + + # Formatting code + - name: Running flutter format + run: dart format lib/ + # Get current branch + - name: Get branch name + id: branch-name + uses: tj-actions/branch-names@v5 + + # Check modified files + - name: Check for modified files + id: git-check + run: echo "modified=$(if git diff-index --quiet HEAD --; then echo "false"; else echo "true"; fi)" >> $GITHUB_OUTPUT + # Push code changes + - name: Push changes + if: steps.git-check.outputs.modified == 'true' + run: | + git config --global user.name 'Lzyct-Bot' + git config --global user.email 'lazycatlabs@users.noreply.github.com' + git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} + git fetch + git checkout ${{ steps.branch-name.outputs.current_branch }} + git commit -am "clean: apply formatting changes" + git push + # Run widget tests for our flutter project. + - name: Run flutter test --machine --coverage + run: flutter test --machine --coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} # Add this secret in your repository settings + file: coverage/lcov.info + + # Build apk. + # - name: Build apk file + # run: flutter build apk --flavor stg -t lib/main_stg.dart + # + # # Upload generated apk to the artifacts. + # - name: Uploading apk file + # uses: actions/upload-artifact@v1 + # with: + # name: staging-apk + # path: build/app/outputs/flutter-apk/app-stg-release.apk + + - name: Delete branch if merged + uses: actions/github-script@v5 + with: + script: | + github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${context.payload.pull_request.head.ref}`, + }) diff --git a/.github/pull_request-ci.yml b/.github/pull_request-ci.yml new file mode 100644 index 0000000..d5bf484 --- /dev/null +++ b/.github/pull_request-ci.yml @@ -0,0 +1,51 @@ +name: Pull Request Checker + +# This workflow is triggered on pull request to the repository. + +# Run this script when PR and status is open +on: pull_request + +jobs: + build: + # This job will run on ubuntu virtual machine + runs-on: ubuntu-latest + steps: + + # Setup Java environment in order to build the Android app. + - uses: actions/checkout@v4.2.0 + - uses: actions/setup-java@v4.2.2 + with: + distribution: 'zulu' # See 'Supported distributions' for available options + java-version: '17' + + # Setup the flutter environment. + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' # 'dev', 'alpha', default to: 'stable' + #flutter-version: '3.24.3' # you can also specify exact version of flutter + + # Check Flutter version + - name: Check Flutter version + run: flutter doctor -v + + # Get flutter dependencies. + - name: Run initialize flutter project + run: flutter pub get ;flutter gen-l10n;flutter pub run build_runner build --delete-conflicting-outputs + + # Statically analyze the Dart code for any errors. + - name: Run flutter analyze + run: flutter analyze --no-fatal-infos --no-fatal-warnings + + # Run widget tests for our flutter project. + - name: Install LCOV + run: sudo apt-get update && sudo apt-get install -y lcov + + - name: Run flutter test --machine --coverage + run: flutter test --coverage;lcov --remove coverage/lcov.info 'lib/core/localization/generated/' 'lib/core/resources/*' 'lib/utils/services/firebase/*' '**/*.g.dart' -o coverage/new_lcov.info ;genhtml coverage/new_lcov.info -o coverage/html + + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} # Add this secret in your repository settings + file: coverage/new_lcov.info diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..4e83832 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +## What it does + +- + +## How to test + +1. +2. +3. + +## Screenshot + diff --git a/.github/workflows/merge-ci.yml b/.github/workflows/merge-ci.yml new file mode 100644 index 0000000..d7d04be --- /dev/null +++ b/.github/workflows/merge-ci.yml @@ -0,0 +1,85 @@ +name: Merge CI + +# This workflow is triggered on pull request to the repository. + +# Run this script when PR and status is closed +on: + pull_request: + types: [ closed ] + +jobs: + build: + # This job will run on ubuntu virtual machine + runs-on: ubuntu-latest + steps: + + # Setup Java environment in order to build the Android app. + - uses: actions/checkout@v4.2.1 + - uses: actions/setup-java@v4.3.0 + with: + distribution: 'zulu' # See 'Supported distributions' for available options + java-version: '17' + + # Setup the flutter environment. + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' # 'dev', 'alpha', default to: 'stable' + # flutter-version: '1.12.x' # you can also specify exact version of flutter + + # Get flutter dependencies. + - name: Run initialize flutter project + run: flutter pub get ;flutter gen-l10n;flutter pub run build_runner build --delete-conflicting-outputs + + # Formatting code + - name: Running flutter format + run: dart format lib/ + # Get current branch + - name: Get branch name + id: branch-name + uses: tj-actions/branch-names@v5 + + # Check modified files + - name: Check for modified files + id: git-check + run: echo "modified=$(if git diff-index --quiet HEAD --; then echo "false"; else echo "true"; fi)" >> $GITHUB_OUTPUT + # Push code changes + - name: Push changes + if: steps.git-check.outputs.modified == 'true' + run: | + git config --global user.name 'Lzyct-Bot' + git config --global user.email 'lazycatlabs@users.noreply.github.com' + git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} + git fetch + git checkout ${{ steps.branch-name.outputs.current_branch }} + git commit -am "clean: apply formatting changes" + git push + # Run widget tests for our flutter project. + - name: Run flutter test --machine --coverage + run: flutter test --machine --coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} # Add this secret in your repository settings + file: coverage/lcov.info + + # Build apk. + # - name: Build apk file + # run: flutter build apk --flavor stg -t lib/main_stg.dart + # + # # Upload generated apk to the artifacts. + # - name: Uploading apk file + # uses: actions/upload-artifact@v1 + # with: + # name: staging-apk + # path: build/app/outputs/flutter-apk/app-stg-release.apk + + - name: Delete branch if merged + uses: actions/github-script@v5 + with: + script: | + github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${context.payload.pull_request.head.ref}`, + }) diff --git a/.github/workflows/pull_request-ci.yml b/.github/workflows/pull_request-ci.yml new file mode 100644 index 0000000..8b3d380 --- /dev/null +++ b/.github/workflows/pull_request-ci.yml @@ -0,0 +1,40 @@ +name: Pull Request Checker + +# This workflow is triggered on pull request to the repository. + +# Run this script when PR and status is open +on: pull_request + +jobs: + build: + # This job will run on ubuntu virtual machine + runs-on: ubuntu-latest + steps: + + # Setup Java environment in order to build the Android app. + - uses: actions/checkout@v4.2.0 + - uses: actions/setup-java@v4.2.2 + with: + distribution: 'zulu' # See 'Supported distributions' for available options + java-version: '17' + + # Setup the flutter environment. + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' # 'dev', 'alpha', default to: 'stable' + #flutter-version: '3.24.3' # you can also specify exact version of flutter + + # Check Flutter version + - name: Check Flutter version + run: flutter doctor -v + + # Get flutter dependencies. + - name: Run initialize flutter project + run: flutter pub get + + # Statically analyze the Dart code for any errors. + - name: Run flutter analyze + run: flutter analyze --no-fatal-infos --no-fatal-warnings + + - name: Run flutter test + run: flutter test diff --git a/.gitignore b/.gitignore index e6303d8..83c45a2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .idea/ build/ +doc/api/ diff --git a/.maestro/readme_adaptive.yaml b/.maestro/readme_adaptive.yaml new file mode 100644 index 0000000..83f8879 --- /dev/null +++ b/.maestro/readme_adaptive.yaml @@ -0,0 +1,12 @@ +appId: com.example.example +--- +- launchApp: + clearState: true +- assertVisible: "Adaptive Tab Width" +- waitForAnimationToEnd +- tapOn: "AAAAAAAAAAAAAAA" +- waitForAnimationToEnd +- assertVisible: "Index selected : 1" +- tapOn: "AAAA" +- waitForAnimationToEnd +- assertVisible: "Index selected : 0" diff --git a/.maestro/readme_basic.yaml b/.maestro/readme_basic.yaml new file mode 100644 index 0000000..e120038 --- /dev/null +++ b/.maestro/readme_basic.yaml @@ -0,0 +1,9 @@ +appId: com.example.example +--- +- launchApp: + clearState: true +- assertVisible: "Basic Tab Toggle" +- waitForAnimationToEnd +- tapOn: "Tab C (19)" +- waitForAnimationToEnd +- assertVisible: "Index selected : 2" diff --git a/.maestro/readme_counter.yaml b/.maestro/readme_counter.yaml new file mode 100644 index 0000000..70ff0d9 --- /dev/null +++ b/.maestro/readme_counter.yaml @@ -0,0 +1,14 @@ +appId: com.example.example +--- +- launchApp: + clearState: true +- scrollUntilVisible: + element: + text: "Basic Tab Toggle with Counter Widget" + direction: DOWN + centerElement: true +- waitForAnimationToEnd +- tapOn: + point: "25%,23%" +- waitForAnimationToEnd +- assertVisible: "Index selected : 0" diff --git a/.maestro/readme_icon_only.yaml b/.maestro/readme_icon_only.yaml new file mode 100644 index 0000000..f01d3ac --- /dev/null +++ b/.maestro/readme_icon_only.yaml @@ -0,0 +1,14 @@ +appId: com.example.example +--- +- launchApp: + clearState: true +- scrollUntilVisible: + element: + text: "With Icon Only and Implement margin for selected item" + direction: DOWN + centerElement: true +- waitForAnimationToEnd +- tapOn: + point: "84%,53%" +- waitForAnimationToEnd +- assertVisible: "Selected sex index: 1" diff --git a/.maestro/readme_programmatic.yaml b/.maestro/readme_programmatic.yaml new file mode 100644 index 0000000..c93b0d7 --- /dev/null +++ b/.maestro/readme_programmatic.yaml @@ -0,0 +1,13 @@ +appId: com.example.example +--- +- launchApp: + clearState: true +- scrollUntilVisible: + element: + text: "Update selected programmatically " + direction: DOWN + centerElement: true +- waitForAnimationToEnd +- tapOn: "Select C" +- waitForAnimationToEnd +- assertVisible: "Selected sex index: 2 " diff --git a/.maestro/readme_text_icon.yaml b/.maestro/readme_text_icon.yaml new file mode 100644 index 0000000..aa03ede --- /dev/null +++ b/.maestro/readme_text_icon.yaml @@ -0,0 +1,13 @@ +appId: com.example.example +--- +- launchApp: + clearState: true +- scrollUntilVisible: + element: + text: "Text With Icon" + direction: DOWN + centerElement: true +- waitForAnimationToEnd +- tapOn: "Female" +- waitForAnimationToEnd +- assertVisible: ".*Selected sex : Female.*" diff --git a/.pubignore b/.pubignore new file mode 100644 index 0000000..4e18d32 --- /dev/null +++ b/.pubignore @@ -0,0 +1,22 @@ +.dart_tool/ +.git/ +.idea/ +*.iml +build/ +doc/api/ + +example/.dart_tool/ +example/.idea/ +example/build/ +example/android/.gradle/ +example/android/local.properties +example/android/gradle/wrapper/gradle-wrapper.jar +example/android/gradlew +example/android/gradlew.bat +example/ios/Flutter/Generated.xcconfig +example/ios/Flutter/ephemeral/ +example/ios/Flutter/flutter_export_environment.sh +example/ios/Pods/ +example/ios/.symlinks/ +example/ios/Runner/GeneratedPluginRegistrant.* +example/ios/xcuserdata/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ab98800 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,92 @@ +# Project Guidance + +## Scope + +This repository publishes the `flutter_toggle_tab` Flutter package and contains +a runnable application under `example/`. Keep library changes backwards +compatible unless the package version, changelog, and migration documentation +explicitly describe a breaking release. + +## Toolchain + +- Use Flutter 3.47.x or newer within the 3.47-compatible package constraints. +- Use Dart 3.13 or newer. +- Import Material APIs from `package:material_ui/material_ui.dart`; do not + reintroduce the legacy Flutter Material import. +- Run commands from the repository root unless a command explicitly targets + the `example/` application. + +## Architecture + +- `lib/flutter_toggle_tab.dart` owns the public `FlutterToggleTab` API and + selects the appropriate internal layout. +- `lib/adaptive_tab_layout.dart` owns content-sized tabs, measured geometry, + adaptive background width, and adaptive indicator positioning. +- `lib/non_adaptive_tab_layout.dart` owns equal-width tabs and normalized + `Alignment` positioning. +- `lib/selected_tab_indicator.dart` owns the animated selected background. +- `lib/button_tab.dart` owns the interactive content of one tab. +- Keep at most one class that extends `StatelessWidget` or `StatefulWidget` in + each Dart file. Plain state and geometry classes may remain beside the widget + they exclusively support. +- Keep adaptive and non-adaptive behavior in their respective layout + components instead of adding width-mode branches inside child widgets. + +## State and rebuilds + +- Keep `FlutterToggleTab` controlled through `selectedIndex` and + `selectedLabelIndex`. +- Use `ValueNotifier` and `ValueListenableBuilder` for selection and internal + measured geometry so updates rebuild only the dependent subtree. +- Dispose every notifier owned by a `State` object. +- Never mutate caller-owned `DataTab` instances to represent selection. + +## Layout behavior + +- Equal-width mode divides the available control width by the number of tabs. +- Adaptive mode measures actual tab geometry. Its background width must equal + the combined tab width, and the indicator must animate both `left` and + `width` values. When the combined width is smaller than the viewport, align + the complete surface with `adaptiveTabAlignment`; its default is center. +- Keep the tab row, adaptive background, and adaptive indicator in the same + horizontal scroll surface. +- Preserve support for text, icons, counter widgets, margins, gradients, + shadows, text scaling, and right-to-left padding resolution. +- Preserve the default equal-width behavior when `isAdaptiveWidth` is false. + +## Documentation + +- Add dartdoc comments for every public library, class, constructor, field, + method, and enum. +- Update `README.md` examples and the API table whenever public behavior or + parameters change. +- Keep README examples aligned with `example/lib/main.dart` and cover every + runnable usage sample in `example/test/widget_test.dart`. +- Update `CHANGELOG.md` for user-visible behavior. +- Validate docs with: + + ```sh + FLUTTER_ROOT=/Users/lzyct/Library/flutter \ + /Users/lzyct/Library/flutter/bin/cache/dart-sdk/bin/dart doc --validate-links + ``` + +## Verification + +Before completing a change, run: + +```sh +/Users/lzyct/Library/flutter/bin/flutter analyze +/Users/lzyct/Library/flutter/bin/flutter test +(cd example && /Users/lzyct/Library/flutter/bin/flutter test) +/Users/lzyct/Library/flutter/bin/flutter pub publish --dry-run +``` + +For layout or platform-facing changes, also build the example for Android and +the iOS Simulator. Confirm `git diff --check` and inspect the final diff. + +## Git hygiene + +- Preserve unrelated local modifications and never stage them accidentally. +- Keep platform, library behavior, tests, documentation, and project-guidance + changes in focused commits when they represent separate concerns. +- Do not commit generated `build/`, `.dart_tool/`, or `doc/api/` output. diff --git a/CHANGELOG.md b/CHANGELOG.md index e7d35a6..5c80423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,112 +1,133 @@ -# 0.0.1 +# 2.0.0 + +* Require Flutter 3.47.x and Dart 3.13. +* Migrate Material widgets to the standalone `material_ui` package. +* Update the example Android and iOS projects to Flutter 3.47 platform baselines. +* Make tab selection controlled without mutating caller-owned `DataTab` values. +* Handle constrained layouts, long labels, and empty gradient color lists. +* Use an explicit `DataTab` constructor compatible with Dart 3.13 analysis. +* Add widget tests for every example usage. +* Complete dartdoc coverage and remove unintended internal widgets and styles + from the public API. +* Rewrite the README for the controlled 2.0 API and Flutter 3.47 requirements. +* Animate the selected-tab indicator and expose configurable duration and + curve options. +* Add opt-in adaptive tab and background widths based on each tab's content. +* Center adaptive tabs by default and support configurable adaptive alignment. +* Separate adaptive and equal-width layout components for maintainability. +* Scope selection and adaptive-geometry updates with `ValueListenable` APIs. +* Refresh every README usage section with a dedicated animated demo and + reproducible Maestro flow. -* Initial Release +# 1.5.1 -# 0.0.1+2 +* Add new parameters `isInnerShadowEnable` and `isShadowEnable` to enable or turn off the shadow (#18) Thanks to @ysak-y -* Update a missed in readme +# 1.5.0 -# 0.0.2 +* Breaking change: remove labels and icons replaced with dataTabs +* support custom counter-widget to fix #16 +* update example project to support AGP >= 8 +* improve widget usage and fix warning from linter -* Support Gradient for Selected and Unselected Background Color +# 1.4.1 -# 0.0.2+1 +* Update docs -* Fix build issue +# 1.4.0 -# 0.0.3 +* Add support dart 3 +* Set text style is not required, use TextTheme.of(context).bodyMedium as default style +* Improve performance, use valueNotifier to update selected index instead of setState -* Change initialLabelIndex to initialIndex -* Support change selected index programmatically +# 1.3.0 -# 0.0.3+1 +* Expose iconSize +* Fix: Invisible a right margin in the button tab if only the icon is set. thanks to @kaedeee -* Fix formatting issue +# 1.2.0 -# 0.0.3+2 +* Update kotlin version on Example +* Reformat library +* Update README file -* Fix missing formatting code +# 1.1.7 -# 0.0.4+3 +* Remove initialIndex replace with selectedIndex +* Set selectedIndex required +* Fix issue double when tap button +* Fix issue update selectedIndex value, selected button not updated -* Add support disable Horizontal Scroll -* Update deprecated Widget +# 1.1.6 -# 0.0.5+4 +* Update selected if selectedIndex changed -* Update Documentation +# 1.1.5 -# 0.0.6+5 +* Disable shadow support -* Remove log on builder +# 1.1.4 -# 1.0.0 +* Fix duplicate button -* Migrate to Null Safety +# 1.1.3 -# 1.1.0 +* Fix margin issue on last item -* Support margin for selected tab +# 1.1.2 + +* Fix issue null safety when icons is null # 1.1.1 * Remove print statement -# 1.1.2 +# 1.1.0 -* Fix issue null safety when icons is null +* Support margin for selected tab -# 1.1.3 +# 1.0.0 -* Fix margin issue on last item +* Migrate to Null Safety -# 1.1.4 +# 0.0.6+5 -* Fix duplicate button +* Remove log on builder -# 1.1.5 +# 0.0.5+4 -* Disable shadow support +* Update Documentation -# 1.1.6 +# 0.0.4+3 -* Update selected if selectedIndex changed +* Add support disable Horizontal Scroll +* Update deprecated Widget -# 1.1.7 +# 0.0.3+2 -* Remove initialIndex replace with selectedIndex -* Set selectedIndex required -* Fix issue double when tap button -* Fix issue update selectedIndex value, selected button not updated +* Fix missing formatting code -# 1.2.0 +# 0.0.3+1 -* Update kotlin version on Example -* Reformat library -* Update README file +* Fix formatting issue -# 1.3.0 +# 0.0.3 -* Expose iconSize -* Fix: Invisible a right margin in the button tab if only the icon is set. thanks to @kaedeee +* Change initialLabelIndex to initialIndex +* Support change selected index programmatically -# 1.4.0 +# 0.0.2+1 -* Add support dart 3 -* Set text style is not required, use Theme.of(context).textTheme.bodyMedium as default style -* Improve performance, use valueNotifier to update selected index instead of setState +* Fix build issue -# 1.4.1 +# 0.0.2 -* Update docs +* Support Gradient for Selected and Unselected Background Color -# 1.5.0 +# 0.0.1+2 -* Breaking change: remove labels and icons replaced with dataTabs -* support custom counter-widget to fix #16 -* update example project to support AGP >= 8 -* improve widget usage and fix warning from linter +* Update a missed in readme -# 1.5.1 +# 0.0.1 -* Add new parameters `isInnerShadowEnable` and `isShadowEnable` to enable or turn off the shadow (#18) Thanks to @ysak-y +* Initial Release diff --git a/README.md b/README.md index 4d56e04..ab4fc07 100644 --- a/README.md +++ b/README.md @@ -1,236 +1,400 @@ -# Flutter Tab Toggle +# Flutter Toggle Tab [![pub package](https://img.shields.io/pub/v/flutter_toggle_tab.svg)](https://pub.dev/packages/flutter_toggle_tab) -A Beautiful and Simple Toggle Tab widget. -It can be fully customized with desired icons, width, colors, text, -corner radius, etc. It also maintains selection state. +A controlled, responsive toggle-tab widget for Flutter. It supports text, +icons, counter widgets, equal or adaptive widths, gradients, custom text +styles, margins, shadows, and animated selection. -## Getting Started +## Interactive demos -In the `pubspec.yaml` of your flutter project, add the following dependency: +Every usage section below includes a GIF captured from its reproducible +Maestro flow. The demos show the current controlled-selection API, animated +indicator, adaptive widths, custom counter, icons, selected margin, and +programmatic selection. -```yaml -dependencies: - flutter_toggle_tab: "^latestVersion" +## Requirements + +- Flutter 3.47 or newer +- Dart 3.13 or newer +- `material_ui` 1.3 or newer + +Version 2.0 uses Flutter's standalone Material package. Applications using +legacy `package:flutter/material.dart` imports should complete the Flutter 3.47 +Material migration before upgrading this package. + +## Installation + +Add the package and the standalone Material package: + +```sh +flutter pub add flutter_toggle_tab material_ui ``` -Import it: +Then import both packages where they are used: ```dart import 'package:flutter_toggle_tab/flutter_toggle_tab.dart'; +import 'package:material_ui/material_ui.dart'; +``` + +## Usage + +### Basic Tab Toggle + +`FlutterToggleTab` is controlled by `selectedIndex`. A `ValueNotifier` keeps +selection updates local to the toggle instead of rebuilding the complete page. + +```dart +class CategoryToggle extends StatefulWidget { + const CategoryToggle({super.key}); + + @override + State createState() => _CategoryToggleState(); +} + +class _CategoryToggleState extends State { + final selectedIndex = ValueNotifier(0); + + final tabs = [ + DataTab(title: 'Popular'), + DataTab(title: 'Recent'), + DataTab(title: 'Saved'), + ]; + + @override + void dispose() { + selectedIndex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: selectedIndex, + builder: (context, currentIndex, _) => FlutterToggleTab( + width: 90, + height: 50, + borderRadius: 30, + dataTabs: tabs, + selectedIndex: currentIndex, + selectedBackgroundColors: const [Colors.blue, Colors.blueAccent], + selectedTextStyle: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + unSelectedTextStyle: const TextStyle( + color: Colors.black87, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + selectedLabelIndex: (index) => selectedIndex.value = index, + animationDuration: const Duration(milliseconds: 400), + animationCurve: Curves.easeInOutCubic, + isScroll: false, + ), + ); + } +} +``` + +![Basic Tab Toggle](gifs/basic_toggle.gif) + +The remaining examples assume `selectedIndex` is a `ValueNotifier` owned and +disposed by the surrounding `State`, as demonstrated in the basic example. +Only the matching `ValueListenableBuilder` subtree rebuilds when its value +changes. + +### Basic Tab Toggle with Counter Widget + +Any widget can be placed after a tab title with `counterWidget`. + +```dart +final tabs = [ + DataTab( + title: 'Inbox', + counterWidget: const Badge(label: Text('3')), + ), + DataTab(title: 'Archived'), +]; + +ValueListenableBuilder( + valueListenable: selectedIndex, + builder: (context, currentIndex, _) => FlutterToggleTab( + dataTabs: tabs, + selectedIndex: currentIndex, + selectedLabelIndex: (index) => selectedIndex.value = index, + ), +); ``` -## Usage Examples - -### Basic tab/toggle switch - -![image](https://github.com/ukieTux/flutter_toggle_tab/blob/master/gifs/basic.gif?raw=true) - - -``` dart - -// Here default theme colors are used for activeBgColor, activeFgColor, inactiveBgColor and inactiveFgColor -FlutterToggleTab -( - width: 90, // width in percent - borderRadius: 30, - height: 50, - selectedIndex: _tabTextIndexSelected, - selectedBackgroundColors: [Colors.blue, Colors.blueAccent], - selectedTextStyle: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w700), - unSelectedTextStyle: TextStyle( - color: Colors.black87, - fontSize: 14, - fontWeight: FontWeight.w500), - dataTabs: _listTextTabToggle, - selectedLabelIndex: (index) { - setState(() { - _tabTextIndexSelected = index; - }); - }, - isScroll:false, -) +![Basic Tab Toggle with Counter Widget](gifs/basic_with_counter.gif) + +### Text With Icon + +```dart +final tabs = [ + DataTab(title: 'Male', icon: Icons.person), + DataTab(title: 'Female', icon: Icons.pregnant_woman), +]; + +ValueListenableBuilder( + valueListenable: selectedIndex, + builder: (context, currentIndex, _) => FlutterToggleTab( + width: 50, + borderRadius: 15, + dataTabs: tabs, + selectedIndex: currentIndex, + selectedLabelIndex: (index) => selectedIndex.value = index, + ), +); ``` +![Text With Icon](gifs/with_icon.gif) ---- - -### Basic tab/toggle switch with Custom Counter Widget - -![image](https://github.com/ukieTux/flutter_toggle_tab/blob/master/gifs/basic_with_counter.gif?raw=true) - -``` dart - -// Here default theme colors are used for activeBgColor, activeFgColor, inactiveBgColor and inactiveFgColor -FlutterToggleTab -( - width: 90, // width in percent - borderRadius: 30, - height: 50, - selectedIndex: _tabTextIndexSelected, - selectedBackgroundColors: [Colors.blue, Colors.blueAccent], - selectedTextStyle: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w700), - unSelectedTextStyle: TextStyle( - color: Colors.black87, - fontSize: 14, - fontWeight: FontWeight.w500), - dataTabs: _listTextTabToggleCounter, - selectedLabelIndex: (index) { - setState(() { - _tabTextIndexSelected = index; - }); - }, - isScroll:false, -) +### With Icon Only and Implement margin for selected item + +```dart +final tabs = [ + DataTab(icon: Icons.person), + DataTab(icon: Icons.pregnant_woman), +]; + +ValueListenableBuilder( + valueListenable: selectedIndex, + builder: (context, currentIndex, _) => FlutterToggleTab( + width: 40, + borderRadius: 15, + dataTabs: tabs, + iconSize: 40, + selectedIndex: currentIndex, + marginSelected: const EdgeInsets.all(4), + selectedLabelIndex: (index) => selectedIndex.value = index, + ), +); ``` ---- +![Icon-only tabs with selected-item margin](gifs/with_icon_only.gif) + +### Update selected programmatically +Because selection is controlled, change `selectedIndex` from any event: -### Basic tab/toggle switch with Icon +```dart +ValueListenableBuilder( + valueListenable: selectedIndex, + builder: (context, currentIndex, _) => Column( + children: [ + FlutterToggleTab( + dataTabs: tabs, + selectedIndex: currentIndex, + selectedLabelIndex: (index) => selectedIndex.value = index, + ), + TextButton( + onPressed: () => selectedIndex.value = 2, + child: const Text('Select the third tab'), + ), + ], + ), +); +``` + +![Update selected programmatically](gifs/programmatic_selection.gif) -![image](https://github.com/ukieTux/flutter_toggle_tab/blob/master/gifs/with_icon_only.gif?raw=true) +### Selection animation + +The selected background slides to the new tab whenever `selectedIndex` +changes. The animation is enabled by default and can be customized: ```dart -FlutterToggleTab -( - width: 50, - borderRadius: 15, - selectedTextStyle: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w600), - unSelectedTextStyle: TextStyle( - color: Colors.blue, - fontSize: 14, - fontWeight: FontWeight.w400), - dataTabs: _listGenderText, - selectedIndex: _tabTextIconIndexSelected, - selectedLabelIndex: (index) { - setState(() { - _tabTextIconIndexSelected = index; - }); - }, -) +ValueListenableBuilder( + valueListenable: selectedIndex, + builder: (context, currentIndex, _) => FlutterToggleTab( + dataTabs: tabs, + selectedIndex: currentIndex, + selectedLabelIndex: (index) => selectedIndex.value = index, + animationDuration: const Duration(milliseconds: 400), + animationCurve: Curves.easeOutBack, + ), +); ``` ---- -### Basic tab/toggle switch with Icon Only and add margin on selected item +Use `Duration.zero` when the selection should change without motion. + +#### How indicator positioning works +The indicator calculation follows the selected width mode. -![image](https://user-images.githubusercontent.com/1531684/170535796-814f380d-2640-4489-8598-97f5a24398fd.png) +In the default equal-width mode, Flutter's horizontal `Alignment` coordinate +runs from `-1` at the left edge, through `0` at the center, to `1` at the right +edge. Because every tab has the same width, the selected index can be mapped +directly onto that range: ```dart -FlutterToggleTab -( - width: 40, - borderRadius: 15, - selectedIndex: _tabIconIndexSelected, - selectedTextStyle: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w600), - unSelectedTextStyle: TextStyle( - color: Colors.grey, - fontSize: 14, - fontWeight: FontWeight.w400), - dataTabs: _listIconTabToggle, - selectedLabelIndex: (index) { - setState(() { - _tabIconIndexSelected = index; - }); - }, - marginSelected: EdgeInsets.symmetric(horizontal: 4,vertical:4), -) +final indicatorAlignment = Alignment( + -1 + (2 * indicatorIndex / (dataTabs.length - 1)), + 0, +); +``` + +The calculation first converts `indicatorIndex` to a value from `0` to `1`, +then scales it to `0` through `2`, and finally subtracts `1` to produce the +required `-1` through `1` alignment. For three tabs this gives: + +| Tab index | Calculation | Horizontal alignment | +|-----------|--------------------------|----------------------| +| `0` | `-1 + (2 * 0 / 2)` | `-1` (left) | +| `1` | `-1 + (2 * 1 / 2)` | `0` (center) | +| `2` | `-1 + (2 * 2 / 2)` | `1` (right) | + +At least two tabs are required, so `dataTabs.length - 1` cannot be zero. + +Adaptive-width mode measures every rendered tab because indexes are no longer +evenly spaced. For selected tab `i`, its geometry is: + +```text +indicatorLeft = width[0] + width[1] + ... + width[i - 1] +indicatorWidth = width[i] +``` + +If that position needs to be expressed as an `Alignment.x` value, account for +the indicator width because `Align` moves within the remaining free space: + +```text +adaptiveAlignmentX = -1 + (2 * indicatorLeft + / (totalWidth - indicatorWidth)) ``` ---- +The implementation uses `AnimatedPositioned` with `indicatorLeft` and +`indicatorWidth` directly. This avoids rounding the measured geometry and lets +the indicator animate its position and width simultaneously. An out-of-range +`selectedIndex` uses the first geometry while the indicator is hidden. + +### Adaptive Tab Width -### Update selected tab Programmatically +Enable `isAdaptiveWidth` when each tab should use the width required by its +content. For example, the second tab below is wider than the first one: ```dart -FlutterToggleTab -( - width: 90, - borderRadius: 15, - selectedIndex: _tabSelectedIndexSelected, - selectedTextStyle: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w600), - unSelectedTextStyle: TextStyle( - color: Colors.grey, - fontSize: 14, - fontWeight: FontWeight.w400), - dataTabs: _listTextSelectedToggle, - selectedLabelIndex: (index) { - setState(() { - _tabSelectedIndexSelected = index; - }); - }, -) +ValueListenableBuilder( + valueListenable: selectedIndex, + builder: (context, currentIndex, _) => FlutterToggleTab( + dataTabs: [ + DataTab(title: 'AAAA'), + DataTab(title: 'AAAAAAAAAAAAAAA'), + ], + selectedIndex: currentIndex, + selectedLabelIndex: (index) => selectedIndex.value = index, + isAdaptiveWidth: true, + adaptiveTabPadding: const EdgeInsets.symmetric(horizontal: 20), + adaptiveTabAlignment: AlignmentDirectional.centerStart, + ), +); +``` + +The indicator animates both its position and width. The unselected background +also follows the combined adaptive tab width instead of filling unused space. +When that background is wider than the control, the complete tab surface and +indicator scroll horizontally if `isScroll` is enabled. + +The complete adaptive tab surface is centered by default when its combined +width is smaller than the control. Set `adaptiveTabAlignment` to another +`AlignmentGeometry`, such as `AlignmentDirectional.centerStart` or +`AlignmentDirectional.centerEnd`, to place it at either logical edge. The +alignment has no visible effect while the content is wider than the viewport +and scrolling is required. + +![Adaptive-width tabs changing selection](gifs/adaptive_layout.gif) + +To replay the interactions used by the README demos on a running example app: + +```sh +maestro test .maestro ``` -## Available Parameters - -| Param | isRequired | -|---------------------------------------------------------------------------------------------|------------| -| **List** dataTabs | Yes | -| **Function(int)** selectedLabelIndex | Yes | -| **TextStyle** selectedTextStyle ***default*:Theme.of(context).textTheme.bodyMedium,** | No | -| **TextStyle** unSelectedTextStyle ***default*:Theme.of(context).textTheme.bodyMedium,** | No | -| **int** selectedIndex (listener for index selected) *see on example* | Yes | -| **double** width (in Percent of width Screen) ***default*: 100** | No | -| **double** height (double) ***default*: 45** | No | -| **double** iconSize | No | -| **double** borderRadius (double) ***default* 30** | No | -| **List** selectedBackgroundColors ***default* : [ Theme.of(context).primaryColor]** | No | -| **List** unSelectedBackgroundColors ***default* [ Color(0xffe0e0e0)]** | No | -| **Alignment** begin ***default* : Alignment.topCenter** | No | -| **Alignment** end ***default* : Alignment.bottomCenter** | No | -| **bool** isScroll ***default* : true** | No | -| **EdgeInsets** marginSelected ***default* : EdgeInsets.zero** | No | -| **bool** isShadowEnable ***default* : true** | No | -| **bool** isInnerShadowEnable ***default* : true** | No | - ---- - - - - - - -
- - LinkedIn - - - - - -
+The six flow files are named after their README sections: +`readme_basic.yaml`, `readme_adaptive.yaml`, `readme_counter.yaml`, +`readme_text_icon.yaml`, `readme_icon_only.yaml`, and +`readme_programmatic.yaml`. + +The complete runnable examples are available in +[`example/lib/main.dart`](https://github.com/Lzyct/flutter_toggle_tab/blob/master/example/lib/main.dart). +Every usage shown above has a corresponding widget test in +[`example/test/widget_test.dart`](https://github.com/Lzyct/flutter_toggle_tab/blob/master/example/test/widget_test.dart). + +## API reference + +### `FlutterToggleTab` + +| Property | Type | Default | Description | +|------------------------------|----------------------|--------------------|-----------------------------------------------------------------------------------| +| `dataTabs` | `List` | required | Tabs in display order. At least two are required. | +| `selectedIndex` | `int` | required | Zero-based selected index. An out-of-range value leaves every tab unselected. | +| `selectedLabelIndex` | `ValueChanged` | required | Called when a tab is pressed. | +| `width` | `double?` | `100` | Percentage of screen width, constrained by the parent. | +| `height` | `double?` | `45` | Control height in logical pixels. | +| `iconSize` | `double?` | icon theme | Icon size in logical pixels. | +| `borderRadius` | `double?` | `30` | Radius of the control and selected tab. | +| `selectedBackgroundColors` | `List?` | theme primary | Selected-tab gradient colors. | +| `unSelectedBackgroundColors` | `List?` | light grey | Control background gradient colors. | +| `selectedTextStyle` | `TextStyle?` | `bodyMedium` | Selected-tab text style. | +| `unSelectedTextStyle` | `TextStyle?` | faded `bodyMedium` | Unselected-tab text style. | +| `begin` | `Alignment?` | `topCenter` | Gradient start alignment. | +| `end` | `Alignment?` | `bottomCenter` | Gradient end alignment. | +| `isScroll` | `bool` | `true` | Enables bouncing horizontal scroll physics. | +| `marginSelected` | `EdgeInsets?` | `EdgeInsets.zero` | Insets applied to the selected tab. | +| `isShadowEnable` | `bool` | `true` | Shows the outer control shadow. | +| `isInnerShadowEnable` | `bool` | `true` | Shows the selected-tab shadow. | +| `animationDuration` | `Duration` | `250ms` | Duration of the sliding selected-tab indicator. | +| `animationCurve` | `Curve` | `easeInOutCubic` | Curve used by the selected-tab indicator. | +| `isAdaptiveWidth` | `bool` | `false` | Sizes every tab from its content instead of dividing the available width. | +| `adaptiveTabPadding` | `EdgeInsetsGeometry` | horizontal `16` | Padding around every tab in adaptive-width mode. | +| `adaptiveTabAlignment` | `AlignmentGeometry` | `Alignment.center` | Positions the complete adaptive tab surface when it is narrower than the control. | + +### `DataTab` + +| Property | Type | Description | +|-----------------|-------------|-------------------------------------------------------------------------| +| `title` | `String?` | Text displayed in the tab. | +| `icon` | `IconData?` | Icon displayed before the title. | +| `counterWidget` | `Widget?` | Custom widget displayed after the title. | +| `isSelected` | `bool` | Legacy compatibility value. Selection is controlled by `selectedIndex`. | + +Gradient lists may be empty or contain one color. Empty lists use the default +colors, while a single color is duplicated automatically. + +## Migrating from 1.x + +Version 2.0 contains intentional breaking changes: + +1. Upgrade to Flutter 3.47 or newer and Dart 3.13 or newer. +2. Migrate Material imports to `package:material_ui/material_ui.dart`. +3. Keep selection state in the parent and pass it through `selectedIndex`. +4. Update `selectedIndex` when `selectedLabelIndex` reports a pressed tab. +5. Do not rely on `DataTab.isSelected`; the widget no longer mutates caller + data. + +## License + +Flutter Toggle Tab is available under the +[MIT License](https://github.com/Lzyct/flutter_toggle_tab/blob/master/LICENSE). +

+

Buy me coffee if you love my works ☕️

buymeacoffe      - ko-fi + ko-fi      paypal - + saweria

-

diff --git a/analysis_options.yaml b/analysis_options.yaml index 633393c..e6e141b 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -5,9 +5,13 @@ analyzer: - '**.g.dart' - '**.mocks.dart' - '**/core/localization/generated/**' + - build/** errors: invalid_annotation_target: ignore linter: rules: sort_pub_dependencies: false avoid_dynamic_calls: false + public_member_api_docs: true + unnecessary_type_name_in_constructor: false + use_primary_constructors: false diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 633393c..6ec92a1 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -5,9 +5,14 @@ analyzer: - '**.g.dart' - '**.mocks.dart' - '**/core/localization/generated/**' + - build/** + - android/** + - ios/** errors: invalid_annotation_target: ignore linter: rules: sort_pub_dependencies: false avoid_dynamic_calls: false + unnecessary_type_name_in_constructor: false + use_primary_constructors: false diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle deleted file mode 100644 index dc123ab..0000000 --- a/example/android/app/build.gradle +++ /dev/null @@ -1,73 +0,0 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -android { - compileSdkVersion 35 - namespace 'com.example.example' - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - lintOptions { - disable 'InvalidPackage' - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8 - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.example.example" - minSdkVersion flutter.minSdkVersion - targetSdkVersion 35 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig signingConfigs.debug - } - } -} - -flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/example/android/app/build.gradle.kts b/example/android/app/build.gradle.kts new file mode 100644 index 0000000..c23d1d7 --- /dev/null +++ b/example/android/app/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + id("com.android.application") + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId = "com.example.example" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 7d8290f..1c30654 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,12 +1,7 @@ - - + - - @@ -44,4 +30,10 @@ android:name="flutterEmbedding" android:value="2" /> + + + + + + diff --git a/example/android/build.gradle b/example/android/build.gradle deleted file mode 100644 index 87ddfb7..0000000 --- a/example/android/build.gradle +++ /dev/null @@ -1,31 +0,0 @@ -buildscript { - ext.kotlin_version = '1.8.0' - repositories { - google() - jcenter() - } - - dependencies { - classpath 'com.android.tools.build:gradle:8.7.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -allprojects { - repositories { - google() - jcenter() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') -} - -tasks.register("clean", Delete) { - delete rootProject.buildDir -} diff --git a/example/android/build.gradle.kts b/example/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 38c8d45..fe9cc42 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,4 +1,5 @@ -org.gradle.jvmargs=-Xmx1536M -android.enableR8=true +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true +# These flags keep AGP 9 compatible with Flutter's current Gradle integration. +android.newDsl=false +android.builtInKotlin=false diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index d955bd4..215f49b 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle deleted file mode 100644 index 44e62bc..0000000 --- a/example/android/settings.gradle +++ /dev/null @@ -1,11 +0,0 @@ -include ':app' - -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() - -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } - -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/example/android/settings.gradle.kts b/example/android/settings.gradle.kts new file mode 100644 index 0000000..b28021a --- /dev/null +++ b/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/example/ios/.gitignore b/example/ios/.gitignore index e96ef60..151026b 100644 --- a/example/ios/.gitignore +++ b/example/ios/.gitignore @@ -18,6 +18,7 @@ Flutter/App.framework Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig +Flutter/ephemeral/ Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 4f8d4d2..ab8e063 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 11.0 diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig index b2f5fae..592ceee 100644 --- a/example/ios/Flutter/Debug.xcconfig +++ b/example/ios/Flutter/Debug.xcconfig @@ -1,3 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig index 88c2914..592ceee 100644 --- a/example/ios/Flutter/Release.xcconfig +++ b/example/ios/Flutter/Release.xcconfig @@ -1,3 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/example/ios/Podfile b/example/ios/Podfile deleted file mode 100644 index dc4d105..0000000 --- a/example/ios/Podfile +++ /dev/null @@ -1,10 +0,0 @@ -# Uncomment the next line to define a global platform for your project -platform :ios, '11.0' - -target 'Runner' do - # Comment the next line if you don't want to use dynamic frameworks - use_frameworks! - - # Pods for Runner - -end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock deleted file mode 100644 index d137f25..0000000 --- a/example/ios/Podfile.lock +++ /dev/null @@ -1,3 +0,0 @@ -PODFILE CHECKSUM: 2fb48e2e296f814d39edd589c5ae762824b0f3c4 - -COCOAPODS: 1.12.1 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 912ffbb..494db81 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 476C954125A420AFF39D75F5 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 689B87B09B98ECABEA77D643 /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -33,12 +32,8 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 406DE257F7CE9EF7C88F9F43 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 5B5631CD1FF0B1746072C646 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 689B87B09B98ECABEA77D643 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 76BCED16BEBAB6E0FC2B2D6E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -54,24 +49,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 476C954125A420AFF39D75F5 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 2A3B7612DBD8A07A32B0FED4 /* Pods */ = { - isa = PBXGroup; - children = ( - 5B5631CD1FF0B1746072C646 /* Pods-Runner.debug.xcconfig */, - 76BCED16BEBAB6E0FC2B2D6E /* Pods-Runner.release.xcconfig */, - 406DE257F7CE9EF7C88F9F43 /* Pods-Runner.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -89,8 +72,6 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, - 2A3B7612DBD8A07A32B0FED4 /* Pods */, - DEC8D26BE4BCB68E4F428C48 /* Frameworks */, ); sourceTree = ""; }; @@ -117,14 +98,6 @@ path = Runner; sourceTree = ""; }; - DEC8D26BE4BCB68E4F428C48 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 689B87B09B98ECABEA77D643 /* Pods_Runner.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -132,7 +105,6 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - EDF93C6EBC476B2592A877F6 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -155,7 +127,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -228,28 +200,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - EDF93C6EBC476B2592A877F6 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -325,7 +275,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -407,7 +357,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -456,7 +406,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3db53b6..fc5ae03 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ @@ -45,11 +46,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcworkspace/contents.xcworkspacedata index 21a3cc1..1d526a1 100644 --- a/example/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,7 +4,4 @@ - - diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 70693e4..c30b367 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -1,13 +1,16 @@ -import UIKit import Flutter +import UIKit -@UIApplicationMain -@objc class AppDelegate: FlutterAppDelegate { +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 4f68a2c..288ad41 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -22,6 +24,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +66,5 @@ UIViewControllerBasedStatusBarAppearance - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/example/lib/main.dart b/example/lib/main.dart index 2520c1e..2805108 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,9 +1,9 @@ -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_toggle_tab/flutter_toggle_tab.dart'; +import 'package:material_ui/material_ui.dart'; void main() { - runApp(MyApp()); + runApp(const MyApp()); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, @@ -12,7 +12,25 @@ void main() { ); } +abstract final class ExampleKeys { + static const basicToggle = Key('basic-toggle'); + static const basicResult = Key('basic-result'); + static const counterToggle = Key('counter-toggle'); + static const counterResult = Key('counter-result'); + static const textWithIconToggle = Key('text-with-icon-toggle'); + static const textWithIconResult = Key('text-with-icon-result'); + static const iconOnlyToggle = Key('icon-only-toggle'); + static const iconOnlyResult = Key('icon-only-result'); + static const programmaticToggle = Key('programmatic-toggle'); + static const programmaticResult = Key('programmatic-result'); + static const selectProgrammatically = Key('select-programmatically'); + static const adaptiveToggle = Key('adaptive-toggle'); + static const adaptiveResult = Key('adaptive-result'); +} + class MyApp extends StatelessWidget { + const MyApp({super.key}); + // This widget is the root of your application. @override Widget build(BuildContext context) { @@ -43,7 +61,7 @@ class MyHomePage extends StatefulWidget { const MyHomePage({super.key}); @override - _MyHomePageState createState() => _MyHomePageState(); + State createState() => _MyHomePageState(); } class _MyHomePageState extends State { @@ -52,12 +70,13 @@ class _MyHomePageState extends State { final ValueNotifier _tabIndexTextWithIcon = ValueNotifier(0); final ValueNotifier _tabIndexIconButton = ValueNotifier(0); final ValueNotifier _tabIndexUpdateProgrammatically = ValueNotifier(0); + final ValueNotifier _tabIndexAdaptive = ValueNotifier(0); List get _listTextTabToggle => [ - DataTab(title: "Tab A (10)"), - DataTab(title: "Tab B (5)"), - DataTab(title: "Tab C (19)"), - ]; + DataTab(title: "Tab A (10)"), + DataTab(title: "Tab B (5)"), + DataTab(title: "Tab C (19)"), + ]; late final _listTextTabToggleCounter = [ DataTab( @@ -65,15 +84,15 @@ class _MyHomePageState extends State { counterWidget: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: DecoratedBox( - decoration: - const BoxDecoration(color: Colors.red, shape: BoxShape.circle), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), child: Padding( padding: const EdgeInsets.all(6), child: Text( "1", - style: Theme.of(context) - .textTheme - .labelSmall + style: TextTheme.of(context).labelSmall ?.copyWith(color: Colors.white), ), ), @@ -85,34 +104,49 @@ class _MyHomePageState extends State { ]; List get _listTextSelectedToggle => [ - DataTab(title: "Select A (10)"), - DataTab(title: "Select B (5)"), - DataTab(title: "Select C (19)"), - ]; + DataTab(title: "Select A (10)"), + DataTab(title: "Select B (5)"), + DataTab(title: "Select C (19)"), + ]; List get _listIconTabToggle => [ - DataTab(icon: Icons.person), - DataTab(icon: Icons.pregnant_woman), - ]; + DataTab(icon: Icons.person), + DataTab(icon: Icons.pregnant_woman), + ]; List get _listGenderText => [ - DataTab(title: "Male", icon: Icons.person), - DataTab(title: "Female", icon: Icons.pregnant_woman), - ]; + DataTab(title: "Male", icon: Icons.person), + DataTab(title: "Female", icon: Icons.pregnant_woman), + ]; + + List get _listAdaptiveTabs => [ + DataTab(title: 'AAAA'), + DataTab(title: 'AAAAAAAAAAAAAAA'), + ]; + + @override + void dispose() { + _tabIndexBasicToggle.dispose(); + _tabIndexBasicToggleCounter.dispose(); + _tabIndexTextWithIcon.dispose(); + _tabIndexIconButton.dispose(); + _tabIndexUpdateProgrammatically.dispose(); + _tabIndexAdaptive.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text("Flutter Tab Toggle"), - elevation: 0, - ), + appBar: AppBar(title: const Text("Flutter Tab Toggle"), elevation: 0), body: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 36, horizontal: 8), child: Column( children: [ _basicTabToggle(), ..._divider(), + _adaptiveTabToggle(), + ..._divider(), _basicTabToggleWithCounter(), ..._divider(), _textWithIcon(), @@ -128,284 +162,309 @@ class _MyHomePageState extends State { } List _divider() => [ - SizedBox(height: heightInPercent(3, context)), - const Divider(thickness: 2), - SizedBox(height: heightInPercent(3, context)), - ]; + SizedBox(height: heightInPercent(3, context)), + const Divider(thickness: 2), + SizedBox(height: heightInPercent(3, context)), + ]; Widget _basicTabToggle() => Column( - children: [ - /// Basic Toggle Sample - SizedBox(height: heightInPercent(3, context)), - const Text( - "Basic Tab Toggle", - style: TextStyle(fontSize: 20, fontStyle: FontStyle.italic), - ), - SizedBox(height: heightInPercent(3, context)), - ValueListenableBuilder( - valueListenable: _tabIndexBasicToggle, - builder: (context, currentIndex, _) { - return FlutterToggleTab( - // width in percent - width: 90, - borderRadius: 30, - height: 50, - selectedIndex: currentIndex, - selectedBackgroundColors: const [ - Colors.blue, - Colors.blueAccent, - ], + children: [ + /// Basic Toggle Sample + SizedBox(height: heightInPercent(3, context)), + const Text( + "Basic Tab Toggle", + style: TextStyle(fontSize: 20, fontStyle: FontStyle.italic), + ), + SizedBox(height: heightInPercent(3, context)), + ValueListenableBuilder( + valueListenable: _tabIndexBasicToggle, + builder: (context, currentIndex, _) { + return FlutterToggleTab( + key: ExampleKeys.basicToggle, + // width in percent + width: 90, + borderRadius: 30, + height: 50, + selectedIndex: currentIndex, + selectedBackgroundColors: const [Colors.blue, Colors.blueAccent], + selectedTextStyle: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + unSelectedTextStyle: const TextStyle( + color: Colors.black87, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + animationDuration: const Duration(seconds: 5), + dataTabs: _listTextTabToggle, + selectedLabelIndex: (index) => _tabIndexBasicToggle.value = index, + isScroll: false, + ); + }, + ), + TextButton( + onPressed: () => _tabIndexBasicToggle.value = 2, + child: const Text("Change to Index 2"), + ), + SizedBox(height: heightInPercent(3, context)), + ValueListenableBuilder( + valueListenable: _tabIndexBasicToggle, + builder: (context, currentIndex, _) => Text( + "Index selected : $currentIndex", + key: ExampleKeys.basicResult, + style: const TextStyle(fontSize: 20), + ), + ), + ], + ); + + Widget _basicTabToggleWithCounter() => Column( + children: [ + /// Basic Toggle Sample + SizedBox(height: heightInPercent(3, context)), + const Text( + "Basic Tab Toggle with Counter Widget", + style: TextStyle(fontSize: 20, fontStyle: FontStyle.italic), + ), + SizedBox(height: heightInPercent(3, context)), + ValueListenableBuilder( + valueListenable: _tabIndexBasicToggleCounter, + builder: (context, currentIndex, _) { + return FlutterToggleTab( + key: ExampleKeys.counterToggle, + // width in percent + width: 90, + borderRadius: 30, + height: 50, + selectedIndex: currentIndex, + selectedBackgroundColors: const [Colors.blue, Colors.blueAccent], + selectedTextStyle: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + unSelectedTextStyle: const TextStyle( + color: Colors.black87, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + dataTabs: _listTextTabToggleCounter, + selectedLabelIndex: (index) => + _tabIndexBasicToggleCounter.value = index, + isScroll: false, + ); + }, + ), + TextButton( + onPressed: () => _tabIndexBasicToggleCounter.value = 0, + child: const Text("Change to Index 0"), + ), + SizedBox(height: heightInPercent(3, context)), + ValueListenableBuilder( + valueListenable: _tabIndexBasicToggleCounter, + builder: (context, currentIndex, _) => Text( + "Index selected : $currentIndex", + key: ExampleKeys.counterResult, + style: const TextStyle(fontSize: 20), + ), + ), + ], + ); + + Widget _adaptiveTabToggle() => Column( + children: [ + const Text( + 'Adaptive Tab Width', + style: TextStyle(fontSize: 20, fontStyle: FontStyle.italic), + ), + SizedBox(height: heightInPercent(3, context)), + ValueListenableBuilder( + valueListenable: _tabIndexAdaptive, + builder: (context, currentIndex, _) => FlutterToggleTab( + key: ExampleKeys.adaptiveToggle, + width: 90, + dataTabs: _listAdaptiveTabs, + selectedIndex: currentIndex, + selectedLabelIndex: (index) => _tabIndexAdaptive.value = index, + isAdaptiveWidth: true, + ), + ), + ValueListenableBuilder( + valueListenable: _tabIndexAdaptive, + builder: (context, currentIndex, _) => Text( + 'Index selected : $currentIndex', + key: ExampleKeys.adaptiveResult, + style: const TextStyle(fontSize: 20), + ), + ), + ], + ); + + Widget _textWithIcon() => Column( + children: [ + /// Text with icon sample + SizedBox(height: heightInPercent(3, context)), + const Text( + "Text With Icon", + style: TextStyle(fontSize: 20, fontStyle: FontStyle.italic), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Select your sex : ", style: TextStyle(fontSize: 20)), + ValueListenableBuilder( + valueListenable: _tabIndexTextWithIcon, + builder: (context, currentIndex, _) => FlutterToggleTab( + key: ExampleKeys.textWithIconToggle, + width: 50, + borderRadius: 15, selectedTextStyle: const TextStyle( color: Colors.white, fontSize: 18, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, ), unSelectedTextStyle: const TextStyle( - color: Colors.black87, + color: Colors.blue, fontSize: 14, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w400, ), - dataTabs: _listTextTabToggle, + dataTabs: _listGenderText, + selectedIndex: currentIndex, selectedLabelIndex: (index) => - _tabIndexBasicToggle.value = index, - isScroll: false, - ); - }, - ), - TextButton( - onPressed: () => _tabIndexBasicToggle.value = 2, - child: const Text("Change to Index 2"), - ), - SizedBox(height: heightInPercent(3, context)), - ValueListenableBuilder( - valueListenable: _tabIndexBasicToggle, - builder: (context, currentIndex, _) => Text( - "Index selected : $currentIndex", - style: const TextStyle(fontSize: 20), + _tabIndexTextWithIcon.value = index, + ), ), - ), - ], - ); + ], + ), + ), - Widget _basicTabToggleWithCounter() => Column( - children: [ - /// Basic Toggle Sample - SizedBox(height: heightInPercent(3, context)), - const Text( - "Basic Tab Toggle with Counter Widget", - style: TextStyle(fontSize: 20, fontStyle: FontStyle.italic), - ), - SizedBox(height: heightInPercent(3, context)), - ValueListenableBuilder( - valueListenable: _tabIndexBasicToggleCounter, - builder: (context, currentIndex, _) { - return FlutterToggleTab( - // width in percent - width: 90, - borderRadius: 30, - height: 50, + /// Icon with Text Button Sample + SizedBox(height: heightInPercent(3, context)), + ValueListenableBuilder( + valueListenable: _tabIndexTextWithIcon, + builder: (context, currentIndex, _) => Text( + "Selected sex : ${_listGenderText[currentIndex].title} ", + key: ExampleKeys.textWithIconResult, + style: const TextStyle(fontSize: 20), + ), + ), + ], + ); + + Widget _iconButton() => Column( + children: [ + /// Icon button sample + const Text( + "With Icon Only and Implement margin for selected item", + textAlign: TextAlign.center, + style: TextStyle(fontSize: 20, fontStyle: FontStyle.italic), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Select your sex : ", style: TextStyle(fontSize: 20)), + ValueListenableBuilder( + valueListenable: _tabIndexIconButton, + builder: (context, currentIndex, _) => FlutterToggleTab( + key: ExampleKeys.iconOnlyToggle, + width: 40, + borderRadius: 15, selectedIndex: currentIndex, - selectedBackgroundColors: const [ - Colors.blue, - Colors.blueAccent, - ], selectedTextStyle: const TextStyle( color: Colors.white, fontSize: 18, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, ), unSelectedTextStyle: const TextStyle( - color: Colors.black87, + color: Colors.grey, fontSize: 14, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w400, ), - dataTabs: _listTextTabToggleCounter, + dataTabs: _listIconTabToggle, + iconSize: 40, selectedLabelIndex: (index) => - _tabIndexBasicToggleCounter.value = index, - isScroll: false, - ); - }, - ), - TextButton( - onPressed: () => _tabIndexBasicToggleCounter.value = 0, - child: const Text("Change to Index 0"), - ), - SizedBox(height: heightInPercent(3, context)), - ValueListenableBuilder( - valueListenable: _tabIndexBasicToggleCounter, - builder: (context, currentIndex, _) => Text( - "Index selected : $currentIndex", - style: const TextStyle(fontSize: 20), - ), - ), - ], - ); - - Widget _textWithIcon() => Column( - children: [ - /// Text with icon sample - SizedBox(height: heightInPercent(3, context)), - const Text( - "Text With Icon", - style: TextStyle(fontSize: 20, fontStyle: FontStyle.italic), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - "Select your sex : ", - style: TextStyle(fontSize: 20), - ), - ValueListenableBuilder( - valueListenable: _tabIndexTextWithIcon, - builder: (context, currentIndex, _) => FlutterToggleTab( - width: 50, - borderRadius: 15, - selectedTextStyle: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - unSelectedTextStyle: const TextStyle( - color: Colors.blue, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - dataTabs: _listGenderText, - selectedIndex: currentIndex, - selectedLabelIndex: (index) => - _tabIndexTextWithIcon.value = index, - ), - ), - ], - ), - ), - - /// Icon with Text Button Sample - SizedBox(height: heightInPercent(3, context)), - ValueListenableBuilder( - valueListenable: _tabIndexTextWithIcon, - builder: (context, currentIndex, _) => Text( - "Selected sex : ${_listGenderText[currentIndex].title} ", - style: const TextStyle(fontSize: 20), - ), - ), - ], - ); - - Widget _iconButton() => Column( - children: [ - /// Icon button sample - const Text( - "With Icon Only and Implement margin for selected item", - textAlign: TextAlign.center, - style: TextStyle(fontSize: 20, fontStyle: FontStyle.italic), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - "Select your sex : ", - style: TextStyle(fontSize: 20), - ), - ValueListenableBuilder( - valueListenable: _tabIndexIconButton, - builder: (context, currentIndex, _) => FlutterToggleTab( - width: 40, - borderRadius: 15, - selectedIndex: currentIndex, - selectedTextStyle: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - unSelectedTextStyle: const TextStyle( - color: Colors.grey, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - dataTabs: _listIconTabToggle, - iconSize: 40, - selectedLabelIndex: (index) => - _tabIndexIconButton.value = index, - marginSelected: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 4, - ), - ), + _tabIndexIconButton.value = index, + marginSelected: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, ), - ], + ), ), - ), - ValueListenableBuilder( - valueListenable: _tabIndexIconButton, - builder: (context, currentIndex, _) => Text( - "Selected sex index: $currentIndex", - style: const TextStyle(fontSize: 20), - ), - ), - ], - ); + ], + ), + ), + ValueListenableBuilder( + valueListenable: _tabIndexIconButton, + builder: (context, currentIndex, _) => Text( + "Selected sex index: $currentIndex", + key: ExampleKeys.iconOnlyResult, + style: const TextStyle(fontSize: 20), + ), + ), + ], + ); Widget _updateProgrammatically() => Column( - children: [ - const Text( - "Update selected programmatically ", - style: TextStyle(fontSize: 20, fontStyle: FontStyle.italic), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - "Select your sex : ", - style: TextStyle(fontSize: 20), - ), - SizedBox(height: heightInPercent(3, context)), - ValueListenableBuilder( - valueListenable: _tabIndexUpdateProgrammatically, - builder: (context, currentIndex, _) => FlutterToggleTab( - width: 85, - borderRadius: 15, - selectedIndex: currentIndex, - selectedTextStyle: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - unSelectedTextStyle: const TextStyle( - color: Colors.grey, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - dataTabs: _listTextSelectedToggle, - selectedLabelIndex: (index) { - _tabIndexUpdateProgrammatically.value = index; - }, - ), + children: [ + const Text( + "Update selected programmatically ", + style: TextStyle(fontSize: 20, fontStyle: FontStyle.italic), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Select your sex : ", style: TextStyle(fontSize: 20)), + SizedBox(height: heightInPercent(3, context)), + ValueListenableBuilder( + valueListenable: _tabIndexUpdateProgrammatically, + builder: (context, currentIndex, _) => FlutterToggleTab( + key: ExampleKeys.programmaticToggle, + width: 85, + borderRadius: 15, + selectedIndex: currentIndex, + selectedTextStyle: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, ), - TextButton( - onPressed: () => _tabIndexUpdateProgrammatically.value = 2, - child: const Text("Select C"), + unSelectedTextStyle: const TextStyle( + color: Colors.grey, + fontSize: 14, + fontWeight: FontWeight.w400, ), - ], + dataTabs: _listTextSelectedToggle, + selectedLabelIndex: (index) { + _tabIndexUpdateProgrammatically.value = index; + }, + ), ), - ), - SizedBox(height: heightInPercent(3, context)), - ValueListenableBuilder( - valueListenable: _tabIndexUpdateProgrammatically, - builder: (context, currentIndex, _) => Text( - "Selected sex index: $currentIndex ", - style: const TextStyle(fontSize: 20), + TextButton( + key: ExampleKeys.selectProgrammatically, + onPressed: () => _tabIndexUpdateProgrammatically.value = 2, + child: const Text("Select C"), ), - ), - ], - ); + ], + ), + ), + SizedBox(height: heightInPercent(3, context)), + ValueListenableBuilder( + valueListenable: _tabIndexUpdateProgrammatically, + builder: (context, currentIndex, _) => Text( + "Selected sex index: $currentIndex ", + key: ExampleKeys.programmaticResult, + style: const TextStyle(fontSize: 20), + ), + ), + ], + ); double heightInPercent(double percent, BuildContext context) { final toDouble = percent / 100; diff --git a/example/pubspec.lock b/example/pubspec.lock index 6d5d7b8..524d128 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -5,55 +5,68 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.1" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.3" collection: dependency: transitive description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.19.1" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: c6747018d37d32feeeb858a1d7043831cb541460ebe1a890a995cdb8df8ea5a5 + url: "https://pub.dev" + source: hosted + version: "1.1.0" fake_async: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -65,71 +78,87 @@ packages: path: ".." relative: true source: path - version: "1.4.1" + version: "2.0.0" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.7" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lint: dependency: "direct dev" description: name: lint - sha256: d758a5211fce7fd3f5e316f804daefecdc34c7e53559716125e6da7388ae8565 + sha256: "78b35cb79457c0cc12d96d0f7e5c283d2661850a70b0ae5332673207065b63c8" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.14.0" matcher: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.20" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + material_ui: + dependency: "direct main" + description: + name: material_ui + sha256: "6d056d60ce745335fcf1c726a4fc46a481a62229f202b65dbc2db5edbbabc7e2" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "1.3.0" meta: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.19.0" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" sky_engine: dependency: transitive description: flutter @@ -139,66 +168,66 @@ packages: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.2" stack_trace: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.12.2" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.12" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.4.3" vm_service: dependency: transitive description: name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "14.3.0" + version: "15.3.0" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.13.0 <4.0.0" + flutter: ">=3.47.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1a95457..39a840a 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,12 +1,14 @@ name: flutter_toggle_tab_example description: Demonstrates how to use the flutter_toggle_tab plugin. +version: 1.0.0+1 # The following line prevents the package from being accidentally published to # pub.dev using `pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev environment: - sdk: '^3.0.0' + sdk: '>=3.13.0 <4.0.0' + flutter: '>=3.47.0' dependencies: flutter: @@ -23,10 +25,11 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. + material_ui: ^1.3.0 dev_dependencies: flutter_test: sdk: flutter - lint: ^2.1.2 + lint: ^2.14.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 570e0e4..8601319 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,8 +1,133 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -void main() {} +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_toggle_tab_example/main.dart'; +import 'package:material_ui/material_ui.dart'; + +void main() { + testWidgets('basic usage updates the selected index after a tap', ( + tester, + ) async { + await _pumpExample(tester); + final tab = find.descendant( + of: find.byKey(ExampleKeys.basicToggle), + matching: find.text('Tab C (19)'), + ); + + await tester.tap(tab); + await tester.pump(); + + expect(_text(tester, ExampleKeys.basicResult), 'Index selected : 2'); + }); + + testWidgets('counter usage renders its counter and updates selection', ( + tester, + ) async { + await _pumpExample(tester); + final toggle = find.byKey(ExampleKeys.counterToggle); + final firstTab = find.descendant(of: toggle, matching: find.text('Tab A')); + + expect( + find.descendant(of: toggle, matching: find.text('1')), + findsOneWidget, + ); + await tester.tap(firstTab); + await tester.pump(); + + expect(_text(tester, ExampleKeys.counterResult), 'Index selected : 0'); + }); + + testWidgets('adaptive usage gives longer content more width', (tester) async { + await _pumpExample(tester); + final toggle = find.byKey(ExampleKeys.adaptiveToggle); + final shortLabel = find.descendant(of: toggle, matching: find.text('AAAA')); + final longLabel = find.descendant( + of: toggle, + matching: find.text('AAAAAAAAAAAAAAA'), + ); + final shortButton = find.ancestor( + of: shortLabel, + matching: find.byType(TextButton), + ); + final longButton = find.ancestor( + of: longLabel, + matching: find.byType(TextButton), + ); + + expect( + tester.getSize(longButton).width, + greaterThan(tester.getSize(shortButton).width), + ); + final background = find.descendant( + of: toggle, + matching: find.byKey(const ValueKey('flutter-toggle-tab-background')), + ); + final screenCenterX = + tester.view.physicalSize.width / tester.view.devicePixelRatio / 2; + expect(tester.getCenter(background).dx, closeTo(screenCenterX, 0.01)); + + await tester.tap(longButton); + await tester.pumpAndSettle(); + + expect(_text(tester, ExampleKeys.adaptiveResult), 'Index selected : 1'); + }); + + testWidgets('text and icon usage reports the selected label', (tester) async { + await _pumpExample(tester); + final femaleTab = find.descendant( + of: find.byKey(ExampleKeys.textWithIconToggle), + matching: find.text('Female'), + ); + await tester.ensureVisible(femaleTab); + + await tester.tap(femaleTab); + await tester.pump(); + + expect( + _text(tester, ExampleKeys.textWithIconResult), + 'Selected sex : Female ', + ); + }); + + testWidgets('icon-only usage reports the selected icon index', ( + tester, + ) async { + await _pumpExample(tester); + final icons = find.descendant( + of: find.byKey(ExampleKeys.iconOnlyToggle), + matching: find.byType(Icon), + ); + await tester.ensureVisible(icons.last); + + await tester.tap(icons.last); + await tester.pump(); + + expect(_text(tester, ExampleKeys.iconOnlyResult), 'Selected sex index: 1'); + }); + + testWidgets('programmatic usage changes selection from its control', ( + tester, + ) async { + await _pumpExample(tester); + final selectButton = find.byKey(ExampleKeys.selectProgrammatically); + await tester.ensureVisible(selectButton); + + await tester.tap(selectButton); + await tester.pump(); + + expect( + _text(tester, ExampleKeys.programmaticResult), + 'Selected sex index: 2 ', + ); + }); +} + +Future _pumpExample(WidgetTester tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 2400); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget(const MyApp()); +} + +String? _text(WidgetTester tester, Key key) { + return tester.widget(find.byKey(key)).data; +} diff --git a/gifs/adaptive_layout.gif b/gifs/adaptive_layout.gif new file mode 100644 index 0000000..4ff34c9 Binary files /dev/null and b/gifs/adaptive_layout.gif differ diff --git a/gifs/basic.gif b/gifs/basic.gif deleted file mode 100644 index 4e14c0b..0000000 Binary files a/gifs/basic.gif and /dev/null differ diff --git a/gifs/basic_toggle.gif b/gifs/basic_toggle.gif new file mode 100644 index 0000000..222cf5e Binary files /dev/null and b/gifs/basic_toggle.gif differ diff --git a/gifs/basic_with_counter.gif b/gifs/basic_with_counter.gif index e592440..dba9d52 100644 Binary files a/gifs/basic_with_counter.gif and b/gifs/basic_with_counter.gif differ diff --git a/gifs/programmatic_selection.gif b/gifs/programmatic_selection.gif new file mode 100644 index 0000000..cf80265 Binary files /dev/null and b/gifs/programmatic_selection.gif differ diff --git a/gifs/with_icon.gif b/gifs/with_icon.gif index f59b01d..d1a7969 100644 Binary files a/gifs/with_icon.gif and b/gifs/with_icon.gif differ diff --git a/gifs/with_icon_only.gif b/gifs/with_icon_only.gif index e01a0e6..9c3e2f0 100644 Binary files a/gifs/with_icon_only.gif and b/gifs/with_icon_only.gif differ diff --git a/lib/adaptive_tab_layout.dart b/lib/adaptive_tab_layout.dart new file mode 100644 index 0000000..4b6ae1f --- /dev/null +++ b/lib/adaptive_tab_layout.dart @@ -0,0 +1,278 @@ +part of 'flutter_toggle_tab.dart'; + +class _AdaptiveTabLayout extends StatefulWidget { + const _AdaptiveTabLayout({ + required this.dataTabs, + required this.selectedIndex, + required this.selectedLabelIndex, + required this.height, + required this.radius, + required this.iconSize, + required this.selectedTextStyle, + required this.unSelectedTextStyle, + required this.selectedColors, + required this.unselectedColors, + required this.begin, + required this.end, + required this.marginSelected, + required this.isShadowEnable, + required this.isInnerShadowEnable, + required this.animationDuration, + required this.animationCurve, + required this.padding, + required this.alignment, + required this.isScroll, + }); + + final List dataTabs; + final int selectedIndex; + final ValueChanged selectedLabelIndex; + final double height; + final double radius; + final double? iconSize; + final TextStyle selectedTextStyle; + final TextStyle unSelectedTextStyle; + final List selectedColors; + final List unselectedColors; + final Alignment? begin; + final Alignment? end; + final EdgeInsetsGeometry marginSelected; + final bool isShadowEnable; + final bool isInnerShadowEnable; + final Duration animationDuration; + final Curve animationCurve; + final EdgeInsetsGeometry padding; + final AlignmentGeometry alignment; + final bool isScroll; + + @override + State<_AdaptiveTabLayout> createState() => _AdaptiveTabLayoutState(); +} + +class _AdaptiveTabLayoutState extends State<_AdaptiveTabLayout> { + final GlobalKey _stackKey = GlobalKey(); + var _tabKeys = []; + final ValueNotifier> _tabGeometries = ValueNotifier([]); + var _measurementScheduled = false; + + @override + void initState() { + super.initState(); + _syncTabKeys(); + } + + @override + void didUpdateWidget(covariant _AdaptiveTabLayout oldWidget) { + super.didUpdateWidget(oldWidget); + _syncTabKeys(); + } + + @override + void dispose() { + _tabGeometries.dispose(); + super.dispose(); + } + + void _syncTabKeys() { + if (_tabKeys.length != widget.dataTabs.length) { + _tabKeys = List.generate(widget.dataTabs.length, (_) => GlobalKey()); + _tabGeometries.value = []; + } + } + + void _scheduleMeasurement() { + if (_measurementScheduled) return; + _measurementScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _measurementScheduled = false; + if (!mounted) return; + + final stackBox = + _stackKey.currentContext?.findRenderObject() as RenderBox?; + if (stackBox == null || !stackBox.hasSize) return; + + final geometries = <_TabGeometry>[]; + for (final key in _tabKeys) { + final tabBox = key.currentContext?.findRenderObject() as RenderBox?; + if (tabBox == null || !tabBox.hasSize) return; + geometries.add( + _TabGeometry( + left: tabBox.localToGlobal(Offset.zero, ancestor: stackBox).dx, + width: tabBox.size.width, + ), + ); + } + + if (!_sameGeometries(_tabGeometries.value, geometries)) { + _tabGeometries.value = geometries; + } + }); + } + + bool _sameGeometries(List<_TabGeometry> first, List<_TabGeometry> second) { + if (first.length != second.length) return false; + for (var index = 0; index < first.length; index++) { + if ((first[index].left - second[index].left).abs() > 0.01 || + (first[index].width - second[index].width).abs() > 0.01) { + return false; + } + } + return true; + } + + double _minimumTabWidth(BuildContext context, DataTab tab) { + final resolvedPadding = widget.padding.resolve(Directionality.of(context)); + var contentWidth = resolvedPadding.horizontal; + final title = tab.title; + + if (tab.icon != null) { + contentWidth += widget.iconSize ?? IconTheme.of(context).size ?? 24; + } + if (tab.icon != null && title != null && title.isNotEmpty) { + contentWidth += 4; + } + if (title != null && title.isNotEmpty) { + double measure(TextStyle style) { + final painter = TextPainter( + text: TextSpan(text: title, style: style), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + return painter.width; + } + + contentWidth += math.max( + measure(widget.selectedTextStyle), + measure(widget.unSelectedTextStyle), + ); + } + if ((tab.icon != null || (title != null && title.isNotEmpty)) && + tab.counterWidget != null) { + contentWidth += 4; + } + + return contentWidth; + } + + @override + Widget build(BuildContext context) { + _scheduleMeasurement(); + return ValueListenableBuilder>( + valueListenable: _tabGeometries, + builder: (context, geometries, _) { + final hasValidSelection = widget.selectedIndex < widget.dataTabs.length; + final hasGeometry = geometries.length == widget.dataTabs.length; + final geometry = hasGeometry + ? geometries[hasValidSelection ? widget.selectedIndex : 0] + : null; + + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: widget.isScroll + ? const BouncingScrollPhysics() + : const NeverScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: Align( + alignment: widget.alignment, + child: DecoratedBox( + key: const ValueKey('flutter-toggle-tab-background'), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: widget.begin ?? Alignment.topCenter, + end: widget.end ?? Alignment.bottomCenter, + colors: widget.unselectedColors, + ), + borderRadius: BorderRadius.circular(widget.radius), + boxShadow: [if (widget.isShadowEnable) _bsInner], + ), + child: Stack( + key: _stackKey, + children: [ + if (geometry != null) + // For adaptive widths, the indicator uses measured geometry. + // Its left edge is the sum of all preceding tab widths and + // its width is the selected tab width. AnimatedPositioned + // can then interpolate both values on selection changes. + AnimatedPositioned( + left: geometry.left, + top: 0, + width: geometry.width, + height: widget.height, + duration: widget.animationDuration, + curve: widget.animationCurve, + child: IgnorePointer( + child: AnimatedOpacity( + opacity: hasValidSelection ? 1 : 0, + duration: widget.animationDuration, + curve: widget.animationCurve, + child: _SelectedTabIndicator( + width: geometry.width, + height: widget.height, + margin: widget.marginSelected, + radius: widget.radius, + colors: widget.selectedColors, + begin: widget.begin, + end: widget.end, + isShadowEnable: widget.isInnerShadowEnable, + ), + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for ( + var index = 0; + index < widget.dataTabs.length; + index++ + ) + KeyedSubtree( + key: _tabKeys[index], + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: _minimumTabWidth( + context, + widget.dataTabs[index], + ), + ), + child: _ButtonsTab( + height: widget.height, + title: widget.dataTabs[index].title, + icons: widget.dataTabs[index].icon, + iconSize: widget.iconSize, + counterWidget: + widget.dataTabs[index].counterWidget, + selectedTextStyle: widget.selectedTextStyle, + unSelectedTextStyle: + widget.unSelectedTextStyle, + isSelected: index == widget.selectedIndex, + radius: widget.radius, + padding: widget.padding, + onPressed: () => + widget.selectedLabelIndex(index), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +class _TabGeometry { + const _TabGeometry({required this.left, required this.width}); + + final double left; + final double width; +} diff --git a/lib/button_tab.dart b/lib/button_tab.dart index 5a737f3..235cc6b 100644 --- a/lib/button_tab.dart +++ b/lib/button_tab.dart @@ -1,131 +1,90 @@ part of 'flutter_toggle_tab.dart'; -class ButtonsTab extends StatelessWidget { - /// Define attribute Widget and State - /// [counterWidget] is a widget to show counter - /// [title] is a title of the button - /// [onPressed] is a function to handle button press - /// [width] is a width of the button - /// [height] is a height of the button - /// [selectedColors] is a list of color when the button is selected - /// [unSelectedColors] is a list of color when the button is unselected - /// [selectedTextStyle] is a text style when the button is selected - /// [unSelectedTextStyle] is a text style when the button is unselected - /// [isSelected] is a boolean to check if the button is selected or not - /// [radius] is a radius of the button - /// [icons] is an icon of the button - /// [iconSize] is a size of the icon - /// [begin] is a begin alignment of the gradient - /// [end] is an end alignment of the gradient - /// [marginSelected] is a margin when the button is selected - /// [isInnerShadowEnable] is optional to set the shadow of the selected tab. - const ButtonsTab({ - super.key, +class _ButtonsTab extends StatelessWidget { + const _ButtonsTab({ this.title, this.onPressed, this.counterWidget, - required this.width, - this.height, - this.isSelected, - this.radius, - this.selectedTextStyle, - this.unSelectedTextStyle, - required this.selectedColors, + this.width, + required this.height, + required this.isSelected, + required this.radius, + required this.selectedTextStyle, + required this.unSelectedTextStyle, this.icons, this.iconSize, - required this.unSelectedColors, - this.begin, - this.end, - this.marginSelected = EdgeInsets.zero, - this.isInnerShadowEnable = true, + this.padding = EdgeInsets.zero, }); final Widget? counterWidget; - final String? title; final VoidCallback? onPressed; final double? width; - final double? height; - final List? selectedColors; - final List? unSelectedColors; - final TextStyle? selectedTextStyle; - final TextStyle? unSelectedTextStyle; - - final bool? isSelected; - final double? radius; + final double height; + final TextStyle selectedTextStyle; + final TextStyle unSelectedTextStyle; + final bool isSelected; + final double radius; final IconData? icons; final double? iconSize; - - final Alignment? begin; - final Alignment? end; - - final EdgeInsets? marginSelected; - final bool isInnerShadowEnable; + final EdgeInsetsGeometry padding; @override Widget build(BuildContext context) { return SizedBox( - width: width ?? _widthInPercent(100, context), - height: height ?? 50, - //wrap with container to fix margin issue - child: Padding( - padding: - isSelected! ? (marginSelected ?? EdgeInsets.zero) : EdgeInsets.zero, - child: DecoratedBox( - decoration: isSelected! - ? (isInnerShadowEnable ? bdHeader : const BoxDecoration()) - .copyWith( - borderRadius: BorderRadius.circular(radius!), - gradient: LinearGradient( - // Where the linear gradient begins and ends - begin: begin ?? Alignment.topCenter, - end: end ?? Alignment.bottomCenter, - colors: selectedColors ?? [Theme.of(context).primaryColor], - ), - ) - : const BoxDecoration(), - child: TextButton( - onPressed: onPressed, - style: ButtonStyle( - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radius!), - ), + width: width, + height: height, + child: TextButton( + onPressed: onPressed, + style: ButtonStyle( + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(radius)), + ), + padding: WidgetStateProperty.all(padding), + minimumSize: WidgetStateProperty.all(Size.zero), + ), + child: Row( + mainAxisSize: width == null ? MainAxisSize.min : MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icons != null) + Icon( + icons, + size: iconSize, + color: isSelected + ? selectedTextStyle.color + : unSelectedTextStyle.color, ), - padding: WidgetStateProperty.all(EdgeInsets.zero), + Visibility( + visible: icons != null && title.toString().isNotEmpty, + child: const SizedBox(width: 4), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (icons != null) - Icon( - icons, - size: iconSize, - color: isSelected! - ? selectedTextStyle!.color - : unSelectedTextStyle!.color, - ), - Visibility( - visible: icons != null && title.toString().isNotEmpty, - child: const SizedBox(width: 4), - ), - if (title != null) - Text( + if (title != null) + if (width == null) + Text( + title!, + maxLines: 1, + style: isSelected ? selectedTextStyle : unSelectedTextStyle, + textAlign: TextAlign.center, + ) + else + Flexible( + child: Text( title!, - style: - isSelected! ? selectedTextStyle : unSelectedTextStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: isSelected ? selectedTextStyle : unSelectedTextStyle, textAlign: TextAlign.center, ), - Visibility( - visible: icons != null && - title.toString().isNotEmpty && - counterWidget != null, - child: const SizedBox(width: 4), ), - if (counterWidget != null) counterWidget!, - ], + Visibility( + visible: + (icons != null || title.toString().isNotEmpty) && + counterWidget != null, + child: const SizedBox(width: 4), ), - ), + ?counterWidget, + ], ), ), ); diff --git a/lib/data_tab.dart b/lib/data_tab.dart index 4d09fbc..f82dab1 100644 --- a/lib/data_tab.dart +++ b/lib/data_tab.dart @@ -1,15 +1,22 @@ part of 'flutter_toggle_tab.dart'; +/// Describes the content of one [FlutterToggleTab] item. class DataTab { + /// Creates a tab with optional text, icon, and counter content. + DataTab({this.title, this.icon, this.counterWidget, this.isSelected = false}); + + /// Text displayed in the tab. final String? title; + + /// Icon displayed before [title]. final IconData? icon; + + /// Optional widget displayed after [title], such as a badge or counter. final Widget? counterWidget; - bool isSelected; - DataTab({ - this.title, - this.isSelected = false, - this.icon, - this.counterWidget, - }); + /// Retained for backwards compatibility. + /// + /// [FlutterToggleTab] derives selection from its `selectedIndex` property and + /// does not mutate this value. + bool isSelected; } diff --git a/lib/flutter_toggle_tab.dart b/lib/flutter_toggle_tab.dart index 4c4eeea..a54fde4 100644 --- a/lib/flutter_toggle_tab.dart +++ b/lib/flutter_toggle_tab.dart @@ -1,37 +1,29 @@ -// You have generated a new plugin project without -// specifying the `--platforms` flag. A plugin project supports no platforms is generated. -// To add platforms, run `flutter create -t plugin --platforms .` under the same -// directory. You can also find a detailed instruction on how to add platforms in the `pubspec.yaml` at https://flutter.dev/docs/development/packages-and-plugins/developing-packages#plugin-platforms. +/// A library for building controlled, customizable toggle-tab interfaces. +library; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'dart:math' as math; + +import 'package:material_ui/material_ui.dart'; part 'button_tab.dart'; part 'data_tab.dart'; part 'helper.dart'; +part 'adaptive_tab_layout.dart'; +part 'non_adaptive_tab_layout.dart'; +part 'selected_tab_indicator.dart'; -/// A custom Flutter toggle tab widget. -class FlutterToggleTab extends StatefulWidget { - /// Define attribute Widget and State - /// [dataTabs] is required to set the data of the widget. - /// [iconSize] is optional to set the size of the icon. - /// [selectedIndex] is required to set the selected index. - /// [width] is optional to set the width of the widget. - /// [height] is optional to set the height of the widget. - /// [isScroll] is optional to set the scrollable or not. - /// - /// [selectedTextStyle] is optional to set the text style of the selected label. - /// [unSelectedTextStyle] is optional to set the text style of the unselected label. - /// [selectedBackgroundColors] is optional to set the background color of the selected label. - /// [unSelectedBackgroundColors] is optional to set the background color of the unselected label. - /// [selectedLabelIndex] is required to set the selected label index. - /// [borderRadius] is optional to set the border radius of the widget. - /// [begin] is optional to set the begin alignment of the gradient. - /// [end] is optional to set the end alignment of the gradient. +/// A customizable, controlled toggle-tab widget. +/// +/// Selection is derived from [selectedIndex]. Callers should update that value +/// after [selectedLabelIndex] is invoked. Prefer a [ValueNotifier] with a +/// [ValueListenableBuilder] around this widget so a selection change rebuilds +/// only the toggle subtree. +class FlutterToggleTab extends StatelessWidget { + /// Creates a controlled toggle tab. /// - /// [marginSelected] is optional to set the margin of the selected label. - /// [isShadowEnable] is optional to set the shadow of the widget. - /// [isInnerShadowEnable] is optional to set the shadow of the selected tab. + /// At least two [dataTabs] are needed to render the control. When a tab is + /// pressed, [selectedLabelIndex] reports its index; update [selectedIndex] + /// in the parent to display the new selection. const FlutterToggleTab({ super.key, required this.dataTabs, @@ -51,215 +43,219 @@ class FlutterToggleTab extends StatefulWidget { this.marginSelected, this.isShadowEnable = true, this.isInnerShadowEnable = true, - }); + this.animationDuration = const Duration(milliseconds: 250), + this.animationCurve = Curves.easeInOutCubic, + this.isAdaptiveWidth = false, + this.adaptiveTabPadding = const EdgeInsets.symmetric(horizontal: 16), + this.adaptiveTabAlignment = Alignment.center, + }) : assert(selectedIndex >= 0, 'selectedIndex must not be negative.'); + /// Size of every icon in logical pixels. + /// + /// When omitted, the current icon theme determines the size. final double? iconSize; + + /// The selected tab. An out-of-range value renders all tabs unselected. final int selectedIndex; + + /// Width as a percentage of the screen width. + /// + /// Defaults to `100` and is constrained by the available parent width. final double? width; + + /// Height of the toggle control in logical pixels. + /// + /// Defaults to `45`. final double? height; + + /// Whether the horizontal tab list uses bouncing scroll physics. + /// + /// Defaults to `true`. When `false`, user scrolling is disabled. final bool isScroll; + + /// Descriptors for the tabs in display order. + /// + /// A validation message is rendered when fewer than two items are supplied. final List dataTabs; -// final BoxDecoration selectedDecoration; -// final BoxDecoration unSelectedDecoration; + /// Gradient colors for the selected tab. + /// + /// An empty list uses the theme primary color. A single color is duplicated + /// to form a valid gradient. final List? selectedBackgroundColors; + + /// Gradient colors for the background behind unselected tabs. + /// + /// An empty list uses the default light-grey background. A single color is + /// duplicated to form a valid gradient. final List? unSelectedBackgroundColors; + + /// Text style used by the selected tab. + /// + /// Defaults to the current theme's `bodyMedium` style. final TextStyle? selectedTextStyle; + + /// Text style used by unselected tabs. + /// + /// Defaults to a partially transparent `bodyMedium` style. final TextStyle? unSelectedTextStyle; - final Function(int) selectedLabelIndex; + + /// Called with the zero-based index of a pressed tab. + /// + /// Assign the reported value to the selection source used by [selectedIndex]. + /// A [ValueNotifier] is recommended for narrowly scoped rebuilds. + final ValueChanged selectedLabelIndex; + + /// Corner radius used by the control and selected tab. + /// + /// Defaults to `30`. final double? borderRadius; + + /// Starting alignment for selected and unselected gradients. + /// + /// Defaults to [Alignment.topCenter]. final Alignment? begin; + + /// Ending alignment for selected and unselected gradients. + /// + /// Defaults to [Alignment.bottomCenter]. final Alignment? end; + /// Insets applied only to the selected tab. + /// + /// Defaults to [EdgeInsets.zero]. final EdgeInsets? marginSelected; - final bool isShadowEnable; - final bool isInnerShadowEnable; - - @override - _FlutterToggleTabState createState() => _FlutterToggleTabState(); -} -class _FlutterToggleTabState extends State { - final ValueNotifier> _labelsNotifier = ValueNotifier([]); - - @override - void initState() { - super.initState(); + /// Whether the outer control shadow is visible. + /// + /// Defaults to `true`. + final bool isShadowEnable; - _labelsNotifier.value.addAll(widget.dataTabs); + /// Whether the selected tab shadow is visible. + /// + /// Defaults to `true`. + final bool isInnerShadowEnable; - _updateSelected(); + /// Duration of the sliding selected-tab indicator animation. + /// + /// Defaults to 250 milliseconds. Use [Duration.zero] to disable the motion. + final Duration animationDuration; - _labelsNotifier.addListener(() => _updateSelected()); - } + /// Curve used by the sliding selected-tab indicator animation. + /// + /// Defaults to [Curves.easeInOutCubic]. + final Curve animationCurve; - /// Update selected when selectedItem changed - void _updateSelected() { - /// set all item to false - for (final item in _labelsNotifier.value) { - item.isSelected = false; - } + /// Whether every tab sizes itself to its content instead of sharing space. + /// + /// When enabled, tabs can have different widths and the control scrolls + /// horizontally when their combined width exceeds the available width. + /// Defaults to `false` to preserve equal-width tabs. + final bool isAdaptiveWidth; - /// Update selectedIndex isSelected to True - _labelsNotifier.value[widget.selectedIndex].isSelected = true; - } + /// Padding around each tab when [isAdaptiveWidth] is enabled. + /// + /// Defaults to 16 logical pixels on the left and right. + final EdgeInsetsGeometry adaptiveTabPadding; - @override - void dispose() { - _labelsNotifier.dispose(); - super.dispose(); - } + /// Alignment of the complete tab surface in adaptive-width mode. + /// + /// This alignment is visible when the combined tab width is smaller than + /// the available control width. Use [AlignmentDirectional.centerStart] or + /// [AlignmentDirectional.centerEnd] for direction-aware positioning. + /// Defaults to [Alignment.center]. + final AlignmentGeometry adaptiveTabAlignment; @override Widget build(BuildContext context) { - /// update selected when selectedIndex changed - _updateSelected(); - - /// set width to 100% if width is null - final width = _widthInPercent(widget.width ?? 100, context); - - /// Show text error if length less 1 - return widget.dataTabs.length <= 1 - ? const Text( - "Error : Label should >1", - style: TextStyle( - color: Colors.redAccent, - fontWeight: FontWeight.bold, - fontSize: 20, - ), - ) - : SizedBox( - width: width, + if (dataTabs.length <= 1) { + return const Text( + 'Error : Label should >1', + style: TextStyle( + color: Colors.redAccent, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ); + } - /// Default height is 45 - height: widget.height ?? 45, + final desiredWidth = _widthInPercent(width ?? 100, context); + final selectedColors = _normalizeGradientColors(selectedBackgroundColors, [ + Theme.of(context).primaryColor, + Theme.of(context).primaryColor, + ]); + final unselectedColors = _normalizeGradientColors( + unSelectedBackgroundColors, + const [Color(0xffe0e0e0), Color(0xffe0e0e0)], + ); + final effectiveSelectedTextStyle = + selectedTextStyle ?? + TextTheme.of(context).bodyMedium ?? + const TextStyle(); + final effectiveUnselectedTextStyle = + unSelectedTextStyle ?? + (TextTheme.of(context).bodyMedium ?? const TextStyle()).copyWith( + color: TextTheme.of(context).bodyMedium?.color + ?.withValues(alpha: 0.7), + ); - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - // Where the linear gradient begins and ends - begin: widget.begin ?? Alignment.topCenter, - end: widget.end ?? Alignment.bottomCenter, + return LayoutBuilder( + builder: (context, constraints) { + final effectiveWidth = constraints.hasBoundedWidth + ? desiredWidth.clamp(0, constraints.maxWidth).toDouble() + : desiredWidth; + final effectiveHeight = height ?? 45; + final effectiveBorderRadius = borderRadius ?? 30; - /// If unSelectedBackground is not null - /// We check again if it's length only 1 - /// Using same color for gradients - colors: widget.unSelectedBackgroundColors != null - ? (widget.unSelectedBackgroundColors!.length == 1 - ? [ - widget.unSelectedBackgroundColors![0], - widget.unSelectedBackgroundColors![0], - ] - : widget.unSelectedBackgroundColors!) - : [const Color(0xffe0e0e0), const Color(0xffe0e0e0)], + return SizedBox( + width: effectiveWidth, + height: effectiveHeight, + child: isAdaptiveWidth + ? _AdaptiveTabLayout( + dataTabs: dataTabs, + selectedIndex: selectedIndex, + selectedLabelIndex: selectedLabelIndex, + height: effectiveHeight, + radius: effectiveBorderRadius, + iconSize: iconSize, + selectedTextStyle: effectiveSelectedTextStyle, + unSelectedTextStyle: effectiveUnselectedTextStyle, + selectedColors: selectedColors, + unselectedColors: unselectedColors, + begin: begin, + end: end, + marginSelected: marginSelected ?? EdgeInsets.zero, + isShadowEnable: isShadowEnable, + isInnerShadowEnable: isInnerShadowEnable, + animationDuration: animationDuration, + animationCurve: animationCurve, + padding: adaptiveTabPadding, + alignment: adaptiveTabAlignment, + isScroll: isScroll, + ) + : _NonAdaptiveTabLayout( + width: effectiveWidth, + height: effectiveHeight, + radius: effectiveBorderRadius, + dataTabs: dataTabs, + selectedIndex: selectedIndex, + selectedLabelIndex: selectedLabelIndex, + iconSize: iconSize, + selectedTextStyle: effectiveSelectedTextStyle, + unSelectedTextStyle: effectiveUnselectedTextStyle, + selectedColors: selectedColors, + unselectedColors: unselectedColors, + begin: begin, + end: end, + marginSelected: marginSelected ?? EdgeInsets.zero, + isShadowEnable: isShadowEnable, + isInnerShadowEnable: isInnerShadowEnable, + animationDuration: animationDuration, + animationCurve: animationCurve, + isScroll: isScroll, ), - borderRadius: BorderRadius.circular(widget.borderRadius ?? 30), - - /// Handle if shadow is Enable or not - boxShadow: [if (widget.isShadowEnable) bsInner], - ), - child: ValueListenableBuilder( - valueListenable: _labelsNotifier, - builder: (context, labels, _) { - return ListView.builder( - itemCount: labels.length, - - /// Handle if isScroll or not - physics: widget.isScroll - ? const BouncingScrollPhysics() - : const NeverScrollableScrollPhysics(), - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) { - IconData? icon; - - // Using try catch to fix error Range array - try { - icon = widget.dataTabs[index].icon; - } catch (_) { - icon = null; - } - - return ButtonsTab( - marginSelected: widget.marginSelected, - - /// If unSelectedBackground is not null - /// We check again if it's length only 1 - /// Using same color for gradients - unSelectedColors: widget.unSelectedBackgroundColors != - null - ? (widget.unSelectedBackgroundColors!.length == 1 - ? [ - widget.unSelectedBackgroundColors![0], - widget.unSelectedBackgroundColors![0], - ] - : widget.unSelectedBackgroundColors) - : [ - const Color(0xffe0e0e0), - const Color(0xffe0e0e0), - ], - width: width / widget.dataTabs.length, - title: labels[index].title, - icons: icon, - iconSize: widget.iconSize, - counterWidget: labels[index].counterWidget, - isInnerShadowEnable: widget.isInnerShadowEnable, - - /// default selectedTextStyle is Theme.of(context).textTheme.bodyMedium - selectedTextStyle: widget.selectedTextStyle ?? - Theme.of(context).textTheme.bodyMedium, - - /// default unSelectedTextStyle is Theme.of(context).textTheme.bodyMedium - unSelectedTextStyle: widget.unSelectedTextStyle ?? - Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context) - .textTheme - .bodyMedium - ?.color - ?.withValues(alpha: 0.7), - ), - isSelected: labels[index].isSelected, - - /// default borderRadius is 30 - radius: widget.borderRadius ?? 30, - - /// If selectedBackgroundColors is not null - /// We check again if it's length only 1 - /// Using same color for gradients - selectedColors: widget.selectedBackgroundColors != null - ? (widget.selectedBackgroundColors!.length == 1 - ? [ - widget.selectedBackgroundColors![0], - widget.selectedBackgroundColors![0], - ] - : widget.selectedBackgroundColors) - : [ - Theme.of(context).primaryColor, - Theme.of(context).primaryColor, - ], - onPressed: () { - try { - for (int x = 0; x < labels.length; x++) { - if (labels[index] == labels[x]) { - labels[x].isSelected = true; - - /// Send value to callback - widget.selectedLabelIndex(index); - } else { - labels[x].isSelected = false; - } - } - } catch (e) { - if (kDebugMode) { - print("err : $e"); - } - } - }, - ); - }, - ); - }, - ), - ), - ); + ); + }, + ); } } diff --git a/lib/helper.dart b/lib/helper.dart index c0957e9..cdc7be4 100644 --- a/lib/helper.dart +++ b/lib/helper.dart @@ -3,20 +3,29 @@ part of 'flutter_toggle_tab.dart'; /// This constant function to distribute reuse function double _widthInPercent(double percent, BuildContext context) { final toDouble = percent / 100; - return MediaQuery.of(context).size.width * toDouble; + return MediaQuery.sizeOf(context).width * toDouble; } -const BoxShadow bsInner = BoxShadow( +List _normalizeGradientColors( + List? colors, + List fallback, +) { + if (colors == null || colors.isEmpty) return fallback; + if (colors.length == 1) return [colors.first, colors.first]; + return colors; +} + +const BoxShadow _bsInner = BoxShadow( color: Colors.black12, offset: Offset(0.0, 1.5), blurRadius: 1.0, spreadRadius: -1.0, ); -const BoxShadow bsOuter = BoxShadow( +const BoxShadow _bsOuter = BoxShadow( color: Colors.black12, offset: Offset(0.0, 1.5), blurRadius: 1.0, spreadRadius: 1.0, ); -const BoxDecoration bdHeader = BoxDecoration(boxShadow: [bsOuter]); +const BoxDecoration _bdHeader = BoxDecoration(boxShadow: [_bsOuter]); diff --git a/lib/non_adaptive_tab_layout.dart b/lib/non_adaptive_tab_layout.dart new file mode 100644 index 0000000..659e006 --- /dev/null +++ b/lib/non_adaptive_tab_layout.dart @@ -0,0 +1,122 @@ +part of 'flutter_toggle_tab.dart'; + +class _NonAdaptiveTabLayout extends StatelessWidget { + const _NonAdaptiveTabLayout({ + required this.width, + required this.height, + required this.radius, + required this.dataTabs, + required this.selectedIndex, + required this.selectedLabelIndex, + required this.iconSize, + required this.selectedTextStyle, + required this.unSelectedTextStyle, + required this.selectedColors, + required this.unselectedColors, + required this.begin, + required this.end, + required this.marginSelected, + required this.isShadowEnable, + required this.isInnerShadowEnable, + required this.animationDuration, + required this.animationCurve, + required this.isScroll, + }); + + final double width; + final double height; + final double radius; + final List dataTabs; + final int selectedIndex; + final ValueChanged selectedLabelIndex; + final double? iconSize; + final TextStyle selectedTextStyle; + final TextStyle unSelectedTextStyle; + final List selectedColors; + final List unselectedColors; + final Alignment? begin; + final Alignment? end; + final EdgeInsetsGeometry marginSelected; + final bool isShadowEnable; + final bool isInnerShadowEnable; + final Duration animationDuration; + final Curve animationCurve; + final bool isScroll; + + @override + Widget build(BuildContext context) { + final hasValidSelection = selectedIndex < dataTabs.length; + final indicatorIndex = hasValidSelection ? selectedIndex : 0; + + // Equal-width tabs have evenly spaced centers. Alignment.x is normalized + // from -1 (left) through 0 (center) to 1 (right), so mapping index 0..n-1 + // onto that range produces x = -1 + (2 * index / (n - 1)). + final indicatorAlignment = Alignment( + -1 + (2 * indicatorIndex / (dataTabs.length - 1)), + 0, + ); + + return DecoratedBox( + key: const ValueKey('flutter-toggle-tab-background'), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: begin ?? Alignment.topCenter, + end: end ?? Alignment.bottomCenter, + colors: unselectedColors, + ), + borderRadius: BorderRadius.circular(radius), + boxShadow: [if (isShadowEnable) _bsInner], + ), + child: Stack( + fit: StackFit.expand, + children: [ + IgnorePointer( + child: AnimatedOpacity( + opacity: hasValidSelection ? 1 : 0, + duration: animationDuration, + curve: animationCurve, + child: AnimatedAlign( + alignment: indicatorAlignment, + duration: animationDuration, + curve: animationCurve, + child: _SelectedTabIndicator( + width: width / dataTabs.length, + height: height, + margin: marginSelected, + radius: radius, + colors: selectedColors, + begin: begin, + end: end, + isShadowEnable: isInnerShadowEnable, + ), + ), + ), + ), + ListView.builder( + itemCount: dataTabs.length, + physics: isScroll + ? const BouncingScrollPhysics() + : const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) { + final tab = dataTabs[index]; + return _ButtonsTab( + width: width / dataTabs.length, + height: height, + title: tab.title, + icons: tab.icon, + iconSize: iconSize, + counterWidget: tab.counterWidget, + selectedTextStyle: selectedTextStyle, + unSelectedTextStyle: unSelectedTextStyle, + isSelected: index == selectedIndex, + radius: radius, + onPressed: () => selectedLabelIndex(index), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/selected_tab_indicator.dart b/lib/selected_tab_indicator.dart new file mode 100644 index 0000000..870486b --- /dev/null +++ b/lib/selected_tab_indicator.dart @@ -0,0 +1,46 @@ +part of 'flutter_toggle_tab.dart'; + +class _SelectedTabIndicator extends StatelessWidget { + const _SelectedTabIndicator({ + required this.width, + required this.height, + required this.margin, + required this.radius, + required this.colors, + required this.begin, + required this.end, + required this.isShadowEnable, + }); + + final double width; + final double height; + final EdgeInsetsGeometry margin; + final double radius; + final List colors; + final Alignment? begin; + final Alignment? end; + final bool isShadowEnable; + + @override + Widget build(BuildContext context) { + return SizedBox( + key: const ValueKey('flutter-toggle-tab-indicator'), + width: width, + height: height, + child: Padding( + padding: margin, + child: DecoratedBox( + decoration: (isShadowEnable ? _bdHeader : const BoxDecoration()) + .copyWith( + borderRadius: BorderRadius.circular(radius), + gradient: LinearGradient( + begin: begin ?? Alignment.topCenter, + end: end ?? Alignment.bottomCenter, + colors: colors, + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 03d7e0c..bc2b9e2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,124 +5,153 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.1" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.3" collection: dependency: transitive description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.19.1" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: c6747018d37d32feeeb858a1d7043831cb541460ebe1a890a995cdb8df8ea5a5 + url: "https://pub.dev" + source: hosted + version: "1.1.0" fake_async: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.7" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lint: dependency: "direct dev" description: name: lint - sha256: d758a5211fce7fd3f5e316f804daefecdc34c7e53559716125e6da7388ae8565 + sha256: "78b35cb79457c0cc12d96d0f7e5c283d2661850a70b0ae5332673207065b63c8" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.14.0" matcher: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.20" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + material_ui: + dependency: "direct main" + description: + name: material_ui + sha256: "6d056d60ce745335fcf1c726a4fc46a481a62229f202b65dbc2db5edbbabc7e2" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "1.3.0" meta: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.19.0" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" sky_engine: dependency: transitive description: flutter @@ -132,66 +161,66 @@ packages: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.2" stack_trace: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.12.2" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.12" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.4.3" vm_service: dependency: transitive description: name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "14.3.0" + version: "15.3.0" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.13.0 <4.0.0" + flutter: ">=3.47.0" diff --git a/pubspec.yaml b/pubspec.yaml index 9d181b7..c66b6c8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,20 +1,21 @@ name: flutter_toggle_tab description: Library to create Beautiful and Customized Toggle Tab Widget on Flutter -version: 1.5.1 +version: 2.0.0 homepage: https://github.com/Lzyct/flutter_toggle_tab environment: - sdk: '^3.0.0' - flutter: ">=1.17.0" + sdk: '>=3.13.0 <4.0.0' + flutter: '>=3.47.0' dependencies: flutter: sdk: flutter + material_ui: ^1.3.0 dev_dependencies: flutter_test: sdk: flutter - lint: ^2.3.0 + lint: ^2.14.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/flutter_toggle_tab_test.dart b/test/flutter_toggle_tab_test.dart index ab73b3a..d7347da 100644 --- a/test/flutter_toggle_tab_test.dart +++ b/test/flutter_toggle_tab_test.dart @@ -1 +1,255 @@ -void main() {} +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_toggle_tab/flutter_toggle_tab.dart'; +import 'package:material_ui/material_ui.dart'; + +void main() { + testWidgets('renders a validation message when only one tab is provided', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: FlutterToggleTab( + dataTabs: [DataTab(title: 'Only tab')], + selectedIndex: 0, + selectedLabelIndex: (_) {}, + ), + ), + ); + + expect(find.text('Error : Label should >1'), findsOneWidget); + }); + + testWidgets('selection is controlled without mutating caller data', ( + tester, + ) async { + final selectedIndex = ValueNotifier(0); + addTearDown(selectedIndex.dispose); + final tabs = [DataTab(title: 'First'), DataTab(title: 'Second')]; + var parentBuildCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + parentBuildCount++; + return ValueListenableBuilder( + valueListenable: selectedIndex, + builder: (context, currentIndex, _) => FlutterToggleTab( + dataTabs: tabs, + selectedIndex: currentIndex, + selectedLabelIndex: (index) => selectedIndex.value = index, + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Second')); + await tester.pump(); + + expect(selectedIndex.value, 1); + expect(parentBuildCount, 1); + expect(tabs.every((tab) => !tab.isSelected), isTrue); + }); + + testWidgets('selected indicator slides toward the newly selected tab', ( + tester, + ) async { + final selectedIndex = ValueNotifier(0); + addTearDown(selectedIndex.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 300, + child: ValueListenableBuilder( + valueListenable: selectedIndex, + builder: (context, currentIndex, _) => FlutterToggleTab( + dataTabs: [ + DataTab(title: 'First'), + DataTab(title: 'Second'), + ], + selectedIndex: currentIndex, + selectedLabelIndex: (index) => selectedIndex.value = index, + animationDuration: const Duration(milliseconds: 300), + ), + ), + ), + ), + ), + ); + + final indicator = find.byKey( + const ValueKey('flutter-toggle-tab-indicator'), + ); + final initialX = tester.getCenter(indicator).dx; + + await tester.tap(find.text('Second')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + final animatedX = tester.getCenter(indicator).dx; + + await tester.pumpAndSettle(); + final finalX = tester.getCenter(indicator).dx; + + expect(animatedX, greaterThan(initialX)); + expect(animatedX, lessThan(finalX)); + }); + + testWidgets('adaptive width follows content and animates indicator size', ( + tester, + ) async { + final selectedIndex = ValueNotifier(0); + addTearDown(selectedIndex.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 400, + child: ValueListenableBuilder( + valueListenable: selectedIndex, + builder: (context, currentIndex, _) => FlutterToggleTab( + dataTabs: [ + DataTab(title: 'AAAA'), + DataTab(title: 'AAAAAAAAAAAAAAA'), + ], + selectedIndex: currentIndex, + selectedLabelIndex: (index) => selectedIndex.value = index, + isAdaptiveWidth: true, + animationDuration: const Duration(milliseconds: 300), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final shortButton = find.ancestor( + of: find.text('AAAA'), + matching: find.byType(TextButton), + ); + final longButton = find.ancestor( + of: find.text('AAAAAAAAAAAAAAA'), + matching: find.byType(TextButton), + ); + final initialIndicatorWidth = tester + .getSize(find.byKey(const ValueKey('flutter-toggle-tab-indicator'))) + .width; + final backgroundWidth = tester + .getSize(find.byKey(const ValueKey('flutter-toggle-tab-background'))) + .width; + final combinedTabWidth = + tester.getSize(shortButton).width + tester.getSize(longButton).width; + + expect( + tester.getSize(longButton).width, + greaterThan(tester.getSize(shortButton).width), + ); + expect(backgroundWidth, closeTo(combinedTabWidth, 0.01)); + expect(backgroundWidth, lessThan(400)); + + await tester.tap(longButton); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + final animatedIndicatorWidth = tester + .getSize(find.byKey(const ValueKey('flutter-toggle-tab-indicator'))) + .width; + + await tester.pumpAndSettle(); + final finalIndicatorWidth = tester + .getSize(find.byKey(const ValueKey('flutter-toggle-tab-indicator'))) + .width; + + expect(animatedIndicatorWidth, greaterThan(initialIndicatorWidth)); + expect(animatedIndicatorWidth, lessThan(finalIndicatorWidth)); + }); + + testWidgets('adaptive tab surface supports configurable alignment', ( + tester, + ) async { + const viewportKey = ValueKey('adaptive-alignment-viewport'); + const backgroundKey = ValueKey('flutter-toggle-tab-background'); + + Widget buildToggle({AlignmentGeometry? alignment}) { + final toggle = alignment == null + ? FlutterToggleTab( + dataTabs: [ + DataTab(title: 'A'), + DataTab(title: 'Longer'), + ], + selectedIndex: 0, + selectedLabelIndex: (_) {}, + isAdaptiveWidth: true, + ) + : FlutterToggleTab( + dataTabs: [ + DataTab(title: 'A'), + DataTab(title: 'Longer'), + ], + selectedIndex: 0, + selectedLabelIndex: (_) {}, + isAdaptiveWidth: true, + adaptiveTabAlignment: alignment, + ); + + return MaterialApp( + home: Center( + child: SizedBox(key: viewportKey, width: 400, child: toggle), + ), + ); + } + + await tester.pumpWidget(buildToggle()); + await tester.pumpAndSettle(); + + var viewport = tester.getRect(find.byKey(viewportKey)); + var background = tester.getRect(find.byKey(backgroundKey)); + expect(background.center.dx, closeTo(viewport.center.dx, 0.01)); + + await tester.pumpWidget(buildToggle(alignment: Alignment.centerLeft)); + await tester.pumpAndSettle(); + + viewport = tester.getRect(find.byKey(viewportKey)); + background = tester.getRect(find.byKey(backgroundKey)); + expect(background.left, closeTo(viewport.left, 0.01)); + + await tester.pumpWidget(buildToggle(alignment: Alignment.centerRight)); + await tester.pumpAndSettle(); + + viewport = tester.getRect(find.byKey(viewportKey)); + background = tester.getRect(find.byKey(backgroundKey)); + expect(background.right, closeTo(viewport.right, 0.01)); + }); + + testWidgets('long labels and empty gradients fit narrow constraints', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 180, + child: FlutterToggleTab( + width: 100, + dataTabs: [ + DataTab(title: 'A very long first tab label'), + DataTab(title: 'A very long second tab label'), + ], + selectedBackgroundColors: const [], + unSelectedBackgroundColors: const [], + selectedIndex: 0, + selectedLabelIndex: (_) {}, + isScroll: false, + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); +}