From 62ecb86b763290cc0cfca4fbdfc7c743664e4662 Mon Sep 17 00:00:00 2001 From: Emily Pauli Date: Tue, 21 Oct 2025 22:15:15 +0200 Subject: [PATCH] Add initial configuration, tokens, and widgets for M3E components - Introduced `.gitignore` and `.metadata` for apps and examples. - Added Flutter/Dart analysis configurations (`analysis_options.yaml`). - Implemented foundational tokens and themes for M3E (colors, shapes). - Created base implementations for `IconButtonM3E` and `SplitButtonM3E`. - Set up CI workflow (`ci.yaml`) to automate testing and analysis. --- .github/workflows/ci.yaml | 23 + .gitignore | 18 + README.md | 24 + analysis_options.yaml | 11 + apps/gallery/.gitignore | 45 ++ apps/gallery/.metadata | 45 ++ apps/gallery/README.md | 16 + apps/gallery/analysis_options.yaml | 28 + apps/gallery/lib/main.dart | 560 +++++++++++++++++ apps/gallery/pubspec.yaml | 16 + apps/gallery/pubspec_overrides.yaml | 30 + melos.yaml | 19 + melos_m3e.iml | 18 + packages/app_bar_m3e/README.md | 90 +++ packages/app_bar_m3e/lib/app_bar_m3e.dart | 5 + .../app_bar_m3e/lib/src/_tokens_adapter.dart | 74 +++ .../lib/src/app_bar_m3e_enums.dart | 3 + .../lib/src/app_bar_m3e_widget.dart | 134 ++++ .../lib/src/sliver_app_bar_m3e.dart | 145 +++++ packages/app_bar_m3e/melos_app_bar_m3e.iml | 29 + packages/app_bar_m3e/pubspec.yaml | 18 + packages/app_bar_m3e/pubspec_overrides.yaml | 4 + .../app_bar_m3e/test/app_bar_m3e_test.dart | 7 + packages/button_group_m3e/README.md | 100 +++ .../lib/button_group_m3e.dart | 5 + .../lib/src/_tokens_adapter.dart | 44 ++ .../lib/src/button_group_m3e_enums.dart | 4 + .../lib/src/button_group_m3e_scope.dart | 64 ++ .../lib/src/button_group_m3e_widget.dart | 186 ++++++ .../melos_button_group_m3e.iml | 29 + packages/button_group_m3e/pubspec.yaml | 18 + .../button_group_m3e/pubspec_overrides.yaml | 4 + .../test/button_group_m3e_test.dart | 7 + packages/button_m3e/LICENSE | 21 + packages/button_m3e/README.md | 70 +++ packages/button_m3e/lib/button_m3e.dart | 5 + packages/button_m3e/lib/src/button_m3e.dart | 144 +++++ .../button_m3e/lib/src/button_m3e_widget.dart | 24 + .../button_m3e/lib/src/button_theme_m3e.dart | 94 +++ packages/button_m3e/lib/src/enums.dart | 4 + packages/button_m3e/melos_button_m3e.iml | 29 + packages/button_m3e/pubspec.yaml | 18 + packages/button_m3e/pubspec_overrides.yaml | 4 + packages/button_m3e/test/button_m3e_test.dart | 7 + packages/fab_m3e/LICENSE | 21 + packages/fab_m3e/README.md | 93 +++ packages/fab_m3e/lib/fab_m3e.dart | 7 + packages/fab_m3e/lib/src/enums.dart | 8 + .../fab_m3e/lib/src/extended_fab_m3e.dart | 93 +++ packages/fab_m3e/lib/src/fab_m3e.dart | 88 +++ packages/fab_m3e/lib/src/fab_m3e_widget.dart | 21 + packages/fab_m3e/lib/src/fab_menu_m3e.dart | 247 ++++++++ packages/fab_m3e/lib/src/fab_theme_m3e.dart | 106 ++++ packages/fab_m3e/melos_fab_m3e.iml | 29 + packages/fab_m3e/pubspec.yaml | 18 + packages/fab_m3e/pubspec_overrides.yaml | 4 + packages/fab_m3e/test/fab_m3e_test.dart | 7 + packages/icon_button_m3e/README.md | 92 +++ packages/icon_button_m3e/example/.gitignore | 45 ++ packages/icon_button_m3e/example/.metadata | 45 ++ packages/icon_button_m3e/example/README.md | 16 + .../example/analysis_options.yaml | 28 + .../icon_button_m3e/example/lib/main.dart | 183 ++++++ packages/icon_button_m3e/example/pubspec.yaml | 21 + .../icon_button_m3e/lib/icon_button_m3e.dart | 4 + .../lib/src/_tokens_adapter.dart | 122 ++++ packages/icon_button_m3e/lib/src/enums.dart | 86 +++ .../lib/src/icon_button_m3e.dart | 139 +++++ .../icon_button_m3e/melos_icon_button_m3e.iml | 29 + packages/icon_button_m3e/pubspec.yaml | 21 + .../icon_button_m3e/pubspec_overrides.yaml | 4 + .../test/icon_button_m3e_test.dart | 46 ++ packages/loading_indicator_m3e/LICENSE | 21 + packages/loading_indicator_m3e/README.md | 56 ++ .../lib/loading_indicator_m3e.dart | 6 + .../loading_indicator_m3e/lib/src/enums.dart | 1 + .../lib/src/expressive_loading_indicator.dart | 329 ++++++++++ .../lib/src/loading_indicator_m3e.dart | 83 +++ .../lib/src/loading_tokens_adapter.dart | 31 + .../melos_loading_indicator_m3e.iml | 29 + packages/loading_indicator_m3e/pubspec.yaml | 19 + .../test/loading_indicator_m3e_test.dart | 7 + packages/m3e_collection/README.md | 3 + .../m3e_collection/lib/m3e_collection.dart | 16 + .../m3e_collection/melos_m3e_collection.iml | 29 + packages/m3e_collection/pubspec.yaml | 39 ++ .../m3e_collection/pubspec_overrides.yaml | 29 + packages/m3e_design/README.md | 4 + packages/m3e_design/lib/m3e_design.dart | 10 + packages/m3e_design/lib/theme/m3e_theme.dart | 99 +++ .../m3e_design/lib/tokens/color_tokens.dart | 196 ++++++ .../m3e_design/lib/tokens/motion_tokens.dart | 43 ++ .../m3e_design/lib/tokens/shape_tokens.dart | 51 ++ .../m3e_design/lib/tokens/spacing_tokens.dart | 37 ++ .../lib/tokens/typography_tokens.dart | 54 ++ .../m3e_design/lib/utils/build_context_x.dart | 7 + .../m3e_design/lib/utils/semantics_x.dart | 5 + packages/m3e_design/melos_m3e_design.iml | 29 + packages/m3e_design/pubspec.yaml | 16 + packages/navigation_bar_m3e/LICENSE | 21 + packages/navigation_bar_m3e/README.md | 77 +++ .../lib/navigation_bar_m3e.dart | 7 + .../navigation_bar_m3e/lib/src/enums.dart | 5 + .../lib/src/nav_badge_m3e.dart | 78 +++ .../lib/src/nav_destination_m3e.dart | 38 ++ .../lib/src/nav_tokens_adapter.dart | 84 +++ .../lib/src/navigation_bar_m3e.dart | 144 +++++ .../lib/src/navigation_bar_m3e_widget.dart | 21 + .../melos_navigation_bar_m3e.iml | 29 + packages/navigation_bar_m3e/pubspec.yaml | 18 + .../navigation_bar_m3e/pubspec_overrides.yaml | 4 + .../test/navigation_bar_m3e_test.dart | 7 + packages/navigation_rail_m3e/LICENSE | 21 + packages/navigation_rail_m3e/README.md | 84 +++ .../lib/navigation_rail_m3e.dart | 7 + .../navigation_rail_m3e/lib/src/enums.dart | 5 + .../lib/src/navigation_rail_m3e.dart | 177 ++++++ .../lib/src/navigation_rail_m3e_widget.dart | 21 + .../lib/src/rail_badge_m3e.dart | 76 +++ .../lib/src/rail_destination_m3e.dart | 38 ++ .../lib/src/rail_tokens_adapter.dart | 88 +++ .../melos_navigation_rail_m3e.iml | 29 + packages/navigation_rail_m3e/pubspec.yaml | 18 + .../pubspec_overrides.yaml | 4 + .../test/navigation_rail_m3e_test.dart | 7 + packages/progress_indicator_m3e/LICENSE | 21 + packages/progress_indicator_m3e/README.md | 65 ++ .../lib/progress_indicator_m3e.dart | 7 + .../lib/src/circular_progress_m3e.dart | 286 +++++++++ .../progress_indicator_m3e/lib/src/enums.dart | 8 + .../lib/src/linear_progress_m3e.dart | 378 ++++++++++++ .../lib/src/progress_with_label_m3e.dart | 74 +++ .../lib/src/tokens_adapter.dart | 94 +++ .../melos_progress_indicator_m3e.iml | 29 + packages/progress_indicator_m3e/pubspec.yaml | 18 + .../pubspec_overrides.yaml | 4 + .../test/progress_indicators_m3e_test.dart | 7 + packages/slider_m3e/LICENSE | 21 + packages/slider_m3e/README.md | 69 +++ packages/slider_m3e/lib/slider_m3e.dart | 7 + packages/slider_m3e/lib/src/enums.dart | 4 + .../slider_m3e/lib/src/range_slider_m3e.dart | 71 +++ packages/slider_m3e/lib/src/slider_m3e.dart | 85 +++ .../slider_m3e/lib/src/slider_m3e_widget.dart | 21 + .../slider_m3e/lib/src/slider_theme_m3e.dart | 114 ++++ .../lib/src/slider_tokens_adapter.dart | 83 +++ packages/slider_m3e/melos_slider_m3e.iml | 29 + packages/slider_m3e/pubspec.yaml | 18 + packages/slider_m3e/pubspec_overrides.yaml | 4 + packages/slider_m3e/test/slider_m3e_test.dart | 7 + packages/split_button_m3e/.gitignore | 31 + packages/split_button_m3e/.metadata | 10 + packages/split_button_m3e/CHANGELOG.md | 7 + packages/split_button_m3e/LICENSE | 21 + packages/split_button_m3e/README.md | 133 ++++ .../split_button_m3e/analysis_options.yaml | 5 + packages/split_button_m3e/example/.gitignore | 45 ++ packages/split_button_m3e/example/.metadata | 45 ++ packages/split_button_m3e/example/README.md | 16 + .../example/analysis_options.yaml | 28 + .../split_button_m3e/example/lib/main.dart | 114 ++++ .../split_button_m3e/example/pubspec.yaml | 21 + .../lib/split_button_m3e.dart | 5 + .../lib/src/_tokens_adapter.dart | 160 +++++ packages/split_button_m3e/lib/src/enums.dart | 46 ++ .../split_button_m3e/lib/src/menu_items.dart | 12 + .../lib/src/split_button.dart | 578 ++++++++++++++++++ packages/split_button_m3e/pubspec.yaml | 22 + .../split_button_m3e/pubspec_overrides.yaml | 4 + .../test/split_button_m3e_test.dart | 70 +++ packages/toolbar_m3e/LICENSE | 21 + packages/toolbar_m3e/README.md | 78 +++ packages/toolbar_m3e/lib/src/enums.dart | 4 + .../lib/src/toolbar_action_m3e.dart | 49 ++ packages/toolbar_m3e/lib/src/toolbar_m3e.dart | 244 ++++++++ .../lib/src/toolbar_m3e_widget.dart | 21 + .../lib/src/toolbar_tokens_adapter.dart | 91 +++ packages/toolbar_m3e/lib/toolbar_m3e.dart | 6 + packages/toolbar_m3e/melos_toolbar_m3e.iml | 29 + packages/toolbar_m3e/pubspec.yaml | 18 + packages/toolbar_m3e/pubspec_overrides.yaml | 4 + .../toolbar_m3e/test/toolbar_m3e_test.dart | 7 + pubspec.yaml | 8 + tool/create_component.dart | 172 ++++++ 184 files changed, 9872 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 apps/gallery/.gitignore create mode 100644 apps/gallery/.metadata create mode 100644 apps/gallery/README.md create mode 100644 apps/gallery/analysis_options.yaml create mode 100644 apps/gallery/lib/main.dart create mode 100644 apps/gallery/pubspec.yaml create mode 100644 apps/gallery/pubspec_overrides.yaml create mode 100644 melos.yaml create mode 100644 melos_m3e.iml create mode 100644 packages/app_bar_m3e/README.md create mode 100644 packages/app_bar_m3e/lib/app_bar_m3e.dart create mode 100644 packages/app_bar_m3e/lib/src/_tokens_adapter.dart create mode 100644 packages/app_bar_m3e/lib/src/app_bar_m3e_enums.dart create mode 100644 packages/app_bar_m3e/lib/src/app_bar_m3e_widget.dart create mode 100644 packages/app_bar_m3e/lib/src/sliver_app_bar_m3e.dart create mode 100644 packages/app_bar_m3e/melos_app_bar_m3e.iml create mode 100644 packages/app_bar_m3e/pubspec.yaml create mode 100644 packages/app_bar_m3e/pubspec_overrides.yaml create mode 100644 packages/app_bar_m3e/test/app_bar_m3e_test.dart create mode 100644 packages/button_group_m3e/README.md create mode 100644 packages/button_group_m3e/lib/button_group_m3e.dart create mode 100644 packages/button_group_m3e/lib/src/_tokens_adapter.dart create mode 100644 packages/button_group_m3e/lib/src/button_group_m3e_enums.dart create mode 100644 packages/button_group_m3e/lib/src/button_group_m3e_scope.dart create mode 100644 packages/button_group_m3e/lib/src/button_group_m3e_widget.dart create mode 100644 packages/button_group_m3e/melos_button_group_m3e.iml create mode 100644 packages/button_group_m3e/pubspec.yaml create mode 100644 packages/button_group_m3e/pubspec_overrides.yaml create mode 100644 packages/button_group_m3e/test/button_group_m3e_test.dart create mode 100644 packages/button_m3e/LICENSE create mode 100644 packages/button_m3e/README.md create mode 100644 packages/button_m3e/lib/button_m3e.dart create mode 100644 packages/button_m3e/lib/src/button_m3e.dart create mode 100644 packages/button_m3e/lib/src/button_m3e_widget.dart create mode 100644 packages/button_m3e/lib/src/button_theme_m3e.dart create mode 100644 packages/button_m3e/lib/src/enums.dart create mode 100644 packages/button_m3e/melos_button_m3e.iml create mode 100644 packages/button_m3e/pubspec.yaml create mode 100644 packages/button_m3e/pubspec_overrides.yaml create mode 100644 packages/button_m3e/test/button_m3e_test.dart create mode 100644 packages/fab_m3e/LICENSE create mode 100644 packages/fab_m3e/README.md create mode 100644 packages/fab_m3e/lib/fab_m3e.dart create mode 100644 packages/fab_m3e/lib/src/enums.dart create mode 100644 packages/fab_m3e/lib/src/extended_fab_m3e.dart create mode 100644 packages/fab_m3e/lib/src/fab_m3e.dart create mode 100644 packages/fab_m3e/lib/src/fab_m3e_widget.dart create mode 100644 packages/fab_m3e/lib/src/fab_menu_m3e.dart create mode 100644 packages/fab_m3e/lib/src/fab_theme_m3e.dart create mode 100644 packages/fab_m3e/melos_fab_m3e.iml create mode 100644 packages/fab_m3e/pubspec.yaml create mode 100644 packages/fab_m3e/pubspec_overrides.yaml create mode 100644 packages/fab_m3e/test/fab_m3e_test.dart create mode 100644 packages/icon_button_m3e/README.md create mode 100644 packages/icon_button_m3e/example/.gitignore create mode 100644 packages/icon_button_m3e/example/.metadata create mode 100644 packages/icon_button_m3e/example/README.md create mode 100644 packages/icon_button_m3e/example/analysis_options.yaml create mode 100644 packages/icon_button_m3e/example/lib/main.dart create mode 100644 packages/icon_button_m3e/example/pubspec.yaml create mode 100644 packages/icon_button_m3e/lib/icon_button_m3e.dart create mode 100644 packages/icon_button_m3e/lib/src/_tokens_adapter.dart create mode 100644 packages/icon_button_m3e/lib/src/enums.dart create mode 100644 packages/icon_button_m3e/lib/src/icon_button_m3e.dart create mode 100644 packages/icon_button_m3e/melos_icon_button_m3e.iml create mode 100644 packages/icon_button_m3e/pubspec.yaml create mode 100644 packages/icon_button_m3e/pubspec_overrides.yaml create mode 100644 packages/icon_button_m3e/test/icon_button_m3e_test.dart create mode 100644 packages/loading_indicator_m3e/LICENSE create mode 100644 packages/loading_indicator_m3e/README.md create mode 100644 packages/loading_indicator_m3e/lib/loading_indicator_m3e.dart create mode 100644 packages/loading_indicator_m3e/lib/src/enums.dart create mode 100644 packages/loading_indicator_m3e/lib/src/expressive_loading_indicator.dart create mode 100644 packages/loading_indicator_m3e/lib/src/loading_indicator_m3e.dart create mode 100644 packages/loading_indicator_m3e/lib/src/loading_tokens_adapter.dart create mode 100644 packages/loading_indicator_m3e/melos_loading_indicator_m3e.iml create mode 100644 packages/loading_indicator_m3e/pubspec.yaml create mode 100644 packages/loading_indicator_m3e/test/loading_indicator_m3e_test.dart create mode 100644 packages/m3e_collection/README.md create mode 100644 packages/m3e_collection/lib/m3e_collection.dart create mode 100644 packages/m3e_collection/melos_m3e_collection.iml create mode 100644 packages/m3e_collection/pubspec.yaml create mode 100644 packages/m3e_collection/pubspec_overrides.yaml create mode 100644 packages/m3e_design/README.md create mode 100644 packages/m3e_design/lib/m3e_design.dart create mode 100644 packages/m3e_design/lib/theme/m3e_theme.dart create mode 100644 packages/m3e_design/lib/tokens/color_tokens.dart create mode 100644 packages/m3e_design/lib/tokens/motion_tokens.dart create mode 100644 packages/m3e_design/lib/tokens/shape_tokens.dart create mode 100644 packages/m3e_design/lib/tokens/spacing_tokens.dart create mode 100644 packages/m3e_design/lib/tokens/typography_tokens.dart create mode 100644 packages/m3e_design/lib/utils/build_context_x.dart create mode 100644 packages/m3e_design/lib/utils/semantics_x.dart create mode 100644 packages/m3e_design/melos_m3e_design.iml create mode 100644 packages/m3e_design/pubspec.yaml create mode 100644 packages/navigation_bar_m3e/LICENSE create mode 100644 packages/navigation_bar_m3e/README.md create mode 100644 packages/navigation_bar_m3e/lib/navigation_bar_m3e.dart create mode 100644 packages/navigation_bar_m3e/lib/src/enums.dart create mode 100644 packages/navigation_bar_m3e/lib/src/nav_badge_m3e.dart create mode 100644 packages/navigation_bar_m3e/lib/src/nav_destination_m3e.dart create mode 100644 packages/navigation_bar_m3e/lib/src/nav_tokens_adapter.dart create mode 100644 packages/navigation_bar_m3e/lib/src/navigation_bar_m3e.dart create mode 100644 packages/navigation_bar_m3e/lib/src/navigation_bar_m3e_widget.dart create mode 100644 packages/navigation_bar_m3e/melos_navigation_bar_m3e.iml create mode 100644 packages/navigation_bar_m3e/pubspec.yaml create mode 100644 packages/navigation_bar_m3e/pubspec_overrides.yaml create mode 100644 packages/navigation_bar_m3e/test/navigation_bar_m3e_test.dart create mode 100644 packages/navigation_rail_m3e/LICENSE create mode 100644 packages/navigation_rail_m3e/README.md create mode 100644 packages/navigation_rail_m3e/lib/navigation_rail_m3e.dart create mode 100644 packages/navigation_rail_m3e/lib/src/enums.dart create mode 100644 packages/navigation_rail_m3e/lib/src/navigation_rail_m3e.dart create mode 100644 packages/navigation_rail_m3e/lib/src/navigation_rail_m3e_widget.dart create mode 100644 packages/navigation_rail_m3e/lib/src/rail_badge_m3e.dart create mode 100644 packages/navigation_rail_m3e/lib/src/rail_destination_m3e.dart create mode 100644 packages/navigation_rail_m3e/lib/src/rail_tokens_adapter.dart create mode 100644 packages/navigation_rail_m3e/melos_navigation_rail_m3e.iml create mode 100644 packages/navigation_rail_m3e/pubspec.yaml create mode 100644 packages/navigation_rail_m3e/pubspec_overrides.yaml create mode 100644 packages/navigation_rail_m3e/test/navigation_rail_m3e_test.dart create mode 100644 packages/progress_indicator_m3e/LICENSE create mode 100644 packages/progress_indicator_m3e/README.md create mode 100644 packages/progress_indicator_m3e/lib/progress_indicator_m3e.dart create mode 100644 packages/progress_indicator_m3e/lib/src/circular_progress_m3e.dart create mode 100644 packages/progress_indicator_m3e/lib/src/enums.dart create mode 100644 packages/progress_indicator_m3e/lib/src/linear_progress_m3e.dart create mode 100644 packages/progress_indicator_m3e/lib/src/progress_with_label_m3e.dart create mode 100644 packages/progress_indicator_m3e/lib/src/tokens_adapter.dart create mode 100644 packages/progress_indicator_m3e/melos_progress_indicator_m3e.iml create mode 100644 packages/progress_indicator_m3e/pubspec.yaml create mode 100644 packages/progress_indicator_m3e/pubspec_overrides.yaml create mode 100644 packages/progress_indicator_m3e/test/progress_indicators_m3e_test.dart create mode 100644 packages/slider_m3e/LICENSE create mode 100644 packages/slider_m3e/README.md create mode 100644 packages/slider_m3e/lib/slider_m3e.dart create mode 100644 packages/slider_m3e/lib/src/enums.dart create mode 100644 packages/slider_m3e/lib/src/range_slider_m3e.dart create mode 100644 packages/slider_m3e/lib/src/slider_m3e.dart create mode 100644 packages/slider_m3e/lib/src/slider_m3e_widget.dart create mode 100644 packages/slider_m3e/lib/src/slider_theme_m3e.dart create mode 100644 packages/slider_m3e/lib/src/slider_tokens_adapter.dart create mode 100644 packages/slider_m3e/melos_slider_m3e.iml create mode 100644 packages/slider_m3e/pubspec.yaml create mode 100644 packages/slider_m3e/pubspec_overrides.yaml create mode 100644 packages/slider_m3e/test/slider_m3e_test.dart create mode 100644 packages/split_button_m3e/.gitignore create mode 100644 packages/split_button_m3e/.metadata create mode 100644 packages/split_button_m3e/CHANGELOG.md create mode 100644 packages/split_button_m3e/LICENSE create mode 100644 packages/split_button_m3e/README.md create mode 100644 packages/split_button_m3e/analysis_options.yaml create mode 100644 packages/split_button_m3e/example/.gitignore create mode 100644 packages/split_button_m3e/example/.metadata create mode 100644 packages/split_button_m3e/example/README.md create mode 100644 packages/split_button_m3e/example/analysis_options.yaml create mode 100644 packages/split_button_m3e/example/lib/main.dart create mode 100644 packages/split_button_m3e/example/pubspec.yaml create mode 100644 packages/split_button_m3e/lib/split_button_m3e.dart create mode 100644 packages/split_button_m3e/lib/src/_tokens_adapter.dart create mode 100644 packages/split_button_m3e/lib/src/enums.dart create mode 100644 packages/split_button_m3e/lib/src/menu_items.dart create mode 100644 packages/split_button_m3e/lib/src/split_button.dart create mode 100644 packages/split_button_m3e/pubspec.yaml create mode 100644 packages/split_button_m3e/pubspec_overrides.yaml create mode 100644 packages/split_button_m3e/test/split_button_m3e_test.dart create mode 100644 packages/toolbar_m3e/LICENSE create mode 100644 packages/toolbar_m3e/README.md create mode 100644 packages/toolbar_m3e/lib/src/enums.dart create mode 100644 packages/toolbar_m3e/lib/src/toolbar_action_m3e.dart create mode 100644 packages/toolbar_m3e/lib/src/toolbar_m3e.dart create mode 100644 packages/toolbar_m3e/lib/src/toolbar_m3e_widget.dart create mode 100644 packages/toolbar_m3e/lib/src/toolbar_tokens_adapter.dart create mode 100644 packages/toolbar_m3e/lib/toolbar_m3e.dart create mode 100644 packages/toolbar_m3e/melos_toolbar_m3e.iml create mode 100644 packages/toolbar_m3e/pubspec.yaml create mode 100644 packages/toolbar_m3e/pubspec_overrides.yaml create mode 100644 packages/toolbar_m3e/test/toolbar_m3e_test.dart create mode 100644 pubspec.yaml create mode 100644 tool/create_component.dart diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..2c1011f --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,23 @@ +name: CI +on: + push: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - name: Install Melos + run: dart pub global activate melos + - name: Bootstrap + run: melos bootstrap + - name: Format + run: melos run format + - name: Analyze + run: melos run analyze + - name: Test + run: melos run test diff --git a/.gitignore b/.gitignore index 39b8814..c44c6f9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,24 @@ .buildlog/ .history +# Other +.dart_tool/ +.packages +.melos_tool +build/ +ios/ +android/ +macos/ +windows/ +linux/ +web/ +.idea/ +.vscode/ +.flutter-plugins +.flutter-plugins-dependencies +pubspec.lock +coverage/ + # Flutter repo-specific diff --git a/README.md b/README.md new file mode 100644 index 0000000..4cdee6b --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Material 3 Expressive – Flutter Monorepo (Starter) + +This is a starter monorepo for **Material 3 Expressive (M3E)** Flutter packages. + +- `packages/m3e_design` – design language core (tokens, ThemeExtension, motion) +- `packages/m3e_collection` – re-exports all component packages +- `packages/icon_button_m3e` – example component (uses `m3e_design`) +- `packages/split_button_m3e` – example split button component +- `apps/gallery` – showcase app that consumes `m3e_collection` + +## Quick start + +```bash +dart pub global activate melos +melos bootstrap + +# run the gallery +cd apps/gallery +flutter run +``` + +## Structure + +See `melos.yaml`, `analysis_options.yaml`, and the package-level READMEs. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..632fc3e --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,11 @@ +linter: + rules: + always_declare_return_types: true + directives_ordering: true + prefer_single_quotes: true + sort_pub_dependencies: true + +analyzer: + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" diff --git a/apps/gallery/.gitignore b/apps/gallery/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/apps/gallery/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/apps/gallery/.metadata b/apps/gallery/.metadata new file mode 100644 index 0000000..84f56b1 --- /dev/null +++ b/apps/gallery/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: android + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: ios + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: linux + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: macos + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: web + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: windows + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/gallery/README.md b/apps/gallery/README.md new file mode 100644 index 0000000..b900e65 --- /dev/null +++ b/apps/gallery/README.md @@ -0,0 +1,16 @@ +# m3e_gallery + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/apps/gallery/analysis_options.yaml b/apps/gallery/analysis_options.yaml new file mode 100644 index 0000000..5577701 --- /dev/null +++ b/apps/gallery/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +# include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/apps/gallery/lib/main.dart b/apps/gallery/lib/main.dart new file mode 100644 index 0000000..e7a59a0 --- /dev/null +++ b/apps/gallery/lib/main.dart @@ -0,0 +1,560 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; + +void main() => runApp(const GalleryApp()); + +class GalleryApp extends StatelessWidget { + const GalleryApp({super.key}); + + @override + Widget build(BuildContext context) { + final base = ThemeData(useMaterial3: true, colorSchemeSeed: Colors.purple); + return MaterialApp( + title: 'M3E Gallery', + theme: withM3ETheme(base), + home: const GalleryHome(), + debugShowCheckedModeBanner: false, + ); + } +} + +class GalleryHome extends StatefulWidget { + const GalleryHome({super.key}); + + @override + State createState() => _GalleryHomeState(); +} + +class _GalleryHomeState extends State { + // Navigation examples + int _navBarIndex = 0; + int _railIndex = 0; + + // Slider examples + double _sliderValue = 0.4; + RangeValues _rangeValues = const RangeValues(0.25, 0.75); + + // Progress examples + double _progressValue = 0.6; + + void onPressed() { + // Placeholder function for button onPressed + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final m3e = + Theme.of(context).extension() ?? M3ETheme.defaults(cs); + return Scaffold( + appBar: AppBarM3E( + titleText: 'M3E Gallery', + actions: const [ + Icon(Icons.search), + SizedBox(width: 8), + Icon(Icons.more_vert), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Icon buttons + Text('IconButtonM3E', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + IconButtonM3E( + icon: Icon(Icons.favorite), + variant: IconButtonM3EVariant.filled, + onPressed: onPressed), + IconButtonM3E( + icon: Icon(Icons.favorite), + variant: IconButtonM3EVariant.tonal, + onPressed: onPressed), + IconButtonM3E( + icon: Icon(Icons.favorite), + variant: IconButtonM3EVariant.outlined, + onPressed: onPressed), + IconButtonM3E( + icon: Icon(Icons.favorite), + variant: IconButtonM3EVariant.standard, + onPressed: onPressed), + ], + ), + + const SizedBox(height: 24), + // Split Button + Text('SplitButtonM3E', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SplitButtonM3E( + label: 'Primary', + onPressed: () {}, + items: const [ + SplitButtonM3EItem(value: 'one', child: 'One'), + SplitButtonM3EItem(value: 'two', child: 'Two'), + ], + ), + SplitButtonM3E( + label: 'Tonal', + emphasis: SplitButtonM3EEmphasis.tonal, + onPressed: () {}, + items: const [ + SplitButtonM3EItem(value: 'one', child: 'One'), + SplitButtonM3EItem(value: 'two', child: 'Two'), + ], + ), + SplitButtonM3E( + label: 'Outlined', + emphasis: SplitButtonM3EEmphasis.outlined, + onPressed: () {}, + items: const [ + SplitButtonM3EItem(value: 'one', child: 'One'), + SplitButtonM3EItem(value: 'two', child: 'Two'), + ], + ), + SplitButtonM3E( + label: 'Text', + emphasis: SplitButtonM3EEmphasis.text, + onPressed: () {}, + items: const [ + SplitButtonM3EItem(value: 'one', child: 'One'), + SplitButtonM3EItem(value: 'two', child: 'Two'), + ], + ), + ], + ), + + const SizedBox(height: 24), + // Buttons + Text('ButtonM3E', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + Wrap(spacing: 12, runSpacing: 12, children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ButtonM3E( + labelText: 'Filled', + variant: ButtonM3EVariant.filled, + onPressed: onPressed), + ButtonM3E( + labelText: 'Tonal', + variant: ButtonM3EVariant.tonal, + onPressed: onPressed), + ButtonM3E( + labelText: 'Outlined', + variant: ButtonM3EVariant.outlined, + onPressed: onPressed), + ButtonM3E( + labelText: 'Text', + variant: ButtonM3EVariant.text, + onPressed: onPressed), + ButtonM3E( + labelText: 'Elevated', + variant: ButtonM3EVariant.elevated, + onPressed: onPressed), + ], + ), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ButtonM3E( + labelText: 'Filled', + variant: ButtonM3EVariant.filled, + shapeFamily: ButtonM3EShapeFamily.square, + onPressed: onPressed), + ButtonM3E( + labelText: 'Tonal', + variant: ButtonM3EVariant.tonal, + shapeFamily: ButtonM3EShapeFamily.square, + onPressed: onPressed), + ButtonM3E( + labelText: 'Outlined', + variant: ButtonM3EVariant.outlined, + shapeFamily: ButtonM3EShapeFamily.square, + onPressed: onPressed), + ButtonM3E( + labelText: 'Text', + variant: ButtonM3EVariant.text, + shapeFamily: ButtonM3EShapeFamily.square, + onPressed: onPressed), + ButtonM3E( + labelText: 'Elevated', + variant: ButtonM3EVariant.elevated, + shapeFamily: ButtonM3EShapeFamily.square, + onPressed: onPressed), + ], + ), + ]), + + const SizedBox(height: 24), + // Button groups + Text('ButtonGroupM3E', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + // Standard group, round, md + ButtonGroupM3E( + type: ButtonGroupM3EType.standard, + shape: ButtonGroupM3EShape.round, + size: ButtonGroupM3ESize.md, + children: const [ + IconButtonM3E(icon: Icon(Icons.skip_previous)), + IconButtonM3E(icon: Icon(Icons.play_arrow)), + IconButtonM3E(icon: Icon(Icons.skip_next)), + ], + ), + const SizedBox(height: 12), + // Connected group with divider and clipping for outer corners + ButtonGroupM3E( + type: ButtonGroupM3EType.connected, + shape: ButtonGroupM3EShape.round, + size: ButtonGroupM3ESize.lg, + showDividers: true, + clipBehavior: Clip.hardEdge, + equalizeWidths: true, + semanticLabel: 'Actions', + children: [ + SplitButtonM3E( + label: 'Primary', + onPressed: () {}, + items: const [ + SplitButtonM3EItem(value: 'one', child: 'One'), + SplitButtonM3EItem(value: 'two', child: 'Two'), + ], + ), + SplitButtonM3E( + label: 'Tonal', + emphasis: SplitButtonM3EEmphasis.tonal, + onPressed: () {}, + items: const [ + SplitButtonM3EItem(value: 'one', child: 'One'), + SplitButtonM3EItem(value: 'two', child: 'Two'), + ], + ), + ], + ), + const SizedBox(height: 12), + // Square, compact, vertical wrap + ButtonGroupM3E( + type: ButtonGroupM3EType.standard, + shape: ButtonGroupM3EShape.square, + size: ButtonGroupM3ESize.sm, + density: ButtonGroupM3EDensity.compact, + direction: Axis.vertical, + children: const [ + IconButtonM3E(icon: Icon(Icons.view_agenda_outlined)), + IconButtonM3E(icon: Icon(Icons.table_rows_outlined)), + IconButtonM3E(icon: Icon(Icons.grid_view_outlined)), + ], + ), + + const SizedBox(height: 24), + // Toolbar + Text('ToolbarM3E', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + ToolbarM3E( + titleText: 'Toolbar Title', + subtitleText: 'Subtitle', + actions: [ + ToolbarActionM3E(icon: Icons.search, onPressed: () {}), + ToolbarActionM3E(icon: Icons.share, onPressed: () {}), + ToolbarActionM3E( + icon: Icons.delete, + onPressed: () {}, + isDestructive: true, + label: 'Delete'), + ToolbarActionM3E( + icon: Icons.settings, onPressed: () {}, label: 'Settings'), + ], + maxInlineActions: 2, + ), + + const SizedBox(height: 24), + // FABs + Text('FabM3E', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: const [ + FabM3E(icon: Icon(Icons.add)), + FabM3E(icon: Icon(Icons.edit), kind: FabM3EKind.secondary), + FabM3E(icon: Icon(Icons.share), kind: FabM3EKind.tertiary), + FabM3E(icon: Icon(Icons.more_horiz), kind: FabM3EKind.surface), + FabM3E(icon: Icon(Icons.add), size: FabM3ESize.small), + FabM3E(icon: Icon(Icons.add), size: FabM3ESize.large), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: const [ + ExtendedFabM3E(label: Text('Create'), icon: Icon(Icons.add)), + ExtendedFabM3E( + label: Text('Edit'), + icon: Icon(Icons.edit), + kind: FabM3EKind.secondary), + ExtendedFabM3E( + label: Text('Share'), + icon: Icon(Icons.share), + kind: FabM3EKind.tertiary), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 180, + child: Stack( + children: [ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: m3e.shapes.round.lg, + ), + ), + ), + Align( + alignment: Alignment.bottomRight, + child: FabMenuM3E( + primaryFab: const FabM3E(icon: Icon(Icons.menu)), + items: [ + FabMenuItem( + icon: const Icon(Icons.image), + label: const Text('Image'), + onPressed: () {}), + FabMenuItem( + icon: const Icon(Icons.camera_alt), + label: const Text('Camera'), + onPressed: () {}), + FabMenuItem( + icon: const Icon(Icons.file_upload), + label: const Text('Upload'), + onPressed: () {}), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + // Loading indicators + Text('LoadingIndicatorM3E', + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + Wrap( + spacing: 16, + runSpacing: 16, + crossAxisAlignment: WrapCrossAlignment.center, + children: const [ + LoadingIndicatorM3E( + variant: LoadingIndicatorM3EVariant.defaultStyle), + LoadingIndicatorM3E( + variant: LoadingIndicatorM3EVariant.contained), + ], + ), + + const SizedBox(height: 24), + // Progress indicators + Text('ProgressIndicatorM3E', + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + Wrap( + spacing: 16, + runSpacing: 16, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + const CircularProgressM3E(), + const CircularProgressM3E(size: ProgressM3ESize.small), + CircularProgressM3E( + size: ProgressM3ESize.large, + value: _progressValue, + showCenterLabel: true), + const LinearProgressM3E(minWidth: 200), + LinearProgressM3E(minWidth: 200, value: _progressValue), + const LinearProgressM3E( + minWidth: 200, + variant: LinearProgressM3EVariant.indeterminate), + const LinearProgressM3E( + minWidth: 200, variant: LinearProgressM3EVariant.query), + const LinearProgressM3E( + minWidth: 200, + variant: LinearProgressM3EVariant.buffer, + bufferValue: 0.8, + value: 0.4), + ], + ), + + const SizedBox(height: 24), + // Sliders + Text('SliderM3E', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + SliderM3E( + value: _sliderValue, + onChanged: (v) => setState(() => _sliderValue = v), + min: 0, + max: 100, + label: '${_sliderValue.toStringAsFixed(0)}', + startIcon: const Icon(Icons.volume_mute), + endIcon: const Icon(Icons.volume_up), + ), + const SizedBox(height: 8), + RangeSliderM3E( + values: _rangeValues, + onChanged: (v) => setState(() => _rangeValues = v), + min: 0, + max: 100, + labels: RangeLabels( + _rangeValues.start.toStringAsFixed(0), + _rangeValues.end.toStringAsFixed(0), + ), + ), + + const SizedBox(height: 24), + // Navigation Bar + Text('NavigationBarM3E', + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + NavigationBarM3E( + selectedIndex: _navBarIndex, + onDestinationSelected: (i) => setState(() => _navBarIndex = i), + indicatorStyle: NavBarM3EIndicatorStyle.pill, + destinations: const [ + NavigationDestinationM3E( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home'), + NavigationDestinationM3E( + icon: Icon(Icons.search_outlined), + selectedIcon: Icon(Icons.search), + label: 'Search', + badgeDot: true), + NavigationDestinationM3E( + icon: Icon(Icons.favorite_outline), + selectedIcon: Icon(Icons.favorite), + label: 'Favorites', + badgeCount: 3), + NavigationDestinationM3E( + icon: Icon(Icons.person_outline), + selectedIcon: Icon(Icons.person), + label: 'Profile'), + ], + ), + + const SizedBox(height: 24), + // Navigation Rail + Text('NavigationRailM3E', + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: m3e.shapes.round.lg, + ), + height: 180, + child: Row( + children: [ + NavigationRailM3E( + selectedIndex: _railIndex, + onDestinationSelected: (i) => setState(() => _railIndex = i), + indicatorStyle: RailIndicatorStyle.pill, + destinations: const [ + RailDestinationM3E( + icon: Icon(Icons.dashboard_outlined), + selectedIcon: Icon(Icons.dashboard), + label: 'Dashboard'), + RailDestinationM3E( + icon: Icon(Icons.analytics_outlined), + selectedIcon: Icon(Icons.analytics), + label: 'Reports'), + RailDestinationM3E( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings'), + ], + ), + const VerticalDivider(width: 1), + Expanded( + child: Center( + child: Text('Selected: $_railIndex'), + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + // Sliver App Bar demo route + Text('App bars', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + Wrap( + spacing: 12, + children: [ + const ButtonM3E(labelText: 'Open SliverAppBarM3E Demo'), + // Use GestureDetector to navigate when tapping the button label area + ] + .map((w) => GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const _SliverAppBarDemoPage()), + ), + child: w, + )) + .toList(), + ), + + const SizedBox(height: 48), + // Theming surface example remains + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: m3e.colors.surfaceStrong, + borderRadius: m3e.shapes.square.lg, + ), + child: Text('Surface strong example', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(color: cs.onSurface)), + ), + ], + ), + ); + } +} + +class _SliverAppBarDemoPage extends StatelessWidget { + const _SliverAppBarDemoPage(); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + const SliverAppBarM3E( + titleText: 'SliverAppBarM3E', + pinned: true, + floating: false, + variant: AppBarM3EVariant.large, + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => ListTile( + title: Text('Item #$index'), + ), + childCount: 30, + ), + ), + ], + ), + ); + } +} diff --git a/apps/gallery/pubspec.yaml b/apps/gallery/pubspec.yaml new file mode 100644 index 0000000..3716d56 --- /dev/null +++ b/apps/gallery/pubspec.yaml @@ -0,0 +1,16 @@ +name: m3e_gallery +description: Gallery app for Material 3 Expressive packages. +publish_to: none + +environment: + sdk: ">=3.5.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + m3e_collection: + path: ../../packages/m3e_collection + material_color_utilities: ^0.11.0 + +flutter: + uses-material-design: true diff --git a/apps/gallery/pubspec_overrides.yaml b/apps/gallery/pubspec_overrides.yaml new file mode 100644 index 0000000..a5b82c3 --- /dev/null +++ b/apps/gallery/pubspec_overrides.yaml @@ -0,0 +1,30 @@ +# melos_managed_dependency_overrides: icon_button_m3e,m3e_collection,m3e_design,split_button_m3e,app_bar_m3e,button_group_m3e,button_m3e,fab_m3e,loading_indicator_m3e,navigation_bar_m3e,navigation_rail_m3e,progress_indicator_m3e,slider_m3e,toolbar_m3e +dependency_overrides: + app_bar_m3e: + path: ..\\..\\packages\\app_bar_m3e + button_group_m3e: + path: ..\\..\\packages\\button_group_m3e + button_m3e: + path: ..\\..\\packages\\button_m3e + fab_m3e: + path: ..\\..\\packages\\fab_m3e + icon_button_m3e: + path: ..\\..\\packages\\icon_button_m3e + loading_indicator_m3e: + path: ..\\..\\packages\\loading_indicator_m3e + m3e_collection: + path: ..\\..\\packages\\m3e_collection + m3e_design: + path: ..\\..\\packages\\m3e_design + navigation_bar_m3e: + path: ..\\..\\packages\\navigation_bar_m3e + navigation_rail_m3e: + path: ..\\..\\packages\\navigation_rail_m3e + progress_indicator_m3e: + path: ..\\..\\packages\\progress_indicator_m3e + slider_m3e: + path: ..\\..\\packages\\slider_m3e + split_button_m3e: + path: ..\\..\\packages\\split_button_m3e + toolbar_m3e: + path: ..\\..\\packages\\toolbar_m3e diff --git a/melos.yaml b/melos.yaml new file mode 100644 index 0000000..df9f216 --- /dev/null +++ b/melos.yaml @@ -0,0 +1,19 @@ +name: m3e +packages: + - "packages/*" + - "apps/*" + +command: + bootstrap: + usePubspecOverrides: true + +scripts: + bootstrap: melos bootstrap + clean: melos exec -- flutter clean + get: melos exec -- flutter pub get + format: melos exec -- dart format --set-exit-if-changed . + analyze: melos exec -- dart analyze --fatal-infos --fatal-warnings + test: melos exec -- flutter test --coverage + create: + run: dart run tool/create_component.dart + description: Scaffold a new [component]_m3e package (melos run create -- name=badge) diff --git a/melos_m3e.iml b/melos_m3e.iml new file mode 100644 index 0000000..7d083a2 --- /dev/null +++ b/melos_m3e.iml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/app_bar_m3e/README.md b/packages/app_bar_m3e/README.md new file mode 100644 index 0000000..8e096ff --- /dev/null +++ b/packages/app_bar_m3e/README.md @@ -0,0 +1,90 @@ +# app_bar_m3e + +Expressive **App Bar** for Flutter (Material 3 Expressive). +Small (standard) bar for `Scaffold.appBar`, plus **Medium** & **Large** collapsing variants via a sliver. + +> Uses `m3e_design` tokens for color, typography, spacing, and shapes. + +## Monorepo Setup + +Place alongside `m3e_design` in your repo: + +``` +packages/ + m3e_design/ + app_bar_m3e/ +``` + +`pubspec.yaml` references `../m3e_design` by default. + +## API + +### Small App Bar + +```dart +AppBarM3E( + leading: IconButton(icon: const BackButtonIcon(), onPressed: () {}), + titleText: 'Inbox', + actions: [IconButton(icon: const Icon(Icons.search), onPressed: () {})], + centerTitle: false, + shapeFamily: AppBarM3EShapeFamily.round, + density: AppBarM3EDensity.regular, +); +``` + +Use in a `Scaffold`: + +```dart +Scaffold( + appBar: const AppBarM3E(titleText: 'Inbox'), + body: ... +); +``` + +### Sliver App Bar (Medium / Large) + +```dart +CustomScrollView( + slivers: [ + SliverAppBarM3E( + variant: AppBarM3EVariant.large, + titleText: 'Gallery', + pinned: true, + ), + // ... content slivers + ], +); +``` + +- `variant: medium` uses expanded height ≈112dp (collapses to ~64dp). +- `variant: large` uses expanded height ≈152dp (collapses to ~64dp). +- Colors, shapes, and typography come from `m3e_design`'s `M3ETheme` extension. + +## Theme Integration + +`app_bar_m3e` reads the `M3ETheme` extension from your `ThemeData`: + +```dart +final m3e = Theme.of(context).extension() ?? + M3ETheme.defaults(Theme.of(context).colorScheme); +``` + +It uses: +- `m3e.colors.surfaceContainerHigh` for background +- `m3e.type.titleLarge` for collapsed titles +- `m3e.type.headlineSmallEmphasized` for expanded titles +- `m3e.shapes.round|square` for container shape +- `m3e.spacing.md` for horizontal padding + +Override by supplying `backgroundColor`, `foregroundColor`, `toolbarHeight`, etc. + +## Notes + +- For collapsing behavior, use the sliver variant inside a `CustomScrollView`. +- `AppBarM3E` (small) is a `PreferredSizeWidget` suitable for `Scaffold.appBar`. +- Medium/Large variants rely on `FlexibleSpaceBar` for expanded titles. +- When `density: compact`, default heights reduce by ~8dp. + +## License + +MIT diff --git a/packages/app_bar_m3e/lib/app_bar_m3e.dart b/packages/app_bar_m3e/lib/app_bar_m3e.dart new file mode 100644 index 0000000..0e52579 --- /dev/null +++ b/packages/app_bar_m3e/lib/app_bar_m3e.dart @@ -0,0 +1,5 @@ +library app_bar_m3e; + +export 'src/app_bar_m3e_enums.dart'; +export 'src/app_bar_m3e_widget.dart'; +export 'src/sliver_app_bar_m3e.dart'; diff --git a/packages/app_bar_m3e/lib/src/_tokens_adapter.dart b/packages/app_bar_m3e/lib/src/_tokens_adapter.dart new file mode 100644 index 0000000..c637423 --- /dev/null +++ b/packages/app_bar_m3e/lib/src/_tokens_adapter.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; +import 'app_bar_m3e_enums.dart'; + +@immutable +class _AppBarMetrics { + final double smallHeight; + final double collapsedHeight; + final double mediumExpanded; + final double largeExpanded; + final EdgeInsetsGeometry horizontalPadding; + final double iconSize; + final double elevation; + const _AppBarMetrics({ + required this.smallHeight, + required this.collapsedHeight, + required this.mediumExpanded, + required this.largeExpanded, + required this.horizontalPadding, + required this.iconSize, + required this.elevation, + }); +} + +_AppBarMetrics metricsFor(BuildContext context, AppBarM3EDensity density) { + final theme = Theme.of(context); + final m3e = theme.extension() ?? M3ETheme.defaults(theme.colorScheme); + final sp = m3e.spacing; + + // Heights (approx per M3 specs; can be tuned via Theme extension in m3e_design if desired) + double small = 64; + double collapsed = 64; + double medium = 112; + double large = 152; + + // Density tweaks + if (density == AppBarM3EDensity.compact) { + small -= 8; + collapsed -= 8; + medium -= 8; + large -= 8; + } + + return _AppBarMetrics( + smallHeight: small, + collapsedHeight: collapsed, + mediumExpanded: medium, + largeExpanded: large, + horizontalPadding: EdgeInsets.symmetric(horizontal: sp.md), + iconSize: 24, + elevation: 0.0, + ); +} + +Color backgroundFor(BuildContext context) { + final theme = Theme.of(context); + final m3e = theme.extension() ?? M3ETheme.defaults(theme.colorScheme); + // Prefer container surfaces for bars + return m3e.colors.surfaceContainerHigh; +} + +TextStyle titleStyleFor(BuildContext context, {bool collapsed = true}) { + final theme = Theme.of(context); + final m3e = theme.extension() ?? M3ETheme.defaults(theme.colorScheme); + return collapsed ? m3e.type.titleLarge : m3e.type.headlineSmallEmphasized; +} + +ShapeBorder shapeFor(BuildContext context, AppBarM3EShapeFamily family) { + final theme = Theme.of(context); + final m3e = theme.extension() ?? M3ETheme.defaults(theme.colorScheme); + final set = family == AppBarM3EShapeFamily.round ? m3e.shapes.round : m3e.shapes.square; + // Use medium size radius for the bar container by default + return RoundedRectangleBorder(borderRadius: set.md); +} diff --git a/packages/app_bar_m3e/lib/src/app_bar_m3e_enums.dart b/packages/app_bar_m3e/lib/src/app_bar_m3e_enums.dart new file mode 100644 index 0000000..1a25c90 --- /dev/null +++ b/packages/app_bar_m3e/lib/src/app_bar_m3e_enums.dart @@ -0,0 +1,3 @@ +enum AppBarM3EVariant { small, medium, large } +enum AppBarM3EShapeFamily { round, square } +enum AppBarM3EDensity { regular, compact } diff --git a/packages/app_bar_m3e/lib/src/app_bar_m3e_widget.dart b/packages/app_bar_m3e/lib/src/app_bar_m3e_widget.dart new file mode 100644 index 0000000..5874ee0 --- /dev/null +++ b/packages/app_bar_m3e/lib/src/app_bar_m3e_widget.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +import '_tokens_adapter.dart'; +import 'app_bar_m3e_enums.dart'; + +class AppBarM3E extends StatelessWidget implements PreferredSizeWidget { + const AppBarM3E({ + super.key, + this.leading, + this.title, + this.titleText, + this.actions, + this.centerTitle = false, + this.backgroundColor, + this.foregroundColor, + this.elevation, + this.shapeFamily = AppBarM3EShapeFamily.round, + this.density = AppBarM3EDensity.regular, + this.toolbarHeight, + this.automaticallyImplyLeading = true, + this.clipBehavior = Clip.none, + this.semanticLabel, + }); + + final Widget? leading; + final Widget? title; + final String? titleText; + final List? actions; + final bool centerTitle; + + final Color? backgroundColor; + final Color? foregroundColor; + final double? elevation; + final AppBarM3EShapeFamily shapeFamily; + final AppBarM3EDensity density; + final double? toolbarHeight; + final bool automaticallyImplyLeading; + final Clip clipBehavior; + final String? semanticLabel; + + @override + Size get preferredSize { + // Provide a reasonable non-null size; actual height applied in build. + return Size.fromHeight(toolbarHeight ?? 64); + } + + @override + Widget build(BuildContext context) { + final metrics = metricsFor(context, density); + final bg = backgroundColor ?? backgroundFor(context); + final fg = foregroundColor ?? Theme.of(context).colorScheme.onSurface; + final shape = shapeFor(context, shapeFamily); + final height = toolbarHeight ?? metrics.smallHeight; + final tStyle = titleStyleFor(context, collapsed: true); + + final resolvedLeading = leading ?? (automaticallyImplyLeading + ? _maybeBackButton(context, fg) + : null); + + final resolvedTitle = title ?? + (titleText != null + ? Text(titleText!, style: tStyle, overflow: TextOverflow.ellipsis) + : null); + + final bar = Material( + color: bg, + elevation: elevation ?? metrics.elevation, + shape: shape, + clipBehavior: clipBehavior, + child: SafeArea( + bottom: false, + child: SizedBox( + height: height, + child: Padding( + padding: metrics.horizontalPadding, + child: IconTheme.merge( + data: IconThemeData(size: metrics.iconSize, color: fg), + child: DefaultTextStyle( + style: tStyle.copyWith(color: fg), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (resolvedLeading != null) resolvedLeading, + if (resolvedLeading != null) const SizedBox(width: 8), + if (resolvedTitle != null) + Expanded( + child: Align( + alignment: centerTitle ? Alignment.center : Alignment.centerLeft, + child: resolvedTitle, + ), + ) + else + const Spacer(), + if (actions != null) ...[ + const SizedBox(width: 8), + ..._withSpacers(actions!), + ], + ], + ), + ), + ), + ), + ), + ), + ); + + if (semanticLabel == null) return bar; + return Semantics( + container: true, + label: semanticLabel, + child: bar, + ); + } + + List _withSpacers(List items) { + final out = []; + for (var i = 0; i < items.length; i++) { + out.add(items[i]); + if (i < items.length - 1) out.add(const SizedBox(width: 4)); + } + return out; + } + + Widget? _maybeBackButton(BuildContext context, Color fg) { + final canPop = Navigator.maybeOf(context)?.canPop() ?? false; + if (!canPop) return null; + return IconButton( + icon: const BackButtonIcon(), + color: fg, + onPressed: () => Navigator.maybeOf(context)?.maybePop(), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + ); + } +} diff --git a/packages/app_bar_m3e/lib/src/sliver_app_bar_m3e.dart b/packages/app_bar_m3e/lib/src/sliver_app_bar_m3e.dart new file mode 100644 index 0000000..2ed4871 --- /dev/null +++ b/packages/app_bar_m3e/lib/src/sliver_app_bar_m3e.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderObject, RenderProxySliver; +import 'package:flutter/semantics.dart' show SemanticsConfiguration; + +import '_tokens_adapter.dart'; +import 'app_bar_m3e_enums.dart'; + +class SliverAppBarM3E extends StatelessWidget { + const SliverAppBarM3E({ + super.key, + this.leading, + this.title, + this.titleText, + this.actions, + this.centerTitle = false, + this.backgroundColor, + this.foregroundColor, + this.pinned = true, + this.floating = false, + this.snap = false, + this.shapeFamily = AppBarM3EShapeFamily.round, + this.density = AppBarM3EDensity.regular, + this.variant = AppBarM3EVariant.medium, + this.semanticLabel, + }); + + final Widget? leading; + final Widget? title; + final String? titleText; + final List? actions; + final bool centerTitle; + + final Color? backgroundColor; + final Color? foregroundColor; + final bool pinned; + final bool floating; + final bool snap; + final AppBarM3EShapeFamily shapeFamily; + final AppBarM3EDensity density; + final AppBarM3EVariant variant; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final metrics = metricsFor(context, density); + final bg = backgroundColor ?? backgroundFor(context); + final fg = foregroundColor ?? Theme.of(context).colorScheme.onSurface; + final shape = shapeFor(context, shapeFamily); + + final collapsedStyle = titleStyleFor(context, collapsed: true); + final expandedStyle = titleStyleFor(context, collapsed: false); + + final collapsed = metrics.collapsedHeight; + final expanded = switch (variant) { + AppBarM3EVariant.medium => metrics.mediumExpanded, + AppBarM3EVariant.large => metrics.largeExpanded, + AppBarM3EVariant.small => metrics.smallHeight, + }; + + final resolvedTitleWidget = title ?? + (titleText != null + ? Text(titleText!, + style: collapsedStyle, overflow: TextOverflow.ellipsis) + : null); + + final bar = SliverAppBar( + pinned: pinned, + floating: floating, + snap: snap && floating, + backgroundColor: bg, + foregroundColor: fg, + collapsedHeight: collapsed, + expandedHeight: expanded, + centerTitle: centerTitle, + leading: leading, + title: resolvedTitleWidget, + actions: actions, + shape: shape, + flexibleSpace: _buildFlexibleSpace(context, expandedStyle), + ); + + if (semanticLabel == null) return bar; + return SliverSemantic( + label: semanticLabel!, + child: bar, + ); + } + + Widget? _buildFlexibleSpace(BuildContext context, TextStyle expandedStyle) { + switch (variant) { + case AppBarM3EVariant.small: + return null; + case AppBarM3EVariant.medium: + case AppBarM3EVariant.large: + final t = title ?? + (titleText != null ? Text(titleText!, style: expandedStyle) : null); + if (t == null) return null; + return FlexibleSpaceBar( + titlePadding: + const EdgeInsetsDirectional.only(start: 16, bottom: 16, end: 16), + title: DefaultTextStyle( + style: expandedStyle.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + child: t, + ), + collapseMode: CollapseMode.pin, + expandedTitleScale: + 1.0, // Typography already larger; avoid scale morph + ); + } + } +} + +/// A helper to wrap a sliver with semantics label. +class SliverSemantic extends SingleChildRenderObjectWidget { + const SliverSemantic({super.key, required this.label, required Widget child}) + : super(child: child); + final String label; + @override + RenderObject createRenderObject(BuildContext context) => + _SliverSemanticRender(label); + @override + void updateRenderObject( + BuildContext context, covariant _SliverSemanticRender renderObject) { + renderObject.label = label; + } +} + +class _SliverSemanticRender extends RenderProxySliver { + _SliverSemanticRender(this._label); + String _label; + set label(String v) { + if (v == _label) return; + _label = v; + markNeedsSemanticsUpdate(); + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + config.label = _label; + config.isSemanticBoundary = true; + } +} diff --git a/packages/app_bar_m3e/melos_app_bar_m3e.iml b/packages/app_bar_m3e/melos_app_bar_m3e.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/app_bar_m3e/melos_app_bar_m3e.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/app_bar_m3e/pubspec.yaml b/packages/app_bar_m3e/pubspec.yaml new file mode 100644 index 0000000..ced9e83 --- /dev/null +++ b/packages/app_bar_m3e/pubspec.yaml @@ -0,0 +1,18 @@ +name: app_bar_m3e +description: Expressive App Bar (Material 3 Expressive) with small/medium/large variants and Sliver integration. +version: 0.1.0 +publish_to: none + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.22.0" + +dependencies: + flutter: + sdk: flutter + m3e_design: + path: ../m3e_design + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/packages/app_bar_m3e/pubspec_overrides.yaml b/packages/app_bar_m3e/pubspec_overrides.yaml new file mode 100644 index 0000000..69acba6 --- /dev/null +++ b/packages/app_bar_m3e/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: m3e_design +dependency_overrides: + m3e_design: + path: ..\\m3e_design diff --git a/packages/app_bar_m3e/test/app_bar_m3e_test.dart b/packages/app_bar_m3e/test/app_bar_m3e_test.dart new file mode 100644 index 0000000..95fb403 --- /dev/null +++ b/packages/app_bar_m3e/test/app_bar_m3e_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('placeholder', () { + expect(2 + 2, 4); + }); +} diff --git a/packages/button_group_m3e/README.md b/packages/button_group_m3e/README.md new file mode 100644 index 0000000..22a8658 --- /dev/null +++ b/packages/button_group_m3e/README.md @@ -0,0 +1,100 @@ +# button_group_m3e + +Wrapper-only **Button Group** for Material 3 Expressive (M3E). +Arranges arbitrary action buttons and applies **group-level presentation**: type (standard/connected), shape family (round/square), size (XS–XL), density, and layout (axis, wrap). + +> Buttons themselves remain independent (no selection logic). Use your own M3E buttons (`icon_button_m3e`, `split_button_m3e`, etc.). + +## Install (in monorepo) + +Place this folder alongside `m3e_design`: + +``` +packages/ + m3e_design/ + button_group_m3e/ +``` + +`pubspec.yaml` already expects `m3e_design` at `../m3e_design`. + +## API + +```dart +class ButtonGroupM3E extends StatelessWidget { + const ButtonGroupM3E({ + required List children, + ButtonGroupM3EType type = ButtonGroupM3EType.standard, + ButtonGroupM3EShape shape = ButtonGroupM3EShape.round, + ButtonGroupM3ESize size = ButtonGroupM3ESize.md, + ButtonGroupM3EDensity density = ButtonGroupM3EDensity.regular, + Axis direction = Axis.horizontal, + bool wrap = false, + double? spacing, + double? runSpacing, + WrapAlignment alignment = WrapAlignment.start, + WrapAlignment runAlignment = WrapAlignment.start, + WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center, + bool showDividers = false, + Color? dividerColor, + double? dividerThickness, + bool equalizeWidths = false, + String? semanticLabel, + Clip clipBehavior = Clip.none, + }); +} +``` + +Enums: +```dart +enum ButtonGroupM3EType { standard, connected } +enum ButtonGroupM3EShape { round, square } +enum ButtonGroupM3ESize { xs, sm, md, lg, xl } +enum ButtonGroupM3EDensity { regular, compact } +``` + +## Scope for cooperative buttons + +The group exposes: `ButtonGroupM3EScope` and `ButtonGroupM3EItemScope`: + +```dart +final g = ButtonGroupM3EScope.of(context); +final i = ButtonGroupM3EItemScope.of(context); +// g.size, g.shape, g.isConnected, g.direction ... +// i.index, i.count, i.isFirst, i.isLast ... +``` + +Buttons can read these to adopt **height, corner radii (outer vs inner), compact paddings**, etc. + +## Defaults (recommended) + +- type: `standard` +- shape: `round` +- size: `md` +- density: `regular` +- direction: `Axis.horizontal` +- wrap: `false` +- standard spacing: token-based (≈8dp at md) +- connected spacing: `0` +- dividers: `false` by default (connected only) +- dividerThickness: `1dp` (hairline) + +## Notes + +- In **wrap** mode, connected **dividers** are not auto-drawn per run (Flutter Wrap lacks per-run hooks). Use standard type, or accept flush seams. +- `equalizeWidths` uses min-widths by size (40, 56, 72, 96, 120). For true equalization per run, implement a custom multi-pass layout if needed. +- The widget does **not** clip children unless `clipBehavior` is set. Prefer cooperative styling via scope. + +## Example + +```dart +ButtonGroupM3E( + type: ButtonGroupM3EType.connected, + shape: ButtonGroupM3EShape.round, + size: ButtonGroupM3ESize.lg, + showDividers: true, + semanticLabel: 'Playback controls', + children: [ + // Your M3E buttons here... + ], +) +``` diff --git a/packages/button_group_m3e/lib/button_group_m3e.dart b/packages/button_group_m3e/lib/button_group_m3e.dart new file mode 100644 index 0000000..30eb731 --- /dev/null +++ b/packages/button_group_m3e/lib/button_group_m3e.dart @@ -0,0 +1,5 @@ +library button_group_m3e; + +export 'src/button_group_m3e_widget.dart'; +export 'src/button_group_m3e_enums.dart'; +export 'src/button_group_m3e_scope.dart' show ButtonGroupM3EScope, ButtonGroupM3EItemScope; diff --git a/packages/button_group_m3e/lib/src/_tokens_adapter.dart b/packages/button_group_m3e/lib/src/_tokens_adapter.dart new file mode 100644 index 0000000..5450baa --- /dev/null +++ b/packages/button_group_m3e/lib/src/_tokens_adapter.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; +import 'button_group_m3e_enums.dart'; + +class _GroupMetrics { + final double spacing; + final double runSpacing; + final double dividerThickness; + const _GroupMetrics({required this.spacing, required this.runSpacing, required this.dividerThickness}); +} + +_GroupMetrics metricsFor(BuildContext context, ButtonGroupM3ESize size, ButtonGroupM3EDensity density) { + final m3e = Theme.of(context).extension() ?? M3ETheme.defaults(Theme.of(context).colorScheme); + final sp = m3e.spacing; + + double space; + double run; + switch (size) { + case ButtonGroupM3ESize.xs: space = 6; run = 6; break; + case ButtonGroupM3ESize.sm: space = sp.sm; run = sp.sm; break; + case ButtonGroupM3ESize.md: space = sp.sm; run = sp.md; break; + case ButtonGroupM3ESize.lg: space = sp.md; run = sp.lg; break; + case ButtonGroupM3ESize.xl: space = sp.lg; run = sp.lg; break; + } + + if (density == ButtonGroupM3EDensity.compact) { + space = (space * 0.75).floorToDouble(); + run = (run * 0.75).floorToDouble(); + } + + return _GroupMetrics(spacing: space, runSpacing: run, dividerThickness: 1); +} + +BorderRadius radiusFor(BuildContext context, ButtonGroupM3EShape shape, ButtonGroupM3ESize size) { + final m3e = Theme.of(context).extension() ?? M3ETheme.defaults(Theme.of(context).colorScheme); + final set = shape == ButtonGroupM3EShape.round ? m3e.shapes.round : m3e.shapes.square; + switch (size) { + case ButtonGroupM3ESize.xs: return set.xs; + case ButtonGroupM3ESize.sm: return set.sm; + case ButtonGroupM3ESize.md: return set.md; + case ButtonGroupM3ESize.lg: return set.lg; + case ButtonGroupM3ESize.xl: return set.xl; + } +} diff --git a/packages/button_group_m3e/lib/src/button_group_m3e_enums.dart b/packages/button_group_m3e/lib/src/button_group_m3e_enums.dart new file mode 100644 index 0000000..ebd4876 --- /dev/null +++ b/packages/button_group_m3e/lib/src/button_group_m3e_enums.dart @@ -0,0 +1,4 @@ +enum ButtonGroupM3EType { standard, connected } +enum ButtonGroupM3EShape { round, square } +enum ButtonGroupM3ESize { xs, sm, md, lg, xl } +enum ButtonGroupM3EDensity { regular, compact } diff --git a/packages/button_group_m3e/lib/src/button_group_m3e_scope.dart b/packages/button_group_m3e/lib/src/button_group_m3e_scope.dart new file mode 100644 index 0000000..25d88c8 --- /dev/null +++ b/packages/button_group_m3e/lib/src/button_group_m3e_scope.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'button_group_m3e_enums.dart'; + +class ButtonGroupM3EScope extends InheritedWidget { + const ButtonGroupM3EScope({ + super.key, + required super.child, + required this.type, + required this.shape, + required this.size, + required this.density, + required this.direction, + required this.isConnected, + }); + + final ButtonGroupM3EType type; + final ButtonGroupM3EShape shape; + final ButtonGroupM3ESize size; + final ButtonGroupM3EDensity density; + final Axis direction; + final bool isConnected; + + static ButtonGroupM3EScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + static ButtonGroupM3EScope of(BuildContext context) => + maybeOf(context)!; + + @override + bool updateShouldNotify(covariant ButtonGroupM3EScope oldWidget) => + type != oldWidget.type || + shape != oldWidget.shape || + size != oldWidget.size || + density != oldWidget.density || + direction != oldWidget.direction || + isConnected != oldWidget.isConnected; +} + +class ButtonGroupM3EItemScope extends InheritedWidget { + const ButtonGroupM3EItemScope({ + super.key, + required super.child, + required this.index, + required this.count, + required this.isFirst, + required this.isLast, + }); + + final int index; + final int count; + final bool isFirst; + final bool isLast; + + static ButtonGroupM3EItemScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + static ButtonGroupM3EItemScope of(BuildContext context) => + maybeOf(context)!; + + @override + bool updateShouldNotify(covariant ButtonGroupM3EItemScope oldWidget) => + index != oldWidget.index || + count != oldWidget.count || + isFirst != oldWidget.isFirst || + isLast != oldWidget.isLast; +} diff --git a/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart b/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart new file mode 100644 index 0000000..131f56a --- /dev/null +++ b/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; +import 'button_group_m3e_enums.dart'; +import '_tokens_adapter.dart'; +import 'button_group_m3e_scope.dart'; + +class ButtonGroupM3E extends StatelessWidget { + const ButtonGroupM3E({ + super.key, + required this.children, + this.type = ButtonGroupM3EType.standard, + this.shape = ButtonGroupM3EShape.round, + this.size = ButtonGroupM3ESize.md, + this.density = ButtonGroupM3EDensity.regular, + this.direction = Axis.horizontal, + this.wrap = false, + this.spacing, + this.runSpacing, + this.alignment = WrapAlignment.start, + this.runAlignment = WrapAlignment.start, + this.crossAxisAlignment = WrapCrossAlignment.center, + this.showDividers = false, + this.dividerColor, + this.dividerThickness, + this.equalizeWidths = false, + this.semanticLabel, + this.clipBehavior = Clip.none, + }); + + final List children; + + final ButtonGroupM3EType type; + final ButtonGroupM3EShape shape; + final ButtonGroupM3ESize size; + final ButtonGroupM3EDensity density; + + final Axis direction; + final bool wrap; + final double? spacing; + final double? runSpacing; + final WrapAlignment alignment; + final WrapAlignment runAlignment; + final WrapCrossAlignment crossAxisAlignment; + + final bool showDividers; + final Color? dividerColor; + final double? dividerThickness; + final bool equalizeWidths; + + final String? semanticLabel; + final Clip clipBehavior; + + bool get _connected => type == ButtonGroupM3EType.connected; + + @override + Widget build(BuildContext context) { + final tokens = metricsFor(context, size, density); + final cs = Theme.of(context).colorScheme; + final dividerClr = dividerColor ?? cs.outlineVariant.withValues(alpha: 0.6); + final dividerThk = (dividerThickness ?? tokens.dividerThickness).clamp(0.5, 2.0); + + final effSpacing = _connected ? 0.0 : (spacing ?? tokens.spacing); + final effRunSpacing = wrap ? (runSpacing ?? tokens.runSpacing) : 0.0; + + final group = ButtonGroupM3EScope( + type: type, + shape: shape, + size: size, + density: density, + direction: direction, + isConnected: _connected, + child: _buildContent(context, effSpacing, effRunSpacing, dividerClr, dividerThk), + ); + + final semantics = Semantics( + container: true, + label: semanticLabel, + child: group, + ); + + if (clipBehavior == Clip.none) return semantics; + return ClipRRect( + clipBehavior: clipBehavior, + borderRadius: radiusFor(context, shape, size), + child: semantics, + ); + } + + Widget _buildContent(BuildContext context, double spacing, double runSpacing, + Color dividerColor, double dividerThickness) { + if (children.isEmpty) return const SizedBox.shrink(); + + if (wrap) { + return _wrapLayout(context, spacing, runSpacing); + } + + final list = []; + for (var i = 0; i < children.length; i++) { + final isFirst = i == 0; + final isLast = i == children.length - 1; + + final child = _wrapItemScope( + context, + index: i, + count: children.length, + isFirst: isFirst, + isLast: isLast, + child: _maybeEqualized(children[i]), + ); + + list.add(child); + + final isBetween = i < children.length - 1; + if (!isBetween) continue; + + if (_connected) { + if (showDividers) { + list.add(_buildDivider(dividerColor, dividerThickness)); + } + } else { + list.add(_spacer(spacing)); + } + } + + return direction == Axis.horizontal + ? Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: list) + : Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: list); + } + + Widget _wrapLayout(BuildContext context, double spacing, double runSpacing) { + final wrapped = List.generate(children.length, (i) { + final isFirst = i == 0; + final isLast = i == children.length - 1; + return _wrapItemScope( + context, + index: i, + count: children.length, + isFirst: isFirst, + isLast: isLast, + child: _maybeEqualized(children[i]), + ); + }); + + return Wrap( + direction: direction, + spacing: spacing, + runSpacing: runSpacing, + alignment: alignment, + runAlignment: runAlignment, + crossAxisAlignment: crossAxisAlignment, + children: wrapped, + ); + } + + Widget _wrapItemScope(BuildContext context, + {required int index, required int count, required bool isFirst, required bool isLast, required Widget child}) { + return ButtonGroupM3EItemScope( + index: index, + count: count, + isFirst: isFirst, + isLast: isLast, + child: child, + ); + } + + Widget _spacer(double spacing) => + direction == Axis.horizontal ? SizedBox(width: spacing) : SizedBox(height: spacing); + + Widget _buildDivider(Color color, double thickness) { + return direction == Axis.horizontal + ? Container(width: thickness, height: 24, color: color) + : Container(height: thickness, width: 24, color: color); + } + + Widget _maybeEqualized(Widget child) { + if (!equalizeWidths) return child; + final minW = switch (size) { + ButtonGroupM3ESize.xs => 40.0, + ButtonGroupM3ESize.sm => 56.0, + ButtonGroupM3ESize.md => 72.0, + ButtonGroupM3ESize.lg => 96.0, + ButtonGroupM3ESize.xl => 120.0, + }; + return ConstrainedBox(constraints: BoxConstraints(minWidth: minW), child: child); + } +} diff --git a/packages/button_group_m3e/melos_button_group_m3e.iml b/packages/button_group_m3e/melos_button_group_m3e.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/button_group_m3e/melos_button_group_m3e.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/button_group_m3e/pubspec.yaml b/packages/button_group_m3e/pubspec.yaml new file mode 100644 index 0000000..70a76e6 --- /dev/null +++ b/packages/button_group_m3e/pubspec.yaml @@ -0,0 +1,18 @@ +name: button_group_m3e +description: Wrapper-only Button Group for Material 3 Expressive (layout, shape, size propagation). +version: 0.1.0 +publish_to: none +repository: https://example.com/your-repo + +environment: + sdk: ">=3.5.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + m3e_design: + path: ../m3e_design + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/packages/button_group_m3e/pubspec_overrides.yaml b/packages/button_group_m3e/pubspec_overrides.yaml new file mode 100644 index 0000000..69acba6 --- /dev/null +++ b/packages/button_group_m3e/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: m3e_design +dependency_overrides: + m3e_design: + path: ..\\m3e_design diff --git a/packages/button_group_m3e/test/button_group_m3e_test.dart b/packages/button_group_m3e/test/button_group_m3e_test.dart new file mode 100644 index 0000000..fe2f48b --- /dev/null +++ b/packages/button_group_m3e/test/button_group_m3e_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('placeholder', () { + expect(1 + 2, 3); + }); +} diff --git a/packages/button_m3e/LICENSE b/packages/button_m3e/LICENSE new file mode 100644 index 0000000..12ca7c2 --- /dev/null +++ b/packages/button_m3e/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) ... + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/button_m3e/README.md b/packages/button_m3e/README.md new file mode 100644 index 0000000..315c194 --- /dev/null +++ b/packages/button_m3e/README.md @@ -0,0 +1,70 @@ +# button_m3e + +Material 3 **Expressive** Buttons for Flutter, built on top of Flutter's buttons but styled via **M3E** tokens. + +Variants: **filled**, **tonal**, **outlined**, **text**, **elevated** +Sizes: **small**, **medium**, **large** +Shape families: **round**, **square** +Density: **regular**, **compact** + +> Depends on `m3e_design` (ThemeExtension with colors/typography/spacing/shapes). + +## Monorepo Layout + +``` +packages/ + m3e_design/ + button_m3e/ +``` + +In `pubspec.yaml` this package references `../m3e_design`. + +## Usage + +```dart +import 'package:button_m3e/button_m3e.dart'; + +ButtonM3E( + variant: ButtonM3EVariant.filled, + size: ButtonM3ESize.medium, + labelText: 'Continue', + leading: const Icon(Icons.arrow_forward), + onPressed: () {}, +); +``` + +Full-width button: + +```dart +const ButtonM3E( + variant: ButtonM3EVariant.tonal, + size: ButtonM3ESize.large, + labelText: 'Buy now', + expand: true, +); +``` + +Outlined/Text/Elevated work similarly. + +## Theming via `m3e_design` + +`button_m3e` reads tokens from your theme: + +- `m3e.colors.*` for background/foreground/border/disabled +- `m3e.type.labelLarge` for the button label +- `m3e.shapes.round|square` (uses `.lg` radius for buttons) +- `m3e.spacing` for horizontal paddings (`sm`, `md`, `lg`) + +If the extension is not present, it falls back to `M3ETheme.defaults(ColorScheme)`. +You can still override `ThemeData.colorScheme` to influence defaults globally. + +## Notes + +- Label can be provided as `labelText` (String) or `label` (Widget). +- `leading`/`trailing` are optional helpers for icons. +- `expand: true` makes the button take full width. +- `density: compact` slightly reduces height for each size. + +## License + +MIT diff --git a/packages/button_m3e/lib/button_m3e.dart b/packages/button_m3e/lib/button_m3e.dart new file mode 100644 index 0000000..0a55c55 --- /dev/null +++ b/packages/button_m3e/lib/button_m3e.dart @@ -0,0 +1,5 @@ +library button_m3e; + +export 'src/enums.dart'; +export 'src/button_m3e.dart'; +export 'src/button_theme_m3e.dart'; diff --git a/packages/button_m3e/lib/src/button_m3e.dart b/packages/button_m3e/lib/src/button_m3e.dart new file mode 100644 index 0000000..290aa67 --- /dev/null +++ b/packages/button_m3e/lib/src/button_m3e.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'enums.dart'; +import 'button_theme_m3e.dart'; + +class ButtonM3E extends StatelessWidget { + const ButtonM3E({ + super.key, + this.onPressed, + this.onLongPress, + this.leading, + this.trailing, + this.label, + this.labelText, + this.expand = false, + this.variant = ButtonM3EVariant.filled, + this.size = ButtonM3ESize.medium, + this.shapeFamily = ButtonM3EShapeFamily.round, + this.density = ButtonM3EDensity.regular, + this.semanticLabel, + }) : assert(label != null || labelText != null, 'Provide either label or labelText'); + + final VoidCallback? onPressed; + final VoidCallback? onLongPress; + final Widget? leading; + final Widget? trailing; + final Widget? label; + final String? labelText; + final bool expand; + + final ButtonM3EVariant variant; + final ButtonM3ESize size; + final ButtonM3EShapeFamily shapeFamily; + final ButtonM3EDensity density; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final t = ButtonTokensAdapter(context); + final m = t.metrics(density); + final shape = t.shape(shapeFamily); + + final (minH, pad) = switch (size) { + ButtonM3ESize.small => (m.heightSmall, m.paddingSmall), + ButtonM3ESize.medium => (m.heightMedium, m.paddingMedium), + ButtonM3ESize.large => (m.heightLarge, m.paddingLarge), + }; + + final style = _styleFor(context, t, shape, minH, pad); + + final childLabel = label ?? Text(labelText!, overflow: TextOverflow.ellipsis); + final content = _buildContent(context, t, childLabel); + + final Widget btn = switch (variant) { + ButtonM3EVariant.filled => FilledButton(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content), + ButtonM3EVariant.tonal => FilledButton.tonal(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content), + ButtonM3EVariant.outlined => OutlinedButton(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content), + ButtonM3EVariant.text => TextButton(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content), + ButtonM3EVariant.elevated => ElevatedButton(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content), + }; + + if (!expand && semanticLabel == null) return btn; + final wrapped = expand ? SizedBox(width: double.infinity, child: btn) : btn; + if (semanticLabel == null) return wrapped; + return Semantics( + button: true, + label: semanticLabel, + child: wrapped, + ); + } + + ButtonStyle _styleFor(BuildContext context, ButtonTokensAdapter t, OutlinedBorder shape, double minH, EdgeInsetsGeometry pad) { + switch (variant) { + case ButtonM3EVariant.filled: + return FilledButton.styleFrom( + backgroundColor: t.bgFilled(), + foregroundColor: t.fgFilled(), + textStyle: t.labelStyle(), + minimumSize: Size(0, minH), + padding: pad, + shape: shape, + ); + case ButtonM3EVariant.tonal: + return FilledButton.styleFrom( + backgroundColor: t.bgTonal(), + foregroundColor: t.fgTonal(), + textStyle: t.labelStyle(), + minimumSize: Size(0, minH), + padding: pad, + shape: shape, + ); + case ButtonM3EVariant.outlined: + return OutlinedButton.styleFrom( + foregroundColor: t.fgOutlined(), + textStyle: t.labelStyle(), + minimumSize: Size(0, minH), + padding: pad, + shape: shape, + side: BorderSide(color: t.borderOutlined()), + ); + case ButtonM3EVariant.text: + return TextButton.styleFrom( + foregroundColor: t.fgText(), + textStyle: t.labelStyle(), + minimumSize: Size(0, minH), + padding: pad, + shape: shape, + ); + case ButtonM3EVariant.elevated: + return ElevatedButton.styleFrom( + backgroundColor: t.bgElevated(), + foregroundColor: t.fgElevated(), + textStyle: t.labelStyle(), + minimumSize: Size(0, minH), + padding: pad, + shape: shape, + elevation: _elevationFor(context, t), + ); + } + } + + double _elevationFor(BuildContext context, ButtonTokensAdapter t) { + // Simple mapping; can be themed further via tokens. + return 1.0; + } + + Widget _buildContent(BuildContext context, ButtonTokensAdapter t, Widget childLabel) { + final style = t.labelStyle(); + final text = DefaultTextStyle.merge(style: style, child: childLabel); + final hasLeading = leading != null; + final hasTrailing = trailing != null; + + if (!hasLeading && !hasTrailing) return text; + + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (hasLeading) ...[leading!, const SizedBox(width: 8)], + Flexible(child: text), + if (hasTrailing) ...[const SizedBox(width: 8), trailing!], + ], + ); + } +} diff --git a/packages/button_m3e/lib/src/button_m3e_widget.dart b/packages/button_m3e/lib/src/button_m3e_widget.dart new file mode 100644 index 0000000..dab3275 --- /dev/null +++ b/packages/button_m3e/lib/src/button_m3e_widget.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; + +class ButtonM3EWidget extends StatelessWidget { + const ButtonM3EWidget({super.key}); + + @override + Widget build(BuildContext context) { + final m3e = context.m3e; + return Container( + padding: EdgeInsets.all(m3e.spacing.md), + decoration: BoxDecoration( + color: m3e.colors.surfaceStrong, + borderRadius: m3e.shapes.square.md, + ), + child: Text('Button placeholder', style: m3e.typography.base.labelLarge), + ); + } +} + +String _pascal(String s) => s + .split('_') + .map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))) + .join(); diff --git a/packages/button_m3e/lib/src/button_theme_m3e.dart b/packages/button_m3e/lib/src/button_theme_m3e.dart new file mode 100644 index 0000000..0664324 --- /dev/null +++ b/packages/button_m3e/lib/src/button_theme_m3e.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; + +import 'enums.dart'; + +@immutable +class _ButtonMetrics { + final double heightSmall; + final double heightMedium; + final double heightLarge; + final EdgeInsetsGeometry paddingSmall; + final EdgeInsetsGeometry paddingMedium; + final EdgeInsetsGeometry paddingLarge; + final BorderSide outlinedBorder; + final double elevation; + const _ButtonMetrics({ + required this.heightSmall, + required this.heightMedium, + required this.heightLarge, + required this.paddingSmall, + required this.paddingMedium, + required this.paddingLarge, + required this.outlinedBorder, + required this.elevation, + }); +} + +_ButtonMetrics _metricsFor(BuildContext context, ButtonM3EDensity density) { + final theme = Theme.of(context); + final m3e = + theme.extension() ?? M3ETheme.defaults(theme.colorScheme); + final sp = m3e.spacing; + + // Heights based on Material 3 expectations; tweakable by density. + double hS = 36; + double hM = 40; + double hL = 48; + + if (density == ButtonM3EDensity.compact) { + hS -= 4; + hM -= 4; + hL -= 4; + } + + return _ButtonMetrics( + heightSmall: hS, + heightMedium: hM, + heightLarge: hL, + paddingSmall: EdgeInsets.symmetric(horizontal: sp.sm), + paddingMedium: EdgeInsets.symmetric(horizontal: sp.md), + paddingLarge: EdgeInsets.symmetric(horizontal: sp.lg), + outlinedBorder: BorderSide(color: m3e.colors.outline, width: 1.0), + elevation: 1.0, + ); +} + +class ButtonTokensAdapter { + ButtonTokensAdapter(this.context); + final BuildContext context; + + M3ETheme get _m3e { + final t = Theme.of(context); + return t.extension() ?? M3ETheme.defaults(t.colorScheme); + } + + // Colors + Color bgFilled() => _m3e.colors.primary; + Color fgFilled() => _m3e.colors.onPrimary; + Color bgTonal() => _m3e.colors.secondaryContainer; + Color fgTonal() => _m3e.colors.onSecondaryContainer; + Color bgElevated() => _m3e.colors.surfaceContainerLowest; + Color fgElevated() => _m3e.colors.primary; + Color fgText() => _m3e.colors.primary; + Color borderOutlined() => _m3e.colors.outline; + Color fgOutlined() => _m3e.colors.primary; + Color disabledFg() => _m3e.colors.onSurface.withValues(alpha: 0.38); + Color disabledBg() => _m3e.colors.onSurface.withValues(alpha: 0.12); + + // Typography + TextStyle labelStyle() => _m3e.type.labelLarge; + + // Shapes + OutlinedBorder shape(ButtonM3EShapeFamily family) { + if (family == ButtonM3EShapeFamily.round) { + return RoundedRectangleBorder(borderRadius: _m3e.shapes.round.lg); + } + // Square family should have sharp corners (no rounding) + return const RoundedRectangleBorder(borderRadius: BorderRadius.zero); + } + + // Spacing & heights + _ButtonMetrics metrics(ButtonM3EDensity density) => + _metricsFor(context, density); +} diff --git a/packages/button_m3e/lib/src/enums.dart b/packages/button_m3e/lib/src/enums.dart new file mode 100644 index 0000000..e46edcd --- /dev/null +++ b/packages/button_m3e/lib/src/enums.dart @@ -0,0 +1,4 @@ +enum ButtonM3EVariant { filled, tonal, outlined, text, elevated } +enum ButtonM3ESize { small, medium, large } +enum ButtonM3EShapeFamily { round, square } +enum ButtonM3EDensity { regular, compact } diff --git a/packages/button_m3e/melos_button_m3e.iml b/packages/button_m3e/melos_button_m3e.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/button_m3e/melos_button_m3e.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/button_m3e/pubspec.yaml b/packages/button_m3e/pubspec.yaml new file mode 100644 index 0000000..8313f16 --- /dev/null +++ b/packages/button_m3e/pubspec.yaml @@ -0,0 +1,18 @@ +name: button_m3e +description: Material 3 Expressive Buttons for Flutter (filled, tonal, outlined, text, elevated) with M3E tokens. +version: 0.1.0 +publish_to: none + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.22.0" + +dependencies: + flutter: + sdk: flutter + m3e_design: + path: ../m3e_design + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/packages/button_m3e/pubspec_overrides.yaml b/packages/button_m3e/pubspec_overrides.yaml new file mode 100644 index 0000000..69acba6 --- /dev/null +++ b/packages/button_m3e/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: m3e_design +dependency_overrides: + m3e_design: + path: ..\\m3e_design diff --git a/packages/button_m3e/test/button_m3e_test.dart b/packages/button_m3e/test/button_m3e_test.dart new file mode 100644 index 0000000..83da334 --- /dev/null +++ b/packages/button_m3e/test/button_m3e_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('placeholder', () { + expect(1 + 1, 2); + }); +} diff --git a/packages/fab_m3e/LICENSE b/packages/fab_m3e/LICENSE new file mode 100644 index 0000000..12ca7c2 --- /dev/null +++ b/packages/fab_m3e/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) ... + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/fab_m3e/README.md b/packages/fab_m3e/README.md new file mode 100644 index 0000000..413024e --- /dev/null +++ b/packages/fab_m3e/README.md @@ -0,0 +1,93 @@ +# fab_m3e + +Material 3 **Expressive** Floating Action Buttons for Flutter: + +- **FabM3E**: circular FAB (small / regular / large) +- **ExtendedFabM3E**: pill-shaped FAB with label (and optional icon) +- **FabMenuM3E**: FAB menu (speed dial) with animated items (up/down/left/right) + +All components read **M3E tokens** from `m3e_design` (ThemeExtension). + +## Monorepo Layout + +``` +packages/ + m3e_design/ + fab_m3e/ +``` + +This package's `pubspec.yaml` references `../m3e_design`. + +## Usage + +### FAB + +```dart +import 'package:fab_m3e/fab_m3e.dart'; + +FabM3E( + icon: const Icon(Icons.add), + kind: FabM3EKind.primary, + size: FabM3ESize.regular, + onPressed: () {}, +); +``` + +### Extended FAB + +```dart +ExtendedFabM3E( + icon: const Icon(Icons.edit), + label: const Text('Compose'), + kind: FabM3EKind.secondary, + onPressed: () {}, +); +``` + +### FAB Menu (Speed dial) + +```dart +final controller = FabMenuController(); + +FabMenuM3E( + controller: controller, + alignment: Alignment.bottomRight, + direction: FabMenuDirection.up, + primaryFab: FabM3E(icon: const Icon(Icons.add), onPressed: controller.toggle), + items: [ + FabMenuItem( + icon: const Icon(Icons.photo), + label: const Text('Photo'), + onPressed: () {}, + ), + FabMenuItem( + icon: const Icon(Icons.note), + label: const Text('Note'), + onPressed: () {}, + ), + ], +); +``` + +## Theming via `m3e_design` + +- Background/foreground colors derive from kind: + - `primary` → `primaryContainer` / `onPrimaryContainer` + - `secondary` → `secondaryContainer` / `onSecondaryContainer` + - `tertiary` → `tertiaryContainer` / `onTertiaryContainer` + - `surface` → `surfaceContainerHigh` / `onSurface` +- Sizes: **small ≈40dp**, **regular ≈56dp**, **large ≈96dp** +- Extended FAB height ≈56dp +- Elevations: rest 6, hover 8, pressed 12 (tweak in code or via tokens) +- Shapes: `round`/`square` from `m3e_design.shapes` (extended uses StadiumBorder) + +## Notes + +- `FabM3E` uses `RawMaterialButton` to directly inject shape/elevation/colors with tokens. +- `ExtendedFabM3E` uses `Material` + `InkWell` with stadium shape and token paddings. +- `FabMenuM3E` stacks items near the primary FAB and animates **scale + fade**. +- Provide your own `Hero` tags if coordinating transitions across pages. + +## License + +MIT diff --git a/packages/fab_m3e/lib/fab_m3e.dart b/packages/fab_m3e/lib/fab_m3e.dart new file mode 100644 index 0000000..57c6f2c --- /dev/null +++ b/packages/fab_m3e/lib/fab_m3e.dart @@ -0,0 +1,7 @@ +library fab_m3e; + +export 'src/enums.dart'; +export 'src/fab_theme_m3e.dart' show FabTokensAdapter; +export 'src/fab_m3e.dart'; +export 'src/extended_fab_m3e.dart'; +export 'src/fab_menu_m3e.dart'; diff --git a/packages/fab_m3e/lib/src/enums.dart b/packages/fab_m3e/lib/src/enums.dart new file mode 100644 index 0000000..31cc537 --- /dev/null +++ b/packages/fab_m3e/lib/src/enums.dart @@ -0,0 +1,8 @@ +enum FabM3EKind { primary, secondary, tertiary, surface } +/// Size mapping follows Material 3: small (≈40), regular (≈56), large (≈96). +enum FabM3ESize { small, regular, large } +enum FabM3EShapeFamily { round, square } +enum FabM3EDensity { regular, compact } + +/// Direction for the FAB menu children to expand. +enum FabMenuDirection { up, down, left, right } diff --git a/packages/fab_m3e/lib/src/extended_fab_m3e.dart b/packages/fab_m3e/lib/src/extended_fab_m3e.dart new file mode 100644 index 0000000..0d645e1 --- /dev/null +++ b/packages/fab_m3e/lib/src/extended_fab_m3e.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +import 'enums.dart'; +import 'fab_theme_m3e.dart'; + +class ExtendedFabM3E extends StatelessWidget { + const ExtendedFabM3E({ + super.key, + required this.label, + this.icon, + this.onPressed, + this.tooltip, + this.heroTag, + this.kind = FabM3EKind.primary, + this.size = FabM3ESize.regular, + this.shapeFamily = FabM3EShapeFamily.round, + this.density = FabM3EDensity.regular, + this.elevation, + this.expand = false, + this.semanticLabel, + }); + + final Widget label; + final Widget? icon; + final VoidCallback? onPressed; + final String? tooltip; + final Object? heroTag; + final FabM3EKind kind; + final FabM3ESize size; + final FabM3EShapeFamily shapeFamily; + final FabM3EDensity density; + final double? elevation; + final bool expand; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final tokens = FabTokensAdapter(context); + final m = tokens.metrics(density); + final bg = tokens.bg(kind); + final fg = tokens.fg(kind); + final shape = tokens.shape(shapeFamily, size, extended: true); + + final minH = m.extendedHeight; + final child = DefaultTextStyle.merge( + style: tokens.labelStyle().copyWith(color: fg), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + IconTheme.merge( + data: IconThemeData(color: fg, size: m.iconSize), child: icon!), + const SizedBox(width: 12) + ], + Flexible(child: label), + ], + ), + ); + + final btn = ConstrainedBox( + constraints: BoxConstraints(minHeight: minH), + child: Material( + shape: shape, + color: bg, + elevation: elevation ?? m.elevationRest, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPressed, + onHover: (_) {}, + child: Padding( + padding: m.extendedPadding, + child: Align(alignment: Alignment.center, child: child), + ), + ), + ), + ); + + final core = Tooltip( + message: tooltip ?? '', + preferBelow: false, + child: expand ? SizedBox(width: double.infinity, child: btn) : btn, + ); + + Widget wrapped = core; + if (heroTag != null && + context.findAncestorWidgetOfExactType() == null) { + wrapped = Hero(tag: heroTag!, child: core); + } + + if (semanticLabel == null) return wrapped; + return Semantics(button: true, label: semanticLabel, child: wrapped); + } +} diff --git a/packages/fab_m3e/lib/src/fab_m3e.dart b/packages/fab_m3e/lib/src/fab_m3e.dart new file mode 100644 index 0000000..b036e34 --- /dev/null +++ b/packages/fab_m3e/lib/src/fab_m3e.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import 'enums.dart'; +import 'fab_theme_m3e.dart'; + +class FabM3E extends StatelessWidget { + const FabM3E({ + super.key, + required this.icon, + this.onPressed, + this.tooltip, + this.heroTag, + this.kind = FabM3EKind.primary, + this.size = FabM3ESize.regular, + this.shapeFamily = FabM3EShapeFamily.round, + this.density = FabM3EDensity.regular, + this.elevation, + this.focusNode, + this.autofocus = false, + this.isPrimaryAction = true, + this.semanticLabel, + }); + + final Widget icon; + final VoidCallback? onPressed; + final String? tooltip; + final Object? heroTag; + final FabM3EKind kind; + final FabM3ESize size; + final FabM3EShapeFamily shapeFamily; + final FabM3EDensity density; + final double? elevation; + final FocusNode? focusNode; + final bool autofocus; + final bool isPrimaryAction; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final tokens = FabTokensAdapter(context); + final m = tokens.metrics(density); + final bg = tokens.bg(kind); + final fg = tokens.fg(kind); + final shape = tokens.shape(shapeFamily, size); + final double dim = switch (size) { + FabM3ESize.small => m.small, + FabM3ESize.regular => m.regular, + FabM3ESize.large => m.large, + }; + + final button = SizedBox( + width: dim, + height: dim, + child: RawMaterialButton( + onPressed: onPressed, + fillColor: bg, + elevation: elevation ?? m.elevationRest, + hoverElevation: m.elevationHover, + highlightElevation: m.elevationPressed, + focusElevation: m.elevationHover, + shape: shape, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: Clip.antiAlias, + child: IconTheme.merge( + data: IconThemeData(color: fg, size: m.iconSize), + child: icon, + ), + ), + ); + + final core = Tooltip( + message: tooltip ?? '', + preferBelow: false, + child: button, + ); + + // Only wrap with Hero when an explicit tag is provided and there is no ancestor hero. + Widget wrapped = core; + if (heroTag != null && + context.findAncestorWidgetOfExactType() == null) { + wrapped = Hero(tag: heroTag!, child: core); + } + + if (semanticLabel == null) return wrapped; + return Semantics(button: true, label: semanticLabel, child: wrapped); + } +} diff --git a/packages/fab_m3e/lib/src/fab_m3e_widget.dart b/packages/fab_m3e/lib/src/fab_m3e_widget.dart new file mode 100644 index 0000000..b5187ec --- /dev/null +++ b/packages/fab_m3e/lib/src/fab_m3e_widget.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; + +class FabM3EWidget extends StatelessWidget { + const FabM3EWidget({super.key}); + + @override + Widget build(BuildContext context) { + final m3e = context.m3e; + return Container( + padding: EdgeInsets.all(m3e.spacing.md), + decoration: BoxDecoration( + color: m3e.colors.surfaceStrong, + borderRadius: m3e.shapes.square.md, + ), + child: Text('Fab placeholder', style: m3e.typography.base.titleMedium), + ); + } +} + +String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join(); diff --git a/packages/fab_m3e/lib/src/fab_menu_m3e.dart b/packages/fab_m3e/lib/src/fab_menu_m3e.dart new file mode 100644 index 0000000..5f1e5ba --- /dev/null +++ b/packages/fab_m3e/lib/src/fab_menu_m3e.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; + +import 'enums.dart'; +import 'extended_fab_m3e.dart'; + +class FabMenuItem { + FabMenuItem({ + required this.icon, + required this.label, + required this.onPressed, + this.semanticLabel, + }); + + final Widget icon; + final Widget label; + final VoidCallback onPressed; + final String? semanticLabel; +} + +class FabMenuController extends ChangeNotifier { + bool _open = false; + bool get isOpen => _open; + void open() { + if (!_open) { + _open = true; + notifyListeners(); + } + } + + void close() { + if (_open) { + _open = false; + notifyListeners(); + } + } + + void toggle() { + _open = !_open; + notifyListeners(); + } +} + +class FabMenuM3E extends StatefulWidget { + const FabMenuM3E({ + super.key, + required this.primaryFab, + required this.items, + this.direction = FabMenuDirection.up, + this.spacing, + this.overlay = true, + this.overlayColor, + this.controller, + this.alignment = Alignment.bottomRight, + this.popOnItemTap = true, + this.heroTag, + }); + + /// The FAB that toggles the menu (typically a primary FabM3E or ExtendedFabM3E). + final Widget primaryFab; + + /// Menu items displayed when open. + final List items; + + /// Direction in which children expand. + final FabMenuDirection direction; + + /// Spacing between items. + final double? spacing; + + /// Show a scrim overlay behind the menu when open. + final bool overlay; + final Color? overlayColor; + + /// Optional external controller; if omitted, an internal one is created. + final FabMenuController? controller; + + /// Alignment within the Stack (e.g., bottomRight in a Scaffold). + final Alignment alignment; + + /// Whether to automatically close the menu when an item is tapped. + final bool popOnItemTap; + + final Object? heroTag; + + @override + State createState() => _FabMenuM3EState(); +} + +class _FabMenuM3EState extends State + with SingleTickerProviderStateMixin { + late final FabMenuController _controller = + widget.controller ?? FabMenuController(); + late final AnimationController _anim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + reverseDuration: const Duration(milliseconds: 150), + ); + late final Animation _scale = CurvedAnimation( + parent: _anim, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic); + + @override + void initState() { + super.initState(); + _controller.addListener(_onChange); + } + + @override + void dispose() { + _controller.removeListener(_onChange); + if (widget.controller == null) _controller.dispose(); + _anim.dispose(); + super.dispose(); + } + + void _onChange() { + if (_controller.isOpen) { + _anim.forward(); + } else { + _anim.reverse(); + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final sp = context.m3e.spacing; // use spacing scale via context extension + final gap = widget.spacing ?? sp.md; + + final children = []; + + for (int i = 0; i < widget.items.length; i++) { + final item = widget.items[i]; + final w = _buildMenuItem(context, item); + final animatedChild = ScaleTransition( + scale: _scale, + child: FadeTransition( + opacity: _scale, + child: w, + ), + ); + // Ensure Positioned is a direct child of the Stack + children.add(_positioned(animatedChild, i, gap)); + } + + final menu = Stack( + alignment: widget.alignment, + clipBehavior: Clip.none, + children: [ + // Primary FAB + Align( + alignment: widget.alignment, + child: _wrapToggle(widget.primaryFab), + ), + // Menu items + ...children, + ], + ); + + final overlay = widget.overlay && _controller.isOpen + ? Positioned.fill( + child: GestureDetector( + onTap: _controller.toggle, + child: ColoredBox( + color: + widget.overlayColor ?? Colors.black.withValues(alpha: 0.25), + ), + ), + ) + : const SizedBox.shrink(); + + return Stack( + children: [ + overlay, + Positioned.fill( + child: IgnorePointer( + ignoring: !_controller.isOpen, child: Container())), + menu, + ], + ); + } + + Widget _wrapToggle(Widget child) { + final core = GestureDetector( + onTap: _controller.toggle, + behavior: HitTestBehavior.opaque, + child: child, + ); + + if (widget.heroTag != null && context.findAncestorWidgetOfExactType() == null) { + return Hero(tag: widget.heroTag!, child: core); + } + return core; + } + + Widget _positioned(Widget child, int index, double gap) { + final offset = (index + 1) * + (gap + + 56); // base step; extended affects height, but 56 is a practical default + switch (widget.direction) { + case FabMenuDirection.up: + return Positioned( + right: 0, + bottom: offset, + child: child, + ); + case FabMenuDirection.down: + return Positioned( + right: 0, + top: offset, + child: child, + ); + case FabMenuDirection.left: + return Positioned( + right: offset, + bottom: 0, + child: child, + ); + case FabMenuDirection.right: + return Positioned( + left: offset, + bottom: 0, + child: child, + ); + } + } + + Widget _buildMenuItem(BuildContext context, FabMenuItem item) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: ExtendedFabM3E( + icon: item.icon, + label: item.label, + onPressed: () { + item.onPressed(); + if (widget.popOnItemTap) _controller.close(); + }, + kind: FabM3EKind.surface, + size: FabM3ESize.regular, + density: FabM3EDensity.regular, + semanticLabel: item.semanticLabel, + ), + ); + } +} diff --git a/packages/fab_m3e/lib/src/fab_theme_m3e.dart b/packages/fab_m3e/lib/src/fab_theme_m3e.dart new file mode 100644 index 0000000..70353f0 --- /dev/null +++ b/packages/fab_m3e/lib/src/fab_theme_m3e.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; +import 'enums.dart'; + +@immutable +class _FabMetrics { + final double small; + final double regular; + final double large; + final double extendedHeight; + final EdgeInsetsGeometry extendedPadding; + final double iconSize; + final double elevationRest; + final double elevationHover; + final double elevationPressed; + const _FabMetrics({ + required this.small, + required this.regular, + required this.large, + required this.extendedHeight, + required this.extendedPadding, + required this.iconSize, + required this.elevationRest, + required this.elevationHover, + required this.elevationPressed, + }); +} + +_FabMetrics _metricsFor(BuildContext context, FabM3EDensity density) { + final theme = Theme.of(context); + final m3e = theme.extension() ?? M3ETheme.defaults(theme.colorScheme); + final sp = m3e.spacing; + + double small = 40; + double regular = 56; + double large = 96; + double extH = 56; + double icon = 24; + + if (density == FabM3EDensity.compact) { + small -= 4; regular -= 4; large -= 4; extH -= 4; + } + + return _FabMetrics( + small: small, + regular: regular, + large: large, + extendedHeight: extH, + extendedPadding: EdgeInsets.symmetric(horizontal: sp.lg), + iconSize: icon, + elevationRest: 6.0, + elevationHover: 8.0, + elevationPressed: 12.0, + ); +} + +class FabTokensAdapter { + FabTokensAdapter(this.context); + final BuildContext context; + + M3ETheme get _m3e { + final t = Theme.of(context); + return t.extension() ?? M3ETheme.defaults(t.colorScheme); + } + + _FabMetrics metrics(FabM3EDensity density) => _metricsFor(context, density); + + // Colors by kind + Color bg(FabM3EKind kind) { + switch (kind) { + case FabM3EKind.primary: + return _m3e.colors.primaryContainer; + case FabM3EKind.secondary: + return _m3e.colors.secondaryContainer; + case FabM3EKind.tertiary: + return _m3e.colors.tertiaryContainer; + case FabM3EKind.surface: + return _m3e.colors.surfaceContainerHigh; + } + } + + Color fg(FabM3EKind kind) { + switch (kind) { + case FabM3EKind.primary: + return _m3e.colors.onPrimaryContainer; + case FabM3EKind.secondary: + return _m3e.colors.onSecondaryContainer; + case FabM3EKind.tertiary: + return _m3e.colors.onTertiaryContainer; + case FabM3EKind.surface: + return _m3e.colors.onSurface; + } + } + + // Shapes + ShapeBorder shape(FabM3EShapeFamily family, FabM3ESize size, {bool extended = false}) { + final set = family == FabM3EShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square; + if (extended) return StadiumBorder(side: BorderSide.none); + // circular-ish fab: use large radius to approach circle; actual size enforced by constraints + final radius = switch (size) { FabM3ESize.small => set.lg, FabM3ESize.regular => set.xl, FabM3ESize.large => set.xl }; + return RoundedRectangleBorder(borderRadius: radius); + } + + // Typography + TextStyle labelStyle() => _m3e.type.labelLarge; +} diff --git a/packages/fab_m3e/melos_fab_m3e.iml b/packages/fab_m3e/melos_fab_m3e.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/fab_m3e/melos_fab_m3e.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/fab_m3e/pubspec.yaml b/packages/fab_m3e/pubspec.yaml new file mode 100644 index 0000000..1a33824 --- /dev/null +++ b/packages/fab_m3e/pubspec.yaml @@ -0,0 +1,18 @@ +name: fab_m3e +description: Material 3 Expressive Floating Action Button (FAB), Extended FAB, and FAB Menu for Flutter using M3E tokens. +version: 0.1.0 +publish_to: none + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.22.0" + +dependencies: + flutter: + sdk: flutter + m3e_design: + path: ../m3e_design + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/packages/fab_m3e/pubspec_overrides.yaml b/packages/fab_m3e/pubspec_overrides.yaml new file mode 100644 index 0000000..69acba6 --- /dev/null +++ b/packages/fab_m3e/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: m3e_design +dependency_overrides: + m3e_design: + path: ..\\m3e_design diff --git a/packages/fab_m3e/test/fab_m3e_test.dart b/packages/fab_m3e/test/fab_m3e_test.dart new file mode 100644 index 0000000..fef40f7 --- /dev/null +++ b/packages/fab_m3e/test/fab_m3e_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('placeholder', () { + expect(3 * 3, 9); + }); +} diff --git a/packages/icon_button_m3e/README.md b/packages/icon_button_m3e/README.md new file mode 100644 index 0000000..5278347 --- /dev/null +++ b/packages/icon_button_m3e/README.md @@ -0,0 +1,92 @@ +# icon_button_m3e + +Expressive Material 3 icon button for Flutter — `IconButtonM3E` — with +five sizes (XS–XL), four variants (standard, filled, tonal, outlined), +round/square shapes, toggle support, and guaranteed 48×48dp tap targets +(even when visual size is 32/40). + +## Highlights + +- Sizes: `M3EIconButtonSize` = XS, SM, MD, LG, XL +- Widths: `M3EIconButtonWidth` = default, narrow, wide +- Variants: standard, filled, tonal, outlined +- Shapes: round (pill) or square (rounded rect) +- Toggle: `isSelected` + `selectedIcon` +- A11y: min 48×48dp hit target; semantics label/selected state +- Tokens: centralized static values in `M3EIconButtonTokens` (no ThemeExtension) + +## Install + +```yaml +dependencies: + icon_button_m3e: + path: ../icon_button_m3e # or from pub once published +``` + +## Quick Start + +```dart +import 'package:icon_button_m3e/icon_button_m3e.dart'; + +IconButtonM3E( + variant: IconButtonM3EVariant.filled, + size: M3EIconButtonSize.md, + width: M3EIconButtonWidth.defaultWidth, + icon: const Icon(Icons.mic), + tooltip: 'Start recording', + onPressed: () {}, +); +``` + +### Toggle + +```dart +bool isFav = false; + +IconButtonM3E( + variant: IconButtonM3EVariant.tonal, + isSelected: isFav, + icon: const Icon(Icons.favorite_border), + selectedIcon: const Icon(Icons.favorite), + tooltip: isFav ? 'Remove from favorites' : 'Add to favorites', + onPressed: () => setState(() => isFav = !isFav), +); +``` + +## Sizing + +- Visual container sizes come from tokens: `M3EIconButtonTokens.visual[size][width]`. +- Minimum interactive target sizes come from `M3EIconButtonTokens.target[size][width]`. + - XS/SM enforce at least 48×48; others match their visual sizes. +- Icon glyph sizes are in `M3EIconButtonTokens.icon[size]`. + +For example (default width): +- XS: 32×32 visual, 48×48 target +- SM: 40×40 visual, 48×48 target (SM wide: 52×48) +- MD: 56×56 +- LG: 96×96 +- XL: 136×136 + +## Colors and shapes + +- Colors are derived from your `ThemeData.colorScheme`: + - standard: transparent bg, onSurfaceVariant fg (selected uses primary) + - filled: primary bg, onPrimary fg + - tonal: secondaryContainer bg, onSecondaryContainer fg + - outlined: transparent bg, primary fg, outline border +- Shapes: `M3EIconButtonShapeVariant.round` (pill) or `.square` (rounded square). + - Pressed state uses a shared, more-square radius per size. + - If used as a toggle, selected state flips round/square for expressive feel. + +## Example + +Run the example app: + +```sh +cd example +flutter run +``` + +## License + +MIT diff --git a/packages/icon_button_m3e/example/.gitignore b/packages/icon_button_m3e/example/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/packages/icon_button_m3e/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/icon_button_m3e/example/.metadata b/packages/icon_button_m3e/example/.metadata new file mode 100644 index 0000000..84f56b1 --- /dev/null +++ b/packages/icon_button_m3e/example/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: android + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: ios + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: linux + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: macos + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: web + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: windows + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/icon_button_m3e/example/README.md b/packages/icon_button_m3e/example/README.md new file mode 100644 index 0000000..60bbe46 --- /dev/null +++ b/packages/icon_button_m3e/example/README.md @@ -0,0 +1,16 @@ +# icon_button_m3e_example + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/icon_button_m3e/example/analysis_options.yaml b/packages/icon_button_m3e/example/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/packages/icon_button_m3e/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/icon_button_m3e/example/lib/main.dart b/packages/icon_button_m3e/example/lib/main.dart new file mode 100644 index 0000000..bf6fa65 --- /dev/null +++ b/packages/icon_button_m3e/example/lib/main.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:icon_button_m3e/icon_button_m3e.dart'; + +void main() => runApp(const DemoApp()); + +class DemoApp extends StatelessWidget { + const DemoApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'IconButtonM3E Demo', + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), +/* extensions: const [IconButtonM3ETokens.fallback()],*/ + ), + home: const DemoHome(), + ); + } +} + +class DemoHome extends StatefulWidget { + const DemoHome({super.key}); + + @override + State createState() => _DemoHomeState(); +} + +class _DemoHomeState extends State { + bool selected = false; + + @override + Widget build(BuildContext context) { + const sizes = IconButtonM3ESize.values; + const variants = IconButtonM3EVariant.values; + + return Scaffold( + appBar: AppBar(title: const Text('IconButtonM3E Demo')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Wrap(spacing: 12, runSpacing: 12, children: [ + Column( + children: [ + const Text('Variants × Sizes (round - width default)', + style: + TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + for (final v in variants) ...[ + Text(v.toString().split('.').last.toUpperCase(), + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final s in sizes) + IconButtonM3E( + variant: v, + size: s, + width: IconButtonM3EWidth.defaultWidth, + icon: const Icon(Icons.mic), + tooltip: 'Mic', + onPressed: () {}, + ), + ], + ), + const SizedBox(height: 16), + ], + ], + ), + Column( + children: [ + const Text('Variants × Sizes (round - width narrow)', + style: + TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + for (final v in variants) ...[ + Text(v.toString().split('.').last.toUpperCase(), + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final s in sizes) + IconButtonM3E( + variant: v, + size: s, + width: IconButtonM3EWidth.narrow, + icon: const Icon(Icons.mic), + tooltip: 'Mic', + onPressed: () {}, + ), + ], + ), + const SizedBox(height: 16), + ], + ], + ), + Column( + children: [ + const Text('Variants × Sizes (round - width narrow)', + style: + TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + for (final v in variants) ...[ + Text(v.toString().split('.').last.toUpperCase(), + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final s in sizes) + IconButtonM3E( + variant: v, + size: s, + width: IconButtonM3EWidth.wide, + icon: const Icon(Icons.mic), + tooltip: 'Mic', + onPressed: () {}, + ), + ], + ), + const SizedBox(height: 16), + ], + ], + ), + ]), + const SizedBox(height: 24), + const Text('Square shape', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final v in variants) + IconButtonM3E( + variant: v, + shape: IconButtonM3EShapeVariant.square, + size: IconButtonM3ESize.md, + icon: const Icon(Icons.share), + tooltip: 'Share', + onPressed: () {}, + ), + ], + ), + const SizedBox(height: 32), + const Text('Toggle example', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + IconButtonM3E( + variant: IconButtonM3EVariant.tonal, + isSelected: selected, + icon: const Icon(Icons.favorite_border), + selectedIcon: const Icon(Icons.favorite), + tooltip: selected ? 'Unfavorite' : 'Favorite', + onPressed: () => setState(() => selected = !selected), + ), + IconButtonM3E( + variant: IconButtonM3EVariant.filled, + isSelected: selected, + icon: const Icon(Icons.bookmark_add_outlined), + selectedIcon: const Icon(Icons.bookmark_added), + tooltip: selected ? 'Remove bookmark' : 'Add bookmark', + onPressed: () => setState(() => selected = !selected), + ), + ], + ), + const SizedBox(height: 32), + ], + ), + ); + } +} diff --git a/packages/icon_button_m3e/example/pubspec.yaml b/packages/icon_button_m3e/example/pubspec.yaml new file mode 100644 index 0000000..1f0face --- /dev/null +++ b/packages/icon_button_m3e/example/pubspec.yaml @@ -0,0 +1,21 @@ +name: icon_button_m3e_example +description: Example for icon_button_m3e +publish_to: "none" + +environment: + sdk: ">=3.2.0 <4.0.0" + flutter: ">=3.19.0" + +dependencies: + flutter: + sdk: flutter + icon_button_m3e: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true diff --git a/packages/icon_button_m3e/lib/icon_button_m3e.dart b/packages/icon_button_m3e/lib/icon_button_m3e.dart new file mode 100644 index 0000000..30fdba3 --- /dev/null +++ b/packages/icon_button_m3e/lib/icon_button_m3e.dart @@ -0,0 +1,4 @@ +library icon_button_m3e; + +export 'src/enums.dart'; +export 'src/icon_button_m3e.dart'; diff --git a/packages/icon_button_m3e/lib/src/_tokens_adapter.dart b/packages/icon_button_m3e/lib/src/_tokens_adapter.dart new file mode 100644 index 0000000..32f52e2 --- /dev/null +++ b/packages/icon_button_m3e/lib/src/_tokens_adapter.dart @@ -0,0 +1,122 @@ +part of 'enums.dart'; + +/// All numeric tokens & constants for M3 Expressive IconButton. +/// No business logic here—just data. +class IconButtonM3ETokens { + const IconButtonM3ETokens._(); + + // ---------------------------- + // Icon glyph sizes (dp) + // ---------------------------- + static const Map icon = { + IconButtonM3ESize.xs: 20.0, // A + IconButtonM3ESize.sm: 24.0, // B + IconButtonM3ESize.md: 24.0, // C + IconButtonM3ESize.lg: 32.0, // D + IconButtonM3ESize.xl: 40.0, // E + }; + + // ---------------------------- + // Visual container sizes (dp) + // width × height + // ---------------------------- + static const Map> visual = { + IconButtonM3ESize.xs: { + IconButtonM3EWidth.defaultWidth: Size(32, 32), + IconButtonM3EWidth.narrow: Size(28, 32), + IconButtonM3EWidth.wide: Size(40, 32), + }, + IconButtonM3ESize.sm: { + IconButtonM3EWidth.defaultWidth: Size(40, 40), + IconButtonM3EWidth.narrow: Size(32, 40), + IconButtonM3EWidth.wide: Size(52, 40), + }, + IconButtonM3ESize.md: { + IconButtonM3EWidth.defaultWidth: Size(56, 56), + IconButtonM3EWidth.narrow: Size(48, 56), + IconButtonM3EWidth.wide: Size(72, 56), + }, + IconButtonM3ESize.lg: { + IconButtonM3EWidth.defaultWidth: Size(96, 96), + IconButtonM3EWidth.narrow: Size(64, 96), + IconButtonM3EWidth.wide: Size(128, 96), + }, + IconButtonM3ESize.xl: { + IconButtonM3EWidth.defaultWidth: Size(136, 136), + IconButtonM3EWidth.narrow: Size(104, 136), + IconButtonM3EWidth.wide: Size(184, 136), + }, + }; + + // ---------------------------- + // Minimum interactive target sizes (dp) + // XS/SM must be ≥48×48 (SM wide = 52×48); others equal visual. + // ---------------------------- + static const Map> target = { + IconButtonM3ESize.xs: { + IconButtonM3EWidth.defaultWidth: Size(48, 48), + IconButtonM3EWidth.narrow: Size(48, 48), + IconButtonM3EWidth.wide: Size(48, 48), + }, + IconButtonM3ESize.sm: { + IconButtonM3EWidth.defaultWidth: Size(48, 48), + IconButtonM3EWidth.narrow: Size(48, 48), + IconButtonM3EWidth.wide: Size(52, 48), + }, + // MD/LG/XL already meet or exceed 48×48 – use visual sizes as targets. + IconButtonM3ESize.md: { + IconButtonM3EWidth.defaultWidth: Size(56, 56), + IconButtonM3EWidth.narrow: Size(48, 56), + IconButtonM3EWidth.wide: Size(72, 56), + }, + IconButtonM3ESize.lg: { + IconButtonM3EWidth.defaultWidth: Size(96, 96), + IconButtonM3EWidth.narrow: Size(64, 96), + IconButtonM3EWidth.wide: Size(128, 96), + }, + IconButtonM3ESize.xl: { + IconButtonM3EWidth.defaultWidth: Size(136, 136), + IconButtonM3EWidth.narrow: Size(104, 136), + IconButtonM3EWidth.wide: Size(184, 136), + }, + }; + + // ---------------------------- + // Corner radii (dp) + // Pressed radius is shared by both variants at the same size and + // is more square than the square resting radius. + // Values are consistent, scalable defaults; tune to match your spec. + // ---------------------------- + static const Map radiusRestRound = { + // Half of the default height → circular/pill look + IconButtonM3ESize.xs: 16.0, // 32/2 + IconButtonM3ESize.sm: 20.0, // 40/2 + IconButtonM3ESize.md: 28.0, // 56/2 + IconButtonM3ESize.lg: 48.0, // 96/2 + IconButtonM3ESize.xl: 68.0, // 136/2 + }; + + static const Map radiusRestSquare = { + // Rounded-square feel (~25% of height) + IconButtonM3ESize.xs: 8.0, // ≈32*0.25 + IconButtonM3ESize.sm: 10.0, // ≈40*0.25 + IconButtonM3ESize.md: 14.0, // ≈56*0.25 + IconButtonM3ESize.lg: 24.0, // ≈96*0.25 + IconButtonM3ESize.xl: 34.0, // ≈136*0.25 + }; + + static const Map radiusPressed = { + // More square than the square resting radius (~20% of height) + IconButtonM3ESize.xs: 6.0, // ≈32*0.20 + IconButtonM3ESize.sm: 8.0, // ≈40*0.20 + IconButtonM3ESize.md: 11.0, // ≈56*0.20 + IconButtonM3ESize.lg: 19.0, // ≈96*0.20 + IconButtonM3ESize.xl: 27.0, // ≈136*0.20 + }; + + // ---------------------------- + // Motion tokens for shape morph (optional, but handy) + // ---------------------------- + static const Duration morphDuration = Duration(milliseconds: 120); + static const Curve morphCurve = Curves.easeOut; +} diff --git a/packages/icon_button_m3e/lib/src/enums.dart b/packages/icon_button_m3e/lib/src/enums.dart new file mode 100644 index 0000000..ea9dbb3 --- /dev/null +++ b/packages/icon_button_m3e/lib/src/enums.dart @@ -0,0 +1,86 @@ +library m3e_iconbutton; + +import 'package:flutter/material.dart'; + +part '_tokens_adapter.dart'; + +/// Visual scale labels (A–E in the spec). +enum IconButtonM3ESize { xs, sm, md, lg, xl } + +/// Width variants of the button’s container (not the icon glyph). +enum IconButtonM3EWidth { defaultWidth, narrow, wide } + +/// The two resting shape variants. +enum IconButtonM3EShapeVariant { round, square } + +/// Visual variants (kept from previous API). +enum IconButtonM3EVariant { standard, filled, tonal, outlined } + +/// Icon glyph size inside the button (reads tokens). +extension IconM3EGlyph on IconButtonM3ESize { + double get icon => IconButtonM3ETokens.icon[this]!; +} + +/// Visual (painted) size & target size helpers (read tokens). +extension IconButtonM3ESizes on IconButtonM3ESize { + Size visual(IconButtonM3EWidth width) => + IconButtonM3ETokens.visual[this]![width]!; + + Size target(IconButtonM3EWidth width) => + IconButtonM3ETokens.target[this]![width]!; + + Size get defaultSize => visual(IconButtonM3EWidth.defaultWidth); + Size get narrowSize => visual(IconButtonM3EWidth.narrow); + Size get wideSize => visual(IconButtonM3EWidth.wide); +} + +/// Shape resolution helpers: resting/pressed radii and toggle behavior. +class IconButtonM3EShapes { + const IconButtonM3EShapes._(); + + static IconButtonM3EShapeVariant restVariant({ + required bool isToggle, + required bool isSelected, + required IconButtonM3EShapeVariant baseVariant, + }) { + if (isToggle && isSelected) { + return baseVariant == IconButtonM3EShapeVariant.round + ? IconButtonM3EShapeVariant.square + : IconButtonM3EShapeVariant.round; + } + return baseVariant; + } + + static double restingRadius({ + required IconButtonM3ESize size, + required IconButtonM3EShapeVariant variant, + }) { + return switch (variant) { + IconButtonM3EShapeVariant.round => + IconButtonM3ETokens.radiusRestRound[size]!, + IconButtonM3EShapeVariant.square => + IconButtonM3ETokens.radiusRestSquare[size]!, + }; + } + + /// Effective corner radius for the given material states. + /// Hover does not change the radius; Pressed uses the shared pressed radius. + static double effectiveRadius({ + required IconButtonM3ESize size, + required IconButtonM3EShapeVariant baseVariant, + required bool isToggle, + required bool isSelected, + required Set states, + }) { + final variant = restVariant( + isToggle: isToggle, + isSelected: isSelected, + baseVariant: baseVariant, + ); + + if (states.contains(WidgetState.pressed)) { + return IconButtonM3ETokens.radiusPressed[size]!; + } + return restingRadius(size: size, variant: variant); + } +} diff --git a/packages/icon_button_m3e/lib/src/icon_button_m3e.dart b/packages/icon_button_m3e/lib/src/icon_button_m3e.dart new file mode 100644 index 0000000..cf909a4 --- /dev/null +++ b/packages/icon_button_m3e/lib/src/icon_button_m3e.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +import 'enums.dart'; + +/// Material 3 Expressive Icon Button +/// +/// - Visual sizes are defined by [IconButtonM3ETokens.visual] (per size × width) +/// - Tap target respects [IconButtonM3ETokens.target] with a minimum of 48×48 on XS/SM +/// - Variants: standard, filled, tonal, outlined +/// - Shapes: round (pill) or square (rounded rect). Toggle can flip shape when selected. +/// - Widths: default, narrow, wide +/// - Toggle: [isSelected] + [selectedIcon] +class IconButtonM3E extends StatelessWidget { + const IconButtonM3E({ + super.key, + required this.icon, + this.onPressed, + this.tooltip, + this.semanticLabel, + this.variant = IconButtonM3EVariant.standard, + this.size = IconButtonM3ESize.sm, + this.shape = IconButtonM3EShapeVariant.round, + this.width = IconButtonM3EWidth.defaultWidth, + this.isSelected, + this.selectedIcon, + this.enableFeedback, + }); + + final Widget icon; + final VoidCallback? onPressed; + final String? tooltip; + final String? semanticLabel; + final IconButtonM3EVariant variant; + final IconButtonM3ESize size; + final IconButtonM3EShapeVariant shape; + final IconButtonM3EWidth width; + final bool? isSelected; + final Widget? selectedIcon; + final bool? enableFeedback; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + final Size visual = size.visual(width); + final Size target = size.target(width); + final double iconPx = size.icon; + + final bool selected = isSelected ?? false; + // Consider it a toggle control if selection can be represented. + final bool isToggle = isSelected != null || selectedIcon != null; + + // Colors per variant (selected tint for standard). + Color bg; + Color fg; + BorderSide? side; + switch (variant) { + case IconButtonM3EVariant.standard: + bg = Colors.transparent; + fg = selected ? scheme.primary : scheme.onSurfaceVariant; + side = null; + break; + case IconButtonM3EVariant.filled: + bg = scheme.primary; + fg = scheme.onPrimary; + side = null; + break; + case IconButtonM3EVariant.tonal: + bg = scheme.secondaryContainer; + fg = scheme.onSecondaryContainer; + side = null; + break; + case IconButtonM3EVariant.outlined: + bg = Colors.transparent; + fg = scheme.primary; + side = BorderSide(color: scheme.outline, width: 1); + break; + } + + // Resolve shape radius based on states (pressed) and toggle/selection. + OutlinedBorder shapeFor(Set states) { + final r = IconButtonM3EShapes.effectiveRadius( + size: size, + baseVariant: shape, + isToggle: isToggle, + isSelected: selected, + states: states, + ); + return RoundedRectangleBorder(borderRadius: BorderRadius.circular(r)); + } + + final Widget innerIcon = IconTheme.merge( + data: IconThemeData(size: iconPx, color: fg), + child: (selected && selectedIcon != null) ? selectedIcon! : icon, + ); + + final Widget button = IconButton( + onPressed: onPressed, + isSelected: isSelected, + selectedIcon: selectedIcon, + icon: innerIcon, + tooltip: tooltip, + enableFeedback: enableFeedback, + style: ButtonStyle( + // Visual (painted) size + fixedSize: WidgetStateProperty.all(visual), + padding: WidgetStateProperty.all(EdgeInsets.zero), + shape: WidgetStateProperty.resolveWith(shapeFor), + backgroundColor: WidgetStateProperty.all(bg), + foregroundColor: WidgetStateProperty.resolveWith((_) => fg), + side: WidgetStateProperty.resolveWith((_) => side), + // Animate pressed shape morph a bit. + animationDuration: IconButtonM3ETokens.morphDuration, + visualDensity: VisualDensity.standard, + ), + ); + + // Compose into an outer box sized to the minimum interactive target. + final Widget core = SizedBox( + width: target.width, + height: target.height, + child: Center( + child: SizedBox( + width: visual.width, + height: visual.height, + child: button, + ), + ), + ); + + final semanticsText = semanticLabel ?? tooltip; + return Semantics( + button: true, + selected: selected, + label: semanticsText, + child: core, + ); + } +} diff --git a/packages/icon_button_m3e/melos_icon_button_m3e.iml b/packages/icon_button_m3e/melos_icon_button_m3e.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/icon_button_m3e/melos_icon_button_m3e.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/icon_button_m3e/pubspec.yaml b/packages/icon_button_m3e/pubspec.yaml new file mode 100644 index 0000000..1317225 --- /dev/null +++ b/packages/icon_button_m3e/pubspec.yaml @@ -0,0 +1,21 @@ +name: icon_button_m3e +description: "Material 3 Expressive IconButton with sizes, variants, shapes, toggle, and accessible hit targets." +version: 0.1.1 +repository: https://github.com/EmilyMonestone/icon_button_m3e +issue_tracker: https://github.com/EmilyMonestone/icon_button_m3e/issues + +environment: + sdk: ^3.9.2 + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true diff --git a/packages/icon_button_m3e/pubspec_overrides.yaml b/packages/icon_button_m3e/pubspec_overrides.yaml new file mode 100644 index 0000000..69acba6 --- /dev/null +++ b/packages/icon_button_m3e/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: m3e_design +dependency_overrides: + m3e_design: + path: ..\\m3e_design diff --git a/packages/icon_button_m3e/test/icon_button_m3e_test.dart b/packages/icon_button_m3e/test/icon_button_m3e_test.dart new file mode 100644 index 0000000..8f8f919 --- /dev/null +++ b/packages/icon_button_m3e/test/icon_button_m3e_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icon_button_m3e/icon_button_m3e.dart'; + +void main() { + testWidgets('Semantics exposes label and selected state', (tester) async { + const label = 'Favorite'; + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: IconButtonM3E( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + isSelected: true, + tooltip: label, + ), + ), + ), + ); + + final semantics = tester.getSemantics(find.byType(IconButtonM3E)); + expect(semantics.flagsCollection.hasSelectedState, true); + expect(semantics.label, label); + }); + + testWidgets('Hit target is at least 48x48 when visual is XS (32)', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: IconButtonM3E( + size: IconButtonM3ESize.xs, + icon: Icon(Icons.mic), + ), + ), + ), + ), + ); + + final size = tester.getSize(find.byType(IconButtonM3E)); + expect(size.width, greaterThanOrEqualTo(48)); + expect(size.height, greaterThanOrEqualTo(48)); + }); +} diff --git a/packages/loading_indicator_m3e/LICENSE b/packages/loading_indicator_m3e/LICENSE new file mode 100644 index 0000000..12ca7c2 --- /dev/null +++ b/packages/loading_indicator_m3e/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) ... + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/loading_indicator_m3e/README.md b/packages/loading_indicator_m3e/README.md new file mode 100644 index 0000000..dccd16f --- /dev/null +++ b/packages/loading_indicator_m3e/README.md @@ -0,0 +1,56 @@ +# loading_indicator_m3e + +Material 3 **Expressive** Loading Indicator for Flutter — a morphing polygon that continuously rotates and morphs between shapes (ported from Android's Material3 `LoadingIndicator`). + +Two configurations: +- **Default** — container uses `secondaryContainer`, active indicator uses `primary` +- **Contained** — container uses `primaryContainer`, active indicator uses `onPrimaryContainer` + +Token-aligned sizes: +- Container: **48 × 48dp** +- Active indicator size: **38dp** +- Container shape: **full** (pill/circular) corners + +## Usage + +```dart +import 'package:loading_indicator_m3e/loading_indicator_m3e.dart'; + +// Default +const LoadingIndicatorM3E(); + +// Contained +const LoadingIndicatorM3E(variant: LoadingIndicatorM3EVariant.contained); + +// Custom colors, custom polygon sequence +LoadingIndicatorM3E( + color: Colors.teal, + polygons: const [ + MaterialShapes.sunny, + MaterialShapes.cookie9Sided, + MaterialShapes.pill, + ], +); +``` + +## Notes +- The inner morph sequence and animation timings match the Compose implementation: + - Morph interval ~650ms, global rotation ~4666ms + - Active size is scaled to ~38dp inside the 48dp container to avoid clipping while rotating +- Requires your monorepo `m3e_design` (for tokens) and `material_new_shapes` (for `RoundedPolygon` + `Morph` + `MaterialShapes`). The `pubspec.yaml` is set up with `path: ../...`. + +## Monorepo Layout + +``` +packages/ + m3e_design/ + material_new_shapes/ + loading_indicator_m3e/ +``` + +## Accessibility +Pass `semanticLabel` and `semanticValue` to announce loading status if needed. + +## License +- Android/Compose implementation © Google, Apache-2.0 +- This package MIT diff --git a/packages/loading_indicator_m3e/lib/loading_indicator_m3e.dart b/packages/loading_indicator_m3e/lib/loading_indicator_m3e.dart new file mode 100644 index 0000000..b0b53c5 --- /dev/null +++ b/packages/loading_indicator_m3e/lib/loading_indicator_m3e.dart @@ -0,0 +1,6 @@ +library loading_indicator_m3e; + +export 'src/enums.dart'; +export 'src/loading_tokens_adapter.dart' show LoadingTokensAdapter; +export 'src/expressive_loading_indicator.dart'; +export 'src/loading_indicator_m3e.dart'; diff --git a/packages/loading_indicator_m3e/lib/src/enums.dart b/packages/loading_indicator_m3e/lib/src/enums.dart new file mode 100644 index 0000000..81f39e3 --- /dev/null +++ b/packages/loading_indicator_m3e/lib/src/enums.dart @@ -0,0 +1 @@ +enum LoadingIndicatorM3EVariant { defaultStyle, contained } diff --git a/packages/loading_indicator_m3e/lib/src/expressive_loading_indicator.dart b/packages/loading_indicator_m3e/lib/src/expressive_loading_indicator.dart new file mode 100644 index 0000000..0236a36 --- /dev/null +++ b/packages/loading_indicator_m3e/lib/src/expressive_loading_indicator.dart @@ -0,0 +1,329 @@ +// Port of Android's LoadingIndicator +// Source: androidx/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/LoadingIndicator.kt +// Copyright (c) 2024 The Android Open Source Project +// Licensed under the Apache License, Version 2.0 + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/semantics.dart'; +import 'package:material_new_shapes/material_new_shapes.dart'; + +/// A Material Design loading indicator. +/// +/// This version of the loading indicator morphs between its [polygons] shapes. +/// ![Loading indicator image](https://developer.android.com/images/reference/androidx/compose/material3/loading-indicator.png) +class ExpressiveLoadingIndicator extends ProgressIndicator { + /// A list of [RoundedPolygon]s for the sequence of shapes this loading indicator + /// will morph between. The loading indicator expects at least two items in that list. + final List? polygons; + + /// Defines minimum and maximum sizes for an [ExpressiveLoadingIndicator]. + /// If null, then the [ProgressIndicatorThemeData.constraints] will be used. Otherwise, defaults to a minimum width and height of 48 pixels. + final BoxConstraints? constraints; + + const ExpressiveLoadingIndicator({ + super.key, + super.color, + this.polygons, + this.constraints, + super.semanticsLabel, + super.semanticsValue, + }) : assert(polygons != null ? polygons.length > 1 : true); + + @override + State createState() => + _ExpressiveLoadingIndicatorState(); +} + +class _ExpressiveLoadingIndicatorState extends State + with TickerProviderStateMixin { + static final List _defaultPolygons = [ + MaterialShapes.softBurst, + MaterialShapes.cookie9Sided, + MaterialShapes.pentagon, + MaterialShapes.pill, + MaterialShapes.sunny, + MaterialShapes.cookie4Sided, + MaterialShapes.oval, + ]; + + static final BoxConstraints _defaultConstraints = BoxConstraints( + minWidth: 48.0, + minHeight: 48.0, + maxWidth: 48.0, + maxHeight: 48.0, + ); // default from kotlin source + + late final List _polygons; + + static const int _globalRotationDurationMs = 4666; + static const int _morphIntervalMs = 650; + static const double _fullRotation = 360.0; + + static const double _quarterRotation = _fullRotation / 4; + static const double _activeSize = 38; // based on source spec + + late final List _morphSequence; + + late final AnimationController _morphController; + late final AnimationController _globalRotationController; + int _currentMorphIndex = 0; + double _morphRotationTargetAngle = _quarterRotation; + + Timer? _morphTimer; + + final _morphAnimationSpec = SpringSimulation( + SpringDescription.withDampingRatio(ratio: 0.6, stiffness: 200.0, mass: 1.0), + 0.0, + 1.0, + 5.0, + snapToEnd: true, + ); + + late BoxConstraints _constraints; + late Color _color; + + @override + Widget build(BuildContext context) { + final indicatorTheme = ProgressIndicatorTheme.of(context); + _color = + widget.color ?? + indicatorTheme.color ?? + Theme.of(context).colorScheme.primary; + _constraints = + widget.constraints ?? indicatorTheme.constraints ?? _defaultConstraints; + + final activeIndicatorScale = + _activeSize / math.min(_constraints.maxWidth, _constraints.maxHeight); + + final shapesScaleFactor = + _calculateScaleFactor(_polygons) * activeIndicatorScale; + + return Semantics.fromProperties( + properties: SemanticsProperties( + label: widget.semanticsLabel, + value: widget.semanticsValue, + ), + child: RepaintBoundary( + child: ConstrainedBox( + constraints: _constraints, + child: AspectRatio( + aspectRatio: 1.0, + child: AnimatedBuilder( + animation: Listenable.merge([ + _morphController, + _globalRotationController, + ]), + builder: (context, child) { + final morphProgress = _morphController.value.clamp(0.0, 1.0); + final globalRotationDegrees = + _globalRotationController.value * _fullRotation; + + // calculate total rotation (clockwise, matching Kotlin implementation) + final totalRotationDegrees = + morphProgress * _quarterRotation + + _morphRotationTargetAngle + + globalRotationDegrees; + + final totalRotationRadians = + totalRotationDegrees * (math.pi / 180.0); + + return Transform.rotate( + angle: totalRotationRadians, + child: CustomPaint( + painter: _MorphPainter( + morph: _morphSequence[_currentMorphIndex], + progress: morphProgress, + color: _color, + scaleFactor: shapesScaleFactor, + repaint: Listenable.merge([ + _morphController, + _globalRotationController, + ]), + ), + child: const SizedBox.expand(), + ), + ); + }, + ), + ), + ), + ), + ); + } + + @override + void dispose() { + _morphTimer?.cancel(); + _morphController.dispose(); + _globalRotationController.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + + _polygons = widget.polygons ?? _defaultPolygons; + + _morphSequence = _createMorphSequence(_polygons, circularSequence: true); + + _morphController = AnimationController.unbounded(vsync: this); + + // continuous linear rotation + _globalRotationController = AnimationController( + duration: const Duration(milliseconds: _globalRotationDurationMs), + vsync: this, + ); + + _startAnimations(); + } + + List _createMorphSequence( + List polygons, { + required bool circularSequence, + }) { + final morphs = []; + + for (int i = 0; i < polygons.length; i++) { + if (i + 1 < polygons.length) { + morphs.add(Morph(polygons[i], polygons[i + 1])); + } else if (circularSequence) { + // morph from last shape back to first shape + morphs.add(Morph(polygons[i], polygons[0])); + } + } + + return morphs; + } + + /// Calculates a scale factor that will be used when scaling the provided [RoundedPolygon]s into a + /// specified sized container. + /// + /// Since the polygons may rotate, a simple [RoundedPolygon.calculateBounds] is not enough to + /// determine the size the polygon will occupy as it rotates. Using the simple bounds calculation may + /// result in a clipped shape. + /// + /// This function calculates and returns a scale factor by utilizing the + /// [RoundedPolygon.calculateMaxBounds] and comparing its result to the + /// [RoundedPolygon.calculateBounds]. The scale factor can later be used when calling [processPath]. + /// + /// Port of Kotlin implementation. + double _calculateScaleFactor(List polygons) { + var scaleFactor = 1.0; + + for (final polygon in polygons) { + final bounds = polygon.calculateBounds(); + final maxBounds = polygon.calculateMaxBounds(); + + final boundsWidth = bounds[2] - bounds[0]; + final boundsHeight = bounds[3] - bounds[1]; + + final maxBoundsWidth = maxBounds[2] - maxBounds[0]; + final maxBoundsHeight = maxBounds[3] - maxBounds[1]; + + final scaleX = boundsWidth / maxBoundsWidth; + final scaleY = boundsHeight / maxBoundsHeight; + + // We use max(scaleX, scaleY) to handle cases like a pill-shape that can throw off the + // entire calculation. + scaleFactor = math.min(scaleFactor, math.max(scaleX, scaleY)); + } + + return scaleFactor; + } + + void _startAnimations() { + // infinite global rotation + _globalRotationController.repeat(); + + // periodic morph cycle + _morphTimer = Timer.periodic( + const Duration(milliseconds: _morphIntervalMs), + (_) => _startMorphCycle(), + ); + + _startMorphCycle(); + } + + void _startMorphCycle() { + if (!mounted) return; + + // move to next morph in sequence + _currentMorphIndex = (_currentMorphIndex + 1) % _morphSequence.length; + + // accumulate rotation target + _morphRotationTargetAngle = + (_morphRotationTargetAngle + _quarterRotation) % _fullRotation; + + // Reset and start morph animation + _morphController + ..value = 0.0 + ..animateWith(_morphAnimationSpec); + } +} + +class _MorphPainter extends CustomPainter { + final Morph morph; + final double progress; + final Color color; + + /// A scale factor that will be taken into account uniformly when the [path] is + /// scaled (i.e. the scaleX would be the [size] width x the scale factor, and the scaleY would be + /// the [size] height x the scale factor) + final double scaleFactor; + + _MorphPainter({ + required this.morph, + required this.progress, + required this.color, + this.scaleFactor = 1.0, + super.repaint, + }); + + @override + void paint(Canvas canvas, Size size) { + final path = morph.toPath(progress: progress); + final processedPath = _processPath(path, size); + canvas.drawPath( + processedPath, + Paint() + ..style = PaintingStyle.fill + ..color = color, + ); + } + + @override + bool shouldRepaint(_MorphPainter oldDelegate) { + return oldDelegate.morph != morph || + oldDelegate.progress != progress || + oldDelegate.color != color || + oldDelegate.scaleFactor != scaleFactor; + } + + /// Process a given path to scale it and center it inside the given size. + /// + /// [path] takes a [Path] that was generated by a _normalized_ [Morph] or [RoundedPolygon]. + /// [size] takes a [Size] that the provided [path] is going to be scaled and centered into. + Path _processPath(Path path, Size size) { + // a [Matrix] that would be used to apply the scaling. Note that any provided + // matrix will be reset in this function. + final Matrix4 scaleMatrix = Matrix4.diagonal3Values( + size.width * scaleFactor, + size.height * scaleFactor, + 1, + ); + final Path scaledPath = path.transform(scaleMatrix.storage); + + // Translate the path so that its center aligns with the center of the container. + final Rect bounds = scaledPath.getBounds(); + final Offset translation = + Offset(size.width / 2, size.height / 2) - bounds.center; + final Path finalPath = scaledPath.shift(translation); + + return finalPath; + } +} diff --git a/packages/loading_indicator_m3e/lib/src/loading_indicator_m3e.dart b/packages/loading_indicator_m3e/lib/src/loading_indicator_m3e.dart new file mode 100644 index 0000000..d83a100 --- /dev/null +++ b/packages/loading_indicator_m3e/lib/src/loading_indicator_m3e.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:material_new_shapes/material_new_shapes.dart'; +import 'expressive_loading_indicator.dart'; +import 'loading_tokens_adapter.dart'; +import 'enums.dart'; + +/// Material 3 Expressive Loading Indicator +/// - Default: floating morphing shape on surface +/// - Contained: icon inside colored container (primary container) using onPrimaryContainer +class LoadingIndicatorM3E extends StatelessWidget { + const LoadingIndicatorM3E({ + super.key, + this.variant = LoadingIndicatorM3EVariant.defaultStyle, + this.color, + this.containerColor, + this.polygons, + this.constraints, + this.padding, + this.semanticLabel, + this.semanticValue, + }); + + final LoadingIndicatorM3EVariant variant; + final Color? color; + final Color? containerColor; + final List? polygons; + final BoxConstraints? constraints; + final EdgeInsetsGeometry? padding; + final String? semanticLabel; + final String? semanticValue; + + @override + Widget build(BuildContext context) { + final tokens = LoadingTokensAdapter(context); + final size = Size(tokens.containerWidth(), tokens.containerHeight()); + + final cons = constraints ?? BoxConstraints.tight(size); + + final activeColor = switch (variant) { + LoadingIndicatorM3EVariant.defaultStyle => color ?? tokens.activeColor(), + LoadingIndicatorM3EVariant.contained => color ?? tokens.containedActiveColor(), + }; + + final containerBg = switch (variant) { + LoadingIndicatorM3EVariant.defaultStyle => containerColor ?? tokens.containerColorDefault(), + LoadingIndicatorM3EVariant.contained => containerColor ?? tokens.containedContainerColor(), + }; + + final indicator = ExpressiveLoadingIndicator( + color: activeColor, + polygons: polygons, + semanticsLabel: semanticLabel, + semanticsValue: semanticValue, + constraints: cons, + ); + + if (variant == LoadingIndicatorM3EVariant.defaultStyle) { + // Default: subtle container (secondaryContainer) + return DecoratedBox( + decoration: BoxDecoration( + color: containerBg, + borderRadius: tokens.containerRadius(), + ), + child: Padding( + padding: padding ?? const EdgeInsets.all(0), + child: indicator, + ), + ); + } + + // Contained: stronger container (primaryContainer) and contrasting active indicator + return DecoratedBox( + decoration: BoxDecoration( + color: containerBg, + borderRadius: tokens.containerRadius(), + ), + child: Padding( + padding: padding ?? const EdgeInsets.all(0), + child: indicator, + ), + ); + } +} diff --git a/packages/loading_indicator_m3e/lib/src/loading_tokens_adapter.dart b/packages/loading_indicator_m3e/lib/src/loading_tokens_adapter.dart new file mode 100644 index 0000000..6a99809 --- /dev/null +++ b/packages/loading_indicator_m3e/lib/src/loading_tokens_adapter.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; + +@immutable +class LoadingTokensAdapter { + const LoadingTokensAdapter(this.context); + final BuildContext context; + + M3ETheme get _m3e { + final t = Theme.of(context); + return t.extension() ?? M3ETheme.defaults(t.colorScheme); + } + + // Active indicator color (Default variant) + Color activeColor() => _m3e.colors.primary; + + // Container color (Default variant -> transparent background) + Color containerColorDefault() => Colors.transparent; + + // Contained variant colors + Color containedContainerColor() => _m3e.colors.primaryContainer; + Color containedActiveColor() => _m3e.colors.onPrimaryContainer; + + // Size tokens (from spec) + double containerWidth() => 48; // container height/width + double containerHeight() => 48; + double activeIndicatorSize() => 38; + + // Shape: full corners + BorderRadius containerRadius() => BorderRadius.circular(999); +} diff --git a/packages/loading_indicator_m3e/melos_loading_indicator_m3e.iml b/packages/loading_indicator_m3e/melos_loading_indicator_m3e.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/loading_indicator_m3e/melos_loading_indicator_m3e.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/loading_indicator_m3e/pubspec.yaml b/packages/loading_indicator_m3e/pubspec.yaml new file mode 100644 index 0000000..ba6bc1e --- /dev/null +++ b/packages/loading_indicator_m3e/pubspec.yaml @@ -0,0 +1,19 @@ +name: loading_indicator_m3e +description: Material 3 Expressive Loading Indicator (morphing polygons) for Flutter, with Default and Contained variants. +version: 0.1.0 +publish_to: none + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.22.0" + +dependencies: + flutter: + sdk: flutter + m3e_design: + path: ../m3e_design + material_new_shapes: ^1.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/packages/loading_indicator_m3e/test/loading_indicator_m3e_test.dart b/packages/loading_indicator_m3e/test/loading_indicator_m3e_test.dart new file mode 100644 index 0000000..95fb403 --- /dev/null +++ b/packages/loading_indicator_m3e/test/loading_indicator_m3e_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('placeholder', () { + expect(2 + 2, 4); + }); +} diff --git a/packages/m3e_collection/README.md b/packages/m3e_collection/README.md new file mode 100644 index 0000000..a559f9b --- /dev/null +++ b/packages/m3e_collection/README.md @@ -0,0 +1,3 @@ +# m3e_collection + +Single import that re-exports all M3E component packages plus `m3e_design`. diff --git a/packages/m3e_collection/lib/m3e_collection.dart b/packages/m3e_collection/lib/m3e_collection.dart new file mode 100644 index 0000000..5cb208a --- /dev/null +++ b/packages/m3e_collection/lib/m3e_collection.dart @@ -0,0 +1,16 @@ +library m3e_collection; + +export 'package:app_bar_m3e/app_bar_m3e.dart'; +export 'package:button_group_m3e/button_group_m3e.dart'; +export 'package:button_m3e/button_m3e.dart'; +export 'package:fab_m3e/fab_m3e.dart'; +export 'package:icon_button_m3e/icon_button_m3e.dart'; +export 'package:loading_indicator_m3e/loading_indicator_m3e.dart'; +export 'package:m3e_design/m3e_design.dart'; +export 'package:material_new_shapes/material_new_shapes.dart'; +export 'package:navigation_bar_m3e/navigation_bar_m3e.dart'; +export 'package:navigation_rail_m3e/navigation_rail_m3e.dart'; +export 'package:progress_indicator_m3e/progress_indicator_m3e.dart'; +export 'package:slider_m3e/slider_m3e.dart'; +export 'package:split_button_m3e/split_button_m3e.dart'; +export 'package:toolbar_m3e/toolbar_m3e.dart'; diff --git a/packages/m3e_collection/melos_m3e_collection.iml b/packages/m3e_collection/melos_m3e_collection.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/m3e_collection/melos_m3e_collection.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/m3e_collection/pubspec.yaml b/packages/m3e_collection/pubspec.yaml new file mode 100644 index 0000000..444047f --- /dev/null +++ b/packages/m3e_collection/pubspec.yaml @@ -0,0 +1,39 @@ +name: m3e_collection +description: Aggregated exports of all Material 3 Expressive components for Flutter. +version: 0.1.0 +publish_to: none + +environment: + sdk: ">=3.5.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + material_new_shapes: ^1.0.0 + m3e_design: + path: ../m3e_design + icon_button_m3e: + path: ../icon_button_m3e + split_button_m3e: + path: ../split_button_m3e + button_group_m3e: + path: ../button_group_m3e + app_bar_m3e: + path: ../app_bar_m3e + button_m3e: + path: ../button_m3e + fab_m3e: + path: ../fab_m3e + loading_indicator_m3e: + path: ../loading_indicator_m3e + progress_indicator_m3e: + path: ../progress_indicator_m3e + navigation_bar_m3e: + path: ../navigation_bar_m3e + navigation_rail_m3e: + path: ../navigation_rail_m3e + slider_m3e: + path: ../slider_m3e + toolbar_m3e: + path: ../toolbar_m3e + diff --git a/packages/m3e_collection/pubspec_overrides.yaml b/packages/m3e_collection/pubspec_overrides.yaml new file mode 100644 index 0000000..4cd7149 --- /dev/null +++ b/packages/m3e_collection/pubspec_overrides.yaml @@ -0,0 +1,29 @@ +# melos_managed_dependency_overrides: icon_button_m3e,m3e_design,split_button_m3e,app_bar_m3e,button_m3e,fab_m3e,loading_indicator_m3e,progress_indicator_m3e,navigation_bar_m3e,navigation_rail_m3e,slider_m3e,toolbar_m3e,button_group_m3e +dependency_overrides: + icon_button_m3e: + path: ..\\icon_button_m3e + m3e_design: + path: ..\\m3e_design + split_button_m3e: + path: ..\\split_button_m3e + app_bar_m3e: + path: ..\\app_bar_m3e + button_m3e: + path: ..\\button_m3e + fab_m3e: + path: ..\\fab_m3e + loading_indicator_m3e: + path: ..\\loading_indicator_m3e + progress_indicator_m3e: + path: ..\\progress_indicator_m3e + navigation_bar_m3e: + path: ..\\navigation_bar_m3e + navigation_rail_m3e: + path: ..\\navigation_rail_m3e + slider_m3e: + path: ..\\slider_m3e + toolbar_m3e: + path: ..\\toolbar_m3e + button_group_m3e: + path: ..\\button_group_m3e + diff --git a/packages/m3e_design/README.md b/packages/m3e_design/README.md new file mode 100644 index 0000000..5459ece --- /dev/null +++ b/packages/m3e_design/README.md @@ -0,0 +1,4 @@ +# m3e_design + +Design language core for Material 3 Expressive (Flutter). +Provides ThemeExtension and token accessors for color, typography, shapes, spacing, motion. diff --git a/packages/m3e_design/lib/m3e_design.dart b/packages/m3e_design/lib/m3e_design.dart new file mode 100644 index 0000000..eb9aa81 --- /dev/null +++ b/packages/m3e_design/lib/m3e_design.dart @@ -0,0 +1,10 @@ +library m3e_design; + +export 'theme/m3e_theme.dart'; +export 'tokens/color_tokens.dart'; +export 'tokens/motion_tokens.dart'; +export 'tokens/shape_tokens.dart'; +export 'tokens/spacing_tokens.dart'; +export 'tokens/typography_tokens.dart'; +export 'utils/build_context_x.dart'; +export 'utils/semantics_x.dart'; diff --git a/packages/m3e_design/lib/theme/m3e_theme.dart b/packages/m3e_design/lib/theme/m3e_theme.dart new file mode 100644 index 0000000..563aca1 --- /dev/null +++ b/packages/m3e_design/lib/theme/m3e_theme.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import '../tokens/color_tokens.dart'; +import '../tokens/motion_tokens.dart'; +import '../tokens/shape_tokens.dart'; +import '../tokens/spacing_tokens.dart'; +import '../tokens/typography_tokens.dart'; + +@immutable +class M3ETheme extends ThemeExtension { + final M3EColors colors; + final M3ETypography typography; + final M3EShapes shapes; + final M3ESpacing spacing; + final M3EMotion motion; + + const M3ETheme({ + required this.colors, + required this.typography, + required this.shapes, + required this.spacing, + required this.motion, + }); + + // Convenience proxy for commonly used text styles in packages (m3e.type.*) + _M3ETypeProxy get type => _M3ETypeProxy(typography); + + static M3ETheme defaults(ColorScheme scheme) => M3ETheme( + colors: M3EColors.from(scheme), + typography: M3ETypography.defaultFor(scheme.brightness), + shapes: M3EShapes.expressive(), + spacing: const M3ESpacing.regular(), + motion: const M3EMotion.expressive(), + ); + + @override + M3ETheme copyWith({ + M3EColors? colors, + M3ETypography? typography, + M3EShapes? shapes, + M3ESpacing? spacing, + M3EMotion? motion, + }) => + M3ETheme( + colors: colors ?? this.colors, + typography: typography ?? this.typography, + shapes: shapes ?? this.shapes, + spacing: spacing ?? this.spacing, + motion: motion ?? this.motion, + ); + + @override + M3ETheme lerp(covariant M3ETheme? other, double t) { + if (other == null) return this; + return M3ETheme( + colors: M3EColors.lerp(colors, other.colors, t), + typography: M3ETypography.lerp(typography, other.typography, t), + shapes: M3EShapes.lerp(shapes, other.shapes, t), + spacing: M3ESpacing.lerp(spacing, other.spacing, t), + motion: M3EMotion.lerp(motion, other.motion, t), + ); + } +} + +/// Inject (or replace) the M3ETheme extension on a ThemeData. +ThemeData withM3ETheme(ThemeData base, {M3ETheme? override}) { + // Use any existing M3ETheme, else the provided override, else defaults. + final current = base.extension(); + final next = override ?? current ?? M3ETheme.defaults(base.colorScheme); + + // Merge existing extensions (values) with our M3ETheme, replacing prior ones. + final Iterable> existing = base.extensions.values; + final List> merged = >[]; + for (final e in existing) { + if (e is! M3ETheme) { + merged.add(e); + } + } + merged.add(next); + + return base.copyWith(extensions: merged); +} + +// Internal proxy for typography shortcuts used by components. +class _M3ETypeProxy { + const _M3ETypeProxy(this._t); + final M3ETypography _t; + + TextStyle get _empty => const TextStyle(); + TextStyle get titleLarge => _t.base.titleLarge ?? _empty; + TextStyle get titleSmall => _t.base.titleSmall ?? _empty; + TextStyle get bodySmall => _t.base.bodySmall ?? _empty; + TextStyle get labelLarge => _t.base.labelLarge ?? _empty; + TextStyle get labelMedium => _t.base.labelMedium ?? _empty; + TextStyle get labelSmall => _t.base.labelSmall ?? _empty; + + TextStyle get headlineSmallEmphasized => + (_t.base.headlineSmall ?? _empty).merge(_t.emphasized.headline); +} diff --git a/packages/m3e_design/lib/tokens/color_tokens.dart b/packages/m3e_design/lib/tokens/color_tokens.dart new file mode 100644 index 0000000..a01dfa8 --- /dev/null +++ b/packages/m3e_design/lib/tokens/color_tokens.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; + +@immutable +class M3EColors { + final Color emphasis; + final Color onEmphasis; + final Color info; + final Color success; + final Color warning; + final Color danger; + + final Color surfaceStrong; + final Color onSurfaceStrong; + final Color outlineStrong; + + // New: proxy common ColorScheme fields used across packages + final Color primary; + final Color onPrimary; + final Color primaryContainer; + final Color onPrimaryContainer; + + final Color secondary; + final Color onSecondary; + final Color secondaryContainer; + final Color onSecondaryContainer; + + final Color tertiary; + final Color onTertiary; + final Color tertiaryContainer; + final Color onTertiaryContainer; + + final Color surface; + final Color onSurface; + final Color onSurfaceVariant; + + final Color error; + final Color onError; + final Color errorContainer; + final Color onErrorContainer; + + final Color outline; + final Color outlineVariant; + + // New: container surface tokens not always present on older ColorScheme + final Color surfaceContainerHigh; + final Color surfaceContainerLowest; + + const M3EColors({ + required this.emphasis, + required this.onEmphasis, + required this.info, + required this.success, + required this.warning, + required this.danger, + required this.surfaceStrong, + required this.onSurfaceStrong, + required this.outlineStrong, + // New fields + required this.primary, + required this.onPrimary, + required this.primaryContainer, + required this.onPrimaryContainer, + required this.secondary, + required this.onSecondary, + required this.secondaryContainer, + required this.onSecondaryContainer, + required this.tertiary, + required this.onTertiary, + required this.tertiaryContainer, + required this.onTertiaryContainer, + required this.surface, + required this.onSurface, + required this.onSurfaceVariant, + required this.error, + required this.onError, + required this.errorContainer, + required this.onErrorContainer, + required this.outline, + required this.outlineVariant, + required this.surfaceContainerHigh, + required this.surfaceContainerLowest, + }); + + factory M3EColors.from(ColorScheme s) { + // Compute container surface variants if not available on the ColorScheme version in use. + // We prefer mild blends that work in both light/dark. + Color computeSurfaceContainerHigh() => + Color.alphaBlend(s.primary.withValues(alpha: 0.12), s.surface); + Color computeSurfaceContainerLowest() => + Color.alphaBlend(s.onSurface.withValues(alpha: 0.05), s.surface); + + return M3EColors( + emphasis: s.primary, + onEmphasis: s.onPrimary, + info: s.tertiary, + success: Color.alphaBlend( + Colors.green.shade400.withValues(alpha: 0.2), s.primaryContainer), + warning: Color.alphaBlend( + Colors.orange.shade400.withValues(alpha: 0.2), s.secondaryContainer), + danger: Color.alphaBlend( + Colors.red.shade400.withValues(alpha: 0.2), s.errorContainer), + surfaceStrong: + Color.alphaBlend(s.primary.withValues(alpha: 0.06), s.surface), + onSurfaceStrong: s.onSurface, + outlineStrong: + Color.alphaBlend(s.primary.withValues(alpha: 0.40), s.outlineVariant), + // New fields mapped from ColorScheme + primary: s.primary, + onPrimary: s.onPrimary, + primaryContainer: s.primaryContainer, + onPrimaryContainer: s.onPrimaryContainer, + secondary: s.secondary, + onSecondary: s.onSecondary, + secondaryContainer: s.secondaryContainer, + onSecondaryContainer: s.onSecondaryContainer, + tertiary: s.tertiary, + onTertiary: s.onTertiary, + tertiaryContainer: s.tertiaryContainer, + onTertiaryContainer: s.onTertiaryContainer, + surface: s.surface, + onSurface: s.onSurface, + onSurfaceVariant: s.onSurfaceVariant, + error: s.error, + onError: s.onError, + errorContainer: s.errorContainer, + onErrorContainer: s.onErrorContainer, + outline: s.outline, + outlineVariant: s.outlineVariant, + surfaceContainerHigh: (() { + // If the ColorScheme already has a matching field, prefer that via dynamic access; otherwise compute. + try { + final dynamic dyn = s; + final c = dyn.surfaceContainerHigh as Color?; + return c ?? computeSurfaceContainerHigh(); + } catch (_) { + return computeSurfaceContainerHigh(); + } + })(), + surfaceContainerLowest: (() { + try { + final dynamic dyn = s; + final c = dyn.surfaceContainerLowest as Color?; + return c ?? computeSurfaceContainerLowest(); + } catch (_) { + return computeSurfaceContainerLowest(); + } + })(), + ); + } + + static M3EColors lerp(M3EColors a, M3EColors b, double t) => M3EColors( + emphasis: Color.lerp(a.emphasis, b.emphasis, t)!, + onEmphasis: Color.lerp(a.onEmphasis, b.onEmphasis, t)!, + info: Color.lerp(a.info, b.info, t)!, + success: Color.lerp(a.success, b.success, t)!, + warning: Color.lerp(a.warning, b.warning, t)!, + danger: Color.lerp(a.danger, b.danger, t)!, + surfaceStrong: Color.lerp(a.surfaceStrong, b.surfaceStrong, t)!, + onSurfaceStrong: Color.lerp(a.onSurfaceStrong, b.onSurfaceStrong, t)!, + outlineStrong: Color.lerp(a.outlineStrong, b.outlineStrong, t)!, + // New fields + primary: Color.lerp(a.primary, b.primary, t)!, + onPrimary: Color.lerp(a.onPrimary, b.onPrimary, t)!, + primaryContainer: + Color.lerp(a.primaryContainer, b.primaryContainer, t)!, + onPrimaryContainer: + Color.lerp(a.onPrimaryContainer, b.onPrimaryContainer, t)!, + secondary: Color.lerp(a.secondary, b.secondary, t)!, + onSecondary: Color.lerp(a.onSecondary, b.onSecondary, t)!, + secondaryContainer: + Color.lerp(a.secondaryContainer, b.secondaryContainer, t)!, + onSecondaryContainer: + Color.lerp(a.onSecondaryContainer, b.onSecondaryContainer, t)!, + tertiary: Color.lerp(a.tertiary, b.tertiary, t)!, + onTertiary: Color.lerp(a.onTertiary, b.onTertiary, t)!, + tertiaryContainer: + Color.lerp(a.tertiaryContainer, b.tertiaryContainer, t)!, + onTertiaryContainer: + Color.lerp(a.onTertiaryContainer, b.onTertiaryContainer, t)!, + surface: Color.lerp(a.surface, b.surface, t)!, + onSurface: Color.lerp(a.onSurface, b.onSurface, t)!, + onSurfaceVariant: + Color.lerp(a.onSurfaceVariant, b.onSurfaceVariant, t)!, + error: Color.lerp(a.error, b.error, t)!, + onError: Color.lerp(a.onError, b.onError, t)!, + errorContainer: Color.lerp(a.errorContainer, b.errorContainer, t)!, + onErrorContainer: + Color.lerp(a.onErrorContainer, b.onErrorContainer, t)!, + outline: Color.lerp(a.outline, b.outline, t)!, + outlineVariant: Color.lerp(a.outlineVariant, b.outlineVariant, t)!, + surfaceContainerHigh: + Color.lerp(a.surfaceContainerHigh, b.surfaceContainerHigh, t)!, + surfaceContainerLowest: + Color.lerp(a.surfaceContainerLowest, b.surfaceContainerLowest, t)!, + ); +} diff --git a/packages/m3e_design/lib/tokens/motion_tokens.dart b/packages/m3e_design/lib/tokens/motion_tokens.dart new file mode 100644 index 0000000..94f6022 --- /dev/null +++ b/packages/m3e_design/lib/tokens/motion_tokens.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +@immutable +class M3EMotion { + final SpringDescription spatialFast; + final SpringDescription spatialMedium; + final SpringDescription spatialGentle; + + final SpringDescription effectsFast; + final SpringDescription effectsMedium; + + final Duration fast; + final Duration medium; + final Duration slow; + + const M3EMotion({ + required this.spatialFast, + required this.spatialMedium, + required this.spatialGentle, + required this.effectsFast, + required this.effectsMedium, + required this.fast, + required this.medium, + required this.slow, + }); + + const M3EMotion.expressive() + : spatialFast = const SpringDescription(mass: 1, stiffness: 500, damping: 30), + spatialMedium = const SpringDescription(mass: 1, stiffness: 350, damping: 28), + spatialGentle = const SpringDescription(mass: 1, stiffness: 220, damping: 24), + effectsFast = const SpringDescription(mass: 1, stiffness: 420, damping: 32), + effectsMedium = const SpringDescription(mass: 1, stiffness: 280, damping: 28), + fast = const Duration(milliseconds: 150), + medium = const Duration(milliseconds: 250), + slow = const Duration(milliseconds: 400); + + static M3EMotion lerp(M3EMotion a, M3EMotion b, double t) => a; +} + +class SpringDescription { + final double mass, stiffness, damping; + const SpringDescription({required this.mass, required this.stiffness, required this.damping}); +} diff --git a/packages/m3e_design/lib/tokens/shape_tokens.dart b/packages/m3e_design/lib/tokens/shape_tokens.dart new file mode 100644 index 0000000..bdc917d --- /dev/null +++ b/packages/m3e_design/lib/tokens/shape_tokens.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +enum M3EShapeVariant { round, square } + +@immutable +class M3EShapeSet { + final BorderRadius xs; + final BorderRadius sm; + final BorderRadius md; + final BorderRadius lg; + final BorderRadius xl; + const M3EShapeSet({required this.xs, required this.sm, required this.md, required this.lg, required this.xl}); +} + +@immutable +class M3EShapes { + final M3EShapeSet round; + final M3EShapeSet square; + + const M3EShapes({required this.round, required this.square}); + + factory M3EShapes.expressive() => const M3EShapes( + round: M3EShapeSet( + xs: BorderRadius.all(Radius.circular(999)), + sm: BorderRadius.all(Radius.circular(20)), + md: BorderRadius.all(Radius.circular(28)), + lg: BorderRadius.all(Radius.circular(44)), + xl: BorderRadius.all(Radius.circular(64)), + ), + square: M3EShapeSet( + xs: BorderRadius.all(Radius.circular(6)), + sm: BorderRadius.all(Radius.circular(8)), + md: BorderRadius.all(Radius.circular(12)), + lg: BorderRadius.all(Radius.circular(16)), + xl: BorderRadius.all(Radius.circular(20)), + ), + ); + + static M3EShapes lerp(M3EShapes a, M3EShapes b, double t) => M3EShapes( + round: _lerpSet(a.round, b.round, t), + square: _lerpSet(a.square, b.square, t), + ); + + static M3EShapeSet _lerpSet(M3EShapeSet a, M3EShapeSet b, double t) => M3EShapeSet( + xs: BorderRadius.lerp(a.xs, b.xs, t)!, + sm: BorderRadius.lerp(a.sm, b.sm, t)!, + md: BorderRadius.lerp(a.md, b.md, t)!, + lg: BorderRadius.lerp(a.lg, b.lg, t)!, + xl: BorderRadius.lerp(a.xl, b.xl, t)!, + ); +} diff --git a/packages/m3e_design/lib/tokens/spacing_tokens.dart b/packages/m3e_design/lib/tokens/spacing_tokens.dart new file mode 100644 index 0000000..3a2ac67 --- /dev/null +++ b/packages/m3e_design/lib/tokens/spacing_tokens.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +@immutable +class M3ESpacing { + final double xs; // 4 + final double sm; // 8 + final double md; // 12 + final double lg; // 16 + final double xl; // 24 + final double xxl; // 32 + + const M3ESpacing({ + required this.xs, + required this.sm, + required this.md, + required this.lg, + required this.xl, + required this.xxl, + }); + + const M3ESpacing.regular() + : xs = 4, + sm = 8, + md = 12, + lg = 16, + xl = 24, + xxl = 32; + + static M3ESpacing lerp(M3ESpacing a, M3ESpacing b, double t) => M3ESpacing( + xs: a.xs + (b.xs - a.xs) * t, + sm: a.sm + (b.sm - a.sm) * t, + md: a.md + (b.md - a.md) * t, + lg: a.lg + (b.lg - a.lg) * t, + xl: a.xl + (b.xl - a.xl) * t, + xxl: a.xxl + (b.xxl - a.xxl) * t, + ); +} diff --git a/packages/m3e_design/lib/tokens/typography_tokens.dart b/packages/m3e_design/lib/tokens/typography_tokens.dart new file mode 100644 index 0000000..fe23136 --- /dev/null +++ b/packages/m3e_design/lib/tokens/typography_tokens.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +@immutable +class M3EEmphasized { + final TextStyle display; + final TextStyle headline; + final TextStyle title; + final TextStyle label; + + const M3EEmphasized({ + required this.display, + required this.headline, + required this.title, + required this.label, + }); + + static M3EEmphasized forBrightness(Brightness b) { + return const M3EEmphasized( + display: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.5), + headline: TextStyle(fontWeight: FontWeight.w700, letterSpacing: -0.25), + title: TextStyle(fontWeight: FontWeight.w700), + label: TextStyle(fontWeight: FontWeight.w700), + ); + } + + static M3EEmphasized lerp(M3EEmphasized a, M3EEmphasized b, double t) => + M3EEmphasized( + display: TextStyle.lerp(a.display, b.display, t)!, + headline: TextStyle.lerp(a.headline, b.headline, t)!, + title: TextStyle.lerp(a.title, b.title, t)!, + label: TextStyle.lerp(a.label, b.label, t)!, + ); +} + +@immutable +class M3ETypography { + final TextTheme base; + final M3EEmphasized emphasized; + + const M3ETypography({required this.base, required this.emphasized}); + + factory M3ETypography.defaultFor(Brightness b) { + // Use a minimal baseline; app's ThemeData will provide fuller TextTheme. + const textTheme = TextTheme(); + return M3ETypography( + base: textTheme, emphasized: M3EEmphasized.forBrightness(b)); + } + + static M3ETypography lerp(M3ETypography a, M3ETypography b, double t) => + M3ETypography( + base: TextTheme.lerp(a.base, b.base, t), + emphasized: M3EEmphasized.lerp(a.emphasized, b.emphasized, t), + ); +} diff --git a/packages/m3e_design/lib/utils/build_context_x.dart b/packages/m3e_design/lib/utils/build_context_x.dart new file mode 100644 index 0000000..bc00d6c --- /dev/null +++ b/packages/m3e_design/lib/utils/build_context_x.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; +import '../theme/m3e_theme.dart'; + +extension BuildContextM3EX on BuildContext { + M3ETheme get m3e => + Theme.of(this).extension() ?? M3ETheme.defaults(Theme.of(this).colorScheme); +} diff --git a/packages/m3e_design/lib/utils/semantics_x.dart b/packages/m3e_design/lib/utils/semantics_x.dart new file mode 100644 index 0000000..2f296bb --- /dev/null +++ b/packages/m3e_design/lib/utils/semantics_x.dart @@ -0,0 +1,5 @@ +import 'package:flutter/material.dart'; + +extension SemanticsX on Widget { + Widget withLabel(String label) => Semantics(label: label, child: this); +} diff --git a/packages/m3e_design/melos_m3e_design.iml b/packages/m3e_design/melos_m3e_design.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/m3e_design/melos_m3e_design.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/m3e_design/pubspec.yaml b/packages/m3e_design/pubspec.yaml new file mode 100644 index 0000000..93da521 --- /dev/null +++ b/packages/m3e_design/pubspec.yaml @@ -0,0 +1,16 @@ +name: m3e_design +description: Material 3 Expressive design language for Flutter (tokens, ThemeExtension, motion). +version: 0.1.0 +publish_to: none +repository: https://example.com/your-repo + +environment: + sdk: ">=3.5.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/packages/navigation_bar_m3e/LICENSE b/packages/navigation_bar_m3e/LICENSE new file mode 100644 index 0000000..12ca7c2 --- /dev/null +++ b/packages/navigation_bar_m3e/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) ... + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/navigation_bar_m3e/README.md b/packages/navigation_bar_m3e/README.md new file mode 100644 index 0000000..5c467ae --- /dev/null +++ b/packages/navigation_bar_m3e/README.md @@ -0,0 +1,77 @@ +# navigation_bar_m3e + +Material 3 **Expressive** Navigation Bar for Flutter with badges, pill/underline indicators, and token-driven styling. + +- `NavigationBarM3E` — wrapper around Flutter's `NavigationBar` with M3E tokens +- `NavigationDestinationM3E` — destination data (icon, selectedIcon, label, badge) +- `NavBadgeM3E` — small badge/dot utility for icons + +All styling is driven by the `m3e_design` ThemeExtension (**M3ETheme**). + +## Monorepo Layout + +``` +packages/ + m3e_design/ + navigation_bar_m3e/ +``` + +`pubspec.yaml` references `../m3e_design`. + +## Usage + +```dart +import 'package:navigation_bar_m3e/navigation_bar_m3e.dart'; + +final items = [ + const NavigationDestinationM3E( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', + ), + const NavigationDestinationM3E( + icon: Icon(Icons.search), + label: 'Search', + badgeCount: 3, + ), + const NavigationDestinationM3E( + icon: Icon(Icons.person), + label: 'Profile', + badgeDot: true, + ), +]; + +NavigationBarM3E( + destinations: items, + selectedIndex: 0, + onDestinationSelected: (i) {}, + labelBehavior: NavBarM3ELabelBehavior.onlySelected, + indicatorStyle: NavBarM3EIndicatorStyle.pill, // pill | underline | none + size: NavBarM3ESize.medium, + density: NavBarM3EDensity.regular, + shapeFamily: NavBarM3EShapeFamily.round, +); +``` + +## Tokens mapping + +- **Container**: `surfaceContainerHigh` +- **Indicator**: `secondaryContainer` (color), pill shape by default; `underline` style uses a bottom border +- **Selected**: `onSecondaryContainer` (icon/label) +- **Unselected**: `onSurfaceVariant` +- **Label style**: `labelMedium` +- **Heights**: `small ≈64dp`, `medium ≈80dp` +- **Icon size**: `24dp` + +## Badges + +Use `badgeCount` for numeric badges or `badgeDot: true` for a small dot. Colors default to `errorContainer / onErrorContainer` and can be overridden via `NavBadgeM3E`. + +## Accessibility + +- Provide `semanticLabel` per destination (used as tooltip) or on the bar. +- Label behavior options: **alwaysShow**, **onlySelected**, or **alwaysHide**. + +## License + +MIT diff --git a/packages/navigation_bar_m3e/lib/navigation_bar_m3e.dart b/packages/navigation_bar_m3e/lib/navigation_bar_m3e.dart new file mode 100644 index 0000000..352d6c3 --- /dev/null +++ b/packages/navigation_bar_m3e/lib/navigation_bar_m3e.dart @@ -0,0 +1,7 @@ +library navigation_bar_m3e; + +export 'src/enums.dart'; +export 'src/nav_tokens_adapter.dart' show NavTokensAdapter; +export 'src/navigation_bar_m3e.dart'; +export 'src/nav_badge_m3e.dart'; +export 'src/nav_destination_m3e.dart'; diff --git a/packages/navigation_bar_m3e/lib/src/enums.dart b/packages/navigation_bar_m3e/lib/src/enums.dart new file mode 100644 index 0000000..f9d0448 --- /dev/null +++ b/packages/navigation_bar_m3e/lib/src/enums.dart @@ -0,0 +1,5 @@ +enum NavBarM3ELabelBehavior { alwaysShow, onlySelected, alwaysHide } +enum NavBarM3ESize { small, medium } +enum NavBarM3EShapeFamily { round, square } +enum NavBarM3EDensity { regular, compact } +enum NavBarM3EIndicatorStyle { pill, underline, none } diff --git a/packages/navigation_bar_m3e/lib/src/nav_badge_m3e.dart b/packages/navigation_bar_m3e/lib/src/nav_badge_m3e.dart new file mode 100644 index 0000000..f84c13c --- /dev/null +++ b/packages/navigation_bar_m3e/lib/src/nav_badge_m3e.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; + +class NavBadgeM3E extends StatelessWidget { + const NavBadgeM3E({ + super.key, + required this.child, + this.count, + this.showDot = false, + this.maxCount = 99, + this.backgroundColor, + this.foregroundColor, + this.semanticLabel, + this.offset = const Offset(8, -6), + }) : assert(count == null || count >= 0); + + final Widget child; + final int? count; + final bool showDot; + final int maxCount; + final Color? backgroundColor; + final Color? foregroundColor; + final String? semanticLabel; + final Offset offset; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context); + final m3e = t.extension() ?? M3ETheme.defaults(t.colorScheme); + final bg = backgroundColor ?? m3e.colors.errorContainer; + final fg = foregroundColor ?? m3e.colors.onErrorContainer; + + final badge = showDot + ? _dot(bg) + : _label(bg, fg, count == null ? '' : _format(count!, maxCount)); + + final stack = Stack( + clipBehavior: Clip.none, + children: [ + child, + Positioned( + right: offset.dx, + top: offset.dy, + child: Semantics( + label: semanticLabel ?? (count != null ? 'Notifications: ${count!}' : 'Notifications'), + child: badge, + ), + ), + ], + ); + + return stack; + } + + Widget _dot(Color bg) { + return Container( + width: 8, height: 8, + decoration: BoxDecoration(color: bg, shape: BoxShape.circle), + ); + } + + Widget _label(Color bg, Color fg, String text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + child: DefaultTextStyle( + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600), + child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: fg)), + ), + ); + } + + String _format(int c, int max) => (c > max) ? '$max+' : '$c'; +} diff --git a/packages/navigation_bar_m3e/lib/src/nav_destination_m3e.dart b/packages/navigation_bar_m3e/lib/src/nav_destination_m3e.dart new file mode 100644 index 0000000..93f28e1 --- /dev/null +++ b/packages/navigation_bar_m3e/lib/src/nav_destination_m3e.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'nav_badge_m3e.dart'; + +class NavigationDestinationM3E { + const NavigationDestinationM3E({ + required this.icon, + required this.label, + this.selectedIcon, + this.badgeCount, + this.badgeDot = false, + this.semanticLabel, + }); + + final Widget icon; + final Widget? selectedIcon; + final String label; + + /// Optional badge counter + final int? badgeCount; + + /// If true, show a small dot instead of a counter. + final bool badgeDot; + + final String? semanticLabel; + + Widget buildIcon([bool selected = false]) { + final base = selected && selectedIcon != null ? selectedIcon! : icon; + if (badgeCount != null || badgeDot) { + return NavBadgeM3E( + child: base, + count: badgeCount, + showDot: badgeDot, + semanticLabel: semanticLabel, + ); + } + return base; + } +} diff --git a/packages/navigation_bar_m3e/lib/src/nav_tokens_adapter.dart b/packages/navigation_bar_m3e/lib/src/nav_tokens_adapter.dart new file mode 100644 index 0000000..ad6cf58 --- /dev/null +++ b/packages/navigation_bar_m3e/lib/src/nav_tokens_adapter.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; +import 'enums.dart'; + +@immutable +class _NavMetrics { + final double heightSmall; + final double heightMedium; + final double iconSize; + final EdgeInsetsGeometry padding; + final double indicatorThickness; // for underline + const _NavMetrics({ + required this.heightSmall, + required this.heightMedium, + required this.iconSize, + required this.padding, + required this.indicatorThickness, + }); +} + +_NavMetrics _metricsFor(BuildContext context, NavBarM3EDensity density) { + final theme = Theme.of(context); + final m3e = theme.extension() ?? M3ETheme.defaults(theme.colorScheme); + final sp = m3e.spacing; + + double hSmall = 64; // compact/phone-tight + double hMedium = 80; // default M3 nav bar height + double icon = 24; + double underline = 3; + + if (density == NavBarM3EDensity.compact) { + hSmall -= 4; hMedium -= 4; underline -= 1; + } + + return _NavMetrics( + heightSmall: hSmall, + heightMedium: hMedium, + iconSize: icon, + padding: EdgeInsets.symmetric(horizontal: sp.md), + indicatorThickness: underline, + ); +} + +class NavTokensAdapter { + NavTokensAdapter(this.context); + final BuildContext context; + + M3ETheme get _m3e { + final t = Theme.of(context); + return t.extension() ?? M3ETheme.defaults(t.colorScheme); + } + + _NavMetrics metrics(NavBarM3EDensity density) => _metricsFor(context, density); + + // Container/background + Color containerColor() => _m3e.colors.surfaceContainerHigh; + + // Indicator + Color indicatorColor() => _m3e.colors.secondaryContainer; + + // Icon/label colors + Color selectedColor() => _m3e.colors.onSecondaryContainer; + Color unselectedColor() => _m3e.colors.onSurfaceVariant; + + // Typography + TextStyle labelStyle() => _m3e.type.labelMedium; + + // Shapes + ShapeBorder containerShape(NavBarM3EShapeFamily family) { + final set = family == NavBarM3EShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square; + return RoundedRectangleBorder(borderRadius: set.lg); + } + + ShapeBorder indicatorShapePill() => const StadiumBorder(); + + // Underline decoration for selected. + BoxDecoration underlineDecoration(Color color, double thickness) { + return BoxDecoration( + border: Border( + bottom: BorderSide(color: color, width: thickness), + ), + ); + } +} diff --git a/packages/navigation_bar_m3e/lib/src/navigation_bar_m3e.dart b/packages/navigation_bar_m3e/lib/src/navigation_bar_m3e.dart new file mode 100644 index 0000000..0a5021e --- /dev/null +++ b/packages/navigation_bar_m3e/lib/src/navigation_bar_m3e.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; +import 'enums.dart'; +import 'nav_tokens_adapter.dart'; +import 'nav_destination_m3e.dart'; + +class NavigationBarM3E extends StatelessWidget { + const NavigationBarM3E({ + super.key, + required this.destinations, + this.selectedIndex = 0, + this.onDestinationSelected, + this.labelBehavior = NavBarM3ELabelBehavior.onlySelected, + this.size = NavBarM3ESize.medium, + this.shapeFamily = NavBarM3EShapeFamily.round, + this.density = NavBarM3EDensity.regular, + this.backgroundColor, + this.elevation, + this.indicatorStyle = NavBarM3EIndicatorStyle.pill, + this.indicatorColor, + this.padding, + this.safeArea = true, + this.semanticLabel, + }); + + final List destinations; + final int selectedIndex; + final ValueChanged? onDestinationSelected; + + final NavBarM3ELabelBehavior labelBehavior; + final NavBarM3ESize size; + final NavBarM3EShapeFamily shapeFamily; + final NavBarM3EDensity density; + + final Color? backgroundColor; + final double? elevation; + + final NavBarM3EIndicatorStyle indicatorStyle; + final Color? indicatorColor; + + final EdgeInsetsGeometry? padding; + final bool safeArea; + + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + assert(destinations.isNotEmpty, 'Provide at least one destination'); + + final tokens = NavTokensAdapter(context); + final metrics = tokens.metrics(density); + final m3e = Theme.of(context).extension() ?? M3ETheme.defaults(Theme.of(context).colorScheme); + + final height = size == NavBarM3ESize.small ? metrics.heightSmall : metrics.heightMedium; + final bg = backgroundColor ?? tokens.containerColor(); + final shape = tokens.containerShape(shapeFamily); + + final nav = Material( + color: bg, + elevation: elevation ?? 0, + shape: shape, + child: SizedBox( + height: height, + child: NavigationBar( + height: height, + elevation: elevation ?? 0, + indicatorColor: indicatorStyle == NavBarM3EIndicatorStyle.none + ? Colors.transparent + : (indicatorColor ?? tokens.indicatorColor()), + indicatorShape: switch (indicatorStyle) { + NavBarM3EIndicatorStyle.pill => tokens.indicatorShapePill(), + NavBarM3EIndicatorStyle.underline => const StadiumBorder(), // we'll fake underline via decoration below + NavBarM3EIndicatorStyle.none => const StadiumBorder(), + }, + backgroundColor: Colors.transparent, // outer Material supplies bg + shape + labelBehavior: switch (labelBehavior) { + NavBarM3ELabelBehavior.alwaysShow => NavigationDestinationLabelBehavior.alwaysShow, + NavBarM3ELabelBehavior.onlySelected => NavigationDestinationLabelBehavior.onlyShowSelected, + NavBarM3ELabelBehavior.alwaysHide => NavigationDestinationLabelBehavior.alwaysHide, + }, + selectedIndex: selectedIndex, + destinations: List.generate(destinations.length, (i) { + final d = destinations[i]; + return NavigationDestination( + icon: _icon(context, false, d, metrics.iconSize), + selectedIcon: _selectedIcon(context, true, d, metrics.iconSize, tokens, indicatorStyle), + label: d.label, + tooltip: d.semanticLabel, + ); + }), + onDestinationSelected: onDestinationSelected, + ), + ), + ); + + final padded = Padding( + padding: padding ?? EdgeInsets.zero, + child: nav, + ); + + final content = DefaultTextStyle.merge( + style: tokens.labelStyle().copyWith( + color: m3e.colors.onSurfaceVariant, + ), + child: IconTheme.merge( + data: IconThemeData(size: metrics.iconSize, color: m3e.colors.onSurfaceVariant), + child: padded, + ), + ); + + if (!safeArea && semanticLabel == null) return content; + final wrapped = SafeArea(top: false, left: false, right: false, bottom: safeArea, child: content); + + if (semanticLabel == null) return wrapped; + return Semantics(container: true, label: semanticLabel!, child: wrapped); + } + + Widget _icon(BuildContext context, bool selected, NavigationDestinationM3E d, double iconSize) { + return SizedBox( + width: iconSize + 8, // give a little space for underline + height: iconSize + 8, + child: Center(child: d.buildIcon(selected)), + ); + } + + Widget _selectedIcon( + BuildContext context, + bool selected, + NavigationDestinationM3E d, + double iconSize, + NavTokensAdapter tokens, + NavBarM3EIndicatorStyle style, + ) { + final w = _icon(context, selected, d, iconSize); + if (style != NavBarM3EIndicatorStyle.underline) return w; + + final metrics = tokens.metrics(density); + final deco = tokens.underlineDecoration(tokens.indicatorColor(), metrics.indicatorThickness); + return DecoratedBox( + decoration: deco, + child: w, + ); + } +} diff --git a/packages/navigation_bar_m3e/lib/src/navigation_bar_m3e_widget.dart b/packages/navigation_bar_m3e/lib/src/navigation_bar_m3e_widget.dart new file mode 100644 index 0000000..bb11146 --- /dev/null +++ b/packages/navigation_bar_m3e/lib/src/navigation_bar_m3e_widget.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; + +class NavigationBarM3EWidget extends StatelessWidget { + const NavigationBarM3EWidget({super.key}); + + @override + Widget build(BuildContext context) { + final m3e = context.m3e; + return Container( + padding: EdgeInsets.all(m3e.spacing.md), + decoration: BoxDecoration( + color: m3e.colors.surfaceStrong, + borderRadius: m3e.shapes.square.md, + ), + child: Text('NavigationBar placeholder', style: m3e.typography.base.titleMedium), + ); + } +} + +String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join(); diff --git a/packages/navigation_bar_m3e/melos_navigation_bar_m3e.iml b/packages/navigation_bar_m3e/melos_navigation_bar_m3e.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/navigation_bar_m3e/melos_navigation_bar_m3e.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/navigation_bar_m3e/pubspec.yaml b/packages/navigation_bar_m3e/pubspec.yaml new file mode 100644 index 0000000..88039e6 --- /dev/null +++ b/packages/navigation_bar_m3e/pubspec.yaml @@ -0,0 +1,18 @@ +name: navigation_bar_m3e +description: Material 3 Expressive Navigation Bar for Flutter with token-driven colors, shapes, and badges. +version: 0.1.0 +publish_to: none + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.22.0" + +dependencies: + flutter: + sdk: flutter + m3e_design: + path: ../m3e_design + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/packages/navigation_bar_m3e/pubspec_overrides.yaml b/packages/navigation_bar_m3e/pubspec_overrides.yaml new file mode 100644 index 0000000..69acba6 --- /dev/null +++ b/packages/navigation_bar_m3e/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: m3e_design +dependency_overrides: + m3e_design: + path: ..\\m3e_design diff --git a/packages/navigation_bar_m3e/test/navigation_bar_m3e_test.dart b/packages/navigation_bar_m3e/test/navigation_bar_m3e_test.dart new file mode 100644 index 0000000..872758b --- /dev/null +++ b/packages/navigation_bar_m3e/test/navigation_bar_m3e_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('placeholder', () { + expect(2 * 3, 6); + }); +} diff --git a/packages/navigation_rail_m3e/LICENSE b/packages/navigation_rail_m3e/LICENSE new file mode 100644 index 0000000..12ca7c2 --- /dev/null +++ b/packages/navigation_rail_m3e/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) ... + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/navigation_rail_m3e/README.md b/packages/navigation_rail_m3e/README.md new file mode 100644 index 0000000..f69fc2f --- /dev/null +++ b/packages/navigation_rail_m3e/README.md @@ -0,0 +1,84 @@ +# navigation_rail_m3e + +Material 3 **Expressive** Navigation Rail for Flutter with badges, pill/stripe indicators, and token-driven styling. + +- `NavigationRailM3E` — wrapper around Flutter's `NavigationRail` with M3E tokens +- `RailDestinationM3E` — destination data (icon, selectedIcon, label, badge) +- `RailBadgeM3E` — small badge/dot utility for icons + +All styling is driven by the `m3e_design` ThemeExtension (**M3ETheme**). + +## Monorepo Layout + +``` +packages/ + m3e_design/ + navigation_rail_m3e/ +``` + +`pubspec.yaml` references `../m3e_design`. + +## Usage + +```dart +import 'package:navigation_rail_m3e/navigation_rail_m3e.dart'; + +final items = [ + const RailDestinationM3E( + icon: Icon(Icons.inbox_outlined), + selectedIcon: Icon(Icons.inbox), + label: 'Inbox', + ), + const RailDestinationM3E( + icon: Icon(Icons.chat_bubble_outline), + label: 'Chat', + badgeCount: 5, + ), + const RailDestinationM3E( + icon: Icon(Icons.settings_outlined), + label: 'Settings', + badgeDot: true, + ), +]; + +NavigationRailM3E( + destinations: items, + selectedIndex: 0, + onDestinationSelected: (i) {}, + labelBehavior: RailLabelBehavior.onlySelected, // none | onlySelected | alwaysShow + indicatorStyle: RailIndicatorStyle.pill, // pill | stripe | none + size: RailSize.regular, // compact | regular + density: RailDensity.regular, // regular | compact + shapeFamily: RailShapeFamily.round, // round | square + extended: false, // true to show labels permanently (wide rail) + groupAlignment: -1.0, // -1 top .. 1 bottom + leading: const Padding( + padding: EdgeInsets.all(8.0), + child: FlutterLogo(size: 24), + ), +); +``` + +## Tokens mapping + +- **Container**: `surfaceContainerHigh` +- **Indicator**: `secondaryContainer` (color). `pill` uses NavigationRail's indicator; `stripe` draws a left border on the selected icon. +- **Selected**: `onSecondaryContainer` (icon/label) +- **Unselected**: `onSurfaceVariant` +- **Label style**: `labelMedium` +- **Widths**: compact **≈64dp**, regular **≈80dp**, extended min **≈256dp** +- **Icon size**: **24dp** +- **Item padding**: from `spacing.sm/md` + +## Badges + +Use `badgeCount` for numeric badges or `badgeDot: true` for a small dot. Colors default to `errorContainer / onErrorContainer` and can be overridden via `RailBadgeM3E`. + +## Accessibility + +- Provide `semanticLabel` per destination (used as tooltip) or on the rail (`semanticLabel` on the widget). +- Choose the label behavior to balance density with readability. + +## License + +MIT diff --git a/packages/navigation_rail_m3e/lib/navigation_rail_m3e.dart b/packages/navigation_rail_m3e/lib/navigation_rail_m3e.dart new file mode 100644 index 0000000..db59373 --- /dev/null +++ b/packages/navigation_rail_m3e/lib/navigation_rail_m3e.dart @@ -0,0 +1,7 @@ +library navigation_rail_m3e; + +export 'src/enums.dart'; +export 'src/rail_tokens_adapter.dart' show RailTokensAdapter; +export 'src/navigation_rail_m3e.dart'; +export 'src/rail_badge_m3e.dart'; +export 'src/rail_destination_m3e.dart'; diff --git a/packages/navigation_rail_m3e/lib/src/enums.dart b/packages/navigation_rail_m3e/lib/src/enums.dart new file mode 100644 index 0000000..8585379 --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/enums.dart @@ -0,0 +1,5 @@ +enum RailLabelBehavior { alwaysShow, onlySelected, alwaysHide } +enum RailSize { compact, regular } +enum RailShapeFamily { round, square } +enum RailDensity { regular, compact } +enum RailIndicatorStyle { pill, stripe, none } diff --git a/packages/navigation_rail_m3e/lib/src/navigation_rail_m3e.dart b/packages/navigation_rail_m3e/lib/src/navigation_rail_m3e.dart new file mode 100644 index 0000000..153be15 --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/navigation_rail_m3e.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; + +import 'enums.dart'; +import 'rail_destination_m3e.dart'; +import 'rail_tokens_adapter.dart'; + +class NavigationRailM3E extends StatelessWidget { + const NavigationRailM3E({ + super.key, + required this.destinations, + this.selectedIndex = 0, + this.onDestinationSelected, + this.labelBehavior = RailLabelBehavior.onlySelected, + this.size = RailSize.regular, + this.shapeFamily = RailShapeFamily.round, + this.density = RailDensity.regular, + this.backgroundColor, + this.elevation, + this.indicatorStyle = RailIndicatorStyle.pill, + this.indicatorColor, + this.padding, + this.groupAlignment, + this.leading, + this.trailing, + this.extended = false, + this.minExtendedWidth, + this.useSafeArea = true, + this.semanticLabel, + }); + + final List destinations; + final int selectedIndex; + final ValueChanged? onDestinationSelected; + + final RailLabelBehavior labelBehavior; + final RailSize size; + final RailShapeFamily shapeFamily; + final RailDensity density; + + final Color? backgroundColor; + final double? elevation; + + final RailIndicatorStyle indicatorStyle; + final Color? indicatorColor; + + final EdgeInsetsGeometry? padding; + + /// Aligns the group of destinations (-1 top .. 1 bottom). + final double? groupAlignment; + + /// Optional leading and trailing widgets (e.g., FAB or menu). + final Widget? leading; + final Widget? trailing; + + /// Whether to show the rail in extended mode (icons + labels). + final bool extended; + + /// Minimum width when extended. + final double? minExtendedWidth; + + final bool useSafeArea; + + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + assert(destinations.isNotEmpty, 'Provide at least one destination'); + + final tokens = RailTokensAdapter(context); + final metrics = tokens.metrics(density); + + final width = + size == RailSize.compact ? metrics.widthCompact : metrics.widthRegular; + final bg = backgroundColor ?? tokens.containerColor(); + final shape = tokens.containerShape(shapeFamily); + + final rail = Material( + color: bg, + elevation: elevation ?? + 0, // null means use theme default; avoids invalid zero assertion + shape: shape, + child: SizedBox( + width: + extended ? (minExtendedWidth ?? metrics.extendedMinWidth) : width, + child: NavigationRail( + backgroundColor: Colors.transparent, + elevation: elevation, // pass through or null + extended: extended, + minExtendedWidth: minExtendedWidth ?? metrics.extendedMinWidth, + selectedIndex: selectedIndex, + groupAlignment: groupAlignment, + leading: leading, + trailing: trailing, + labelType: switch (labelBehavior) { + RailLabelBehavior.alwaysShow => NavigationRailLabelType.all, + RailLabelBehavior.onlySelected => NavigationRailLabelType.selected, + RailLabelBehavior.alwaysHide => NavigationRailLabelType.none, + }, + useIndicator: indicatorStyle != RailIndicatorStyle.none, + indicatorColor: indicatorColor ?? tokens.indicatorColor(), + indicatorShape: switch (indicatorStyle) { + RailIndicatorStyle.pill => tokens.indicatorShapePill(), + RailIndicatorStyle.stripe => + const StadiumBorder(), // we'll fake stripe using decoration on selected icon + RailIndicatorStyle.none => const StadiumBorder(), + }, + selectedIconTheme: IconThemeData( + color: tokens.selectedColor(), size: metrics.iconSize), + unselectedIconTheme: IconThemeData( + color: tokens.unselectedColor(), size: metrics.iconSize), + selectedLabelTextStyle: + tokens.labelStyle().copyWith(color: tokens.selectedColor()), + unselectedLabelTextStyle: + tokens.labelStyle().copyWith(color: tokens.unselectedColor()), + destinations: List.generate(destinations.length, (i) { + final d = destinations[i]; + return NavigationRailDestination( + icon: _icon(context, false, d, metrics.iconSize), + selectedIcon: _selectedIcon( + context, true, d, metrics.iconSize, tokens, indicatorStyle), + label: Text(d.label), + padding: metrics.itemPadding as EdgeInsets?, + ); + }), + onDestinationSelected: onDestinationSelected, + ), + ), + ); + + final padded = Padding( + padding: padding ?? EdgeInsets.zero, + child: rail, + ); + + if (!useSafeArea && semanticLabel == null) return padded; + + final wrapped = SafeArea( + top: true, + bottom: true, + left: true, + right: false, + child: padded, + ); + + if (semanticLabel == null) return wrapped; + return Semantics(container: true, label: semanticLabel!, child: wrapped); + } + + Widget _icon(BuildContext context, bool selected, RailDestinationM3E d, + double iconSize) { + return SizedBox( + width: iconSize + 8, + height: iconSize + 8, + child: Center(child: d.buildIcon(selected)), + ); + } + + Widget _selectedIcon( + BuildContext context, + bool selected, + RailDestinationM3E d, + double iconSize, + RailTokensAdapter tokens, + RailIndicatorStyle style, + ) { + final w = _icon(context, selected, d, iconSize); + if (style != RailIndicatorStyle.stripe) return w; + + final metrics = tokens.metrics(density); + final deco = tokens.stripeDecoration( + tokens.indicatorColor(), metrics.stripeThickness); + return DecoratedBox( + decoration: deco, + child: w, + ); + } +} diff --git a/packages/navigation_rail_m3e/lib/src/navigation_rail_m3e_widget.dart b/packages/navigation_rail_m3e/lib/src/navigation_rail_m3e_widget.dart new file mode 100644 index 0000000..ac21ebb --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/navigation_rail_m3e_widget.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; + +class NavigationRailM3EWidget extends StatelessWidget { + const NavigationRailM3EWidget({super.key}); + + @override + Widget build(BuildContext context) { + final m3e = context.m3e; + return Container( + padding: EdgeInsets.all(m3e.spacing.md), + decoration: BoxDecoration( + color: m3e.colors.surfaceStrong, + borderRadius: m3e.shapes.square.md, + ), + child: Text('NavigationRail placeholder', style: m3e.typography.base.titleMedium), + ); + } +} + +String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join(); diff --git a/packages/navigation_rail_m3e/lib/src/rail_badge_m3e.dart b/packages/navigation_rail_m3e/lib/src/rail_badge_m3e.dart new file mode 100644 index 0000000..1cae400 --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/rail_badge_m3e.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; + +class RailBadgeM3E extends StatelessWidget { + const RailBadgeM3E({ + super.key, + required this.child, + this.count, + this.showDot = false, + this.maxCount = 99, + this.backgroundColor, + this.foregroundColor, + this.semanticLabel, + this.offset = const Offset(8, -6), + }) : assert(count == null || count >= 0); + + final Widget child; + final int? count; + final bool showDot; + final int maxCount; + final Color? backgroundColor; + final Color? foregroundColor; + final String? semanticLabel; + final Offset offset; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context); + final m3e = t.extension() ?? M3ETheme.defaults(t.colorScheme); + final bg = backgroundColor ?? m3e.colors.errorContainer; + final fg = foregroundColor ?? m3e.colors.onErrorContainer; + + final badge = showDot + ? _dot(bg) + : _label(bg, fg, count == null ? '' : _format(count!, maxCount)); + + return Stack( + clipBehavior: Clip.none, + children: [ + child, + Positioned( + right: offset.dx, + top: offset.dy, + child: Semantics( + label: semanticLabel ?? (count != null ? 'Notifications: ${count!}' : 'Notifications'), + child: badge, + ), + ), + ], + ); + } + + Widget _dot(Color bg) { + return Container( + width: 8, height: 8, + decoration: BoxDecoration(color: bg, shape: BoxShape.circle), + ); + } + + Widget _label(Color bg, Color fg, String text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + child: DefaultTextStyle( + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600), + child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: fg)), + ), + ); + } + + String _format(int c, int max) => (c > max) ? '$max+' : '$c'; +} diff --git a/packages/navigation_rail_m3e/lib/src/rail_destination_m3e.dart b/packages/navigation_rail_m3e/lib/src/rail_destination_m3e.dart new file mode 100644 index 0000000..8ee12ea --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/rail_destination_m3e.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'rail_badge_m3e.dart'; + +class RailDestinationM3E { + const RailDestinationM3E({ + required this.icon, + required this.label, + this.selectedIcon, + this.badgeCount, + this.badgeDot = false, + this.semanticLabel, + }); + + final Widget icon; + final Widget? selectedIcon; + final String label; + + /// Optional badge counter + final int? badgeCount; + + /// If true, show a small dot instead of a counter. + final bool badgeDot; + + final String? semanticLabel; + + Widget buildIcon([bool selected = false]) { + final base = selected && selectedIcon != null ? selectedIcon! : icon; + if (badgeCount != null || badgeDot) { + return RailBadgeM3E( + child: base, + count: badgeCount, + showDot: badgeDot, + semanticLabel: semanticLabel, + ); + } + return base; + } +} diff --git a/packages/navigation_rail_m3e/lib/src/rail_tokens_adapter.dart b/packages/navigation_rail_m3e/lib/src/rail_tokens_adapter.dart new file mode 100644 index 0000000..1f71d02 --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/rail_tokens_adapter.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; +import 'enums.dart'; + +@immutable +class _RailMetrics { + final double widthCompact; + final double widthRegular; + final double extendedMinWidth; + final double iconSize; + final EdgeInsetsGeometry itemPadding; + final double stripeThickness; + const _RailMetrics({ + required this.widthCompact, + required this.widthRegular, + required this.extendedMinWidth, + required this.iconSize, + required this.itemPadding, + required this.stripeThickness, + }); +} + +_RailMetrics _metricsFor(BuildContext context, RailDensity density) { + final theme = Theme.of(context); + final m3e = theme.extension() ?? M3ETheme.defaults(theme.colorScheme); + final sp = m3e.spacing; + + double wC = 64; // compact width + double wR = 80; // regular width + double ext = 256; // extended min width + double icon = 24; + double stripe = 3; + + if (density == RailDensity.compact) { + wC -= 4; wR -= 4; stripe -= 1; + } + + return _RailMetrics( + widthCompact: wC, + widthRegular: wR, + extendedMinWidth: ext, + iconSize: icon, + itemPadding: EdgeInsets.symmetric(horizontal: sp.md, vertical: sp.sm), + stripeThickness: stripe, + ); +} + +class RailTokensAdapter { + RailTokensAdapter(this.context); + final BuildContext context; + + M3ETheme get _m3e { + final t = Theme.of(context); + return t.extension() ?? M3ETheme.defaults(t.colorScheme); + } + + _RailMetrics metrics(RailDensity density) => _metricsFor(context, density); + + // Container/background + Color containerColor() => _m3e.colors.surfaceContainerHigh; + + // Indicator + Color indicatorColor() => _m3e.colors.secondaryContainer; + + // Icon/label colors + Color selectedColor() => _m3e.colors.onSecondaryContainer; + Color unselectedColor() => _m3e.colors.onSurfaceVariant; + + // Typography + TextStyle labelStyle() => _m3e.type.labelMedium; + + // Shapes + ShapeBorder containerShape(RailShapeFamily family) { + final set = family == RailShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square; + return RoundedRectangleBorder(borderRadius: set.lg); + } + + ShapeBorder indicatorShapePill() => const StadiumBorder(); + + // Stripe decoration for selected destination + BoxDecoration stripeDecoration(Color color, double thickness) { + return BoxDecoration( + border: Border( + left: BorderSide(color: color, width: thickness), + ), + ); + } +} diff --git a/packages/navigation_rail_m3e/melos_navigation_rail_m3e.iml b/packages/navigation_rail_m3e/melos_navigation_rail_m3e.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/navigation_rail_m3e/melos_navigation_rail_m3e.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/navigation_rail_m3e/pubspec.yaml b/packages/navigation_rail_m3e/pubspec.yaml new file mode 100644 index 0000000..5493c4f --- /dev/null +++ b/packages/navigation_rail_m3e/pubspec.yaml @@ -0,0 +1,18 @@ +name: navigation_rail_m3e +description: Material 3 Expressive Navigation Rail for Flutter with token-driven colors, shapes, indicators, and badges. +version: 0.1.0 +publish_to: none + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.22.0" + +dependencies: + flutter: + sdk: flutter + m3e_design: + path: ../m3e_design + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/packages/navigation_rail_m3e/pubspec_overrides.yaml b/packages/navigation_rail_m3e/pubspec_overrides.yaml new file mode 100644 index 0000000..69acba6 --- /dev/null +++ b/packages/navigation_rail_m3e/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: m3e_design +dependency_overrides: + m3e_design: + path: ..\\m3e_design diff --git a/packages/navigation_rail_m3e/test/navigation_rail_m3e_test.dart b/packages/navigation_rail_m3e/test/navigation_rail_m3e_test.dart new file mode 100644 index 0000000..56e7cc8 --- /dev/null +++ b/packages/navigation_rail_m3e/test/navigation_rail_m3e_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('placeholder', () { + expect(3 + 4, 7); + }); +} diff --git a/packages/progress_indicator_m3e/LICENSE b/packages/progress_indicator_m3e/LICENSE new file mode 100644 index 0000000..12ca7c2 --- /dev/null +++ b/packages/progress_indicator_m3e/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) ... + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/progress_indicator_m3e/README.md b/packages/progress_indicator_m3e/README.md new file mode 100644 index 0000000..5326931 --- /dev/null +++ b/packages/progress_indicator_m3e/README.md @@ -0,0 +1,65 @@ +# progress_indicators_m3e + +Material 3 **Expressive** progress indicators for Flutter: + +- `LinearProgressM3E` — determinate, indeterminate, **buffer**, **query**, with **flat** or **wavy** shape +- `CircularProgressM3E` — determinate & indeterminate, **flat** or **wavy** stroke (animated for indeterminate) +- `ProgressWithLabelM3E` — compose a linear bar with inline/top/bottom/center labels + +All widgets read **M3E tokens** (`m3e_design`) for color, sizing, and typography. + +## Defaults (from the spec illustrations) + +- Linear: default thickness 4dp; configurable via `size` or `strokeHeight` +- Linear (wavy): `wavelength=40dp`, `amplitude≈height/3`, **4dp** left/right inset +- Circular: small/medium/large diameters ≈ 24/32/48 with stroke ≈ 3/4/6 +- Circular (wavy): default **10 waves** around the circle, amplitude ≈ 35% of stroke + +## Quick start + +```dart +import 'package:progress_indicators_m3e/progress_indicators_m3e.dart'; + +// Linear (wavy, determinate) +LinearProgressM3E( + value: 0.62, + shape: LinearBarShapeM3E.wavy, +); + +// Circular (wavy, indeterminate) +const CircularProgressM3E( + shape: CircularBarShapeM3E.wavy, +); + +// Linear (buffer) flat +LinearProgressM3E( + variant: LinearProgressM3EVariant.buffer, + value: 0.3, + bufferValue: 0.6, +); + +// Circular (flat) with center label +CircularProgressM3E( + value: 0.5, + showCenterLabel: true, +); +``` + +## Monorepo layout + +``` +packages/ + m3e_design/ + progress_indicators_m3e/ +``` + +`pubspec.yaml` references `../m3e_design`. + +## Accessibility + +- Provide `semanticLabel` and (for determinate) the widgets expose a numeric **value** for screen readers. +- Indeterminate wavy animations use modest motion; gate the speed with a future `m3e_design.motion` flag if you support "reduce motion". + +## License + +MIT diff --git a/packages/progress_indicator_m3e/lib/progress_indicator_m3e.dart b/packages/progress_indicator_m3e/lib/progress_indicator_m3e.dart new file mode 100644 index 0000000..887284f --- /dev/null +++ b/packages/progress_indicator_m3e/lib/progress_indicator_m3e.dart @@ -0,0 +1,7 @@ +library progress_indicators_m3e; + +export 'src/enums.dart'; +export 'src/tokens_adapter.dart' show ProgressTokensAdapter; +export 'src/linear_progress_m3e.dart'; +export 'src/circular_progress_m3e.dart'; +export 'src/progress_with_label_m3e.dart'; diff --git a/packages/progress_indicator_m3e/lib/src/circular_progress_m3e.dart b/packages/progress_indicator_m3e/lib/src/circular_progress_m3e.dart new file mode 100644 index 0000000..7ab7d8a --- /dev/null +++ b/packages/progress_indicator_m3e/lib/src/circular_progress_m3e.dart @@ -0,0 +1,286 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'enums.dart'; +import 'tokens_adapter.dart'; + +class CircularProgressM3E extends StatefulWidget { + const CircularProgressM3E({ + super.key, + this.value, + this.size = ProgressM3ESize.medium, + this.emphasis = ProgressM3EEmphasis.primary, + this.density = ProgressM3EDensity.regular, + this.backgroundColor, + this.strokeWidth, + this.semanticLabel, + this.showCenterLabel = false, + this.centerLabelBuilder, + this.shape = CircularBarShapeM3E.wavy, + this.waveCount, + this.waveAmplitude, + this.rotateClockwise = true, + }); + + /// Determinate value (0..1). If null, renders indeterminate. + final double? value; + + final ProgressM3ESize size; + final ProgressM3EEmphasis emphasis; + final ProgressM3EDensity density; + + final Color? backgroundColor; + final double? strokeWidth; + + /// Optional semantics label. + final String? semanticLabel; + + /// Show a label centered inside (e.g., percentage). + final bool showCenterLabel; + + /// Builder for custom center label; if null and showCenterLabel==true, shows percentage text. + final Widget Function(BuildContext context, double? value)? + centerLabelBuilder; + + /// Expressive shape + final CircularBarShapeM3E shape; + final int? waveCount; + final double? waveAmplitude; + final bool rotateClockwise; + + @override + State createState() => _CircularProgressM3EState(); +} + +class _CircularProgressM3EState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _anim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + )..repeat(); + + @override + void dispose() { + _anim.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final tokens = ProgressTokensAdapter(context); + final m = tokens.metrics(widget.density); + final color = tokens.color(widget.emphasis); + final bg = widget.backgroundColor ?? tokens.trackColor(); + + final (diameter, stroke) = switch (widget.size) { + ProgressM3ESize.small => ( + m.circularSmall, + widget.strokeWidth ?? m.strokeSmall + ), + ProgressM3ESize.medium => ( + m.circularMedium, + widget.strokeWidth ?? m.strokeMedium + ), + ProgressM3ESize.large => ( + m.circularLarge, + widget.strokeWidth ?? m.strokeLarge + ), + }; + + final indicator = SizedBox( + width: diameter, + height: diameter, + child: Stack( + alignment: Alignment.center, + children: [ + // Track ring + CustomPaint( + size: Size.square(diameter), + painter: _RingPainter(color: bg, stroke: stroke), + ), + // Progress + if (widget.shape == CircularBarShapeM3E.flat) ...[ + CustomPaint( + size: Size.square(diameter), + painter: _ArcPainter( + color: color, + stroke: stroke, + value: widget.value, + clockwise: widget.rotateClockwise, + ), + ), + ] else ...[ + AnimatedBuilder( + animation: _anim, + builder: (context, _) => CustomPaint( + size: Size.square(diameter), + painter: _WavyArcPainter( + color: color, + stroke: stroke, + value: widget.value, + waves: widget.waveCount ?? m.circularWavesPerCircle, + amplitude: (widget.waveAmplitude ?? + (m.circularWaveAmplitudeFactor * stroke)) + .clamp(0, stroke / 2), + phase: + (widget.value == null ? 2 * math.pi * _anim.value : 0) * + (widget.rotateClockwise ? 1 : -1), + clockwise: widget.rotateClockwise, + ), + ), + ), + ], + if (widget.showCenterLabel) + DefaultTextStyle( + style: tokens.labelStyle().copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + child: widget.centerLabelBuilder?.call(context, widget.value) ?? + Text(widget.value != null + ? '${(widget.value! * 100).toStringAsFixed(0)}%' + : ''), + ), + ], + ), + ); + + if (widget.semanticLabel == null) return indicator; + return Semantics( + label: widget.semanticLabel, + value: widget.value != null + ? '${(widget.value! * 100).toStringAsFixed(0)}%' + : null, + child: indicator, + ); + } +} + +class _RingPainter extends CustomPainter { + _RingPainter({required this.color, required this.stroke}); + final Color color; + final double stroke; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round; + final rect = Offset.zero & size; + final center = rect.center; + final radius = (size.shortestSide - stroke) / 2; + canvas.drawCircle(center, radius, paint); + } + + @override + bool shouldRepaint(covariant _RingPainter old) => + old.color != color || old.stroke != stroke; +} + +class _ArcPainter extends CustomPainter { + _ArcPainter({ + required this.color, + required this.stroke, + required this.value, + required this.clockwise, + }); + + final Color color; + final double stroke; + final double? value; + final bool clockwise; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round; + + final rect = Offset.zero & size; + final center = rect.center; + final radius = (size.shortestSide - stroke) / 2; + + final start = -math.pi / 2; + final sweep = (value ?? 0.25) * 2 * math.pi * (clockwise ? 1 : -1); + + canvas.drawArc(Rect.fromCircle(center: center, radius: radius), start, + sweep, false, paint); + if (value == null) { + // indeterminate - draw a moving arc; this painter is used only for determinate (flat) + } + } + + @override + bool shouldRepaint(covariant _ArcPainter old) => + old.color != color || + old.stroke != stroke || + old.value != value || + old.clockwise != clockwise; +} + +class _WavyArcPainter extends CustomPainter { + _WavyArcPainter({ + required this.color, + required this.stroke, + required this.value, + required this.waves, + required this.amplitude, + required this.phase, + required this.clockwise, + }); + + final Color color; + final double stroke; + final double? value; + final int waves; + final double amplitude; + final double phase; + final bool clockwise; + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + final center = rect.center; + final baseRadius = (size.shortestSide - stroke) / 2; + + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round; + + final totalAngle = (value ?? 1.0) * 2 * math.pi * (clockwise ? 1 : -1); + final start = -math.pi / 2; + + final path = Path(); + final steps = (200 * (value ?? 1.0)).clamp(40, 300).toInt(); // resolution + for (int i = 0; i <= steps; i++) { + final t = i / steps; + final theta = start + totalAngle * t; + final wave = math.sin((t * waves * 2 * math.pi) + phase); + final r = baseRadius + amplitude * wave; + final p = Offset( + center.dx + r * math.cos(theta), center.dy + r * math.sin(theta)); + if (i == 0) { + path.moveTo(p.dx, p.dy); + } else { + path.lineTo(p.dx, p.dy); + } + } + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _WavyArcPainter old) => + old.color != color || + old.stroke != stroke || + old.value != value || + old.waves != waves || + old.amplitude != amplitude || + old.phase != phase || + old.clockwise != clockwise; +} diff --git a/packages/progress_indicator_m3e/lib/src/enums.dart b/packages/progress_indicator_m3e/lib/src/enums.dart new file mode 100644 index 0000000..e26aa63 --- /dev/null +++ b/packages/progress_indicator_m3e/lib/src/enums.dart @@ -0,0 +1,8 @@ +enum ProgressM3ESize { small, medium, large } +enum ProgressM3EEmphasis { primary, secondary, surface } +enum ProgressM3EDensity { regular, compact } +enum LinearProgressM3EVariant { determinate, indeterminate, buffer, query } +enum ProgressLabelPosition { none, leading, trailing, top, bottom, center } + +enum LinearBarShapeM3E { flat, wavy } +enum CircularBarShapeM3E { flat, wavy } diff --git a/packages/progress_indicator_m3e/lib/src/linear_progress_m3e.dart b/packages/progress_indicator_m3e/lib/src/linear_progress_m3e.dart new file mode 100644 index 0000000..f5bf012 --- /dev/null +++ b/packages/progress_indicator_m3e/lib/src/linear_progress_m3e.dart @@ -0,0 +1,378 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'enums.dart'; +import 'tokens_adapter.dart'; + +class LinearProgressM3E extends StatefulWidget { + const LinearProgressM3E({ + super.key, + this.value, + this.bufferValue, + this.variant = LinearProgressM3EVariant.determinate, + this.size = ProgressM3ESize.medium, + this.emphasis = ProgressM3EEmphasis.primary, + this.density = ProgressM3EDensity.regular, + this.backgroundColor, + this.progressColor, + this.bufferColor, + this.semanticLabel, + this.minWidth = double.infinity, + this.strokeHeight, + this.borderRadius, + this.shape = LinearBarShapeM3E.wavy, + this.wavelength, + this.amplitude, + this.leftRightInset, + }); + + final double? value; + final double? bufferValue; + final LinearProgressM3EVariant variant; + final ProgressM3ESize size; + final ProgressM3EEmphasis emphasis; + final ProgressM3EDensity density; + final Color? backgroundColor; + final Color? progressColor; + final Color? bufferColor; + final String? semanticLabel; + final double minWidth; + final double? strokeHeight; + final BorderRadius? borderRadius; + final LinearBarShapeM3E shape; + final double? wavelength; + final double? amplitude; + final double? leftRightInset; + + @override + State createState() => _LinearProgressM3EState(); +} + +class _LinearProgressM3EState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _anim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + + @override + void dispose() { + _anim.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final tokens = ProgressTokensAdapter(context); + final m = tokens.metrics(widget.density); + + final height = switch (widget.size) { + ProgressM3ESize.small => widget.strokeHeight ?? m.linearThicknessSmall, + ProgressM3ESize.medium => widget.strokeHeight ?? m.linearThicknessMedium, + ProgressM3ESize.large => widget.strokeHeight ?? m.linearThicknessLarge, + }; + + final track = widget.backgroundColor ?? tokens.trackColor(); + final progress = widget.progressColor ?? tokens.color(widget.emphasis); + final buffer = widget.bufferColor ?? tokens.bufferColor(progress); + + final borderRadius = + widget.borderRadius ?? BorderRadius.circular(height / 2); + final inset = widget.leftRightInset ?? m.horizontalInset; + + final content = Padding( + padding: EdgeInsets.symmetric(horizontal: inset), + child: _buildBar( + context, height, borderRadius, track, progress, buffer, tokens), + ); + + final bar = ClipRRect( + borderRadius: borderRadius, + child: SizedBox( + height: height, + width: widget.minWidth == double.infinity ? null : widget.minWidth, + child: content, + ), + ); + + if (widget.semanticLabel == null) return bar; + return Semantics( + label: widget.semanticLabel, + value: (widget.variant == LinearProgressM3EVariant.determinate && + widget.value != null) + ? '${(widget.value!.clamp(0.0, 1.0) * 100).toStringAsFixed(0)}%' + : null, + child: bar, + ); + } + + Widget _buildBar( + BuildContext context, + double height, + BorderRadius borderRadius, + Color track, + Color progress, + Color buffer, + ProgressTokensAdapter tokens, + ) { + final variant = widget.variant; + final shape = widget.shape; + + if (shape == LinearBarShapeM3E.flat) { + // Use standard LinearProgressIndicator behaviors. + if (variant == LinearProgressM3EVariant.indeterminate || + (variant == LinearProgressM3EVariant.determinate && + widget.value == null)) { + return LinearProgressIndicator( + color: progress, + backgroundColor: track, + minHeight: height, + ); + } else if (variant == LinearProgressM3EVariant.query) { + return Transform( + alignment: Alignment.center, + transform: Matrix4.identity()..scale(-1.0, 1.0, 1.0), + child: LinearProgressIndicator( + color: progress, + backgroundColor: track, + minHeight: height, + ), + ); + } else if (variant == LinearProgressM3EVariant.buffer) { + return _BufferBar( + height: height, + track: track, + buffer: buffer, + progress: progress, + value: widget.value ?? 0.0, + bufferValue: widget.bufferValue ?? 0.0, + ); + } else { + return LinearProgressIndicator( + value: (widget.value ?? 0.0).clamp(0.0, 1.0), + color: progress, + backgroundColor: track, + minHeight: height, + ); + } + } + + final wavelength = + widget.wavelength ?? tokens.metrics(widget.density).wavyWavelength; + final amplitude = widget.amplitude ?? + tokens.metrics(widget.density).wavyAmplitudeFactor * height; + + if (variant == LinearProgressM3EVariant.determinate && + widget.value != null) { + return _WavyBar( + value: widget.value!.clamp(0.0, 1.0), + height: height, + wavelength: wavelength, + amplitude: amplitude.clamp(0.0, height / 2), + track: track, + fill: progress, + ); + } + + // Indeterminate / query / missing value → animate phase + return AnimatedBuilder( + animation: _anim, + builder: (context, _) { + final phase = 2 * math.pi * _anim.value; + final reverse = widget.variant == LinearProgressM3EVariant.query; + return _WavyIndeterminateBar( + height: height, + wavelength: wavelength, + amplitude: amplitude.clamp(0.0, height / 2), + track: track, + fill: progress, + phase: reverse ? -phase : phase, + ); + }, + ); + } +} + +class _BufferBar extends StatelessWidget { + const _BufferBar({ + required this.height, + required this.track, + required this.buffer, + required this.progress, + required this.value, + required this.bufferValue, + }); + + final double height; + final Color track; + final Color buffer; + final Color progress; + final double value; + final double bufferValue; + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, constraints) { + final w = constraints.maxWidth; + final pv = (w.isFinite ? w : 0) * value.clamp(0.0, 1.0); + final bv = (w.isFinite ? w : 0) * bufferValue.clamp(0.0, 1.0); + + Widget seg(double width, Color color) => Align( + alignment: Alignment.centerLeft, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + width: width, + height: height, + color: color, + ), + ); + + return Stack( + fit: StackFit.passthrough, + children: [ + ColoredBox(color: track), + seg(bv, buffer), + seg(pv, progress), + ], + ); + }); + } +} + +class _WavyBar extends StatelessWidget { + const _WavyBar({ + required this.value, + required this.height, + required this.wavelength, + required this.amplitude, + required this.track, + required this.fill, + }); + + final double value; + final double height; + final double wavelength; + final double amplitude; + final Color track; + final Color fill; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WavyPainter( + value: value, + height: height, + wavelength: wavelength, + amplitude: amplitude, + track: track, + fill: fill, + phase: 0, + indeterminate: false, + ), + ); + } +} + +class _WavyIndeterminateBar extends StatelessWidget { + const _WavyIndeterminateBar({ + required this.height, + required this.wavelength, + required this.amplitude, + required this.track, + required this.fill, + required this.phase, + }); + + final double height; + final double wavelength; + final double amplitude; + final Color track; + final Color fill; + final double phase; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WavyPainter( + value: 0.6, + height: height, + wavelength: wavelength, + amplitude: amplitude, + track: track, + fill: fill, + phase: phase, + indeterminate: true, + ), + ); + } +} + +class _WavyPainter extends CustomPainter { + _WavyPainter({ + required this.value, + required this.height, + required this.wavelength, + required this.amplitude, + required this.track, + required this.fill, + required this.phase, + required this.indeterminate, + }); + + final double value; + final double height; + final double wavelength; + final double amplitude; + final Color track; + final Color fill; + final double phase; + final bool indeterminate; + + @override + void paint(Canvas canvas, Size size) { + final paintTrack = Paint()..color = track; + final paintFill = Paint()..color = fill; + + final r = RRect.fromRectAndRadius( + Offset.zero & Size(size.width, height), Radius.circular(height / 2)); + canvas.drawRRect(r, paintTrack); + + final w = size.width; + final progressW = indeterminate ? w : (w * value.clamp(0.0, 1.0)); + + final centerY = height / 2; + final path = Path()..moveTo(0, height); + path.lineTo(0, centerY); + + final k = 2 * math.pi / wavelength; + final step = 2.0; + double x = 0; + while (x <= progressW) { + final y = centerY - amplitude * math.sin(k * x + phase); + path.lineTo(x, y); + x += step; + } + path.lineTo(progressW, height); + path.close(); + + canvas.save(); + final clip = Path()..addRRect(r); + canvas.clipPath(clip); + canvas.drawPath(path, paintFill); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _WavyPainter old) { + return old.value != value || + old.height != height || + old.wavelength != wavelength || + old.amplitude != amplitude || + old.phase != phase || + old.track != track || + old.fill != fill || + old.indeterminate != indeterminate; + } +} diff --git a/packages/progress_indicator_m3e/lib/src/progress_with_label_m3e.dart b/packages/progress_indicator_m3e/lib/src/progress_with_label_m3e.dart new file mode 100644 index 0000000..6ba7a19 --- /dev/null +++ b/packages/progress_indicator_m3e/lib/src/progress_with_label_m3e.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'enums.dart'; +import 'tokens_adapter.dart'; +import 'linear_progress_m3e.dart'; + +class ProgressWithLabelM3E extends StatelessWidget { + const ProgressWithLabelM3E({ + super.key, + required this.progress, + this.position = ProgressLabelPosition.trailing, + this.label, + this.spacing, + this.textStyle, + }); + + final LinearProgressM3E progress; + final ProgressLabelPosition position; + final Widget? label; + final double? spacing; + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + if (position == ProgressLabelPosition.none) return progress; + + final tokens = ProgressTokensAdapter(context); + final style = textStyle ?? tokens.labelStyle().copyWith( + color: Theme.of(context).colorScheme.onSurface, + ); + final gap = spacing ?? 8.0; + + final value = progress.value; + final builtLabel = label ?? Text( + value != null ? '${(value * 100).toStringAsFixed(0)}%' : '', + style: style, + ); + + switch (position) { + case ProgressLabelPosition.leading: + case ProgressLabelPosition.trailing: + final children = [ + if (position == ProgressLabelPosition.leading) builtLabel, + if (position == ProgressLabelPosition.leading) SizedBox(width: gap), + Expanded(child: progress), + if (position == ProgressLabelPosition.trailing) SizedBox(width: gap), + if (position == ProgressLabelPosition.trailing) builtLabel, + ]; + return Row(children: children); + case ProgressLabelPosition.top: + case ProgressLabelPosition.bottom: + final children = [ + if (position == ProgressLabelPosition.top) builtLabel, + if (position == ProgressLabelPosition.top) SizedBox(height: gap), + progress, + if (position == ProgressLabelPosition.bottom) SizedBox(height: gap), + if (position == ProgressLabelPosition.bottom) builtLabel, + ]; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ); + case ProgressLabelPosition.center: + return Stack( + alignment: Alignment.center, + children: [ + progress, + builtLabel, + ], + ); + case ProgressLabelPosition.none: + return progress; + } + } +} diff --git a/packages/progress_indicator_m3e/lib/src/tokens_adapter.dart b/packages/progress_indicator_m3e/lib/src/tokens_adapter.dart new file mode 100644 index 0000000..4066f91 --- /dev/null +++ b/packages/progress_indicator_m3e/lib/src/tokens_adapter.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; +import 'enums.dart'; + +@immutable +class _ProgressMetrics { + final double circularSmall; + final double circularMedium; + final double circularLarge; + final double strokeSmall; + final double strokeMedium; + final double strokeLarge; + final double linearThicknessSmall; + final double linearThicknessMedium; + final double linearThicknessLarge; + final double wavyWavelength; // dp (linear) + final double wavyAmplitudeFactor; // fraction of bar height (linear) + final double horizontalInset; // 4dp + final double circularWaveAmplitudeFactor; // fraction of stroke + final int circularWavesPerCircle; // rough default + const _ProgressMetrics({ + required this.circularSmall, + required this.circularMedium, + required this.circularLarge, + required this.strokeSmall, + required this.strokeMedium, + required this.strokeLarge, + required this.linearThicknessSmall, + required this.linearThicknessMedium, + required this.linearThicknessLarge, + required this.wavyWavelength, + required this.wavyAmplitudeFactor, + required this.horizontalInset, + required this.circularWaveAmplitudeFactor, + required this.circularWavesPerCircle, + }); +} + +_ProgressMetrics _metricsFor(BuildContext context, ProgressM3EDensity density) { + double cS = 24, cM = 32, cL = 48; + double sS = 3, sM = 4, sL = 6; + double ltS = 3, ltM = 4, ltL = 6; + + if (density == ProgressM3EDensity.compact) { + cS -= 2; cM -= 2; cL -= 4; + sS -= 0.5; sM -= 0.5; sL -= 1; + ltS -= 0.5; ltM -= 0.5; ltL -= 1; + } + + return _ProgressMetrics( + circularSmall: cS, + circularMedium: cM, + circularLarge: cL, + strokeSmall: sS, + strokeMedium: sM, + strokeLarge: sL, + linearThicknessSmall: ltS, + linearThicknessMedium: ltM, + linearThicknessLarge: ltL, + wavyWavelength: 40, // per spec illustration + wavyAmplitudeFactor: 0.33, // amplitude ≈ 1/3 of height + horizontalInset: 4, // 4dp inset L/R + circularWaveAmplitudeFactor: 0.35, // ~1/3 of stroke + circularWavesPerCircle: 10, // a nice default + ); +} + +class ProgressTokensAdapter { + ProgressTokensAdapter(this.context); + final BuildContext context; + + M3ETheme get _m3e { + final t = Theme.of(context); + return t.extension() ?? M3ETheme.defaults(t.colorScheme); + } + + _ProgressMetrics metrics(ProgressM3EDensity density) => _metricsFor(context, density); + + Color color(ProgressM3EEmphasis emphasis) { + switch (emphasis) { + case ProgressM3EEmphasis.primary: + return _m3e.colors.primary; + case ProgressM3EEmphasis.secondary: + return _m3e.colors.secondary; + case ProgressM3EEmphasis.surface: + return _m3e.colors.onSurface; + } + } + + Color trackColor() => _m3e.colors.onSurface.withOpacity(0.12); + Color bufferColor(Color progress) => progress.withOpacity(0.24); + + TextStyle labelStyle() => _m3e.type.bodySmall; +} diff --git a/packages/progress_indicator_m3e/melos_progress_indicator_m3e.iml b/packages/progress_indicator_m3e/melos_progress_indicator_m3e.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/progress_indicator_m3e/melos_progress_indicator_m3e.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/progress_indicator_m3e/pubspec.yaml b/packages/progress_indicator_m3e/pubspec.yaml new file mode 100644 index 0000000..40f63af --- /dev/null +++ b/packages/progress_indicator_m3e/pubspec.yaml @@ -0,0 +1,18 @@ +name: progress_indicator_m3e +description: Material 3 Expressive Progress Indicator for Flutter (linear + circular; flat + wavy) using M3E tokens. +version: 0.1.0 +publish_to: none + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.22.0" + +dependencies: + flutter: + sdk: flutter + m3e_design: + path: ../m3e_design + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/packages/progress_indicator_m3e/pubspec_overrides.yaml b/packages/progress_indicator_m3e/pubspec_overrides.yaml new file mode 100644 index 0000000..69acba6 --- /dev/null +++ b/packages/progress_indicator_m3e/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: m3e_design +dependency_overrides: + m3e_design: + path: ..\\m3e_design diff --git a/packages/progress_indicator_m3e/test/progress_indicators_m3e_test.dart b/packages/progress_indicator_m3e/test/progress_indicators_m3e_test.dart new file mode 100644 index 0000000..fe2f48b --- /dev/null +++ b/packages/progress_indicator_m3e/test/progress_indicators_m3e_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('placeholder', () { + expect(1 + 2, 3); + }); +} diff --git a/packages/slider_m3e/LICENSE b/packages/slider_m3e/LICENSE new file mode 100644 index 0000000..12ca7c2 --- /dev/null +++ b/packages/slider_m3e/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) ... + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/slider_m3e/README.md b/packages/slider_m3e/README.md new file mode 100644 index 0000000..65f114d --- /dev/null +++ b/packages/slider_m3e/README.md @@ -0,0 +1,69 @@ +# slider_m3e + +Material 3 **Expressive** Sliders for Flutter. Single-value and range sliders, with token-driven colors, sizes, and shapes. + +- `SliderM3E` — single-value slider, optional start/end icons, discrete or continuous +- `RangeSliderM3E` — range selection with the same styling +- `sliderThemeM3E(...)` — generate a `SliderThemeData` from **M3E** tokens + +All styling reads the `M3ETheme` ThemeExtension from your `m3e_design` package. + +## Monorepo Layout + +``` +packages/ + m3e_design/ + slider_m3e/ +``` + +`pubspec.yaml` references `../m3e_design`. + +## Usage + +```dart +import 'package:slider_m3e/slider_m3e.dart'; + +// Single slider +SliderM3E( + value: 0.35, + onChanged: (v) {}, + divisions: 10, // discrete + size: SliderM3ESize.large, + emphasis: SliderM3EEmphasis.primary, + shapeFamily: SliderM3EShapeFamily.round, // or square (expressive) + startIcon: const Icon(Icons.volume_mute), + endIcon: const Icon(Icons.volume_up), +); + +// Range slider +RangeSliderM3E( + values: const RangeValues(0.2, 0.8), + onChanged: (r) {}, + divisions: 8, + size: SliderM3ESize.medium, + emphasis: SliderM3EEmphasis.secondary, + shapeFamily: SliderM3EShapeFamily.square, +); +``` + +## Tokens mapping + +- **Colors:** + - Active: `primary` / `secondary` / `onSurface` (by emphasis) + - Inactive track: `onSurface` @ 24% opacity + - Overlay: active color @ 12% opacity + - Value indicator: `secondaryContainer` with `onSecondaryContainer` text +- **Sizes:** + - Track height: small **≈2dp**, medium **≈4dp**, large **≈6dp** + - Thumb radius: small **≈10dp**, medium **≈12dp**, large **≈14dp** +- **Density:** `compact` slightly reduces track and thumb sizes +- **Shapes:** `round` uses round thumb, `square` uses a rounded-rect thumb for an expressive look + +## Accessibility + +- Set `semanticLabel` to announce values (percentage format by default). +- Discrete sliders (with `divisions`) will show value indicators when `showValueIndicator` is enabled (or `onlyForDiscrete` by default). + +## License + +MIT diff --git a/packages/slider_m3e/lib/slider_m3e.dart b/packages/slider_m3e/lib/slider_m3e.dart new file mode 100644 index 0000000..1a96e03 --- /dev/null +++ b/packages/slider_m3e/lib/slider_m3e.dart @@ -0,0 +1,7 @@ +library slider_m3e; + +export 'src/enums.dart'; +export 'src/slider_tokens_adapter.dart' show SliderTokensAdapter; +export 'src/slider_theme_m3e.dart'; +export 'src/slider_m3e.dart'; +export 'src/range_slider_m3e.dart'; diff --git a/packages/slider_m3e/lib/src/enums.dart b/packages/slider_m3e/lib/src/enums.dart new file mode 100644 index 0000000..b1d7786 --- /dev/null +++ b/packages/slider_m3e/lib/src/enums.dart @@ -0,0 +1,4 @@ +enum SliderM3ESize { small, medium, large } +enum SliderM3EEmphasis { primary, secondary, surface } +enum SliderM3EShapeFamily { round, square } +enum SliderM3EDensity { regular, compact } diff --git a/packages/slider_m3e/lib/src/range_slider_m3e.dart b/packages/slider_m3e/lib/src/range_slider_m3e.dart new file mode 100644 index 0000000..df57640 --- /dev/null +++ b/packages/slider_m3e/lib/src/range_slider_m3e.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'slider_theme_m3e.dart'; +import 'enums.dart'; + +class RangeSliderM3E extends StatelessWidget { + const RangeSliderM3E({ + super.key, + required this.values, + required this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.divisions, + this.labels, + this.semanticLabel, + this.size = SliderM3ESize.medium, + this.emphasis = SliderM3EEmphasis.primary, + this.shapeFamily = SliderM3EShapeFamily.round, + this.density = SliderM3EDensity.regular, + this.showValueIndicator, + }); + + final RangeValues values; + final ValueChanged? onChanged; + final ValueChanged? onChangeStart; + final ValueChanged? onChangeEnd; + final double min; + final double max; + final int? divisions; + final RangeLabels? labels; + final String? semanticLabel; + + final SliderM3ESize size; + final SliderM3EEmphasis emphasis; + final SliderM3EShapeFamily shapeFamily; + final SliderM3EDensity density; + final bool? showValueIndicator; + + @override + Widget build(BuildContext context) { + final theme = sliderThemeM3E( + context, + size: size, + emphasis: emphasis, + shapeFamily: shapeFamily, + density: density, + showValueIndicator: showValueIndicator ?? false, + ); + + return SliderTheme( + data: theme, + child: RangeSlider( + values: RangeValues( + values.start.clamp(min, max), + values.end.clamp(min, max), + ), + onChanged: onChanged, + onChangeStart: onChangeStart, + onChangeEnd: onChangeEnd, + min: min, + max: max, + divisions: divisions, + labels: labels, + semanticFormatterCallback: semanticLabel != null + ? (v) => '$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%' + : null, + ), + ); + } +} diff --git a/packages/slider_m3e/lib/src/slider_m3e.dart b/packages/slider_m3e/lib/src/slider_m3e.dart new file mode 100644 index 0000000..cf6537f --- /dev/null +++ b/packages/slider_m3e/lib/src/slider_m3e.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'slider_theme_m3e.dart'; +import 'enums.dart'; + +class SliderM3E extends StatelessWidget { + const SliderM3E({ + super.key, + required this.value, + required this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.divisions, + this.label, + this.semanticLabel, + this.size = SliderM3ESize.medium, + this.emphasis = SliderM3EEmphasis.primary, + this.shapeFamily = SliderM3EShapeFamily.round, + this.density = SliderM3EDensity.regular, + this.showValueIndicator, + this.startIcon, + this.endIcon, + }); + + final double value; + final ValueChanged? onChanged; + final ValueChanged? onChangeStart; + final ValueChanged? onChangeEnd; + final double min; + final double max; + final int? divisions; + final String? label; + final String? semanticLabel; + + final SliderM3ESize size; + final SliderM3EEmphasis emphasis; + final SliderM3EShapeFamily shapeFamily; + final SliderM3EDensity density; + final bool? showValueIndicator; + + final Widget? startIcon; + final Widget? endIcon; + + @override + Widget build(BuildContext context) { + final theme = sliderThemeM3E( + context, + size: size, + emphasis: emphasis, + shapeFamily: shapeFamily, + density: density, + showValueIndicator: showValueIndicator ?? false, + ); + + final slider = Slider( + value: value.clamp(min, max), + onChanged: onChanged, + onChangeStart: onChangeStart, + onChangeEnd: onChangeEnd, + min: min, + max: max, + divisions: divisions, + label: label, + semanticFormatterCallback: semanticLabel != null + ? (v) => '$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%' + : null, + ); + + if (startIcon == null && endIcon == null) { + return SliderTheme(data: theme, child: slider); + } + + return SliderTheme( + data: theme, + child: Row( + children: [ + if (startIcon != null) ...[startIcon!, const SizedBox(width: 8)], + Expanded(child: slider), + if (endIcon != null) ...[const SizedBox(width: 8), endIcon!], + ], + ), + ); + } +} diff --git a/packages/slider_m3e/lib/src/slider_m3e_widget.dart b/packages/slider_m3e/lib/src/slider_m3e_widget.dart new file mode 100644 index 0000000..524f4a2 --- /dev/null +++ b/packages/slider_m3e/lib/src/slider_m3e_widget.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; + +class SliderM3EWidget extends StatelessWidget { + const SliderM3EWidget({super.key}); + + @override + Widget build(BuildContext context) { + final m3e = context.m3e; + return Container( + padding: EdgeInsets.all(m3e.spacing.md), + decoration: BoxDecoration( + color: m3e.colors.surfaceStrong, + borderRadius: m3e.shapes.square.md, + ), + child: Text('Slider placeholder', style: m3e.typography.base.titleMedium), + ); + } +} + +String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join(); diff --git a/packages/slider_m3e/lib/src/slider_theme_m3e.dart b/packages/slider_m3e/lib/src/slider_theme_m3e.dart new file mode 100644 index 0000000..c6d70e8 --- /dev/null +++ b/packages/slider_m3e/lib/src/slider_theme_m3e.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'slider_tokens_adapter.dart'; +import 'enums.dart'; + +SliderThemeData sliderThemeM3E( + BuildContext context, { + SliderM3ESize size = SliderM3ESize.medium, + SliderM3EEmphasis emphasis = SliderM3EEmphasis.primary, + SliderM3EShapeFamily shapeFamily = SliderM3EShapeFamily.round, + SliderM3EDensity density = SliderM3EDensity.regular, + bool showValueIndicator = false, +}) { + final t = SliderTokensAdapter(context); + final m = t.metrics(density); + + final trackHeight = switch (size) { + SliderM3ESize.small => m.trackSmall, + SliderM3ESize.medium => m.trackMedium, + SliderM3ESize.large => m.trackLarge, + }; + + final thumbRadius = switch (size) { + SliderM3ESize.small => m.thumbSmall, + SliderM3ESize.medium => m.thumbMedium, + SliderM3ESize.large => m.thumbLarge, + }; + + final thumbShape = shapeFamily == SliderM3EShapeFamily.round + ? RoundSliderThumbShape(enabledThumbRadius: thumbRadius) + : _SquareThumbShape(side: thumbRadius * 2); + + return SliderTheme.of(context).copyWith( + trackHeight: trackHeight, + activeTrackColor: t.activeColor(emphasis), + inactiveTrackColor: t.inactiveColor(), + disabledActiveTrackColor: t.inactiveColor(), + disabledInactiveTrackColor: t.inactiveColor(), + activeTickMarkColor: t.tickColorActive(emphasis), + inactiveTickMarkColor: t.tickColorInactive(), + thumbColor: t.thumbColor(emphasis), + disabledThumbColor: t.inactiveColor(), + overlayColor: t.overlayColor(emphasis), + valueIndicatorColor: t.valueIndicatorColor(), + valueIndicatorTextStyle: t.valueIndicatorTextStyle(), + showValueIndicator: showValueIndicator ? ShowValueIndicator.onDrag : ShowValueIndicator.onlyForDiscrete, + thumbShape: thumbShape, + overlayShape: RoundSliderOverlayShape(overlayRadius: m.overlayRadius), + rangeThumbShape: shapeFamily == SliderM3EShapeFamily.round + ? const RoundRangeSliderThumbShape() + : const _SquareRangeThumbShape(), + rangeTrackShape: const RoundedRectRangeSliderTrackShape(), + rangeValueIndicatorShape: const PaddleRangeSliderValueIndicatorShape(), + valueIndicatorShape: const PaddleSliderValueIndicatorShape(), + ); +} + +class _SquareThumbShape extends SliderComponentShape { + const _SquareThumbShape({required this.side}); + final double side; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) => Size.square(side); + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter? labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + final canvas = context.canvas; + final rect = Rect.fromCenter(center: center, width: side, height: side); + final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(4)); + final paint = Paint()..color = sliderTheme.thumbColor ?? Colors.blue; + canvas.drawRRect(rrect, paint); + } +} + +class _SquareRangeThumbShape extends RangeSliderThumbShape { + const _SquareRangeThumbShape(); + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) => const Size(24, 24); + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + bool isDiscrete = false, + bool isEnabled = true, + bool isOnTop = false, + bool isPressed = false, + required SliderThemeData sliderTheme, + TextDirection textDirection = TextDirection.ltr, + Thumb thumb = Thumb.start, + }) { + final canvas = context.canvas; + final side = 24.0; + final rect = Rect.fromCenter(center: center, width: side, height: side); + final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(4)); + final paint = Paint()..color = sliderTheme.thumbColor ?? Colors.blue; + canvas.drawRRect(rrect, paint); + } +} diff --git a/packages/slider_m3e/lib/src/slider_tokens_adapter.dart b/packages/slider_m3e/lib/src/slider_tokens_adapter.dart new file mode 100644 index 0000000..60d8593 --- /dev/null +++ b/packages/slider_m3e/lib/src/slider_tokens_adapter.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; +import 'enums.dart'; + +@immutable +class _SliderMetrics { + final double trackSmall; + final double trackMedium; + final double trackLarge; + final double thumbSmall; + final double thumbMedium; + final double thumbLarge; + final double overlayRadius; + final double tickRadius; + const _SliderMetrics({ + required this.trackSmall, + required this.trackMedium, + required this.trackLarge, + required this.thumbSmall, + required this.thumbMedium, + required this.thumbLarge, + required this.overlayRadius, + required this.tickRadius, + }); +} + +_SliderMetrics _metricsFor(BuildContext context, SliderM3EDensity density) { + // Based on M3 defaults with a slightly more expressive large option. + double trS = 2, trM = 4, trL = 6; + double thS = 10, thM = 12, thL = 14; + double overlay = 20, tick = 2; + + if (density == SliderM3EDensity.compact) { + trS -= 0.5; trM -= 0.5; trL -= 1.0; + thS -= 1; thM -= 1; thL -= 2; + overlay -= 2; + } + + return _SliderMetrics( + trackSmall: trS, + trackMedium: trM, + trackLarge: trL, + thumbSmall: thS, + thumbMedium: thM, + thumbLarge: thL, + overlayRadius: overlay, + tickRadius: tick, + ); +} + +class SliderTokensAdapter { + SliderTokensAdapter(this.context); + final BuildContext context; + + M3ETheme get _m3e { + final t = Theme.of(context); + return t.extension() ?? M3ETheme.defaults(t.colorScheme); + } + + _SliderMetrics metrics(SliderM3EDensity density) => _metricsFor(context, density); + + // Colors + Color activeColor(SliderM3EEmphasis e) { + switch (e) { + case SliderM3EEmphasis.primary: return _m3e.colors.primary; + case SliderM3EEmphasis.secondary: return _m3e.colors.secondary; + case SliderM3EEmphasis.surface: return _m3e.colors.onSurface; + } + } + Color inactiveColor() => _m3e.colors.onSurface.withValues(alpha: 0.24); + Color tickColorActive(SliderM3EEmphasis e) => activeColor(e).withValues(alpha: 0.9); + Color tickColorInactive() => _m3e.colors.onSurface.withValues(alpha: 0.38); + Color thumbColor(SliderM3EEmphasis e) => activeColor(e); + Color overlayColor(SliderM3EEmphasis e) => activeColor(e).withValues(alpha: 0.12); + Color valueIndicatorColor() => _m3e.colors.secondaryContainer; + TextStyle valueIndicatorTextStyle() => _m3e.type.labelSmall.copyWith(color: _m3e.colors.onSecondaryContainer); + + // Shapes + OutlinedBorder containerShape(SliderM3EShapeFamily family) { + final set = family == SliderM3EShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square; + return RoundedRectangleBorder(borderRadius: set.md); + } +} diff --git a/packages/slider_m3e/melos_slider_m3e.iml b/packages/slider_m3e/melos_slider_m3e.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/slider_m3e/melos_slider_m3e.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/slider_m3e/pubspec.yaml b/packages/slider_m3e/pubspec.yaml new file mode 100644 index 0000000..7d059a7 --- /dev/null +++ b/packages/slider_m3e/pubspec.yaml @@ -0,0 +1,18 @@ +name: slider_m3e +description: Material 3 Expressive Sliders (single & range) for Flutter, powered by M3E tokens. +version: 0.1.0 +publish_to: none + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.22.0" + +dependencies: + flutter: + sdk: flutter + m3e_design: + path: ../m3e_design + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/packages/slider_m3e/pubspec_overrides.yaml b/packages/slider_m3e/pubspec_overrides.yaml new file mode 100644 index 0000000..69acba6 --- /dev/null +++ b/packages/slider_m3e/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: m3e_design +dependency_overrides: + m3e_design: + path: ..\\m3e_design diff --git a/packages/slider_m3e/test/slider_m3e_test.dart b/packages/slider_m3e/test/slider_m3e_test.dart new file mode 100644 index 0000000..c271d79 --- /dev/null +++ b/packages/slider_m3e/test/slider_m3e_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('placeholder', () { + expect(4 * 5, 20); + }); +} diff --git a/packages/split_button_m3e/.gitignore b/packages/split_button_m3e/.gitignore new file mode 100644 index 0000000..dd5eb98 --- /dev/null +++ b/packages/split_button_m3e/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/packages/split_button_m3e/.metadata b/packages/split_button_m3e/.metadata new file mode 100644 index 0000000..b3d8d13 --- /dev/null +++ b/packages/split_button_m3e/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2" + channel: "stable" + +project_type: package diff --git a/packages/split_button_m3e/CHANGELOG.md b/packages/split_button_m3e/CHANGELOG.md new file mode 100644 index 0000000..b43a835 --- /dev/null +++ b/packages/split_button_m3e/CHANGELOG.md @@ -0,0 +1,7 @@ +## 0.1.0 + +- Initial release: SplitButtonM3E (Material 3 Expressive) +- Sizes XS–XL, variants filled/tonal/outlined/elevated +- A11y: 48×48 min tap targets for each segment +- Menu via MenuAnchor, caret rotation, keyboard support +- Example app + tests diff --git a/packages/split_button_m3e/LICENSE b/packages/split_button_m3e/LICENSE new file mode 100644 index 0000000..b77bf2a --- /dev/null +++ b/packages/split_button_m3e/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/split_button_m3e/README.md b/packages/split_button_m3e/README.md new file mode 100644 index 0000000..08feb80 --- /dev/null +++ b/packages/split_button_m3e/README.md @@ -0,0 +1,133 @@ +# split_button_m3e + +Material 3 Expressive Split Button for Flutter. Two-segment control: +- Leading: primary action (icon, label, or both) +- Trailing: menu trigger (chevron) + +All sizes, paddings, radii, and offsets are token-driven, aligned to measurement boards. + +## Quick start + +```dart +import 'package:split_button_m3e/split_button_m3e.dart'; + +SplitButtonM3E( + size: SplitButtonM3ESize.md, + shape: SplitButtonM3EShape.round, + emphasis: SplitButtonM3EEmphasis.tonal, + label: 'Save', + leadingIcon: Icons.save_outlined, + onPressed: () => debugPrint('Primary pressed'), + items: const [ + SplitButtonM3EItem(value: 'draft', child: 'Save as draft'), + SplitButtonM3EItem(value: 'close', child: 'Save & close'), + ], + onSelected: (v) => debugPrint('Selected: $v'), + // Optional tooltips help with semantics and tests + leadingTooltip: 'Save', + trailingTooltip: 'Open menu', +); +``` + +## Behavior and layout + +- Two segments with a fixed inner gap of 2dp. +- Trailing chevron rotates 180° when the menu is open. +- Menu opens aligned to the trailing edge of the arrow button (right edge in LTR, left in RTL). +- Optical chevron offset is applied only in the unselected (closed) state for asymmetrical layout. +- Pressed/expanded shape morph follows the expressive M3 pattern: + - MD/LG/XL: when shape = round and arrow is pressed or menu is open, the trailing segment morphs into a perfect circle (diameter = control height), no inner padding, no optical offset. + - XS/SM: no circle morph in selected state. The selected trailing segment uses a fixed total width of 48dp with side paddings of 13dp. +- Each segment maintains a minimum touch target of 48dp. + +## Tokens (by size) + +Heights +- XS 32 · S 40 · M 56 · L 96 · XL 136 + +Trailing width (centered chevron) +- XS 22 · S 22 · M 26 · L 38 · XL 50 + +Inner gap (between segments) +- 2dp + +Inner corner radius (facing edges) +- XS 4 · S 4 · M 4 · L 8 · XL 12 + +Icon sizes +- XS 20 · S 24 · M 24 · L 32 · XL 40 + +Optical chevron offset (unselected/resting) +- XS −1 · S −1 · M −2 · L −3 · XL −6 + +Asymmetrical (unselected) paddings and blocks +- XS: leadingIconBlock 20, leftOuter 12, gap icon→label 4, labelRight 10, trailingLeftInner 12, rightOuter 14 +- S: leadingIconBlock 20, leftOuter 16, gap 8, labelRight 12, trailingLeftInner 12, rightOuter 14 +- M: leadingIconBlock 24, leftOuter 24, gap 8, labelRight 24, trailingLeftInner 13, rightOuter 17 +- L: leadingIconBlock 32, leftOuter 48, gap 12, labelRight 48, trailingLeftInner 26, rightOuter 32 +- XL: leadingIconBlock 40, leftOuter 64, gap 16, labelRight 64, trailingLeftInner 37, rightOuter 49 + +Symmetrical (selected) trailing segment +- Trailing width (centered chevron) + side padding ×2 +- Side padding per size: XS 13 · S 13 · M 15 · L 29 · XL 43 +- Special case: XS/SM selected total width is 48 (22 + 13 + 13) with 13dp side padding; no full rounding. + +Pressed morph radii +- Per-size pressed radius tokens are applied to the pressed segment; when round and MD/LG/XL, trailing becomes a circle while pressed/open. + +## API summary + +Props +- size: SplitButtonM3ESize (xs, sm, md, lg, xl) +- shape: SplitButtonM3EShape (round, square) +- emphasis: SplitButtonM3EEmphasis (filled, tonal, elevated, outlined, text) +- label: String? (leading segment) +- leadingIcon: IconData? (leading segment icon) +- onPressed: VoidCallback? (leading action) +- items: List>? (trailing menu items), or +- menuBuilder: List> Function(BuildContext)? +- onSelected: ValueChanged? (when an item is chosen) +- trailingAlignment: SplitButtonM3ETrailingAlignment (opticalCenter, geometricCenter) +- leadingTooltip, trailingTooltip: String? (for semantics/UX) +- enabled: bool (default true) + +Items +```dart +const SplitButtonM3EItem({ + required T value, + required Object child, // plain string or Widget + bool enabled = true, +}); +``` + +## Accessibility +- Each segment is independently focusable; minimum 48×48dp hit target. +- Tooltips provide accessible names; you can supply copy like “Open menu”. +- Chevron rotation and selected state are conveyed via menu open/close. + +## Example (menuBuilder) + +```dart +SplitButtonM3E( + size: SplitButtonM3ESize.md, + shape: SplitButtonM3EShape.square, + label: 'More', + leadingIcon: Icons.more_horiz, + onPressed: () => debugPrint('Primary'), + menuBuilder: (context) => [ + const PopupMenuItem(value: 1, child: Text('Option 1')), + const PopupMenuItem(value: 2, child: Text('Option 2')), + ], + onSelected: (v) => debugPrint('Picked $v'), + trailingAlignment: SplitButtonM3ETrailingAlignment.opticalCenter, +); +``` + +## Notes +- Menu aligns to the trailing arrow’s edge (LTR right, RTL left). +- Optical centering is applied only when the menu is closed (unselected asymmetrical state). +- When shape=round and size is MD/LG/XL, the trailing segment becomes a perfect circle while pressed/open; XS/SM remain rectangular with the selected geometry (48 total width, 13 side padding). + +## License + +MIT diff --git a/packages/split_button_m3e/analysis_options.yaml b/packages/split_button_m3e/analysis_options.yaml new file mode 100644 index 0000000..1b5cdfc --- /dev/null +++ b/packages/split_button_m3e/analysis_options.yaml @@ -0,0 +1,5 @@ +# include: package:flutter_lints/flutter.yaml +linter: + rules: + prefer_single_quotes: true + diff --git a/packages/split_button_m3e/example/.gitignore b/packages/split_button_m3e/example/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/packages/split_button_m3e/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/split_button_m3e/example/.metadata b/packages/split_button_m3e/example/.metadata new file mode 100644 index 0000000..84f56b1 --- /dev/null +++ b/packages/split_button_m3e/example/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: android + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: ios + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: linux + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: macos + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: web + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: windows + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/split_button_m3e/example/README.md b/packages/split_button_m3e/example/README.md new file mode 100644 index 0000000..11550df --- /dev/null +++ b/packages/split_button_m3e/example/README.md @@ -0,0 +1,16 @@ +# split_button_m3e_example + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/split_button_m3e/example/analysis_options.yaml b/packages/split_button_m3e/example/analysis_options.yaml new file mode 100644 index 0000000..5577701 --- /dev/null +++ b/packages/split_button_m3e/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +# include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/split_button_m3e/example/lib/main.dart b/packages/split_button_m3e/example/lib/main.dart new file mode 100644 index 0000000..cbf1da7 --- /dev/null +++ b/packages/split_button_m3e/example/lib/main.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:split_button_m3e/split_button_m3e.dart'; + +void main() => runApp(const DemoApp()); + +class DemoApp extends StatelessWidget { + const DemoApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'SplitButtonM3E Demo', + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), + ), + home: const DemoHome(), + ); + } +} + +class DemoHome extends StatelessWidget { + const DemoHome({super.key}); + + void _snack(BuildContext ctx, String msg) { + ScaffoldMessenger.of(ctx).showSnackBar(SnackBar(content: Text(msg))); + } + + @override + Widget build(BuildContext context) { + const sizes = SplitButtonM3ESize.values; + const variants = SplitButtonM3EEmphasis.values; + + return Scaffold( + appBar: AppBar(title: const Text('SplitButtonM3E Demo')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text('Basic usage', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + SplitButtonM3E( + label: 'Save', + leadingIcon: Icons.save_outlined, + onPressed: () => _snack(context, 'Save pressed'), + items: const [ + SplitButtonM3EItem( + value: 'draft', child: 'Save as draft'), + SplitButtonM3EItem(value: 'close', child: 'Save & close'), + ], + onSelected: (v) => _snack(context, 'Selected: $v'), + ), + const SizedBox(height: 24), + const Text('Variants × Sizes (round)', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + for (final v in variants) ...[ + Text(v.toString().split('.').last.toUpperCase(), + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final s in sizes) + SplitButtonM3E( + label: 'Action', + leadingIcon: Icons.play_arrow, + onPressed: () => _snack(context, 'Primary: $v/$s'), + items: const [ + SplitButtonM3EItem(value: 'alt1', child: 'Alt 1'), + SplitButtonM3EItem(value: 'alt2', child: 'Alt 2'), + ], + onSelected: (v) => _snack(context, 'Selected: $v ($s)'), + emphasis: v, + size: s, + shape: SplitButtonM3EShape.round, + ), + ], + ), + const SizedBox(height: 16), + ], + const SizedBox(height: 24), + const Text('Square shape', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final v in variants) + SplitButtonM3E( + label: 'Share', + leadingIcon: Icons.share, + onPressed: () => _snack(context, 'Share primary'), + items: const [ + SplitButtonM3EItem( + value: 'link', child: 'Copy link'), + SplitButtonM3EItem(value: 'email', child: 'Email'), + ], + onSelected: (v) => _snack(context, 'Selected: $v'), + emphasis: v, + size: SplitButtonM3ESize.md, + shape: SplitButtonM3EShape.square, + ), + ], + ), + const SizedBox(height: 32), + ], + ), + ); + } +} diff --git a/packages/split_button_m3e/example/pubspec.yaml b/packages/split_button_m3e/example/pubspec.yaml new file mode 100644 index 0000000..7279304 --- /dev/null +++ b/packages/split_button_m3e/example/pubspec.yaml @@ -0,0 +1,21 @@ +name: split_button_m3e_example +description: Example for split_button_m3e +publish_to: "none" + +environment: + sdk: ">=3.2.0 <4.0.0" + flutter: ">=3.19.0" + +dependencies: + flutter: + sdk: flutter + split_button_m3e: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true diff --git a/packages/split_button_m3e/lib/split_button_m3e.dart b/packages/split_button_m3e/lib/split_button_m3e.dart new file mode 100644 index 0000000..6b3e852 --- /dev/null +++ b/packages/split_button_m3e/lib/split_button_m3e.dart @@ -0,0 +1,5 @@ +library split_button_m3e; + +export 'src/enums.dart'; +export 'src/menu_items.dart'; +export 'src/split_button.dart'; diff --git a/packages/split_button_m3e/lib/src/_tokens_adapter.dart b/packages/split_button_m3e/lib/src/_tokens_adapter.dart new file mode 100644 index 0000000..2cef430 --- /dev/null +++ b/packages/split_button_m3e/lib/src/_tokens_adapter.dart @@ -0,0 +1,160 @@ +part of 'enums.dart'; + +/// Token bucket for SplitButtonM3E (dp, ms, turns, etc.). +class SplitButtonM3ETokens { + const SplitButtonM3ETokens._(); + + // Control heights per size + static const Map height = { + SplitButtonM3ESize.xs: 32, + SplitButtonM3ESize.sm: 40, + SplitButtonM3ESize.md: 56, + SplitButtonM3ESize.lg: 96, + SplitButtonM3ESize.xl: 136, + }; + + // Trailing segment width + static const Map trailingSegmentWidth = { + SplitButtonM3ESize.xs: 22, + SplitButtonM3ESize.sm: 22, + SplitButtonM3ESize.md: 26, + SplitButtonM3ESize.lg: 38, + SplitButtonM3ESize.xl: 50, + }; + + // Inner “gap” between segments + static const double innerGap = 2.0; + + // Inner corner radius (both facing edges) + static const Map innerCornerRadius = { + SplitButtonM3ESize.xs: 4, + SplitButtonM3ESize.sm: 4, + SplitButtonM3ESize.md: 4, + SplitButtonM3ESize.lg: 8, + SplitButtonM3ESize.xl: 12, + }; + + // Inner padding (from inner edge to content) + static const Map innerPadding = { + SplitButtonM3ESize.xs: 4, + SplitButtonM3ESize.sm: 4, + SplitButtonM3ESize.md: 4, + SplitButtonM3ESize.lg: 8, + SplitButtonM3ESize.xl: 12, + }; + + // Menu chevron optical offset (unselected/resting; negative = shift left) + static const Map menuIconOffsetUnselected = { + SplitButtonM3ESize.xs: -1, + SplitButtonM3ESize.sm: -1, + SplitButtonM3ESize.md: -2, + SplitButtonM3ESize.lg: -3, + SplitButtonM3ESize.xl: -6, + }; + + // Icon glyph size (for both segments) + static const Map icon = { + SplitButtonM3ESize.xs: 20.0, + SplitButtonM3ESize.sm: 24.0, + SplitButtonM3ESize.md: 24.0, + SplitButtonM3ESize.lg: 32.0, + SplitButtonM3ESize.xl: 40.0, + }; + + // Minimum touch target per segment + static const double minTapTarget = 48.0; + + // Shape radii (outer corners) and pressed morph + // round = half height; square ≈ 25% height; pressed ≈ 20% height + static const Map outerRadiusRound = { + SplitButtonM3ESize.xs: 16, + SplitButtonM3ESize.sm: 20, + SplitButtonM3ESize.md: 28, + SplitButtonM3ESize.lg: 48, + SplitButtonM3ESize.xl: 68, + }; + + static const Map outerRadiusSquare = { + SplitButtonM3ESize.xs: 8, + SplitButtonM3ESize.sm: 10, + SplitButtonM3ESize.md: 14, + SplitButtonM3ESize.lg: 24, + SplitButtonM3ESize.xl: 34, + }; + + static const Map pressedRadius = { + SplitButtonM3ESize.xs: 6, + SplitButtonM3ESize.sm: 8, + SplitButtonM3ESize.md: 11, + SplitButtonM3ESize.lg: 19, + SplitButtonM3ESize.xl: 27, + }; + + // Layout: Asymmetrical (optically centered trailing; unselected) + static const Map leadingIconBlockWidth = { + SplitButtonM3ESize.xs: 20, + SplitButtonM3ESize.sm: 20, + SplitButtonM3ESize.md: 24, + SplitButtonM3ESize.lg: 32, + SplitButtonM3ESize.xl: 40, + }; + + static const Map leftOuterPadding = { + SplitButtonM3ESize.xs: 12, + SplitButtonM3ESize.sm: 16, + SplitButtonM3ESize.md: 24, + SplitButtonM3ESize.lg: 48, + SplitButtonM3ESize.xl: 64, + }; + + static const Map gapIconToLabel = { + SplitButtonM3ESize.xs: 4, + SplitButtonM3ESize.sm: 8, + SplitButtonM3ESize.md: 8, + SplitButtonM3ESize.lg: 12, + SplitButtonM3ESize.xl: 16, + }; + + static const Map labelRightPaddingBeforeDivider = + { + SplitButtonM3ESize.xs: 10, + SplitButtonM3ESize.sm: 12, + SplitButtonM3ESize.md: 24, + SplitButtonM3ESize.lg: 48, + SplitButtonM3ESize.xl: 64, + }; + + static const Map trailingLeftInnerPadding = { + SplitButtonM3ESize.xs: 12, + SplitButtonM3ESize.sm: 12, + SplitButtonM3ESize.md: 13, + SplitButtonM3ESize.lg: 26, + SplitButtonM3ESize.xl: 37, + }; + + static const Map rightOuterPadding = { + SplitButtonM3ESize.xs: 14, + SplitButtonM3ESize.sm: 14, + SplitButtonM3ESize.md: 17, + SplitButtonM3ESize.lg: 32, + SplitButtonM3ESize.xl: 49, + }; + + // Layout: Symmetrical (trailing centered; selected) + static const Map sidePaddingSelected = { + SplitButtonM3ESize.xs: 13, + SplitButtonM3ESize.sm: 13, + SplitButtonM3ESize.md: 15, + SplitButtonM3ESize.lg: 29, + SplitButtonM3ESize.xl: 43, + }; + + // Animation + static const Duration morphDuration = Duration(milliseconds: 120); + static const Curve morphCurve = Curves.easeOut; + static const double chevronOpenTurns = 0.5; // 180° + static const Duration chevronDuration = Duration(milliseconds: 120); + + // Focus ring + static const double focusStrokeWidth = 2.0; +} diff --git a/packages/split_button_m3e/lib/src/enums.dart b/packages/split_button_m3e/lib/src/enums.dart new file mode 100644 index 0000000..d904b18 --- /dev/null +++ b/packages/split_button_m3e/lib/src/enums.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +part '_tokens_adapter.dart'; + +/// 5-step size scale (rows 1–5 in the spec). +enum SplitButtonM3ESize { xs, sm, md, lg, xl } + +/// Base silhouette for the outer corners in resting state. +/// (Pressed state morphs using tokens regardless of the base.) +enum SplitButtonM3EShape { round, square } + +/// Emphasis family (choose container/elevation per theme). +enum SplitButtonM3EEmphasis { filled, tonal, elevated, outlined, text } + +/// Trailing icon alignment: +/// - opticalCenter → apply per-size negative offset in resting (menu closed) state +/// - geometricCenter → no offset, purely geometric center +enum SplitButtonM3ETrailingAlignment { opticalCenter, geometricCenter } + +/// Public helpers to access tokens without exposing numbers. +extension SplitButtonM3ETokensX on SplitButtonM3ESize { + double get height => SplitButtonM3ETokens.height[this]!; + double get trailingWidthCentered => + SplitButtonM3ETokens.trailingSegmentWidth[this]!; + double get innerCornerRadius => SplitButtonM3ETokens.innerCornerRadius[this]!; + double get innerPadding => SplitButtonM3ETokens.innerPadding[this]!; + double get menuIconOffsetUnselected => + SplitButtonM3ETokens.menuIconOffsetUnselected[this]!; + double get iconPx => SplitButtonM3ETokens.icon[this]!; + double get outerRoundRadius => SplitButtonM3ETokens.outerRadiusRound[this]!; + double get outerSquareRadius => SplitButtonM3ETokens.outerRadiusSquare[this]!; + double get pressedRadius => SplitButtonM3ETokens.pressedRadius[this]!; + + // New layout getters (per spec tables) + double get leadingIconBlockWidth => + SplitButtonM3ETokens.leadingIconBlockWidth[this]!; + double get leftOuterPadding => SplitButtonM3ETokens.leftOuterPadding[this]!; + double get gapIconToLabel => SplitButtonM3ETokens.gapIconToLabel[this]!; + double get labelRightPaddingBeforeDivider => + SplitButtonM3ETokens.labelRightPaddingBeforeDivider[this]!; + double get trailingLeftInnerPadding => + SplitButtonM3ETokens.trailingLeftInnerPadding[this]!; + double get rightOuterPadding => SplitButtonM3ETokens.rightOuterPadding[this]!; + double get sidePaddingSelected => + SplitButtonM3ETokens.sidePaddingSelected[this]!; +} diff --git a/packages/split_button_m3e/lib/src/menu_items.dart b/packages/split_button_m3e/lib/src/menu_items.dart new file mode 100644 index 0000000..00f8c2e --- /dev/null +++ b/packages/split_button_m3e/lib/src/menu_items.dart @@ -0,0 +1,12 @@ +/// Simple generic menu item model for SplitButtonM3E. +class SplitButtonM3EItem { + const SplitButtonM3EItem({ + required this.value, + required this.child, + this.enabled = true, + }); + + final T value; + final Object child; // Widget or plain string; the caller builds PopupMenuItem + final bool enabled; +} diff --git a/packages/split_button_m3e/lib/src/split_button.dart b/packages/split_button_m3e/lib/src/split_button.dart new file mode 100644 index 0000000..0a0f8d8 --- /dev/null +++ b/packages/split_button_m3e/lib/src/split_button.dart @@ -0,0 +1,578 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; + +import 'enums.dart'; +import 'menu_items.dart'; + +/// Two-segment Material 3 Expressive split button. +/// +/// - Leading segment: primary action (icon, label, or both) +/// - Trailing segment: menu trigger (chevron), opens a menu of alternatives +/// +/// All numeric values (sizes, paddings, radii, durations) are pulled from +/// `tokens.dart` via the enums extension getters. +class SplitButtonM3E extends StatefulWidget { + const SplitButtonM3E({ + super.key, + this.shape = SplitButtonM3EShape.round, + this.size = SplitButtonM3ESize.sm, + this.emphasis = SplitButtonM3EEmphasis.filled, + this.label, + this.leadingIcon, + this.onPressed, + required this.items, + this.onSelected, + this.trailingAlignment = SplitButtonM3ETrailingAlignment.opticalCenter, + this.leadingTooltip, + this.trailingTooltip, + this.enabled = true, + this.menuBuilder, + }) : assert( + items != null || menuBuilder != null, + 'Provide either `items` or `menuBuilder`.', + ); + + /// Size row (XS→XL). + final SplitButtonM3ESize size; + + /// Resting outer shape (round/square). Pressed morph uses tokens. + final SplitButtonM3EShape shape; + + /// Visual emphasis family. + final SplitButtonM3EEmphasis emphasis; + + /// Leading segment content. + final String? label; + final IconData? leadingIcon; + + /// Leading action. + final VoidCallback? onPressed; + + /// Trailing menu definition. Use either a static list... + final List>? items; + + /// ...or a builder that returns a list of PopupMenuEntries. + final List> Function(BuildContext)? menuBuilder; + + /// Called when a menu item is selected. + final ValueChanged? onSelected; + + /// Trailing chevron alignment strategy. + final SplitButtonM3ETrailingAlignment trailingAlignment; + + /// Optional tooltips. + final String? leadingTooltip; + final String? trailingTooltip; + + /// Set to false to disable both segments. + final bool enabled; + + @override + State> createState() => _SplitButtonM3EState(); +} + +class _SplitButtonM3EState extends State> { + bool _leadingPressed = false; + bool _trailingPressed = false; + bool _menuOpen = false; + final GlobalKey _trailingKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final dir = Directionality.of(context); + + // Container/foreground colors per emphasis family + final ( + Color cont, + Color onCont, + BorderSide? outlineSide, + double? elevation, + ) = _resolveColorsAndShapes( + context, + ); + + final height = widget.size.height; + const minTap = SplitButtonM3ETokens.minTapTarget; + final outerRadius = switch (widget.shape) { + SplitButtonM3EShape.round => widget.size.outerRoundRadius, + SplitButtonM3EShape.square => widget.size.outerSquareRadius, + }; + final pressedRadius = widget.size.pressedRadius; + final innerRadius = widget.size.innerCornerRadius; + const innerGap = SplitButtonM3ETokens.innerGap; + final chevronTurns = _menuOpen + ? SplitButtonM3ETokens.chevronOpenTurns + : 0.0; + + // Build segments + final leading = _SegmentContainer( + height: height, + minTapHeight: minTap, + color: cont, + onColor: onCont, + elevation: elevation, + outlineSide: outlineSide, + pressed: _leadingPressed, + radius: _leadingRadii( + dir: dir, + outer: outerRadius, + inner: innerRadius, + pressed: _leadingPressed ? pressedRadius : null, + ), + tooltip: widget.leadingTooltip, + onHighlightChanged: (v) { + if (!widget.enabled) return; + setState(() => _leadingPressed = v); + }, + onTap: widget.enabled ? widget.onPressed : null, + child: _LeadingContent( + size: widget.size, + icon: widget.leadingIcon, + label: widget.label, + color: onCont, + ), + ); + + final trailingIconOffsetBase = + (widget.trailingAlignment == + SplitButtonM3ETrailingAlignment.opticalCenter && + !_menuOpen) + ? widget.size.menuIconOffsetUnselected + : 0.0; + + // Trailing segment total width per state (asymmetrical vs symmetrical) + final trailingWidthUnselected = + widget.size.trailingLeftInnerPadding + + widget.size.trailingWidthCentered + + widget.size.rightOuterPadding; + final trailingWidthSelected = + widget.size.sidePaddingSelected * 2 + widget.size.trailingWidthCentered; + + // When round + pressed/open, morph trailing into a perfect circle + final bool allowCircle = + widget.size == SplitButtonM3ESize.md || + widget.size == SplitButtonM3ESize.lg || + widget.size == SplitButtonM3ESize.xl; + final bool circleTrailing = + widget.shape == SplitButtonM3EShape.round && + allowCircle && + (_trailingPressed || _menuOpen); + + // XS/SM selected: fully rounded (capsule), not a circle + final bool smallSelectedCapsule = + widget.shape == SplitButtonM3EShape.round && + (widget.size == SplitButtonM3ESize.xs || + widget.size == SplitButtonM3ESize.sm) && + _menuOpen; + + final trailingFixedWidth = circleTrailing + ? height + : (_menuOpen ? trailingWidthSelected : trailingWidthUnselected); + + final trailingLeftPad = circleTrailing + ? 0.0 + : (_menuOpen + ? widget.size.sidePaddingSelected + : widget.size.trailingLeftInnerPadding); + final trailingRightPad = circleTrailing + ? 0.0 + : (_menuOpen + ? widget.size.sidePaddingSelected + : widget.size.rightOuterPadding); + + final trailingChevronDx = circleTrailing ? 0.0 : trailingIconOffsetBase; + + final trailingRadius = circleTrailing + ? _CornerRadii( + topStart: height / 2, + bottomStart: height / 2, + topEnd: height / 2, + bottomEnd: height / 2, + ) + : smallSelectedCapsule + ? _CornerRadii( + topStart: height / 2, + bottomStart: height / 2, + topEnd: height / 2, + bottomEnd: height / 2, + ) + : _trailingRadii( + dir: dir, + outer: outerRadius, + inner: innerRadius, + pressed: (_trailingPressed || _menuOpen) ? pressedRadius : null, + ); + + final trailing = KeyedSubtree( + key: _trailingKey, + child: _SegmentContainer( + height: height, + minTapHeight: minTap, + fixedWidth: trailingFixedWidth, + color: cont, + onColor: onCont, + elevation: elevation, + outlineSide: outlineSide, + pressed: _trailingPressed || _menuOpen, + radius: trailingRadius, + tooltip: widget.trailingTooltip, + onHighlightChanged: (v) { + if (!widget.enabled) return; + setState(() => _trailingPressed = v); + }, + onTap: widget.enabled ? () => _openMenu(context) : null, + child: Padding( + padding: EdgeInsetsDirectional.only( + start: trailingLeftPad, + end: trailingRightPad, + ), + child: SizedBox( + width: circleTrailing ? height : widget.size.trailingWidthCentered, + child: Center( + child: _TrailingChevron( + color: onCont, + size: widget.size.iconPx, + turns: chevronTurns, + dxOffset: trailingChevronDx, + ), + ), + ), + ), + ), + ); + + return FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: minTap), + child: Row( + mainAxisSize: MainAxisSize.min, + textDirection: dir, + children: [ + leading, + const SizedBox(width: innerGap), + trailing, + ], + ), + ), + ); + } + + (Color, Color, BorderSide?, double?) _resolveColorsAndShapes( + BuildContext context, + ) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + + switch (widget.emphasis) { + case SplitButtonM3EEmphasis.filled: + return (cs.primary, cs.onPrimary, null, null); + case SplitButtonM3EEmphasis.tonal: + return (cs.secondaryContainer, cs.onSecondaryContainer, null, null); + case SplitButtonM3EEmphasis.elevated: + return ( + theme.colorScheme.surfaceContainerHigh, + cs.onSurface, + null, + 1.0, + ); + case SplitButtonM3EEmphasis.outlined: + final side = BorderSide(color: cs.outline); + return (Colors.transparent, cs.primary, side, null); + case SplitButtonM3EEmphasis.text: + return (Colors.transparent, cs.primary, null, null); + } + } + + _CornerRadii _leadingRadii({ + required TextDirection dir, + required double outer, + required double inner, + double? pressed, + }) { + final o = pressed ?? outer; + final i = pressed ?? inner; + // Leading segment: outer = start corners, inner = end corners + return _CornerRadii(topStart: o, bottomStart: o, topEnd: i, bottomEnd: i); + } + + _CornerRadii _trailingRadii({ + required TextDirection dir, + required double outer, + required double inner, + double? pressed, + }) { + final o = pressed ?? outer; + final i = pressed ?? inner; + // Trailing segment: inner = start corners, outer = end corners + return _CornerRadii(topStart: i, bottomStart: i, topEnd: o, bottomEnd: o); + } + + Future _openMenu(BuildContext context) async { + if (widget.menuBuilder != null) { + setState(() => _menuOpen = true); + final res = await showMenu( + context: context, + position: _menuPosition(context), + items: widget.menuBuilder!(context), + ); + if (mounted) { + setState(() => _menuOpen = false); + if (res != null && widget.onSelected != null) widget.onSelected!(res); + } + return; + } + + // Convert simple items to PopupMenuEntries + final items = widget.items!; + setState(() => _menuOpen = true); + final res = await showMenu( + context: context, + position: _menuPosition(context), + items: items + .map( + (e) => PopupMenuItem( + value: e.value, + enabled: e.enabled, + child: e.child is Widget ? e.child as Widget : Text('${e.child}'), + ), + ) + .toList(), + ); + if (!mounted) return; + setState(() => _menuOpen = false); + if (res != null && widget.onSelected != null) widget.onSelected!(res); + } + + RelativeRect _menuPosition(BuildContext context) { + final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + final textDir = Directionality.of(context); + + // Default to whole control if trailing key is missing + RenderBox? tb; + Offset tTopLeft = Offset.zero; + Size tSize = Size.zero; + final tCtx = _trailingKey.currentContext; + if (tCtx != null) { + tb = tCtx.findRenderObject() as RenderBox?; + } + if (tb != null) { + tTopLeft = tb.localToGlobal(Offset.zero); + tSize = tb.size; + } else { + final box = context.findRenderObject() as RenderBox?; + if (box != null) { + tTopLeft = box.localToGlobal(Offset.zero); + tSize = box.size; + } + } + + final top = tTopLeft.dy + tSize.height; + + late double left; + late double right; + + if (textDir == TextDirection.ltr) { + final endX = tTopLeft.dx + tSize.width; // right edge + left = endX; + right = overlay.size.width - endX; + } else { + final startX = tTopLeft.dx; // left edge is trailing in RTL + left = startX; + right = overlay.size.width - startX; + } + + return RelativeRect.fromLTRB(left, top, right, overlay.size.height - top); + } +} + +/// --- Internal: segment container ------------------------------------------------ + +class _SegmentContainer extends StatelessWidget { + const _SegmentContainer({ + required this.height, + required this.minTapHeight, + required this.color, + required this.onColor, + this.fixedWidth, + this.elevation, + this.outlineSide, + required this.pressed, + required this.radius, + required this.child, + required this.onHighlightChanged, + required this.onTap, + this.tooltip, + }); + + final double height; + final double minTapHeight; + final double? fixedWidth; + final Color color; + final Color onColor; + final double? elevation; + final BorderSide? outlineSide; + final bool pressed; + final _CornerRadii radius; + final Widget child; + final ValueChanged onHighlightChanged; + final VoidCallback? onTap; + final String? tooltip; + + @override + Widget build(BuildContext context) { + final shape = RoundedRectangleBorder( + borderRadius: radius.toBorderRadius(Directionality.of(context)), + side: outlineSide ?? BorderSide.none, + ); + + final button = Center( + child: Material( + color: color, + elevation: elevation ?? 0, + shape: shape, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + onHighlightChanged: onHighlightChanged, + customBorder: shape, + child: SizedBox( + height: height, + width: fixedWidth, + child: Center(child: child), + ), + ), + ), + ); + + final cont = ConstrainedBox( + constraints: BoxConstraints(minWidth: 0, minHeight: minTapHeight), + child: button, + ); + + if (tooltip == null) return cont; + return Tooltip(message: tooltip!, child: cont); + } +} + +/// --- Internal: leading content -------------------------------------------------- + +class _LeadingContent extends StatelessWidget { + const _LeadingContent({ + required this.size, + required this.icon, + required this.label, + required this.color, + }); + + final SplitButtonM3ESize size; + final IconData? icon; + final String? label; + final Color color; + + @override + Widget build(BuildContext context) { + final m3e = context.m3e; + final iconSize = size.iconPx; + final lp = size.leftOuterPadding; + final rp = size.labelRightPaddingBeforeDivider; + final iconBlock = size.leadingIconBlockWidth; + final gap = size.gapIconToLabel; + + Widget content; + if (icon != null && (label?.isNotEmpty ?? false)) { + content = Padding( + padding: EdgeInsetsDirectional.only(start: lp, end: rp), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: iconBlock, + child: Center( + child: Icon(icon, size: iconSize, color: color), + ), + ), + SizedBox(width: gap), + Flexible( + child: Text( + label!, + overflow: TextOverflow.ellipsis, + style: m3e.typography.base.labelLarge?.copyWith(color: color), + ), + ), + ], + ), + ); + } else if (icon != null) { + content = Padding( + padding: EdgeInsetsDirectional.only(start: lp, end: rp), + child: SizedBox( + width: iconBlock, + child: Center( + child: Icon(icon, size: iconSize, color: color), + ), + ), + ); + } else { + content = Padding( + padding: EdgeInsetsDirectional.only(start: lp, end: rp), + child: Text( + label ?? '', + overflow: TextOverflow.ellipsis, + style: DefaultTextStyle.of(context).style.copyWith(color: color), + ), + ); + } + return content; + } +} + +/// --- Internal: trailing chevron ------------------------------------------------- + +class _TrailingChevron extends StatelessWidget { + const _TrailingChevron({ + required this.color, + required this.size, + required this.turns, + required this.dxOffset, + }); + + final Color color; + final double size; + final double turns; + final double dxOffset; + + @override + Widget build(BuildContext context) { + final icon = Icon(Icons.keyboard_arrow_down, size: size, color: color); + + return AnimatedRotation( + duration: SplitButtonM3ETokens.chevronDuration, + turns: turns, + curve: SplitButtonM3ETokens.morphCurve, + child: Transform.translate(offset: Offset(dxOffset, 0.0), child: icon), + ); + } +} + +/// --- Internal: corner radii helper (private to this file) ----------------------- + +class _CornerRadii { + const _CornerRadii({ + required this.topStart, + required this.bottomStart, + required this.topEnd, + required this.bottomEnd, + }); + + final double topStart, bottomStart, topEnd, bottomEnd; + + BorderRadius toBorderRadius(TextDirection direction) { + return BorderRadiusDirectional.only( + topStart: Radius.circular(topStart), + bottomStart: Radius.circular(bottomStart), + topEnd: Radius.circular(topEnd), + bottomEnd: Radius.circular(bottomEnd), + ).resolve(direction); + } +} diff --git a/packages/split_button_m3e/pubspec.yaml b/packages/split_button_m3e/pubspec.yaml new file mode 100644 index 0000000..4c8c464 --- /dev/null +++ b/packages/split_button_m3e/pubspec.yaml @@ -0,0 +1,22 @@ +name: split_button_m3e +description: "Material 3 Expressive Split Button with sizes, variants, shapes, a11y, and menu." +version: 0.1.0 +repository: https://github.com/EmilyMonestone/split_button_m3e +issue_tracker: https://github.com/EmilyMonestone/split_button_m3e/issues + +environment: + sdk: ^3.9.2 + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + m3e_design: + path: ../m3e_design + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true \ No newline at end of file diff --git a/packages/split_button_m3e/pubspec_overrides.yaml b/packages/split_button_m3e/pubspec_overrides.yaml new file mode 100644 index 0000000..69acba6 --- /dev/null +++ b/packages/split_button_m3e/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: m3e_design +dependency_overrides: + m3e_design: + path: ..\\m3e_design diff --git a/packages/split_button_m3e/test/split_button_m3e_test.dart b/packages/split_button_m3e/test/split_button_m3e_test.dart new file mode 100644 index 0000000..5ddc253 --- /dev/null +++ b/packages/split_button_m3e/test/split_button_m3e_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:split_button_m3e/split_button_m3e.dart'; + +void main() { + testWidgets('Semantics: primary and trigger have labels', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: SplitButtonM3E( + size: SplitButtonM3ESize.md, + shape: SplitButtonM3EShape.round, + label: 'Download', + leadingIcon: Icons.download_outlined, + onPressed: () {}, + items: const [ + SplitButtonM3EItem( + value: 'zip', + child: 'Download as ZIP', + ), + SplitButtonM3EItem( + value: 'pdf', + child: 'Download as PDF', + ), + ], + trailingTooltip: 'Open menu', + // Optional leading tooltip to also tag the primary segment semantics + leadingTooltip: 'Download', + ), + ), + ), + ); + + // Primary labeled as 'Download' + expect(find.bySemanticsLabel('Download'), findsWidgets); + // Trigger labeled as 'Open menu' + expect(find.bySemanticsLabel('Open menu'), findsOneWidget); + }); + + testWidgets('Hit targets are >= 48 when size is XS', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: Center( + child: SplitButtonM3E( + size: SplitButtonM3ESize.xs, + shape: SplitButtonM3EShape.round, + label: 'Edit', + leadingIcon: Icons.edit_outlined, + onPressed: () {}, + items: const [ + SplitButtonM3EItem(value: 'rename', child: 'Rename'), + ], + // Use tooltips to ensure we measure the segment containers (not just Text) + leadingTooltip: 'Edit', + trailingTooltip: 'Open menu', + ), + ), + ), + ), + ); + + final primary = find.bySemanticsLabel('Edit'); + final trigger = find.bySemanticsLabel('Open menu'); + expect(tester.getSize(primary).height, greaterThanOrEqualTo(48)); + expect(tester.getSize(trigger).height, greaterThanOrEqualTo(48)); + }); +} diff --git a/packages/toolbar_m3e/LICENSE b/packages/toolbar_m3e/LICENSE new file mode 100644 index 0000000..12ca7c2 --- /dev/null +++ b/packages/toolbar_m3e/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) ... + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/toolbar_m3e/README.md b/packages/toolbar_m3e/README.md new file mode 100644 index 0000000..a4e5e58 --- /dev/null +++ b/packages/toolbar_m3e/README.md @@ -0,0 +1,78 @@ +# toolbar_m3e + +Material 3 **Expressive** toolbar for Flutter – a compact action bar that can host a title/subtitle, leading widget, inline actions, and an overflow menu. All styling is driven by `m3e_design` tokens. + +- `ToolbarM3E` — the main toolbar widget (`PreferredSizeWidget`) for use in `Scaffold` or as a standalone header +- `ToolbarActionM3E` — action model consumed by the toolbar +- Inline actions render as icon buttons; extra actions go into a `PopupMenuButton` overflow + +## Monorepo Layout + +``` +packages/ + m3e_design/ + toolbar_m3e/ +``` + +`pubspec.yaml` references `../m3e_design`. + +## Usage + +```dart +import 'package:toolbar_m3e/toolbar_m3e.dart'; + +final actions = [ + ToolbarActionM3E( + icon: Icons.search, + onPressed: () {}, + tooltip: 'Search', + ), + ToolbarActionM3E( + icon: Icons.share_outlined, + onPressed: () {}, + tooltip: 'Share', + ), + ToolbarActionM3E( + icon: Icons.delete_outline, + onPressed: () {}, + tooltip: 'Delete', + isDestructive: true, + label: 'Delete', // used in overflow + ), +]; + +ToolbarM3E( + leading: const BackButton(), + titleText: 'Selection', + subtitleText: '3 items', + actions: actions, + maxInlineActions: 2, // remaining actions go to overflow + variant: ToolbarM3EVariant.tonal, // surface | tonal | primary + size: ToolbarM3ESize.medium, // small | medium | large + density: ToolbarM3EDensity.regular, + shapeFamily: ToolbarM3EShapeFamily.round, + centerTitle: false, +); +``` + +## Tokens mapping + +- **Container**: `surfaceContainerHigh` (surface) / `secondaryContainer` (tonal) / `primaryContainer` (primary) +- **Foreground**: `onSurface` / `onSecondaryContainer` / `onPrimaryContainer` +- **Shape**: uses M3E `round` / `square` set (`md` radius) +- **Heights**: small **≈40dp**, medium **≈48dp**, large **≈56dp** +- **Icon size**: **24dp** +- **Padding**: horizontal from tokens (`spacing.md`) + +## Overflow + +Set `maxInlineActions` to the number of actions that should stay inline. Any additional actions go to the overflow menu (labels pulled from `label` or `tooltip`/`semanticLabel`). Destructive actions can be highlighted by `isDestructive: true`. + +## Accessibility + +- Provide `semanticLabel` on the toolbar if useful. +- Actions expose `tooltip` and can set `semanticLabel` to improve assistive tech hints. + +## License + +MIT diff --git a/packages/toolbar_m3e/lib/src/enums.dart b/packages/toolbar_m3e/lib/src/enums.dart new file mode 100644 index 0000000..d53338f --- /dev/null +++ b/packages/toolbar_m3e/lib/src/enums.dart @@ -0,0 +1,4 @@ +enum ToolbarM3ESize { small, medium, large } +enum ToolbarM3EShapeFamily { round, square } +enum ToolbarM3EDensity { regular, compact } +enum ToolbarM3EVariant { surface, tonal, primary } diff --git a/packages/toolbar_m3e/lib/src/toolbar_action_m3e.dart b/packages/toolbar_m3e/lib/src/toolbar_action_m3e.dart new file mode 100644 index 0000000..acbf8d4 --- /dev/null +++ b/packages/toolbar_m3e/lib/src/toolbar_action_m3e.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class ToolbarActionM3E { + const ToolbarActionM3E({ + required this.icon, + required this.onPressed, + this.tooltip, + this.semanticLabel, + this.enabled = true, + this.label, // used in overflow menu + this.isDestructive = false, + }); + + final IconData icon; + final VoidCallback onPressed; + final String? tooltip; + final String? semanticLabel; + final bool enabled; + + /// Optional label used in the overflow menu; if null, tooltip or semanticLabel will be used. + final String? label; + + /// If true, the action is styled as destructive in overflow (e.g., error color). + final bool isDestructive; +} + +class ToolbarIconButtonM3E extends StatelessWidget { + const ToolbarIconButtonM3E({ + super.key, + required this.action, + this.color, + this.iconSize, + }); + + final ToolbarActionM3E action; + final Color? color; + final double? iconSize; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: action.enabled ? action.onPressed : null, + tooltip: action.tooltip ?? action.label, + icon: Icon(action.icon), + color: color, + iconSize: iconSize, + ); + } +} diff --git a/packages/toolbar_m3e/lib/src/toolbar_m3e.dart b/packages/toolbar_m3e/lib/src/toolbar_m3e.dart new file mode 100644 index 0000000..5c390d3 --- /dev/null +++ b/packages/toolbar_m3e/lib/src/toolbar_m3e.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; +import 'enums.dart'; +import 'toolbar_tokens_adapter.dart'; +import 'toolbar_action_m3e.dart'; + +class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget { + const ToolbarM3E({ + super.key, + this.leading, + this.title, + this.titleText, + this.subtitle, + this.subtitleText, + this.actions = const [], + this.maxInlineActions = 4, + this.overflowIcon = const Icon(Icons.more_vert), + this.centerTitle = false, + this.variant = ToolbarM3EVariant.surface, + this.size = ToolbarM3ESize.medium, + this.density = ToolbarM3EDensity.regular, + this.shapeFamily = ToolbarM3EShapeFamily.round, + this.backgroundColor, + this.foregroundColor, + this.elevation, + this.padding, + this.safeArea = true, + this.clipBehavior = Clip.none, + this.semanticLabel, + }); + + final Widget? leading; + final Widget? title; + final String? titleText; + final Widget? subtitle; + final String? subtitleText; + + final List actions; + final int maxInlineActions; + final Widget overflowIcon; + + final bool centerTitle; + + final ToolbarM3EVariant variant; + final ToolbarM3ESize size; + final ToolbarM3EDensity density; + final ToolbarM3EShapeFamily shapeFamily; + + final Color? backgroundColor; + final Color? foregroundColor; + final double? elevation; + final EdgeInsetsGeometry? padding; + final bool safeArea; + final Clip clipBehavior; + + final String? semanticLabel; + + @override + Size get preferredSize { + // A rough default; actual height is resolved at build based on size/density. + switch (size) { + case ToolbarM3ESize.small: return const Size.fromHeight(40); + case ToolbarM3ESize.medium: return const Size.fromHeight(48); + case ToolbarM3ESize.large: return const Size.fromHeight(56); + } + } + + @override + Widget build(BuildContext context) { + final tokens = ToolbarTokensAdapter(context); + final metrics = tokens.metrics(density); + final m3e = Theme.of(context).extension() ?? M3ETheme.defaults(Theme.of(context).colorScheme); + + final height = switch (size) { + ToolbarM3ESize.small => metrics.heightSmall, + ToolbarM3ESize.medium => metrics.heightMedium, + ToolbarM3ESize.large => metrics.heightLarge, + }; + + final bg = backgroundColor ?? tokens.containerColor(variant); + final fg = foregroundColor ?? tokens.foregroundOn(variant); + final shape = tokens.shape(shapeFamily); + final pad = padding ?? metrics.horizontalPadding; + + final resolvedTitle = title ?? + (titleText != null + ? Text(titleText!, style: tokens.titleStyle().copyWith(color: fg), overflow: TextOverflow.ellipsis) + : null); + + final resolvedSubtitle = subtitle ?? + (subtitleText != null + ? Text(subtitleText!, style: tokens.subtitleStyle().copyWith(color: fg.withValues(alpha: 0.8)), overflow: TextOverflow.ellipsis) + : null); + + final toolbarRow = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (leading != null) leading!, + if (leading != null) SizedBox(width: metrics.gap), + Expanded( + child: _TitleBlock( + title: resolvedTitle, + subtitle: resolvedSubtitle, + center: centerTitle, + ), + ), + SizedBox(width: metrics.gap), + _ActionsRow( + actions: actions, + maxInline: maxInlineActions, + overflowIcon: overflowIcon, + iconColor: fg, + iconSize: metrics.iconSize, + m3e: m3e, + ), + ], + ); + + final bar = Material( + color: bg, + elevation: elevation ?? (variant == ToolbarM3EVariant.surface ? metrics.elevationSurface : metrics.elevationProminent), + shape: shape, + clipBehavior: clipBehavior, + child: SizedBox( + height: height, + child: Padding( + padding: pad, + child: IconTheme.merge( + data: IconThemeData(color: fg, size: metrics.iconSize), + child: DefaultTextStyle.merge(style: TextStyle(color: fg), child: toolbarRow), + ), + ), + ), + ); + + final content = safeArea ? SafeArea(top: false, left: false, right: false, bottom: false, child: bar) : bar; + + if (semanticLabel == null) return content; + return Semantics(container: true, label: semanticLabel!, child: content); + } +} + +class _TitleBlock extends StatelessWidget { + const _TitleBlock({required this.title, required this.subtitle, required this.center}); + final Widget? title; + final Widget? subtitle; + final bool center; + + @override + Widget build(BuildContext context) { + if (title == null && subtitle == null) return const SizedBox.shrink(); + + final col = Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: center ? CrossAxisAlignment.center : CrossAxisAlignment.start, + children: [ + if (title != null) DefaultTextStyle.merge(style: Theme.of(context).textTheme.titleSmall!, child: title!), + if (subtitle != null) DefaultTextStyle.merge(style: Theme.of(context).textTheme.bodySmall!, child: subtitle!), + ], + ); + + if (center) { + return Align(alignment: Alignment.center, child: col); + } + return col; + } +} + +class _ActionsRow extends StatelessWidget { + const _ActionsRow({ + required this.actions, + required this.maxInline, + required this.overflowIcon, + required this.iconColor, + required this.iconSize, + required this.m3e, + }); + + final List actions; + final int maxInline; + final Widget overflowIcon; + final Color iconColor; + final double iconSize; + final M3ETheme m3e; + + @override + Widget build(BuildContext context) { + if (actions.isEmpty) return const SizedBox.shrink(); + final inline = actions.take(maxInline).toList(growable: false); + final overflow = actions.length > maxInline ? actions.sublist(maxInline) : const []; + + final row = Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final a in inline) ToolbarIconButtonM3E(action: a, color: iconColor, iconSize: iconSize), + if (overflow.isNotEmpty) + _OverflowMenu( + actions: overflow, + icon: overflowIcon, + textStyle: Theme.of(context).textTheme.labelLarge?.copyWith(color: m3e.colors.onSurface), + destructiveColor: m3e.colors.error, + ), + ], + ); + return row; + } +} + +class _OverflowMenu extends StatelessWidget { + const _OverflowMenu({ + required this.actions, + required this.icon, + this.textStyle, + this.destructiveColor, + }); + + final List actions; + final Widget icon; + final TextStyle? textStyle; + final Color? destructiveColor; + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + tooltip: 'More options', + itemBuilder: (context) => [ + for (var i = 0; i < actions.length; i++) + PopupMenuItem( + value: i, + enabled: actions[i].enabled, + child: DefaultTextStyle.merge( + style: (actions[i].isDestructive + ? (textStyle?.copyWith(color: destructiveColor) ?? TextStyle(color: destructiveColor)) + : textStyle) ?? + const TextStyle(), + child: Text(actions[i].label ?? actions[i].tooltip ?? actions[i].semanticLabel ?? 'Action ${i + 1}'), + ), + ), + ], + onSelected: (index) => actions[index].onPressed(), + child: icon, + ); + } +} diff --git a/packages/toolbar_m3e/lib/src/toolbar_m3e_widget.dart b/packages/toolbar_m3e/lib/src/toolbar_m3e_widget.dart new file mode 100644 index 0000000..a8b7d87 --- /dev/null +++ b/packages/toolbar_m3e/lib/src/toolbar_m3e_widget.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; + +class ToolbarM3EWidget extends StatelessWidget { + const ToolbarM3EWidget({super.key}); + + @override + Widget build(BuildContext context) { + final m3e = context.m3e; + return Container( + padding: EdgeInsets.all(m3e.spacing.md), + decoration: BoxDecoration( + color: m3e.colors.surfaceStrong, + borderRadius: m3e.shapes.square.md, + ), + child: Text('Toolbar placeholder', style: m3e.typography.base.titleMedium), + ); + } +} + +String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join(); diff --git a/packages/toolbar_m3e/lib/src/toolbar_tokens_adapter.dart b/packages/toolbar_m3e/lib/src/toolbar_tokens_adapter.dart new file mode 100644 index 0000000..fb7ff72 --- /dev/null +++ b/packages/toolbar_m3e/lib/src/toolbar_tokens_adapter.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; +import 'enums.dart'; + +@immutable +class _ToolbarMetrics { + final double heightSmall; + final double heightMedium; + final double heightLarge; + final EdgeInsetsGeometry horizontalPadding; + final double gap; + final double iconSize; + final double elevationSurface; + final double elevationProminent; + const _ToolbarMetrics({ + required this.heightSmall, + required this.heightMedium, + required this.heightLarge, + required this.horizontalPadding, + required this.gap, + required this.iconSize, + required this.elevationSurface, + required this.elevationProminent, + }); +} + +_ToolbarMetrics _metricsFor(BuildContext context, ToolbarM3EDensity density) { + final theme = Theme.of(context); + final m3e = theme.extension() ?? M3ETheme.defaults(theme.colorScheme); + final sp = m3e.spacing; + + double hS = 40; + double hM = 48; + double hL = 56; + double icon = 24; + double gap = sp.sm; + + if (density == ToolbarM3EDensity.compact) { + hS -= 4; hM -= 4; hL -= 4; + } + + return _ToolbarMetrics( + heightSmall: hS, + heightMedium: hM, + heightLarge: hL, + horizontalPadding: EdgeInsets.symmetric(horizontal: sp.md), + gap: gap, + iconSize: icon, + elevationSurface: 0.0, + elevationProminent: 2.0, + ); +} + +class ToolbarTokensAdapter { + ToolbarTokensAdapter(this.context); + final BuildContext context; + + M3ETheme get _m3e { + final t = Theme.of(context); + return t.extension() ?? M3ETheme.defaults(t.colorScheme); + } + + _ToolbarMetrics metrics(ToolbarM3EDensity density) => _metricsFor(context, density); + + // Container/background color by variant + Color containerColor(ToolbarM3EVariant variant) { + switch (variant) { + case ToolbarM3EVariant.surface: return _m3e.colors.surfaceContainerHigh; + case ToolbarM3EVariant.tonal: return _m3e.colors.secondaryContainer; + case ToolbarM3EVariant.primary: return _m3e.colors.primaryContainer; + } + } + + Color foregroundOn(ToolbarM3EVariant variant) { + switch (variant) { + case ToolbarM3EVariant.surface: return _m3e.colors.onSurface; + case ToolbarM3EVariant.tonal: return _m3e.colors.onSecondaryContainer; + case ToolbarM3EVariant.primary: return _m3e.colors.onPrimaryContainer; + } + } + + // Shapes + ShapeBorder shape(ToolbarM3EShapeFamily family) { + final set = family == ToolbarM3EShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square; + return RoundedRectangleBorder(borderRadius: set.md); + } + + // Typography + TextStyle titleStyle() => _m3e.type.titleSmall; + TextStyle subtitleStyle() => _m3e.type.bodySmall; +} diff --git a/packages/toolbar_m3e/lib/toolbar_m3e.dart b/packages/toolbar_m3e/lib/toolbar_m3e.dart new file mode 100644 index 0000000..cb7234a --- /dev/null +++ b/packages/toolbar_m3e/lib/toolbar_m3e.dart @@ -0,0 +1,6 @@ +library toolbar_m3e; + +export 'src/enums.dart'; +export 'src/toolbar_tokens_adapter.dart' show ToolbarTokensAdapter; +export 'src/toolbar_action_m3e.dart'; +export 'src/toolbar_m3e.dart'; diff --git a/packages/toolbar_m3e/melos_toolbar_m3e.iml b/packages/toolbar_m3e/melos_toolbar_m3e.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/toolbar_m3e/melos_toolbar_m3e.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/toolbar_m3e/pubspec.yaml b/packages/toolbar_m3e/pubspec.yaml new file mode 100644 index 0000000..ea63ca7 --- /dev/null +++ b/packages/toolbar_m3e/pubspec.yaml @@ -0,0 +1,18 @@ +name: toolbar_m3e +description: Material 3 Expressive Toolbars for Flutter with token-driven colors, shapes, density, and overflow handling. +version: 0.1.0 +publish_to: none + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.22.0" + +dependencies: + flutter: + sdk: flutter + m3e_design: + path: ../m3e_design + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/packages/toolbar_m3e/pubspec_overrides.yaml b/packages/toolbar_m3e/pubspec_overrides.yaml new file mode 100644 index 0000000..69acba6 --- /dev/null +++ b/packages/toolbar_m3e/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: m3e_design +dependency_overrides: + m3e_design: + path: ..\\m3e_design diff --git a/packages/toolbar_m3e/test/toolbar_m3e_test.dart b/packages/toolbar_m3e/test/toolbar_m3e_test.dart new file mode 100644 index 0000000..b914ca0 --- /dev/null +++ b/packages/toolbar_m3e/test/toolbar_m3e_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('placeholder', () { + expect(6 - 1, 5); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..9affeb9 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,8 @@ +name: m3e_workspace +publish_to: 'none' + +environment: + sdk: ">=3.5.0 <4.0.0" + +dev_dependencies: + melos: ^6.1.0 diff --git a/tool/create_component.dart b/tool/create_component.dart new file mode 100644 index 0000000..e94dfe3 --- /dev/null +++ b/tool/create_component.dart @@ -0,0 +1,172 @@ +import 'dart:io'; + +String _pascal(String s) => s + .split('_') + .map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))) + .join(); + +void _ensureCollectionExport(String pkg) { + final path = 'packages/m3e_collection/lib/m3e_collection.dart'; + final file = File(path); + if (!file.existsSync()) return; + final content = file.readAsStringSync(); + final exportLine = "export 'package:$pkg/$pkg.dart';"; + if (content.contains(exportLine)) return; + + // Append the export line at the end, preserving a trailing newline. + final updated = content.trimRight() + '\n' + exportLine + '\n'; + file.writeAsStringSync(updated); +} + +void _ensureCollectionDependency(String pkg) { + final path = 'packages/m3e_collection/pubspec.yaml'; + final file = File(path); + if (!file.existsSync()) return; + final content = file.readAsStringSync(); + + // If dependency already present, skip. + final keyPattern = + RegExp(r'^\s*' + RegExp.escape(pkg) + r'\s*:', multiLine: true); + if (keyPattern.hasMatch(content)) return; + + // Ensure we are within dependencies block. If the file contains a dependencies: section, + // we can safely append additional two-space indented entries at EOF since there is no + // subsequent top-level section in the current file structure. + if (!content.contains(RegExp(r'^dependencies:\s*$', multiLine: true))) { + stderr.writeln( + 'Warning: packages/m3e_collection/pubspec.yaml has no dependencies section; skipping adding $pkg.'); + return; + } + + final addition = ' $pkg:\n path: ../$pkg\n'; + final updated = content.trimRight() + '\n' + addition + '\n'; + file.writeAsStringSync(updated); +} + +void _ensureCollectionOverride(String pkg) { + final path = 'packages/m3e_collection/pubspec_overrides.yaml'; + final file = File(path); + if (!file.existsSync()) return; + var content = file.readAsStringSync(); + + // If override already present, skip. + final keyPattern = + RegExp(r'^\s*' + RegExp.escape(pkg) + r'\s*:', multiLine: true); + if (keyPattern.hasMatch(content)) return; + + // Update the melos managed header list if present. + final headerRe = RegExp(r'^(#\s*melos_managed_dependency_overrides:\s*)(.*)$', + multiLine: true); + content = content.replaceFirstMapped(headerRe, (m) { + final prefix = m.group(1)!; + final list = m.group(2)!.trim(); + if (list.isEmpty) return '${prefix}$pkg'; + final items = list.split(',').map((s) => s.trim()).toList(); + if (!items.contains(pkg)) { + items.add(pkg); + } + return prefix + items.join(','); + }); + + // Ensure dependency_overrides: section exists; if not, create it. + if (!content + .contains(RegExp(r'^dependency_overrides:\s*$', multiLine: true))) { + if (!content.endsWith('\n')) content += '\n'; + content += 'dependency_overrides:\n'; + } + + // Append our override using Windows-style path to match existing file style. + final addition = ' $pkg:\n path: ..\\$pkg\n'; + content = content.trimRight() + '\n' + addition + '\n'; + file.writeAsStringSync(content); +} + +void main(List args) async { + final nameArg = + args.firstWhere((a) => a.startsWith('name='), orElse: () => 'name='); + final name = nameArg.split('=').last; + if (name.isEmpty) { + stderr.writeln( + 'Usage: melos run create -- name= (_m3e suffix added automatically)'); + exit(64); + } + final pkg = '${name}_m3e'; + final dir = Directory('packages/$pkg'); + if (dir.existsSync()) { + stderr.writeln('Package $pkg already exists.'); + exit(1); + } + dir.createSync(recursive: true); + + File('packages/$pkg/pubspec.yaml').writeAsStringSync(''' +name: $pkg +description: $name (Material 3 Expressive) +version: 0.1.0 +publish_to: none + +environment: + sdk: ">=3.5.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + m3e_design: + path: ../m3e_design + +dev_dependencies: + flutter_test: + sdk: flutter +'''); + + Directory('packages/$pkg/lib').createSync(); + File('packages/$pkg/lib/$pkg.dart').writeAsStringSync(''' +library $pkg; + +// TODO: implement $name M3E widget +export 'src/${name}_m3e_widget.dart'; +'''); + + Directory('packages/$pkg/lib/src').createSync(); + File('packages/$pkg/lib/src/${name}_m3e_widget.dart').writeAsStringSync(''' +import 'package:flutter/material.dart'; +import 'package:m3e_design/m3e_design.dart'; + +class ${_pascal(name)}M3EWidget extends StatelessWidget { + const ${_pascal(name)}M3EWidget({super.key}); + + @override + Widget build(BuildContext context) { + final m3e = context.m3e; + return Container( + padding: EdgeInsets.all(m3e.spacing.md), + decoration: BoxDecoration( + color: m3e.colors.surfaceStrong, + borderRadius: m3e.shapes.square.md, + ), + child: Text('${_pascal(name)} placeholder', style: m3e.typography.base.titleMedium), + ); + } +} + +String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join(); +'''); + + Directory('packages/$pkg/test').createSync(); + File('packages/$pkg/test/${name}_m3e_test.dart').writeAsStringSync(''' +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('placeholder', () { + expect(1 + 1, 2); + }); +} +'''); + + // Also add this new package to the aggregated collection package. + _ensureCollectionExport(pkg); + _ensureCollectionDependency(pkg); + _ensureCollectionOverride(pkg); + + stdout.writeln( + 'Created packages/$pkg and updated m3e_collection to include it.'); +}