diff --git a/.cursor/rules/flutter.mdc b/.cursor/rules/flutter.mdc new file mode 100644 index 00000000..7b7ec160 --- /dev/null +++ b/.cursor/rules/flutter.mdc @@ -0,0 +1,8 @@ +--- +globs: app/**/*.dart,core/**/*.dart +alwaysApply: false +--- + +## Widgets + +- Use SizedBox, DecoratedBox, Padding, etc. instead of Container. diff --git a/.vscode/launch.json b/.vscode/launch.json index 82d56d42..532ffaaf 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,6 +20,13 @@ "type": "dart", "cwd": "${workspaceFolder}/app", "flutterMode": "release" + }, + { + "name": "ui_library_showcase", + "request": "launch", + "type": "dart", + "cwd": "${workspaceFolder}/core/ui_library/example", + "program": "lib/main.dart" } ] } \ No newline at end of file diff --git a/core/ui_library/example/.gitignore b/core/ui_library/example/.gitignore new file mode 100644 index 00000000..79c113f9 --- /dev/null +++ b/core/ui_library/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 +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# 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/core/ui_library/example/.metadata b/core/ui_library/example/.metadata new file mode 100644 index 00000000..1bac95ed --- /dev/null +++ b/core/ui_library/example/.metadata @@ -0,0 +1,30 @@ +# 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: "be698c48a6750c8cb8e61c740ca9991bb947aba2" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 + base_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 + - platform: macos + create_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 + base_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 + + # 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/core/ui_library/example/README.md b/core/ui_library/example/README.md new file mode 100644 index 00000000..1b7a4e3d --- /dev/null +++ b/core/ui_library/example/README.md @@ -0,0 +1,3 @@ +# example + +A new Flutter project. diff --git a/core/ui_library/example/devtools_options.yaml b/core/ui_library/example/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/core/ui_library/example/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/core/ui_library/example/lib/main.dart b/core/ui_library/example/lib/main.dart new file mode 100644 index 00000000..5fd017e3 --- /dev/null +++ b/core/ui_library/example/lib/main.dart @@ -0,0 +1,52 @@ +import 'package:example/src/previews/button_preview.dart'; +import 'package:example/src/previews/card_preview.dart'; +import 'package:example/src/previews/checkbox_preview.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ui_showcase/ui_showcase.dart'; + +void main() { + runApp(const MyApp()); +} + +final showcaseNodes = [ + ShowcaseNode(name: 'Button', widget: const SomeButtonPreview()), + ShowcaseNode(name: 'Card', widget: const CardPreview()), + ShowcaseNode(name: 'Checkbox', widget: const CheckboxPreview()), +]; + +class MyApp extends StatefulWidget { + const MyApp({super.key}); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + late final RouterConfig router; + + @override + void initState() { + super.initState(); + router = GoRouter( + routes: [ + createRootShowcaseRoute( + nodes: showcaseNodes, + builder: (context, child) => ThemeOptionProvider( + options: [ + ThemeOption(name: 'Light', theme: ThemeData.light()), + ThemeOption(name: 'Dark', theme: ThemeData.dark()), + ], + child: child, + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) => MaterialApp.router( + routerConfig: router, + debugShowCheckedModeBanner: false, + ); +} diff --git a/core/ui_library/example/lib/src/previews/button_preview.dart b/core/ui_library/example/lib/src/previews/button_preview.dart new file mode 100644 index 00000000..65ed50c3 --- /dev/null +++ b/core/ui_library/example/lib/src/previews/button_preview.dart @@ -0,0 +1,39 @@ +import 'package:example/src/widget/component_preview.dart'; +import 'package:flutter/material.dart'; +import 'package:ui_showcase/ui_showcase.dart'; + +class SomeButtonPreview extends StatefulWidget { + const SomeButtonPreview({super.key}); + + @override + State createState() => _SomeButtonPreviewState(); +} + +class _SomeButtonPreviewState extends State { + final _labelController = TextEditingController(text: 'Click Me!'); + final _enabledController = ValueNotifier(true); + + @override + Widget build(BuildContext context) { + return ComponentPreview( + inputs: [ + StringInput( + label: 'Label', + description: 'The label of the button', + controller: _labelController, + ), + BooleanInput( + label: 'Enabled', + description: 'Whether the button is enabled', + notifier: _enabledController, + ), + ], + builder: (context) { + return ElevatedButton( + onPressed: _enabledController.value ? () {} : null, + child: Text(_labelController.text), + ); + }, + ); + } +} diff --git a/core/ui_library/example/lib/src/previews/card_preview.dart b/core/ui_library/example/lib/src/previews/card_preview.dart new file mode 100644 index 00000000..5f412c63 --- /dev/null +++ b/core/ui_library/example/lib/src/previews/card_preview.dart @@ -0,0 +1,35 @@ +import 'package:example/src/widget/component_preview.dart'; +import 'package:flutter/material.dart'; +import 'package:ui_showcase/ui_showcase.dart'; + +class CardPreview extends StatefulWidget { + const CardPreview({super.key}); + + @override + State createState() => _CardPreviewState(); +} + +class _CardPreviewState extends State { + final _controller = TextEditingController(text: 'Card'); + + @override + Widget build(BuildContext context) { + return ComponentPreview( + inputs: [ + StringInput( + label: 'Label', + description: 'The label of the button', + controller: _controller, + ), + ], + builder: (context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(_controller.text), + ), + ); + }, + ); + } +} diff --git a/core/ui_library/example/lib/src/previews/checkbox_preview.dart b/core/ui_library/example/lib/src/previews/checkbox_preview.dart new file mode 100644 index 00000000..e9baa57d --- /dev/null +++ b/core/ui_library/example/lib/src/previews/checkbox_preview.dart @@ -0,0 +1,35 @@ +import 'package:example/src/widget/component_preview.dart'; +import 'package:flutter/material.dart'; +import 'package:ui_showcase/ui_showcase.dart'; + +class CheckboxPreview extends StatefulWidget { + const CheckboxPreview({super.key}); + + @override + State createState() => _CheckboxPreviewState(); +} + +class _CheckboxPreviewState extends State { + final _enabledController = ValueNotifier(true); + + @override + Widget build(BuildContext context) { + return ComponentPreview( + inputs: [ + BooleanInput( + label: 'Value', + description: 'The value of the checkbox', + notifier: _enabledController, + ), + ], + builder: (context) { + return Checkbox( + value: _enabledController.value, + onChanged: (value) { + _enabledController.value = value ?? false; + }, + ); + }, + ); + } +} diff --git a/core/ui_library/example/lib/src/widget/component_preview.dart b/core/ui_library/example/lib/src/widget/component_preview.dart new file mode 100644 index 00000000..d139e3fd --- /dev/null +++ b/core/ui_library/example/lib/src/widget/component_preview.dart @@ -0,0 +1,27 @@ +import 'package:example/src/widget/sidebar.dart'; +import 'package:flutter/material.dart'; +import 'package:ui_showcase/ui_showcase.dart'; + +class ComponentPreview extends StatelessWidget { + const ComponentPreview({ + required this.inputs, + required this.builder, + super.key, + }); + + final List inputs; + final WidgetBuilder builder; + + @override + Widget build(BuildContext context) => ShowcaseNodePreview( + builder: builder, + listenables: inputs.map((e) => e.listenable).toList(growable: false), + sidebar: Sidebar(children: inputs), + wrapWith: (child) { + return MaterialApp( + theme: ThemeOptionProvider.of(context).theme, + home: Scaffold(body: Center(child: child)), + ); + }, + ); +} diff --git a/core/ui_library/example/lib/src/widget/sidebar.dart b/core/ui_library/example/lib/src/widget/sidebar.dart new file mode 100644 index 00000000..39aad3e6 --- /dev/null +++ b/core/ui_library/example/lib/src/widget/sidebar.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:ui_showcase/ui_showcase.dart'; + +class Sidebar extends StatelessWidget { + const Sidebar({ + required this.children, + this.includeThemeSwitcher = true, + super.key, + }); + + final bool includeThemeSwitcher; + final List children; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 24, + children: [ + if (includeThemeSwitcher) const ThemeOptionInput(), + ...children, + ], + ), + ); + } +} diff --git a/core/ui_library/example/pubspec.lock b/core/ui_library/example/pubspec.lock new file mode 100644 index 00000000..6e12cca3 --- /dev/null +++ b/core/ui_library/example/pubspec.lock @@ -0,0 +1,414 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + device_frame: + dependency: transitive + description: + name: device_frame + sha256: "7b2ebb2a09d6cc0f086b51bd1412d7be83e0170056a7290349169be41164c86a" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + device_preview: + dependency: "direct main" + description: + name: device_preview + sha256: "88aa1cc73ee9a8ec771b309dcbc4000cc66b5d8456b825980997640ab1195bf5" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: c489908a54ce2131f1d1b7cc631af9c1a06fac5ca7c449e959192089f9489431 + url: "https://pub.dev" + source: hosted + version: "16.0.0" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + url: "https://pub.dev" + source: hosted + version: "10.0.9" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: transitive + description: + name: provider + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + url: "https://pub.dev" + source: hosted + version: "2.4.10" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + two_dimensional_scrollables: + dependency: transitive + description: + name: two_dimensional_scrollables + sha256: "0f77ecb96596f2f82eec2b0a8e60d9305c58315557da9fa3b610c7dbf5ded621" + url: "https://pub.dev" + source: hosted + version: "0.3.7" + ui_showcase: + dependency: "direct main" + description: + path: "../../ui_showcase" + relative: true + source: path + version: "0.0.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.8.0 <4.0.0" + flutter: ">=3.32.0" diff --git a/core/ui_library/example/pubspec.yaml b/core/ui_library/example/pubspec.yaml new file mode 100644 index 00000000..5cfbcbb1 --- /dev/null +++ b/core/ui_library/example/pubspec.yaml @@ -0,0 +1,21 @@ +name: example +description: "A new Flutter project." +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.8.0 + +dependencies: + flutter: + sdk: flutter + ui_showcase: + path: ../../ui_showcase + go_router: ^16.0.0 + device_preview: ^1.3.1 + +dev_dependencies: + flutter_test: + sdk: flutter +flutter: + uses-material-design: true diff --git a/core/ui_showcase/.gitignore b/core/ui_showcase/.gitignore new file mode 100644 index 00000000..eb6c05cd --- /dev/null +++ b/core/ui_showcase/.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 +.flutter-plugins-dependencies +build/ diff --git a/core/ui_showcase/.metadata b/core/ui_showcase/.metadata new file mode 100644 index 00000000..91a5e0b2 --- /dev/null +++ b/core/ui_showcase/.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: "be698c48a6750c8cb8e61c740ca9991bb947aba2" + channel: "stable" + +project_type: package diff --git a/core/ui_showcase/lib/src/core/showcase_node.dart b/core/ui_showcase/lib/src/core/showcase_node.dart new file mode 100644 index 00000000..3bf31eab --- /dev/null +++ b/core/ui_showcase/lib/src/core/showcase_node.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; + +class ShowcaseNode { + ShowcaseNode({ + required this.name, + this.description, + this.tags = const [], + this.category, + this.isDeprecated = false, + this.widget, + this.children = const [], + }); + + // Private fields + ShowcaseNode? _parent; + int _depth = 0; + + // Public properties + final String name; + final String? description; + final List tags; + final String? category; + final bool isDeprecated; + final Widget? widget; + final List children; + + // Computed getters + /// Get parent node + ShowcaseNode? get parent => _parent; + + /// Get depth in the tree + int get depth => _depth; + + /// Check if this node is a leaf (has no children) + bool get isLeaf => children.isEmpty; + + /// Full path from root to this node + String get fullPath { + if (_parent == null) return _encodeNameToPath(name); + return '${_parent!.fullPath}/${_encodeNameToPath(name)}'; + } + + String get path { + return _encodeNameToPath(name); + } + + String _encodeNameToPath(String name) { + return name.toLowerCase().replaceAll(' ', '-'); + } + + // Tree manipulation methods + void adoptChild(ShowcaseNode child) { + assert(child._parent == null, 'Node already has a parent'); + child._parent = this; + redepthChild(child); + } + + void redepthChild(ShowcaseNode child) { + if (child._depth <= _depth) { + child._depth = _depth + 1; + child.redepthChildren(); + } + } + + void redepthChildren() { + for (final child in children) { + redepthChild(child); + } + } + + void visitChildren(void Function(ShowcaseNode child) visitor) { + children.forEach(visitor); + + for (final child in children) { + child.visitChildren(visitor); + } + } + + // Navigation and search methods + /// Find a node by path + ShowcaseNode? findByPath(String path) { + if (fullPath == path) return this; + + ShowcaseNode? result; + visitChildren((child) { + result ??= child.findByPath(path); + }); + return result; + } + + ShowcaseNode? findByName(String name) { + if (this.name == name) return this; + + ShowcaseNode? result; + visitChildren((child) { + result ??= child.findByName(name); + }); + return result; + } + + /// Get breadcrumb path to this node + List getBreadcrumbs() { + final breadcrumbs = []; + ShowcaseNode? current = this; + + while (current != null) { + breadcrumbs.insert(0, current); + current = current.parent; + } + + return breadcrumbs; + } +} diff --git a/core/ui_showcase/lib/src/core/showcase_nodes.dart b/core/ui_showcase/lib/src/core/showcase_nodes.dart new file mode 100644 index 00000000..718e06f6 --- /dev/null +++ b/core/ui_showcase/lib/src/core/showcase_nodes.dart @@ -0,0 +1,57 @@ +import 'package:ui_showcase/ui_showcase.dart'; + +extension type ShowcaseNodes(List nodes) { + ShowcaseNode? findNodeByName(String name) { + for (final node in nodes) { + if (node.name == name) return node; + ShowcaseNode? found; + + node.visitChildren((child) { + if (child.name == name) { + found = child; + } + }); + + if (found != null) return found; + } + + return null; + } + + ShowcaseNode? findNodeByPath(String path) { + for (final node in nodes) { + if (node.fullPath == path) return node; + ShowcaseNode? found; + + node.visitChildren((child) { + if (child.fullPath == path) { + found = child; + } + }); + + if (found != null) return found; + } + + return null; + } + + void assignParents({ + int depth = 0, + List? nodes, + ShowcaseNode? parent, + }) { + nodes ??= this.nodes; + + for (final node in nodes) { + parent?.adoptChild(node); + + if (node.children.isNotEmpty) { + assignParents( + depth: depth + 1, + nodes: node.children, + parent: node, + ); + } + } + } +} diff --git a/core/ui_showcase/lib/src/routing/go_router.dart b/core/ui_showcase/lib/src/routing/go_router.dart new file mode 100644 index 00000000..a1994e02 --- /dev/null +++ b/core/ui_showcase/lib/src/routing/go_router.dart @@ -0,0 +1,107 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ui_showcase/ui_showcase.dart'; + +RouteBase createRootShowcaseRoute({ + required List nodes, + Widget Function(BuildContext context, Widget child)? builder, +}) { + ShowcaseNodes(nodes).assignParents(); + + return ShellRoute( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const SizedBox.shrink(), + routes: _createRoutes(nodes), + ), + ], + builder: (context, state, child) { + return GoRouterNotifier( + nodes: nodes, + child: Builder( + builder: (context) { + final showcaseView = ShowcaseView(nodes: nodes, navigator: child); + + return builder?.call(context, showcaseView) ?? showcaseView; + }, + ), + ); + }, + ); +} + +List _createRoutes(List nodes) { + final routes = []; + + for (final node in nodes) { + final childrenRoutes = node.children.isEmpty ? [] : _createRoutes(node.children); + + routes.add( + GoRoute( + path: node.path, + pageBuilder: (context, state) { + return NoTransitionPage(child: node.widget ?? const SizedBox.shrink()); + }, + routes: childrenRoutes, + ), + ); + } + + return routes; +} + +class NodeRouterGoRouter extends ValueNotifier implements NodeRouter { + NodeRouterGoRouter(this._router, this._nodes) : super(null) { + _router.routerDelegate.addListener(_onLocationChanged); + } + + final GoRouter _router; + final List _nodes; + + @override + ShowcaseNode? get activeNode => value; + + void _onLocationChanged() { + final path = _router.state.fullPath ?? ''; + final node = ShowcaseNodes(_nodes).findNodeByPath(path.replaceFirst('/', '')); + + value = node; + } + + @override + void updateActiveNode(ShowcaseNode? node) { + _router.go('/${node?.fullPath ?? ''}'); + } +} + +class GoRouterNotifier extends StatefulWidget { + const GoRouterNotifier({ + required this.nodes, + required this.child, + super.key, + }); + + final List nodes; + final Widget child; + + @override + State createState() => _GoRouterNotifierState(); +} + +class _GoRouterNotifierState extends State { + late final NodeRouterGoRouter _nodeRouter; + late final GoRouter _router; + + @override + void initState() { + super.initState(); + _router = GoRouter.of(context); + _nodeRouter = NodeRouterGoRouter(_router, widget.nodes); + } + + @override + Widget build(BuildContext context) { + return ActiveNodeNotifier(nodeRouter: _nodeRouter, child: widget.child); + } +} diff --git a/core/ui_showcase/lib/src/widget/inputs/boolean_input.dart b/core/ui_showcase/lib/src/widget/inputs/boolean_input.dart new file mode 100644 index 00000000..227cd759 --- /dev/null +++ b/core/ui_showcase/lib/src/widget/inputs/boolean_input.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:ui_showcase/src/widget/restoration/page_storage_reader.dart'; +import 'package:ui_showcase/ui_showcase.dart'; + +class BooleanInput extends StatefulWidget with InputWidget { + const BooleanInput({ + required this.label, + required this.notifier, + this.description, + super.key, + }); + + final String label; + final String? description; + final ValueNotifier notifier; + + @override + Listenable get listenable => notifier; + + @override + State createState() => _BooleanInputState(); +} + +class _BooleanInputState extends State with PageStorageReader { + @override + Object? obtainPageStorageIdentifier() { + final node = ActiveNodeNotifier.of(context, listen: false).activeNode; + + return '${node?.fullPath}-${widget.label}-boolean-input'; + } + + @override + void restoreState(bool data) { + widget.notifier.value = data; + } + + @override + bool getCurrentValue() => widget.notifier.value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.label, style: textTheme.labelLarge), + ValueListenableBuilder( + valueListenable: widget.notifier, + builder: (context, value, child) { + return Switch( + value: value, + onChanged: (value) => widget.notifier.value = value, + ); + }, + ), + if (widget.description case final description?) + Text( + description, + style: textTheme.bodySmall?.copyWith(color: colorScheme.secondary), + ), + ], + ); + } +} diff --git a/core/ui_showcase/lib/src/widget/inputs/input_widget.dart b/core/ui_showcase/lib/src/widget/inputs/input_widget.dart new file mode 100644 index 00000000..48e7c501 --- /dev/null +++ b/core/ui_showcase/lib/src/widget/inputs/input_widget.dart @@ -0,0 +1,5 @@ +import 'package:flutter/widgets.dart'; + +mixin InputWidget on StatefulWidget { + Listenable get listenable; +} diff --git a/core/ui_showcase/lib/src/widget/inputs/integer_input.dart b/core/ui_showcase/lib/src/widget/inputs/integer_input.dart new file mode 100644 index 00000000..7fa468a4 --- /dev/null +++ b/core/ui_showcase/lib/src/widget/inputs/integer_input.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:ui_showcase/src/widget/restoration/page_storage_reader.dart'; +import 'package:ui_showcase/ui_showcase.dart'; + +class IntegerInput extends StatefulWidget with InputWidget { + const IntegerInput({ + required this.notifier, + required this.label, + this.description, + this.min, + this.max, + }); + + final ValueNotifier notifier; + final String label; + final int? min; + final int? max; + final String? description; + + @override + Listenable get listenable => notifier; + + @override + State createState() => _IntegerInputState(); +} + +class _IntegerInputState extends State with PageStorageReader { + @override + Object? obtainPageStorageIdentifier() { + final node = ActiveNodeNotifier.of(context, listen: false).activeNode; + + return '${node?.fullPath}-${widget.label}-integer-input'; + } + + @override + void restoreState(int data) { + widget.notifier.value = data; + } + + @override + int getCurrentValue() => widget.notifier.value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label, + style: textTheme.labelLarge?.copyWith(color: colorScheme.onSurface), + ), + ValueListenableBuilder( + valueListenable: widget.notifier, + builder: (context, value, child) { + return SliderTheme( + data: const SliderThemeData(showValueIndicator: ShowValueIndicator.always), + child: Slider( + value: value.toDouble(), + min: widget.min?.toDouble() ?? 0, + max: widget.max?.toDouble() ?? 100, + label: value.toString(), + onChanged: (value) => widget.notifier.value = value.toInt(), + ), + ); + }, + ), + if (widget.description case final description?) + Text( + description, + style: textTheme.bodySmall?.copyWith(color: colorScheme.outline), + ), + ], + ); + } +} diff --git a/core/ui_showcase/lib/src/widget/inputs/string_input.dart b/core/ui_showcase/lib/src/widget/inputs/string_input.dart new file mode 100644 index 00000000..7eca8ac3 --- /dev/null +++ b/core/ui_showcase/lib/src/widget/inputs/string_input.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:ui_showcase/src/widget/restoration/page_storage_reader.dart'; +import 'package:ui_showcase/ui_showcase.dart'; + +class StringInput extends StatefulWidget with InputWidget { + const StringInput({ + required this.label, + required this.controller, + this.description, + this.hint, + }); + + final TextEditingController controller; + final String label; + final String? description; + final String? hint; + + @override + Listenable get listenable => controller; + + @override + State createState() => _StringInputState(); +} + +class _StringInputState extends State with PageStorageReader { + @override + Object? obtainPageStorageIdentifier() { + final node = ActiveNodeNotifier.of(context, listen: false).activeNode; + + return '${node?.fullPath}-${widget.label}-string-input'; + } + + @override + void restoreState(String data) { + widget.controller.text = data; + } + + @override + String getCurrentValue() => widget.controller.text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + decoration: InputDecoration( + labelText: widget.label, + hintText: widget.hint, + ), + ), + if (widget.description case final description?) + Text( + description, + style: textTheme.bodySmall?.copyWith(color: colorScheme.secondary), + ), + ], + ); + } +} diff --git a/core/ui_showcase/lib/src/widget/inputs/theme_input.dart b/core/ui_showcase/lib/src/widget/inputs/theme_input.dart new file mode 100644 index 00000000..68117631 --- /dev/null +++ b/core/ui_showcase/lib/src/widget/inputs/theme_input.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +class ThemeOptionInput extends StatelessWidget { + const ThemeOptionInput({super.key}); + + @override + Widget build(BuildContext context) { + final theme = ThemeOptionProvider.of(context); + + return DropdownMenu( + dropdownMenuEntries: theme.options + .map((e) => DropdownMenuEntry(value: e.theme, label: e.name)) + .toList(), + initialSelection: theme.theme, + onSelected: (value) { + if (value == null) return; + ThemeOptionProvider.setTheme(context, value); + }, + ); + } +} + +class ThemeOptionProvider extends StatefulWidget { + const ThemeOptionProvider({required this.options, this.child, this.builder, super.key}); + + final Widget? child; + final Widget Function(BuildContext context, ThemeData theme)? builder; + final List options; + + static void setTheme(BuildContext context, ThemeData theme) { + final inherited = + context.getElementForInheritedWidgetOfExactType<_ThemeOptionInherited>()?.widget + as _ThemeOptionInherited?; + inherited?.state.setTheme(theme); + } + + static ThemeOptionProviderState of(BuildContext context, {bool listen = true}) { + final inherited = listen + ? context.dependOnInheritedWidgetOfExactType<_ThemeOptionInherited>() + : context.getElementForInheritedWidgetOfExactType<_ThemeOptionInherited>()?.widget + as _ThemeOptionInherited?; + + if (inherited == null) { + throw FlutterError.fromParts([ + ErrorSummary('ThemeOptionProvider not found in context'), + ErrorDescription('ThemeOptionProvider is required to be a parent of the widget that uses it'), + ErrorHint('Make sure to wrap your widget in ThemeOptionProvider'), + ]); + } + + return inherited.state; + } + + @override + State createState() => ThemeOptionProviderState(); +} + +class ThemeOptionProviderState extends State { + late ThemeData theme = widget.options.first.theme; + List get options => widget.options; + + void setTheme(ThemeData theme) { + if (this.theme != theme) { + setState(() { + this.theme = theme; + }); + } + } + + @override + Widget build(BuildContext context) { + return _ThemeOptionInherited( + theme: theme, + state: this, + child: widget.builder?.call(context, theme) ?? widget.child!, + ); + } +} + +class _ThemeOptionInherited extends InheritedWidget { + const _ThemeOptionInherited({required this.theme, required super.child, required this.state}); + + final ThemeOptionProviderState state; + final ThemeData theme; + + @override + bool updateShouldNotify(_ThemeOptionInherited oldWidget) { + return theme != oldWidget.theme; + } +} + +class ThemeOption { + const ThemeOption({required this.name, required this.theme}); + + final String name; + final ThemeData theme; + + @override + bool operator ==(Object other) { + if (other is ThemeOption) { + return name == other.name && theme == other.theme; + } + return false; + } + + @override + int get hashCode => name.hashCode ^ theme.hashCode; +} diff --git a/core/ui_showcase/lib/src/widget/restoration/page_storage_reader.dart b/core/ui_showcase/lib/src/widget/restoration/page_storage_reader.dart new file mode 100644 index 00000000..f7d3a1b7 --- /dev/null +++ b/core/ui_showcase/lib/src/widget/restoration/page_storage_reader.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:ui_showcase/src/widget/inputs/input_widget.dart'; + +mixin PageStorageReader on State { + @override + void initState() { + super.initState(); + widget.listenable.addListener(_onListenableChanged); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + final data = readStoredData(); + + if (data case final data?) restoreState(data); + }); + } + + @override + void dispose() { + widget.listenable.removeListener(_onListenableChanged); + super.dispose(); + } + + K getCurrentValue(); + + Object? obtainPageStorageIdentifier(); + + K? readStoredData([BuildContext? context]) { + final ctx = context ?? this.context; + final identifier = obtainPageStorageIdentifier(); + + final data = PageStorage.maybeOf(ctx)?.readState(ctx, identifier: identifier); + + return data as K?; + } + + void restoreState(K data) {} + + void writeStoredData(K data, [BuildContext? context]) { + final ctx = context ?? this.context; + final identifier = obtainPageStorageIdentifier(); + + PageStorage.maybeOf(ctx)?.writeState(ctx, data, identifier: identifier); + } + + void _onListenableChanged() { + final data = getCurrentValue(); + writeStoredData(data); + } +} diff --git a/core/ui_showcase/lib/src/widget/showcase/active_node_metadata.dart b/core/ui_showcase/lib/src/widget/showcase/active_node_metadata.dart new file mode 100644 index 00000000..5da1e8a9 --- /dev/null +++ b/core/ui_showcase/lib/src/widget/showcase/active_node_metadata.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:ui_showcase/ui_showcase.dart'; + +class ActiveNodeMetadata extends StatelessWidget { + const ActiveNodeMetadata({required this.node}); + + final ShowcaseNode node; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Breadcrumbs(node: node), + if (node.description case final description?) Text(description), + ], + ), + ); + } +} + +class Breadcrumbs extends StatelessWidget { + const Breadcrumbs({ + required this.node, + }); + + final ShowcaseNode node; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + final textStyle = textTheme.bodyMedium?.copyWith(color: colorScheme.secondary); + + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final parent in node.getBreadcrumbs()) ...[ + Text(parent.path, style: textStyle), + if (parent != node) Text('/', style: textStyle), + ], + ], + ); + } +} diff --git a/core/ui_showcase/lib/src/widget/showcase/active_node_notifier.dart b/core/ui_showcase/lib/src/widget/showcase/active_node_notifier.dart new file mode 100644 index 00000000..bdf3fbbe --- /dev/null +++ b/core/ui_showcase/lib/src/widget/showcase/active_node_notifier.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:ui_showcase/ui_showcase.dart'; + +abstract class NodeRouter implements Listenable { + void updateActiveNode(ShowcaseNode? node); + + ShowcaseNode? get activeNode; +} + +class ActiveNodeNotifier extends InheritedNotifier { + const ActiveNodeNotifier({ + required this.nodeRouter, + required super.child, + }) : super(notifier: nodeRouter); + + final NodeRouter nodeRouter; + + static ActiveNodeNotifier of(BuildContext context, {bool listen = true}) { + final widget = listen + ? context.dependOnInheritedWidgetOfExactType() + : context.getInheritedWidgetOfExactType(); + + if (widget == null) { + throw FlutterError('No ActiveNodeNotifier found in context'); + } + + return widget; + } + + ShowcaseNode? get activeNode => nodeRouter.activeNode; + + void updateActiveNode(ShowcaseNode? node) => nodeRouter.updateActiveNode(node); +} diff --git a/core/ui_showcase/lib/src/widget/showcase/showcase_node_preview.dart b/core/ui_showcase/lib/src/widget/showcase/showcase_node_preview.dart new file mode 100644 index 00000000..ab5a73c4 --- /dev/null +++ b/core/ui_showcase/lib/src/widget/showcase/showcase_node_preview.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +class ShowcaseNodePreview extends StatelessWidget { + ShowcaseNodePreview({ + required this.builder, + List listenables = const [], + this.sidebar, + this.wrapWith, + super.key, + }) : listenable = Listenable.merge(listenables); + + final Widget? sidebar; + final Listenable listenable; + final WidgetBuilder builder; + final Widget Function(Widget child)? wrapWith; + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + + Widget child = ListenableBuilder( + listenable: listenable, + builder: (context, child) => builder(context), + ); + + if (wrapWith != null) { + child = wrapWith!(child); + } + + if (size.width < 800) { + return _ComponentPreviewSmall(sidebar: sidebar, child: child); + } + + return _ComponentPreviewStandard(sidebar: sidebar, child: child); + } +} + +class _ComponentPreviewStandard extends StatelessWidget { + const _ComponentPreviewStandard({ + required this.child, + required this.sidebar, + }); + + final Widget child; + final Widget? sidebar; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Row( + children: [ + Expanded(child: child), + if (sidebar != null) + DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + border: Border( + left: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ), + child: SizedBox( + width: 200, + height: double.infinity, + child: sidebar, + ), + ), + ], + ); + } +} + +class _ComponentPreviewSmall extends StatelessWidget { + const _ComponentPreviewSmall({ + required this.child, + required this.sidebar, + }); + + final Widget? sidebar; + final Widget child; + + void _openSidebar(BuildContext context) { + showDialog( + context: context, + useRootNavigator: false, + builder: (context) { + return _SidebarFullscreenDialog(sidebar: sidebar!); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + child, + if (sidebar != null) + Positioned( + right: 16, + bottom: MediaQuery.paddingOf(context).bottom + 16, + child: FloatingActionButton( + onPressed: () => _openSidebar(context), + child: const Icon(Icons.edit_rounded), + ), + ), + ], + ); + } +} + +class _SidebarFullscreenDialog extends StatelessWidget { + const _SidebarFullscreenDialog({ + required this.sidebar, + }); + + final Widget sidebar; + + @override + Widget build(BuildContext context) { + return Dialog.fullscreen( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded), + ), + Expanded(child: sidebar), + ], + ), + ); + } +} diff --git a/core/ui_showcase/lib/src/widget/showcase/showcase_tree_view.dart b/core/ui_showcase/lib/src/widget/showcase/showcase_tree_view.dart new file mode 100644 index 00000000..8eb774ee --- /dev/null +++ b/core/ui_showcase/lib/src/widget/showcase/showcase_tree_view.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; +import 'package:ui_showcase/ui_showcase.dart'; + +/// A tree view widget for displaying showcase nodes in a hierarchical structure. +/// +/// This widget provides an intuitive interface for navigating through UI components +/// with features like expandable folders, selection highlighting, and smooth animations. +class ShowcaseTreeView extends StatefulWidget { + const ShowcaseTreeView({ + required this.nodes, + this.selectedNode, + this.onNodeSelected, + this.width, + super.key, + }); + + final double? width; + + /// The root nodes to display in the tree + final List nodes; + + /// The currently selected node, if any + final ShowcaseNode? selectedNode; + + /// Callback fired when a node is selected + final ValueChanged? onNodeSelected; + + static const double _nodeHeight = 40.0; + static const double _nodeIndentation = 8.0; + static const double _iconSize = 18.0; + static const Duration _animationDuration = Duration(milliseconds: 150); + static const Curve _animationCurve = Curves.easeInOutCubic; + + @override + State createState() => _ShowcaseTreeViewState(); +} + +class _ShowcaseTreeViewState extends State { + late final List> _treeNodes; + final TreeViewController _treeViewController = TreeViewController(); + + @override + void initState() { + super.initState(); + _treeNodes = _buildTreeNodes(widget.nodes); + } + + List> _buildTreeNodes(List nodes) { + return nodes.map(_createTreeViewNode).toList(); + } + + TreeViewNode _createTreeViewNode(ShowcaseNode node) { + final isExpanded = _shouldNodeBeExpanded(node); + + return TreeViewNode( + node, + children: _buildTreeNodes(node.children), + expanded: isExpanded, + ); + } + + bool _shouldNodeBeExpanded(ShowcaseNode node) { + final selectedNode = widget.selectedNode; + if (selectedNode == null) return false; + + return node.findByName(selectedNode.name) != null; + } + + void _handleNodeSelection(TreeViewNode treeNode) { + HapticFeedback.selectionClick(); + + if (treeNode.children.isNotEmpty) { + _treeViewController.toggleNode(treeNode); + } else { + widget.onNodeSelected?.call(treeNode.content); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + border: Border( + right: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ), + child: TreeView( + tree: _treeNodes, + controller: _treeViewController, + indentation: TreeViewIndentationType.none, + treeRowBuilder: (_) => const TreeRow( + extent: FixedTreeRowExtent(ShowcaseTreeView._nodeHeight), + ), + treeNodeBuilder: (context, node, animationStyle) => _TreeNodeWidget( + node: node, + animationStyle: animationStyle, + onNodeSelected: _handleNodeSelection, + isSelected: node.content == widget.selectedNode, + width: widget.width, + ), + toggleAnimationStyle: const AnimationStyle( + duration: ShowcaseTreeView._animationDuration, + curve: ShowcaseTreeView._animationCurve, + reverseCurve: ShowcaseTreeView._animationCurve, + ), + ), + ); + } +} + +class _TreeNodeWidget extends StatefulWidget { + const _TreeNodeWidget({ + required this.node, + required this.animationStyle, + required this.onNodeSelected, + required this.isSelected, + this.width, + }); + + final TreeViewNode node; + final AnimationStyle animationStyle; + final ValueChanged> onNodeSelected; + final bool isSelected; + final double? width; + + @override + State<_TreeNodeWidget> createState() => _TreeNodeWidgetState(); +} + +class _TreeNodeWidgetState extends State<_TreeNodeWidget> { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final node = widget.node; + final showcaseNode = node.content; + final hasChildren = node.children.isNotEmpty; + + final backgroundColor = _getBackgroundColor(theme); + final textColor = _getTextColor(theme); + final iconColor = _getIconColor(theme); + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => _setHovered(true), + onExit: (_) => _setHovered(false), + child: GestureDetector( + onTap: () => widget.onNodeSelected(node), + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: widget.width ?? 200), + child: Padding( + padding: const EdgeInsets.only(left: 4, right: 4, top: 4), + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(6), + border: widget.isSelected + ? Border.all(color: theme.colorScheme.primary.withValues(alpha: 0.3)) + : null, + ), + child: Padding( + padding: EdgeInsets.only( + left: ShowcaseTreeView._nodeIndentation * (showcaseNode.depth + 1), + top: 6, + bottom: 6, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasChildren) ...[ + _buildExpansionIcon(iconColor), + const SizedBox(width: 8), + ] else ...[ + Padding( + padding: const EdgeInsets.only(right: 8), + child: SizedBox( + width: ShowcaseTreeView._iconSize, + height: ShowcaseTreeView._iconSize, + child: DecoratedBox( + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: Icon( + Icons.album_rounded, + size: 12, + color: iconColor, + ), + ), + ), + ), + ], + + Flexible( + child: Text( + showcaseNode.name, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: textColor, + height: 1.2, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildExpansionIcon(Color iconColor) { + final animationDuration = widget.animationStyle.duration ?? ShowcaseTreeView._animationDuration; + final animationCurve = widget.animationStyle.curve ?? ShowcaseTreeView._animationCurve; + + return AnimatedSwitcher( + duration: animationDuration, + switchInCurve: animationCurve, + switchOutCurve: animationCurve, + child: Icon( + widget.node.isExpanded ? Icons.folder_open_outlined : Icons.folder_outlined, + color: iconColor, + size: ShowcaseTreeView._iconSize, + key: ValueKey(widget.node.isExpanded), + ), + ); + } + + Color _getBackgroundColor(ThemeData theme) { + if (widget.isSelected) { + return theme.colorScheme.primaryContainer.withValues(alpha: 0.3); + } + + if (_isHovered) { + return theme.colorScheme.onSurface.withValues(alpha: 0.05); + } + + return Colors.transparent; + } + + Color _getTextColor(ThemeData theme) { + if (widget.isSelected) { + return theme.colorScheme.onPrimaryContainer; + } + + if (widget.node.content.isDeprecated) { + return theme.colorScheme.onSurface.withValues(alpha: 0.6); + } + + return theme.colorScheme.onSurface; + } + + Color _getIconColor(ThemeData theme) { + if (widget.isSelected) { + return theme.colorScheme.primary; + } + if (_isHovered) { + return theme.colorScheme.onSurface.withValues(alpha: 0.8); + } + return theme.colorScheme.onSurfaceVariant; + } + + void _setHovered(bool hovered) { + if (_isHovered != hovered) { + setState(() { + _isHovered = hovered; + }); + } + } +} diff --git a/core/ui_showcase/lib/src/widget/showcase/showcase_view.dart b/core/ui_showcase/lib/src/widget/showcase/showcase_view.dart new file mode 100644 index 00000000..71a3624e --- /dev/null +++ b/core/ui_showcase/lib/src/widget/showcase/showcase_view.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:ui_showcase/ui_showcase.dart'; + +/// A view that displays a list of showcase nodes. +/// +/// See more: +/// - [ShowcaseMobile] +/// - [ShowcaseDesktop] +class ShowcaseView extends StatefulWidget { + const ShowcaseView({ + required this.nodes, + required this.navigator, + }); + + final List nodes; + final Widget navigator; + + @override + State createState() => _ShowcaseViewState(); +} + +class _ShowcaseViewState extends State { + void _onNodeSelected(ShowcaseNode node) { + ActiveNodeNotifier.of(context, listen: false).updateActiveNode(node); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + Widget child; + + if (size.width < 600) { + child = ShowcaseMobile( + navigator: widget.navigator, + nodes: widget.nodes, + onNodeSelected: _onNodeSelected, + ); + } else { + child = ShowcaseDesktop( + navigator: widget.navigator, + nodes: widget.nodes, + onNodeSelected: _onNodeSelected, + ); + } + + return child; + } +} + +class ShowcaseMobile extends StatelessWidget { + const ShowcaseMobile({ + required this.navigator, + required this.nodes, + required this.onNodeSelected, + super.key, + }); + + final List nodes; + final ValueChanged onNodeSelected; + final Widget navigator; + + @override + Widget build(BuildContext context) { + final activeNode = ActiveNodeNotifier.of(context).activeNode; + + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + drawer: Drawer( + child: ShowcaseTreeView( + nodes: nodes, + selectedNode: activeNode, + width: 304, + onNodeSelected: (value) { + onNodeSelected(value); + Navigator.pop(context); + }, + ), + ), + body: navigator, + // body: Builder( + // builder: (context) { + // return Column( + // crossAxisAlignment: CrossAxisAlignment.start, + // children: [ + // Padding( + // padding: const EdgeInsets.all(4), + // child: Row( + // children: [ + // IconButton( + // icon: const Icon(Icons.menu_rounded), + // onPressed: () { + // Scaffold.of(context).openDrawer(); + // }, + // ), + // if (activeNode case final activeNode?) ActiveNodeMetadata(node: activeNode), + // ], + // ), + // ), + // Expanded(child: navigator), + // ], + // ); + // }, + // ), + ); + } +} + +class ShowcaseDesktop extends StatelessWidget { + const ShowcaseDesktop({ + required this.navigator, + required this.nodes, + required this.onNodeSelected, + super.key, + }); + + final List nodes; + final ValueChanged onNodeSelected; + final Widget navigator; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + body: Row( + children: [ + SizedBox( + width: 200, + child: ShowcaseTreeView( + nodes: nodes, + onNodeSelected: onNodeSelected, + selectedNode: ActiveNodeNotifier.of(context).activeNode, + ), + ), + Expanded(child: navigator), + ], + ), + ); + } +} diff --git a/core/ui_showcase/lib/ui_showcase.dart b/core/ui_showcase/lib/ui_showcase.dart new file mode 100644 index 00000000..339105fd --- /dev/null +++ b/core/ui_showcase/lib/ui_showcase.dart @@ -0,0 +1,12 @@ +export 'src/core/showcase_node.dart'; +export 'src/core/showcase_nodes.dart'; +export 'src/routing/go_router.dart'; +export 'src/widget/inputs/boolean_input.dart'; +export 'src/widget/inputs/input_widget.dart'; +export 'src/widget/inputs/integer_input.dart'; +export 'src/widget/inputs/string_input.dart'; +export 'src/widget/inputs/theme_input.dart'; +export 'src/widget/showcase/active_node_notifier.dart'; +export 'src/widget/showcase/showcase_node_preview.dart'; +export 'src/widget/showcase/showcase_tree_view.dart'; +export 'src/widget/showcase/showcase_view.dart'; diff --git a/core/ui_showcase/pubspec.yaml b/core/ui_showcase/pubspec.yaml new file mode 100644 index 00000000..3978364b --- /dev/null +++ b/core/ui_showcase/pubspec.yaml @@ -0,0 +1,19 @@ +name: ui_showcase +description: "A new Flutter package project." +version: 0.0.1 +resolution: workspace + +environment: + sdk: ^3.8.0 + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + two_dimensional_scrollables: ^0.3.7 + go_router: ^16.0.0 + collection: ^1.19.1 + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/pubspec.lock b/pubspec.lock index c4b3f0e6..78ae3088 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -349,6 +349,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + go_router: + dependency: transitive + description: + name: go_router + sha256: c752e2d08d088bf83742cb05bf83003f3e9d276ff1519b5c92f9d5e60e5ddd23 + url: "https://pub.dev" + source: hosted + version: "16.2.4" graphs: dependency: transitive description: @@ -850,6 +858,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + two_dimensional_scrollables: + dependency: transitive + description: + name: two_dimensional_scrollables + sha256: "0f77ecb96596f2f82eec2b0a8e60d9305c58315557da9fa3b610c7dbf5ded621" + url: "https://pub.dev" + source: hosted + version: "0.3.7" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b3af1f91..1ad05a4e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,9 @@ workspace: - core/rest_client - core/error_reporter - core/ui_library + - core/ui_showcase - core/database + # Feature - feature/settings - feature/home