├── analysis_options.yaml ├── .gitattributes ├── web ├── favicon.png ├── icons │ ├── Icon-192.png │ ├── Icon-512.png │ ├── Icon-maskable-192.png │ └── Icon-maskable-512.png ├── index.html └── manifest.json ├── tsconfig.json ├── renovate.json ├── worker.ts ├── package.json ├── docs ├── widgets │ ├── cards │ │ ├── three_d_card.json │ │ └── three-d-card.md │ ├── navigations │ │ ├── dock.json │ │ ├── floating_dock.json │ │ ├── motion_tabs.json │ │ ├── motion-tabs.md │ │ ├── floating-dock.md │ │ └── dock.md │ ├── borders │ │ ├── gliding_glow_box.json │ │ └── gliding-glow-box.md │ ├── backgrounds │ │ ├── flickering_grid.json │ │ ├── black_hole_background.json │ │ ├── flickering-grid.md │ │ └── black-hole-background.md │ └── games │ │ ├── space_shooter.json │ │ └── space-shooter.md └── getting-started.md ├── wrangler.jsonc ├── lib ├── widgets │ ├── backgrounds │ │ ├── flickering_grid_demo.dart │ │ ├── black_hole_background_demo.dart │ │ ├── flickering_grid.dart │ │ └── black_hole_background.dart │ ├── navigations │ │ ├── dock_demo.dart │ │ ├── floating_dock_demo.dart │ │ ├── motion_tabs_demo.dart │ │ ├── floating_dock.dart │ │ ├── dock.dart │ │ └── motion_tabs.dart │ ├── borders │ │ ├── gliding_glow_box_demo.dart │ │ └── gliding_glow_box.dart │ ├── cards │ │ ├── three_d_card_demo.dart │ │ └── three_d_card.dart │ └── games │ │ └── space_shooter_demo.dart ├── models │ ├── docs_metadata.dart │ └── widget_metadata.dart ├── services │ ├── docs_loader.dart │ └── widget_loader.dart ├── pages │ ├── docs │ │ ├── getting_started_page.dart │ │ ├── docs_search_delegate.dart │ │ ├── widgets_list_page.dart │ │ ├── docs_index_page.dart │ │ └── docs_shell.dart │ └── home_page.dart ├── main.dart └── components │ ├── widget_code.dart │ ├── widget_preview.dart │ └── markdown_renderer.dart ├── README.md ├── pubspec.yaml ├── .gitignore ├── .metadata ├── LICENSE └── pubspec.lock /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:flutter_lints/flutter.yaml 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /web/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medz/flutter-arcade-ui/HEAD/web/favicon.png -------------------------------------------------------------------------------- /web/icons/Icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medz/flutter-arcade-ui/HEAD/web/icons/Icon-192.png -------------------------------------------------------------------------------- /web/icons/Icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medz/flutter-arcade-ui/HEAD/web/icons/Icon-512.png -------------------------------------------------------------------------------- /web/icons/Icon-maskable-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medz/flutter-arcade-ui/HEAD/web/icons/Icon-maskable-192.png -------------------------------------------------------------------------------- /web/icons/Icon-maskable-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medz/flutter-arcade-ui/HEAD/web/icons/Icon-maskable-512.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["./worker-configuration.d.ts"] 4 | }, 5 | "files": ["./worker.ts"] 6 | } 7 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /worker.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | async fetch(req, env) { 3 | const res = await env.ASSETS.fetch(req); 4 | if (res.status !== 404) return res; 5 | 6 | return env.ASSETS.fetch("index.html", req); 7 | }, 8 | } satisfies ExportedHandler; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "homepage": "https://arcade.medz.dev", 4 | "scripts": { 5 | "types": "wrangler types", 6 | "build": "flutter build web --wasm", 7 | "deploy": "wrangler deploy", 8 | "predeploy": "bun run build" 9 | }, 10 | "dependencies": { 11 | "wrangler": "^4.51.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docs/widgets/cards/three_d_card.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "3D Card", 3 | "group": "cards", 4 | "description": "An interactive card with 3D tilt effect that follows pointer movement.", 5 | "sourcePath": "lib/widgets/cards/three_d_card.dart", 6 | "demoPath": "lib/widgets/cards/three_d_card_demo.dart", 7 | "docPath": "widgets/cards/three-d-card.md", 8 | "tags": ["card", "3d", "interactive"] 9 | } 10 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/wrangler/config-schema.json", 3 | "compatibility_date": "2025-11-28", 4 | "name": "fluter-arcade", 5 | "main": "worker.ts", 6 | "assets": { 7 | "binding": "ASSETS", 8 | "directory": "build/web", 9 | "not_found_handling": "single-page-application", 10 | }, 11 | "route": { "custom_domain": true, "pattern": "arcade.medz.dev" }, 12 | } 13 | -------------------------------------------------------------------------------- /docs/widgets/navigations/dock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dock", 3 | "group": "navigations", 4 | "description": "A macOS-style dock with magnifying icon effect on hover.", 5 | "sourcePath": "lib/widgets/navigations/dock.dart", 6 | "demoPath": "lib/widgets/navigations/dock_demo.dart", 7 | "docPath": "widgets/navigations/dock.md", 8 | "tags": [ 9 | "navigation", 10 | "dock", 11 | "animation" 12 | ] 13 | } -------------------------------------------------------------------------------- /docs/widgets/borders/gliding_glow_box.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GlidingGlowBox", 3 | "group": "borders", 4 | "description": "A container with animated glowing spots that glide along the edges.", 5 | "sourcePath": "lib/widgets/borders/gliding_glow_box.dart", 6 | "demoPath": "lib/widgets/borders/gliding_glow_box_demo.dart", 7 | "docPath": "widgets/borders/gliding-glow-box.md", 8 | "tags": [ 9 | "border", 10 | "animation", 11 | "glow" 12 | ] 13 | } -------------------------------------------------------------------------------- /docs/widgets/navigations/floating_dock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "FloatingDock", 3 | "group": "navigations", 4 | "description": "A floating dock with magnification effect and hover tooltips.", 5 | "sourcePath": "lib/widgets/navigations/floating_dock.dart", 6 | "demoPath": "lib/widgets/navigations/floating_dock_demo.dart", 7 | "docPath": "widgets/navigations/floating-dock.md", 8 | "tags": [ 9 | "navigation", 10 | "dock", 11 | "tooltip" 12 | ] 13 | } -------------------------------------------------------------------------------- /docs/widgets/backgrounds/flickering_grid.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "FlickeringGrid", 3 | "group": "backgrounds", 4 | "description": "A mesmerizing animated grid background with randomly flickering cells.", 5 | "sourcePath": "lib/widgets/backgrounds/flickering_grid.dart", 6 | "demoPath": "lib/widgets/backgrounds/flickering_grid_demo.dart", 7 | "docPath": "widgets/backgrounds/flickering-grid.md", 8 | "tags": [ 9 | "background", 10 | "animation", 11 | "grid" 12 | ] 13 | } -------------------------------------------------------------------------------- /docs/widgets/backgrounds/black_hole_background.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BlackHoleBackground", 3 | "group": "backgrounds", 4 | "description": "A stunning black hole tunnel effect with animated discs and particles.", 5 | "sourcePath": "lib/widgets/backgrounds/black_hole_background.dart", 6 | "demoPath": "lib/widgets/backgrounds/black_hole_background_demo.dart", 7 | "docPath": "widgets/backgrounds/black-hole-background.md", 8 | "tags": [ 9 | "background", 10 | "animation", 11 | "3d" 12 | ] 13 | } -------------------------------------------------------------------------------- /docs/widgets/games/space_shooter.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SpaceShooter", 3 | "group": "games", 4 | "description": "A minimalist space shooter game inspired by 1KB game challenges. Control your ship and destroy enemies!", 5 | "sourcePath": "lib/widgets/games/space_shooter.dart", 6 | "demoPath": "lib/widgets/games/space_shooter_demo.dart", 7 | "docPath": "widgets/games/space-shooter.md", 8 | "tags": [ 9 | "game", 10 | "animation", 11 | "interactive", 12 | "shooter" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /docs/widgets/navigations/motion_tabs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MotionTabs", 3 | "group": "navigations", 4 | "description": "A pill-style tab set with a translucent overlay that stretches across all tabs and collapses to the hovered item.", 5 | "sourcePath": "lib/widgets/navigations/motion_tabs.dart", 6 | "demoPath": "lib/widgets/navigations/motion_tabs_demo.dart", 7 | "docPath": "widgets/navigations/motion-tabs.md", 8 | "tags": [ 9 | "tabs", 10 | "navigation", 11 | "hover", 12 | "animation" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /lib/widgets/backgrounds/flickering_grid_demo.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'flickering_grid.dart'; 3 | 4 | /// Simple and elegant demo for FlickeringGrid 5 | class FlickeringGridDemo extends StatelessWidget { 6 | const FlickeringGridDemo({super.key}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return FlickeringGrid( 11 | color: Colors.deepPurple, 12 | child: Center( 13 | child: Text( 14 | 'Flickering Grid', 15 | style: TextStyle( 16 | fontSize: 32, 17 | fontWeight: FontWeight.bold, 18 | color: Colors.black.withValues(alpha: 0.8), 19 | letterSpacing: 1.5, 20 | ), 21 | ), 22 | ), 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/widgets/navigations/dock_demo.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'dock.dart'; 3 | 4 | /// Simple and elegant demo for Dock 5 | class DockDemo extends StatelessWidget { 6 | const DockDemo({super.key}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Container( 11 | color: Colors.grey[100], 12 | padding: const EdgeInsets.all(24), 13 | child: const Center( 14 | child: Dock( 15 | items: [ 16 | DockIcon(child: Icon(Icons.home)), 17 | DockIcon(child: Icon(Icons.mail)), 18 | DockIcon(child: Icon(Icons.home)), 19 | DockIcon(child: Icon(Icons.folder)), 20 | DockSeparator(), 21 | DockIcon(child: Icon(Icons.settings)), 22 | ], 23 | ), 24 | ), 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/models/docs_metadata.dart: -------------------------------------------------------------------------------- 1 | /// Metadata for a documentation page. 2 | class DocsMetadata { 3 | /// The title of the documentation page 4 | final String title; 5 | 6 | /// Path to the markdown file relative to docs/ 7 | final String path; 8 | 9 | /// Route path for navigation (e.g., "/docs/getting-started") 10 | final String route; 11 | 12 | /// Optional parent page for hierarchical navigation 13 | final DocsMetadata? parent; 14 | 15 | /// Child pages if this is a section 16 | final List children; 17 | 18 | /// Icon name for navigation display 19 | final String? icon; 20 | 21 | /// Order for display in navigation 22 | final int order; 23 | 24 | const DocsMetadata({ 25 | required this.title, 26 | required this.path, 27 | required this.route, 28 | this.parent, 29 | this.children = const [], 30 | this.icon, 31 | this.order = 0, 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /lib/widgets/backgrounds/black_hole_background_demo.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'black_hole_background.dart'; 3 | 4 | /// Simple and elegant demo for BlackHoleBackground 5 | class BlackHoleBackgroundDemo extends StatelessWidget { 6 | const BlackHoleBackgroundDemo({super.key}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return const BlackHoleBackground( 11 | child: Center( 12 | child: Text( 13 | 'Black Hole', 14 | style: TextStyle( 15 | fontSize: 32, 16 | fontWeight: FontWeight.bold, 17 | color: Colors.white, 18 | letterSpacing: 2.0, 19 | shadows: [ 20 | Shadow( 21 | blurRadius: 10.0, 22 | color: Colors.black45, 23 | offset: Offset(0, 2), 24 | ), 25 | ], 26 | ), 27 | ), 28 | ), 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Arcade UI 2 | 3 | Create stunning user interfaces with beautifully crafted Flutter widgets. 4 | 5 | ## About 6 | 7 | **Arcade UI** is not a package — it's a collection of exquisite, ready-to-use UI widgets for Flutter. Browse our showcase, find the widgets you love, and copy them directly into your project. 8 | 9 | No dependency or few dependencies. No version conflicts. Just beautiful code that you own. 10 | 11 | ## How to Use 12 | 13 | 1. Visit the [Arcade UI website](https://arcade.medz.dev) 14 | 2. Browse the widget gallery 15 | 3. Find the widget you want 16 | 4. Copy the code into your Flutter project 17 | 5. Customize it to fit your needs 18 | 19 | ## Contributing 20 | 21 | We welcome contributions! If you have ideas for new widgets or improvements to existing ones, feel free to open an issue or submit a pull request. 22 | 23 | ## License 24 | 25 | [MIT License](LICENSE) - feel free to use these widgets in your personal and commercial projects. 26 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: arcade 2 | description: "Create stunning user interfaces with beautifully crafted Flutter widgets." 3 | publish_to: "none" 4 | homepage: "https://arcade.medz.dev" 5 | repository: https://github.com/medz/flutter-arcade-ui 6 | 7 | version: 1.0.0+1 8 | 9 | environment: 10 | sdk: ^3.10.1 11 | 12 | dependencies: 13 | flutter: 14 | sdk: flutter 15 | flutter_web_plugins: 16 | sdk: flutter 17 | go_router: ^17.0.0 18 | flutter_markdown_plus: ^1.0.5 19 | google_fonts: ^6.3.2 20 | url_launcher: ^6.3.2 21 | 22 | dev_dependencies: 23 | flutter_lints: ^6.0.0 24 | 25 | flutter: 26 | uses-material-design: true 27 | assets: 28 | - lib/widgets/backgrounds/ 29 | - lib/widgets/navigations/ 30 | - lib/widgets/borders/ 31 | - lib/widgets/cards/ 32 | - lib/widgets/games/ 33 | - docs/ 34 | - docs/widgets/backgrounds/ 35 | - docs/widgets/navigations/ 36 | - docs/widgets/borders/ 37 | - docs/widgets/cards/ 38 | - docs/widgets/games/ 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .build/ 9 | .buildlog/ 10 | .history 11 | .svn/ 12 | .swiftpm/ 13 | migrate_working_dir/ 14 | 15 | # IntelliJ related 16 | *.iml 17 | *.ipr 18 | *.iws 19 | .idea/ 20 | 21 | # The .vscode folder contains launch configuration and tasks you configure in 22 | # VS Code which you may wish to be included in version control, so this line 23 | # is commented out by default. 24 | #.vscode/ 25 | 26 | # Flutter/Dart/Pub related 27 | **/doc/api/ 28 | **/ios/Flutter/.last_build_id 29 | .dart_tool/ 30 | .flutter-plugins-dependencies 31 | .pub-cache/ 32 | .pub/ 33 | /build/ 34 | /coverage/ 35 | 36 | # Symbolication related 37 | app.*.symbols 38 | 39 | # Obfuscation related 40 | app.*.map.json 41 | 42 | # Android Studio will place build artifacts here 43 | /android/app/debug 44 | /android/app/profile 45 | /android/app/release 46 | 47 | # Cloudflare workers 48 | .wrangler 49 | node_modules 50 | worker-configuration.d.ts 51 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Flutter Arcade UI 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /web/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "arcade", 3 | "short_name": "arcade", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#0175C2", 7 | "theme_color": "#0175C2", 8 | "description": "A new Flutter project.", 9 | "orientation": "portrait-primary", 10 | "prefer_related_applications": false, 11 | "icons": [ 12 | { 13 | "src": "icons/Icon-192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "icons/Icon-512.png", 19 | "sizes": "512x512", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "icons/Icon-maskable-192.png", 24 | "sizes": "192x192", 25 | "type": "image/png", 26 | "purpose": "maskable" 27 | }, 28 | { 29 | "src": "icons/Icon-maskable-512.png", 30 | "sizes": "512x512", 31 | "type": "image/png", 32 | "purpose": "maskable" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "19074d12f7eaf6a8180cd4036a430c1d76de904e" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 17 | base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 18 | - platform: web 19 | create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 20 | base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Seven Du 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/widgets/navigations/floating_dock_demo.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'floating_dock.dart'; 3 | 4 | /// Simple and elegant demo for FloatingDock 5 | class FloatingDockDemo extends StatelessWidget { 6 | const FloatingDockDemo({super.key}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Center( 11 | child: FloatingDock( 12 | items: [ 13 | FloatingDockItem( 14 | icon: const Icon(Icons.home, size: 24), 15 | title: 'Home', 16 | onTap: () {}, 17 | ), 18 | FloatingDockItem( 19 | icon: const Icon(Icons.search, size: 24), 20 | title: 'Search', 21 | onTap: () {}, 22 | ), 23 | FloatingDockItem( 24 | icon: const Icon(Icons.favorite, size: 24), 25 | title: 'Favorites', 26 | onTap: () {}, 27 | ), 28 | FloatingDockItem( 29 | icon: const Icon(Icons.person, size: 24), 30 | title: 'Profile', 31 | onTap: () {}, 32 | ), 33 | ], 34 | ), 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/services/docs_loader.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/services.dart'; 2 | 3 | /// Service for loading documentation markdown files. 4 | class DocsLoader { 5 | /// Load a markdown file from the docs directory 6 | static Future loadMarkdown(String path) async { 7 | try { 8 | // Normalize path: remove leading slash and docs/ prefix if present 9 | var normalizedPath = path.startsWith('/') ? path.substring(1) : path; 10 | 11 | // If path already starts with 'docs/', don't add it again 12 | if (!normalizedPath.startsWith('docs/')) { 13 | normalizedPath = 'docs/$normalizedPath'; 14 | } 15 | 16 | // Ensure .md extension 17 | final fullPath = normalizedPath.endsWith('.md') 18 | ? normalizedPath 19 | : '$normalizedPath.md'; 20 | 21 | return await rootBundle.loadString(fullPath); 22 | } catch (e) { 23 | return '# Error Loading Documentation\n\nCould not load documentation file: $path\n\nError: $e'; 24 | } 25 | } 26 | 27 | /// Load widget documentation by identifier (e.g., "backgrounds/flickering-grid") 28 | static Future loadWidgetDoc(String identifier) async { 29 | return loadMarkdown('widgets/$identifier.md'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/widgets/borders/gliding_glow_box_demo.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'gliding_glow_box.dart'; 3 | 4 | /// Simple and elegant demo for GlidingGlowBox 5 | class GlidingGlowBoxDemo extends StatelessWidget { 6 | const GlidingGlowBoxDemo({super.key}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Container( 11 | color: Colors.grey[900], 12 | child: Center( 13 | child: GlidingGlowBox( 14 | color: Colors.cyan, 15 | borderWidth: 4, 16 | borderRadius: 16, 17 | glowPadding: 0, 18 | child: Container( 19 | padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 24), 20 | decoration: BoxDecoration( 21 | color: Colors.grey[800], 22 | borderRadius: BorderRadius.circular(16), 23 | ), 24 | child: const Text( 25 | 'Gliding Glow Box', 26 | style: TextStyle( 27 | fontSize: 24, 28 | fontWeight: FontWeight.w600, 29 | color: Colors.white, 30 | letterSpacing: 1.0, 31 | ), 32 | ), 33 | ), 34 | ), 35 | ), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/pages/docs/getting_started_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../../components/markdown_renderer.dart'; 3 | import '../../services/docs_loader.dart'; 4 | 5 | /// Getting started page (/docs/getting-started) 6 | class GettingStartedPage extends StatelessWidget { 7 | const GettingStartedPage({super.key}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | return Title( 12 | title: 'Getting Started - Flutter Arcade UI', 13 | color: Theme.of(context).primaryColor, 14 | child: Scaffold( 15 | body: FutureBuilder( 16 | future: DocsLoader.loadMarkdown('getting-started'), 17 | builder: (context, snapshot) { 18 | if (snapshot.connectionState == ConnectionState.waiting) { 19 | return const Center(child: CircularProgressIndicator()); 20 | } 21 | 22 | if (snapshot.hasError) { 23 | return Center( 24 | child: Text('Error loading documentation: ${snapshot.error}'), 25 | ); 26 | } 27 | 28 | return SingleChildScrollView( 29 | padding: const EdgeInsets.all(24), 30 | child: MarkdownRenderer( 31 | markdown: 32 | snapshot.data ?? 33 | '# Getting Started\n\nDocumentation is being loaded...', 34 | ), 35 | ); 36 | }, 37 | ), 38 | ), 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /docs/widgets/backgrounds/flickering-grid.md: -------------------------------------------------------------------------------- 1 | A mesmerizing animated grid background with randomly flickering cells. Perfect for creating dynamic, eye-catching backgrounds. 2 | 3 | ## Preview 4 | 5 | @{WidgetPreview:backgrounds/flickering_grid} 6 | 7 | ## Features 8 | 9 | - Smooth flickering animation with random cell opacity changes 10 | - Fully customizable grid appearance (size, gap, color) 11 | - Canvas-based rendering for optimal performance 12 | - Support for child widgets as overlay 13 | - Zero external dependencies 14 | 15 | ## Properties 16 | 17 | | Property | Type | Default | Description | 18 | |----------|------|---------|-------------| 19 | | `color` | `Color` | `Colors.black` | Color of the grid squares | 20 | | `squareSize` | `double` | `4.0` | Size of each grid square in pixels | 21 | | `gridGap` | `double` | `6.0` | Gap between grid squares | 22 | | `flickerChance` | `double` | `0.3` | Probability of a square flickering per frame | 23 | | `maxOpacity` | `double` | `0.3` | Maximum opacity of the flickering squares | 24 | | `duration` | `Duration` | `1 second` | Animation loop duration | 25 | | `child` | `Widget?` | `null` | Optional child widget displayed over the grid | 26 | 27 | ## Usage 28 | 29 | ```dart 30 | import 'widgets/backgrounds/flickering_grid.dart'; 31 | 32 | FlickeringGrid( 33 | color: Colors.deepPurple, 34 | child: Center( 35 | child: Text('Hello, Arcade UI!'), 36 | ), 37 | ) 38 | ``` 39 | 40 | ## Examples 41 | 42 | ### Dense Grid 43 | 44 | ```dart 45 | FlickeringGrid( 46 | color: Colors.blue, 47 | squareSize: 2.0, 48 | gridGap: 3.0, 49 | maxOpacity: 0.5, 50 | ) 51 | ``` 52 | 53 | ### Sparse Grid with Slow Animation 54 | 55 | ```dart 56 | FlickeringGrid( 57 | color: Colors.green, 58 | squareSize: 8.0, 59 | gridGap: 12.0, 60 | flickerChance: 0.1, 61 | duration: Duration(seconds: 2), 62 | ) 63 | ``` 64 | 65 | ## Source Code 66 | 67 | @{WidgetCode:backgrounds/flickering_grid} -------------------------------------------------------------------------------- /lib/models/widget_metadata.dart: -------------------------------------------------------------------------------- 1 | /// Metadata for a widget in the Arcade UI library. 2 | class WidgetMetadata { 3 | /// The display name of the widget (e.g., "FlickeringGrid") 4 | final String name; 5 | 6 | /// The category/group this widget belongs to (e.g., "backgrounds") 7 | final String group; 8 | 9 | /// A brief description of what the widget does 10 | final String description; 11 | 12 | /// Path to the widget source file relative to lib/ 13 | final String sourcePath; 14 | 15 | /// Path to the widget demo file relative to lib/ 16 | final String demoPath; 17 | 18 | /// Optional path to the documentation markdown file 19 | final String? docPath; 20 | 21 | /// Tags for filtering and searching 22 | final List tags; 23 | 24 | /// Unique identifier (e.g., "backgrounds/black_hole_background") 25 | final String identifier; 26 | 27 | const WidgetMetadata({ 28 | required this.name, 29 | required this.group, 30 | required this.description, 31 | required this.sourcePath, 32 | required this.demoPath, 33 | required this.identifier, 34 | this.docPath, 35 | this.tags = const [], 36 | }); 37 | 38 | factory WidgetMetadata.fromJson( 39 | Map json, { 40 | required String identifier, 41 | }) { 42 | return WidgetMetadata( 43 | name: json['name'] as String, 44 | group: json['group'] as String, 45 | description: json['description'] as String, 46 | sourcePath: json['sourcePath'] as String, 47 | demoPath: json['demoPath'] as String, 48 | docPath: json['docPath'] as String?, 49 | tags: (json['tags'] as List?)?.cast() ?? const [], 50 | identifier: identifier, 51 | ); 52 | } 53 | 54 | /// Get the display category name with proper capitalization 55 | String get categoryName => capitalize(group); 56 | 57 | static String capitalize(String input) { 58 | if (input.isEmpty) return input; 59 | return input[0].toUpperCase() + input.substring(1); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /docs/widgets/backgrounds/black-hole-background.md: -------------------------------------------------------------------------------- 1 | A captivating black hole effect with rotating discs, radial lines, and floating particles. Creates a mesmerizing 3D depth illusion perfect for immersive hero sections. 2 | 3 | ## Preview 4 | 5 | @{WidgetPreview:backgrounds/black_hole_background} 6 | 7 | ## Features 8 | 9 | - Animated rotating elliptical discs creating depth illusion 10 | - Radial line patterns converging to center 11 | - Floating particle effects with varying opacity 12 | - Smooth canvas-based rendering with clipping 13 | - Support for child widgets as overlay 14 | - Zero external dependencies 15 | 16 | ## Properties 17 | 18 | | Property | Type | Default | Description | 19 | |----------|------|---------|-------------| 20 | | `strokeColor` | `Color` | `Color(0x33737373)` | Color of the disc outlines and radial lines | 21 | | `numberOfLines` | `int` | `50` | Number of radial lines emanating from center | 22 | | `numberOfDiscs` | `int` | `50` | Number of rotating elliptical discs | 23 | | `particleColor` | `Color` | `Color(0x33FFFFFF)` | Color of the floating particles | 24 | | `child` | `Widget?` | `null` | Optional child widget displayed over the effect | 25 | 26 | ## Usage 27 | 28 | ```dart 29 | import 'widgets/backgrounds/black_hole_background.dart'; 30 | 31 | BlackHoleBackground( 32 | child: Center( 33 | child: Text( 34 | 'Into the Void', 35 | style: TextStyle(fontSize: 48, color: Colors.white), 36 | ), 37 | ), 38 | ) 39 | ``` 40 | 41 | ## Examples 42 | 43 | ### Vibrant Style 44 | 45 | ```dart 46 | BlackHoleBackground( 47 | strokeColor: Colors.purple.withOpacity(0.3), 48 | particleColor: Colors.white.withOpacity(0.5), 49 | numberOfLines: 72, 50 | numberOfDiscs: 60, 51 | ) 52 | ``` 53 | 54 | ### Minimal Style 55 | 56 | ```dart 57 | BlackHoleBackground( 58 | strokeColor: Colors.grey.withOpacity(0.1), 59 | particleColor: Colors.white.withOpacity(0.2), 60 | numberOfLines: 24, 61 | numberOfDiscs: 30, 62 | ) 63 | ``` 64 | 65 | ## Performance Tips 66 | 67 | - Reduce `numberOfDiscs` and `numberOfLines` on lower-end devices 68 | - Consider pausing animation when widget is not visible 69 | - Use sparingly—one instance per screen is recommended 70 | 71 | ## Source Code 72 | 73 | @{WidgetCode:backgrounds/black_hole_background} -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_web_plugins/url_strategy.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'pages/home_page.dart'; 5 | import 'pages/docs/docs_index_page.dart'; 6 | import 'pages/docs/getting_started_page.dart'; 7 | 8 | import 'pages/docs/widget_detail_page.dart'; 9 | import 'pages/docs/docs_shell.dart'; 10 | 11 | import 'services/widget_loader.dart'; 12 | 13 | void main() async { 14 | WidgetsFlutterBinding.ensureInitialized(); 15 | // Configure URL strategy for Flutter Web to use path-based URLs 16 | usePathUrlStrategy(); 17 | 18 | // Initialize widget loader 19 | await WidgetLoader.initialize(); 20 | 21 | runApp(const App()); 22 | } 23 | 24 | class App extends StatelessWidget { 25 | const App({super.key}); 26 | 27 | @override 28 | Widget build(BuildContext context) { 29 | return MaterialApp.router( 30 | title: 'Fluter Arcade UI', 31 | theme: ThemeData.from( 32 | useMaterial3: true, 33 | colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), 34 | ), 35 | darkTheme: ThemeData.from( 36 | useMaterial3: true, 37 | colorScheme: ColorScheme.fromSeed( 38 | seedColor: Colors.deepPurple, 39 | brightness: Brightness.dark, 40 | ), 41 | ), 42 | themeMode: ThemeMode.system, 43 | routerConfig: _router, 44 | ); 45 | } 46 | } 47 | 48 | final _router = GoRouter( 49 | initialLocation: '/', 50 | routes: [ 51 | GoRoute(path: '/', builder: (context, state) => const HomePage()), 52 | ShellRoute( 53 | builder: (context, state, child) { 54 | return DocsShell(child: child); 55 | }, 56 | routes: [ 57 | GoRoute( 58 | path: '/get-started', 59 | pageBuilder: (context, state) => NoTransitionPage( 60 | key: state.pageKey, 61 | child: const GettingStartedPage(), 62 | ), 63 | ), 64 | GoRoute( 65 | path: '/widgets', 66 | pageBuilder: (context, state) => NoTransitionPage( 67 | key: state.pageKey, 68 | child: const DocsIndexPage(), 69 | ), 70 | ), 71 | GoRoute( 72 | path: '/widgets/:group/:name', 73 | pageBuilder: (context, state) { 74 | final group = state.pathParameters['group']!; 75 | final name = state.pathParameters['name']!; 76 | return NoTransitionPage( 77 | key: state.pageKey, 78 | child: WidgetDetailPage(group: group, name: name), 79 | ); 80 | }, 81 | ), 82 | ], 83 | ), 84 | ], 85 | ); 86 | -------------------------------------------------------------------------------- /docs/widgets/borders/gliding-glow-box.md: -------------------------------------------------------------------------------- 1 | A smooth gliding glow ring that orbits the container edges. Perfect for highlighting buttons, cards, or any interactive elements. 2 | 3 | ## Preview 4 | 5 | @{WidgetPreview:borders/gliding_glow_box} 6 | 7 | ## Features 8 | 9 | - Smooth orbiting glow that wraps around the container 10 | - Customizable glow color and intensity 11 | - Adjustable animation speed 12 | - Configurable border width 13 | - Canvas-based rendering with radial gradients 14 | - Zero external dependencies 15 | 16 | ## Properties 17 | 18 | | Property | Type | Default | Description | 19 | |----------|------|---------|-------------| 20 | | `child` | `Widget` | *required* | The child widget to wrap with the glow effect | 21 | | `color` | `Color` | `Color(0xFFE0E0E0)` | Color of the glowing spots | 22 | | `speed` | `Duration` | `6 seconds` | Duration of one complete animation cycle | 23 | | `borderWidth` | `double` | `3.0` | Width of the glow ring | 24 | | `borderRadius` | `double?` | `null` (pill by height) | Corner radius for the glow shape | 25 | | `glowPadding` | `double` | `0` | Extra gap between the glow ring and the child | 26 | 27 | ## Usage 28 | 29 | ```dart 30 | import 'widgets/borders/gliding_glow_box.dart'; 31 | 32 | GlidingGlowBox( 33 | color: Colors.purple, 34 | borderRadius: 12, 35 | glowPadding: 6, 36 | child: ElevatedButton( 37 | onPressed: () {}, 38 | child: Text('Click Me'), 39 | ), 40 | ) 41 | ``` 42 | 43 | ## Examples 44 | 45 | ### Fast Purple Glow 46 | 47 | ```dart 48 | GlidingGlowBox( 49 | color: Colors.purple, 50 | speed: Duration(seconds: 3), 51 | borderRadius: 10, 52 | glowPadding: 4, 53 | child: Container( 54 | padding: EdgeInsets.all(16), 55 | child: Text('Premium Feature'), 56 | ), 57 | ) 58 | ``` 59 | 60 | ### Subtle Slow Animation 61 | 62 | ```dart 63 | GlidingGlowBox( 64 | color: Colors.blue.withOpacity(0.6), 65 | speed: Duration(seconds: 10), 66 | borderWidth: 2.0, 67 | borderRadius: 14, 68 | glowPadding: 2, 69 | child: Card( 70 | child: ListTile(title: Text('Highlighted Item')), 71 | ), 72 | ) 73 | ``` 74 | 75 | ### Thick Border Accent 76 | 77 | ```dart 78 | GlidingGlowBox( 79 | color: Colors.amber, 80 | borderWidth: 5.0, 81 | borderRadius: 18, 82 | glowPadding: 8, 83 | child: Padding( 84 | padding: EdgeInsets.all(20), 85 | child: Text('Call to Action'), 86 | ), 87 | ) 88 | ``` 89 | 90 | ## Use Cases 91 | 92 | - Call-to-action buttons 93 | - Premium feature highlights 94 | - Interactive form fields 95 | - Portfolio showcase items 96 | - Navigation elements 97 | 98 | ## Source Code 99 | 100 | @{WidgetCode:borders/gliding_glow_box} 101 | -------------------------------------------------------------------------------- /lib/pages/home_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:google_fonts/google_fonts.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'package:url_launcher/url_launcher.dart'; 5 | import '../widgets/backgrounds/flickering_grid.dart'; 6 | import '../widgets/navigations/floating_dock.dart'; 7 | 8 | /// Homepage with hero section and action buttons 9 | class HomePage extends StatelessWidget { 10 | const HomePage({super.key}); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return Title( 15 | title: 'Flutter Arcade UI', 16 | color: Theme.of(context).primaryColor, 17 | child: Scaffold( 18 | body: FlickeringGrid( 19 | color: Colors.deepPurple, 20 | child: Center( 21 | child: Column( 22 | mainAxisAlignment: MainAxisAlignment.center, 23 | children: [ 24 | // Project title 25 | Text( 26 | 'Arcade UI', 27 | style: GoogleFonts.inter( 28 | fontSize: 72, 29 | fontWeight: FontWeight.bold, 30 | // color: Theme.of(context).col, 31 | letterSpacing: -2, 32 | ), 33 | ), 34 | const SizedBox(height: 24), 35 | // Description 36 | Text( 37 | 'Create stunning user interfaces with beautifully\ncrafted Flutter widgets.', 38 | style: GoogleFonts.inter( 39 | fontSize: 20, 40 | // color: Colors.black54, // Changed for better visibility 41 | height: 1.5, 42 | ), 43 | textAlign: TextAlign.center, 44 | ), 45 | const SizedBox(height: 80), 46 | // Floating dock with functional navigation 47 | FloatingDock( 48 | items: [ 49 | FloatingDockItem( 50 | icon: Icon(Icons.rocket_launch, color: Colors.grey[700]), 51 | title: 'Get Started', 52 | onTap: () { 53 | context.go('/get-started'); 54 | }, 55 | ), 56 | FloatingDockItem( 57 | icon: Icon(Icons.widgets, color: Colors.grey[700]), 58 | title: 'Widgets', 59 | onTap: () { 60 | context.go('/widgets'); 61 | }, 62 | ), 63 | FloatingDockItem( 64 | icon: Icon(Icons.search, color: Colors.grey[700]), 65 | title: 'Search', 66 | onTap: () { 67 | context.go('/widgets'); 68 | // Open search after navigation 69 | Future.delayed(const Duration(milliseconds: 100), () { 70 | // This will be handled by DocsShell 71 | }); 72 | }, 73 | ), 74 | FloatingDockItem( 75 | icon: Icon(Icons.code, color: Colors.grey[700]), 76 | title: 'GitHub', 77 | onTap: () { 78 | launchUrl( 79 | Uri.parse( 80 | 'https://github.com/medz/flutter-arcade-ui', 81 | ), 82 | mode: LaunchMode.externalApplication, 83 | ); 84 | }, 85 | ), 86 | ], 87 | ), 88 | ], 89 | ), 90 | ), 91 | ), 92 | ), 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /lib/widgets/borders/gliding_glow_box.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' as math; 2 | import 'package:flutter/widgets.dart'; 3 | 4 | class GlidingGlowBox extends StatefulWidget { 5 | final Widget child; 6 | final Color color; 7 | final Duration speed; 8 | final double borderWidth; 9 | final double? borderRadius; 10 | final double glowPadding; 11 | 12 | const GlidingGlowBox({ 13 | super.key, 14 | required this.child, 15 | this.color = const Color(0xFFE0E0E0), 16 | this.speed = const Duration(seconds: 6), 17 | this.borderWidth = 3.0, 18 | this.borderRadius, 19 | this.glowPadding = 0, 20 | }); 21 | 22 | @override 23 | State createState() => _GlidingGlowBoxState(); 24 | } 25 | 26 | class _GlidingGlowBoxState extends State 27 | with SingleTickerProviderStateMixin { 28 | late AnimationController _controller; 29 | 30 | @override 31 | void initState() { 32 | super.initState(); 33 | _controller = AnimationController(vsync: this, duration: widget.speed) 34 | ..repeat(reverse: true); 35 | } 36 | 37 | @override 38 | void dispose() { 39 | _controller.dispose(); 40 | super.dispose(); 41 | } 42 | 43 | @override 44 | Widget build(BuildContext context) { 45 | final inset = widget.borderWidth / 2 + widget.glowPadding; 46 | 47 | return AnimatedBuilder( 48 | animation: _controller, 49 | builder: (context, child) { 50 | return CustomPaint( 51 | painter: _GlidingGlowBoxPainter( 52 | progress: _controller.value, 53 | color: widget.color, 54 | borderWidth: widget.borderWidth, 55 | borderRadius: widget.borderRadius, 56 | ), 57 | child: Padding(padding: EdgeInsets.all(inset), child: child), 58 | ); 59 | }, 60 | child: widget.child, 61 | ); 62 | } 63 | } 64 | 65 | class _GlidingGlowBoxPainter extends CustomPainter { 66 | final double progress; 67 | final Color color; 68 | final double borderWidth; 69 | final double? borderRadius; 70 | 71 | _GlidingGlowBoxPainter({ 72 | required this.progress, 73 | required this.color, 74 | required this.borderWidth, 75 | required this.borderRadius, 76 | }); 77 | 78 | @override 79 | void paint(Canvas canvas, Size size) { 80 | final rect = Offset.zero & size; 81 | final radiusValue = borderRadius ?? size.height / 2; 82 | final radius = Radius.circular(radiusValue); 83 | final rrect = RRect.fromRectAndRadius(rect, radius); 84 | 85 | final sweep = SweepGradient( 86 | startAngle: 0, 87 | endAngle: math.pi * 2, 88 | colors: [ 89 | color.withValues(alpha: 0.0), 90 | color.withValues(alpha: 0.0), 91 | color.withValues(alpha: 0.6), 92 | color, 93 | color.withValues(alpha: 0.6), 94 | color.withValues(alpha: 0.0), 95 | ], 96 | stops: const [0.0, 0.32, 0.42, 0.5, 0.58, 0.74], 97 | transform: GradientRotation(math.pi * 2 * progress), 98 | ); 99 | 100 | final glowPaint = Paint() 101 | ..style = PaintingStyle.stroke 102 | ..strokeWidth = borderWidth 103 | ..shader = sweep.createShader(rect) 104 | ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6); 105 | canvas.drawRRect(rrect, glowPaint); 106 | 107 | final highlightPaint = Paint() 108 | ..style = PaintingStyle.stroke 109 | ..strokeWidth = borderWidth * 0.5 110 | ..shader = sweep.createShader(rect); 111 | canvas.drawRRect(rrect.inflate(-borderWidth * 0.25), highlightPaint); 112 | } 113 | 114 | @override 115 | bool shouldRepaint(covariant _GlidingGlowBoxPainter oldDelegate) { 116 | return oldDelegate.progress != progress || 117 | oldDelegate.color != color || 118 | oldDelegate.borderWidth != borderWidth; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /lib/services/widget_loader.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'package:flutter/cupertino.dart'; 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:flutter/services.dart'; 5 | import '../models/widget_metadata.dart'; 6 | 7 | /// Service for loading and managing widget metadata and source code. 8 | class WidgetLoader { 9 | /// Registry of all available widgets in the library 10 | /// Registry of all available widgets in the library 11 | static List _widgets = []; 12 | 13 | /// Initialize the loader by loading metadata from assets 14 | static Future initialize() async { 15 | try { 16 | final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); 17 | final jsonPaths = manifest 18 | .listAssets() 19 | .where( 20 | (String key) => 21 | key.contains('docs/widgets/') && key.endsWith('.json'), 22 | ) 23 | .toList(); 24 | 25 | final loadedWidgets = []; 26 | 27 | for (final path in jsonPaths) { 28 | try { 29 | final jsonContent = await rootBundle.loadString(path); 30 | final jsonMap = json.decode(jsonContent); 31 | 32 | // Extract identifier from file path 33 | // e.g., "docs/widgets/backgrounds/black_hole_background.json" 34 | // -> "backgrounds/black_hole_background" 35 | final parts = path.split('/'); 36 | final fileName = parts.last.replaceAll('.json', ''); 37 | final group = parts[parts.length - 2]; 38 | final identifier = '$group/$fileName'; 39 | 40 | loadedWidgets.add( 41 | WidgetMetadata.fromJson(jsonMap, identifier: identifier), 42 | ); 43 | } catch (e) { 44 | debugPrint('Error loading widget metadata from $path: $e'); 45 | } 46 | } 47 | 48 | // Sort widgets by name 49 | loadedWidgets.sort((a, b) => a.name.compareTo(b.name)); 50 | _widgets = loadedWidgets; 51 | } catch (e) { 52 | debugPrint('Error initializing WidgetLoader: $e'); 53 | } 54 | } 55 | 56 | /// Get all widgets 57 | static List get widgets => List.unmodifiable(_widgets); 58 | 59 | /// Get widgets by group/category 60 | static List getWidgetsByGroup(String group) { 61 | return _widgets.where((w) => w.group == group).toList(); 62 | } 63 | 64 | /// Get all unique groups 65 | static List get groups { 66 | return _widgets.map((w) => w.group).toSet().toList()..sort(); 67 | } 68 | 69 | /// Find a widget by identifier (e.g., "backgrounds/flickering_grid") 70 | static WidgetMetadata? findByIdentifier(String identifier) { 71 | return _widgets.cast().firstWhere( 72 | (w) => w?.identifier == identifier, 73 | orElse: () => null, 74 | ); 75 | } 76 | 77 | /// Load the source code for a widget 78 | static Future loadSourceCode(WidgetMetadata widget) async { 79 | try { 80 | return await rootBundle.loadString(widget.sourcePath); 81 | } catch (e) { 82 | return '// Error loading source code: $e'; 83 | } 84 | } 85 | 86 | /// Load the demo code for a widget 87 | static Future loadDemoCode(WidgetMetadata widget) async { 88 | try { 89 | return await rootBundle.loadString(widget.demoPath); 90 | } catch (e) { 91 | return '// Error loading demo code: $e'; 92 | } 93 | } 94 | 95 | /// Search widgets by name or description 96 | static List search(String query) { 97 | final lowerQuery = query.toLowerCase(); 98 | return _widgets.where((w) { 99 | return w.name.toLowerCase().contains(lowerQuery) || 100 | w.description.toLowerCase().contains(lowerQuery) || 101 | w.tags.any((tag) => tag.toLowerCase().contains(lowerQuery)); 102 | }).toList(); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /docs/widgets/navigations/motion-tabs.md: -------------------------------------------------------------------------------- 1 | Bright, pill-shaped Motion Tabs with a color backdrop. A translucent wash sits behind every item and collapses to the hovered tab while the selected tab keeps its own solid background. 2 | 3 | ## Preview 4 | 5 | @{WidgetPreview:navigations/motion_tabs} 6 | 7 | ## Features 8 | 9 | - Colored base with a soft translucent overlay that stretches across every tab 10 | - Hovering collapses the overlay to the hovered tab; leaving expands it again 11 | - Selected tab keeps a solid background and contrasting label color 12 | - Customizable spacing, sizing, timing, and colors 13 | - Optional icons next to labels 14 | 15 | ## Properties 16 | 17 | ### MotionTabs 18 | 19 | | Property | Type | Default | Description | 20 | |----------|------|---------|-------------| 21 | | `items` | `List` | *required* | Tabs to display | 22 | | `initialIndex` | `int` | `0` | Starting selected tab | 23 | | `onChanged` | `ValueChanged?` | `null` | Called when selection changes | 24 | | `backgroundColor` | `Color` | `Color(0xFF2F4BFF)` | Base background color | 25 | | `overlayColor` | `Color` | `Color(0x24FFFFFF)` | Color for the translucent overlay | 26 | | `selectedColor` | `Color` | `Colors.white` | Background for the selected tab | 27 | | `textColor` | `Color` | `Colors.white` | Label color for unselected tabs | 28 | | `selectedTextColor` | `Color` | `Color(0xFF1C1F3E)` | Label color for the selected tab | 29 | | `height` | `double` | `56` | Overall height of the tab set | 30 | | `borderRadius` | `double` | `18` | Corner radius for the container and tabs | 31 | | `padding` | `EdgeInsets` | `EdgeInsets.symmetric(horizontal: 12, vertical: 10)` | Inner padding around the row | 32 | | `gap` | `double` | `8` | Space between tabs | 33 | | `animationDuration` | `Duration` | `220ms` | Duration for hover and selection transitions | 34 | | `animationCurve` | `Curve` | `Curves.easeOutCubic` | Curve for hover and selection transitions | 35 | 36 | ### MotionTabItem 37 | 38 | | Property | Type | Default | Description | 39 | |----------|------|---------|-------------| 40 | | `label` | `String` | *required* | Tab label | 41 | | `icon` | `Widget?` | `null` | Optional leading icon | 42 | | `onTap` | `VoidCallback?` | `null` | Called when this tab is tapped | 43 | 44 | ## Usage 45 | 46 | ```dart 47 | import 'widgets/navigations/motion_tabs.dart'; 48 | 49 | MotionTabs( 50 | items: const [ 51 | MotionTabItem(label: 'Overview'), 52 | MotionTabItem(label: 'Design'), 53 | MotionTabItem(label: 'Develop'), 54 | ], 55 | onChanged: (index) { 56 | // react to selection 57 | }, 58 | ) 59 | ``` 60 | 61 | ## Examples 62 | 63 | ### Custom Colors 64 | 65 | ```dart 66 | MotionTabs( 67 | backgroundColor: const Color(0xFF00BFA6), 68 | overlayColor: const Color(0x22FFFFFF), 69 | selectedColor: const Color(0xFF07211E), 70 | selectedTextColor: const Color(0xFF7FF4DC), 71 | gap: 12, 72 | borderRadius: 20, 73 | items: const [ 74 | MotionTabItem(label: 'Dashboard', icon: Icon(Icons.dashboard)), 75 | MotionTabItem(label: 'Reports', icon: Icon(Icons.auto_graph)), 76 | MotionTabItem(label: 'Automation', icon: Icon(Icons.memory)), 77 | ], 78 | ) 79 | ``` 80 | 81 | ### With Callbacks 82 | 83 | ```dart 84 | MotionTabs( 85 | items: [ 86 | MotionTabItem( 87 | label: 'Inbox', 88 | icon: const Icon(Icons.inbox_rounded), 89 | onTap: () => debugPrint('Inbox pressed'), 90 | ), 91 | MotionTabItem( 92 | label: 'Mentions', 93 | icon: const Icon(Icons.alternate_email_rounded), 94 | ), 95 | ], 96 | onChanged: (index) => debugPrint('Selected $index'), 97 | ) 98 | ``` 99 | 100 | ## Tips 101 | 102 | - Keep labels short so the hover overlay aligns cleanly 103 | - If the container is very narrow, reduce `gap` to avoid squashed tabs 104 | - Pair `selectedColor` and `selectedTextColor` for strong contrast 105 | - Wrap with `ConstrainedBox` to control max width in wide layouts 106 | 107 | ## Source Code 108 | 109 | @{WidgetCode:navigations/motion_tabs} 110 | -------------------------------------------------------------------------------- /docs/widgets/navigations/floating-dock.md: -------------------------------------------------------------------------------- 1 | A sleek floating dock with magnification effect and tooltip labels. Features a frosted glass aesthetic and smooth hover animations, ideal for app navigation or quick actions. 2 | 3 | ## Preview 4 | 5 | @{WidgetPreview:navigations/floating_dock} 6 | 7 | ## Features 8 | 9 | - Proximity-based magnification effect on hover 10 | - Tooltip labels appear above icons on hover 11 | - Customizable dock and item appearance 12 | - Frosted glass default styling 13 | - Smooth animation transitions 14 | - Zero external dependencies 15 | 16 | ## Properties 17 | 18 | ### FloatingDock 19 | 20 | | Property | Type | Default | Description | 21 | |----------|------|---------|-------------| 22 | | `items` | `List` | *required* | List of dock items to display | 23 | | `baseItemSize` | `double` | `48.0` | Base size of each dock item | 24 | | `maxItemSize` | `double` | `68.0` | Maximum size when cursor is closest | 25 | | `distance` | `double` | `150.0` | Distance in pixels for magnification effect | 26 | | `gap` | `double` | `12.0` | Gap between dock items | 27 | | `padding` | `EdgeInsetsGeometry?` | `EdgeInsets.symmetric(horizontal: 16, vertical: 12)` | Padding inside the dock | 28 | | `decoration` | `BoxDecoration?` | Light rounded box | Decoration for the dock container | 29 | | `itemDecoration` | `BoxDecoration?` | Light grey box | Default decoration for items | 30 | 31 | ### FloatingDockItem 32 | 33 | | Property | Type | Default | Description | 34 | |----------|------|---------|-------------| 35 | | `icon` | `Widget` | *required* | The icon widget to display | 36 | | `title` | `String?` | `null` | Tooltip text shown on hover | 37 | | `decoration` | `BoxDecoration?` | `null` | Custom decoration for this item | 38 | | `onTap` | `VoidCallback?` | `null` | Callback when item is tapped | 39 | 40 | ## Usage 41 | 42 | ```dart 43 | import 'widgets/navigations/floating_dock.dart'; 44 | 45 | FloatingDock( 46 | items: [ 47 | FloatingDockItem( 48 | icon: Icon(Icons.home), 49 | title: 'Home', 50 | onTap: () => print('Home'), 51 | ), 52 | FloatingDockItem( 53 | icon: Icon(Icons.mail), 54 | title: 'Mail', 55 | onTap: () => print('Mail'), 56 | ), 57 | FloatingDockItem( 58 | icon: Icon(Icons.folder), 59 | title: 'Files', 60 | onTap: () => print('Files'), 61 | ), 62 | ], 63 | ) 64 | ``` 65 | 66 | ## Examples 67 | 68 | ### Dark Theme 69 | 70 | ```dart 71 | FloatingDock( 72 | decoration: BoxDecoration( 73 | color: Colors.grey[900], 74 | borderRadius: BorderRadius.circular(20), 75 | ), 76 | itemDecoration: BoxDecoration( 77 | color: Colors.grey[800], 78 | ), 79 | items: [ 80 | FloatingDockItem( 81 | icon: Icon(Icons.rocket, color: Colors.white), 82 | title: 'Launch', 83 | ), 84 | FloatingDockItem( 85 | icon: Icon(Icons.settings, color: Colors.white), 86 | title: 'Settings', 87 | ), 88 | ], 89 | ) 90 | ``` 91 | 92 | ### Larger Icons 93 | 94 | ```dart 95 | FloatingDock( 96 | baseItemSize: 56.0, 97 | maxItemSize: 80.0, 98 | gap: 16.0, 99 | items: [...], 100 | ) 101 | ``` 102 | 103 | ### Subtle Magnification 104 | 105 | ```dart 106 | FloatingDock( 107 | baseItemSize: 48.0, 108 | maxItemSize: 56.0, 109 | distance: 100.0, 110 | items: [...], 111 | ) 112 | ``` 113 | 114 | ### Custom Item Styling 115 | 116 | ```dart 117 | FloatingDockItem( 118 | icon: Icon(Icons.favorite, color: Colors.white), 119 | title: 'Favorites', 120 | decoration: BoxDecoration( 121 | gradient: LinearGradient( 122 | colors: [Colors.pink, Colors.red], 123 | ), 124 | ), 125 | onTap: () {}, 126 | ) 127 | ``` 128 | 129 | ## Tips 130 | 131 | - Keep titles short (1-2 words) for clean tooltips 132 | - Limit to 5-7 items for best user experience 133 | - Position at bottom center for familiar interaction pattern 134 | - Use consistent icon colors for cohesive appearance 135 | - Consider using colored icons to differentiate actions 136 | 137 | ## Source Code 138 | 139 | @{WidgetCode:navigations/floating_dock} -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Arcade UI 2 | 3 | Welcome! This guide will help you get started with Arcade UI widgets in your Flutter projects. 4 | 5 | ## What is Arcade UI? 6 | 7 | Arcade UI is **not a package**—it's a curated collection of ready-to-use Flutter widgets. You don't install it via `pubspec.yaml`. Instead, you browse our showcase, find the widgets you love, and copy them directly into your project. 8 | 9 | No dependency or few dependencies. No version conflicts. Just beautiful code that you own. 10 | 11 | ## Navigation 12 | 13 | The Arcade UI website has a simple structure: 14 | 15 | - [Home](/) - The main landing page with project introduction 16 | - [Get Started](/get-started) - This guide you're reading now 17 | - [Widgets](/widgets) - Browse all available widgets by category 18 | 19 | ## Widget Categories 20 | 21 | Our widgets are organized into the following categories: 22 | 23 | | Category | Description | Examples | 24 | |----------|-------------|----------| 25 | | **Backgrounds** | Animated background effects | FlickeringGrid, BlackHoleBackground | 26 | | **Borders** | Glowing and animated border effects | GlidingGlowBox | 27 | | **Cards** | Interactive card components | ThreeDCard | 28 | | **Navigations** | Navigation UI components | Dock, FloatingDock | 29 | 30 | ## How to Use 31 | 32 | ### Step 1: Browse the Widget Gallery 33 | 34 | Visit the [Widgets](/widgets) page to see all available widgets organized by category. Each widget card shows a brief description and tags. 35 | 36 | ### Step 2: View Widget Details 37 | 38 | Click on any widget to open its detail page at `/widgets/:group/:name`. Each detail page includes: 39 | 40 | - **Live preview** - See the widget in action 41 | - **Source code** - Complete, copy-ready code 42 | - **Properties table** - All configurable options 43 | - **Usage examples** - Common use cases 44 | 45 | ### Step 3: Copy the Code 46 | 47 | 1. On the widget detail page, find the **Source Code** section 48 | 2. Click the copy button 49 | 3. The complete widget code is now in your clipboard 50 | 51 | ### Step 4: Add to Your Project 52 | 53 | Create a new file in your Flutter project and paste the code: 54 | 55 | ```dart 56 | // Example: lib/widgets/backgrounds/flickering_grid.dart 57 | 58 | // Paste the copied code here 59 | ``` 60 | 61 | We recommend organizing widgets by category, mirroring our structure: 62 | 63 | ``` 64 | lib/ 65 | widgets/ 66 | backgrounds/ 67 | flickering_grid.dart 68 | borders/ 69 | gliding_glow_box.dart 70 | cards/ 71 | three_d_card.dart 72 | navigations/ 73 | floating_dock.dart 74 | ``` 75 | 76 | ### Step 5: Import and Use 77 | 78 | ```dart 79 | import 'package:flutter/material.dart'; 80 | import 'widgets/backgrounds/flickering_grid.dart'; 81 | 82 | class MyPage extends StatelessWidget { 83 | @override 84 | Widget build(BuildContext context) { 85 | return FlickeringGrid( 86 | color: Colors.deepPurple, 87 | child: Center( 88 | child: Text('Hello, Arcade UI!'), 89 | ), 90 | ); 91 | } 92 | } 93 | ``` 94 | 95 | ## Dependencies 96 | 97 | Most widgets have **zero external dependencies** and only use: 98 | 99 | - `package:flutter/widgets.dart` 100 | - `dart:math` 101 | - `dart:async` 102 | 103 | If a widget requires additional packages, it will be clearly documented on the widget's detail page. 104 | 105 | ## Customization 106 | 107 | Since you own the code, you have complete freedom to: 108 | 109 | - Adjust colors, sizes, and timing 110 | - Modify animations and behaviors 111 | - Combine multiple widgets 112 | - Add your own enhancements 113 | 114 | ## Tips 115 | 116 | - **Read the code** - Understanding how widgets work helps you customize them 117 | - **Check properties** - Each widget documents its configurable properties 118 | - **Experiment** - Try different values to match your design 119 | - **Combine widgets** - Layer multiple effects for unique results 120 | 121 | ## Resources 122 | 123 | - [GitHub Repository](https://github.com/medz/flutter-arcade-ui) - Source code and examples 124 | - [Widget Gallery](/widgets) - Browse all widgets 125 | 126 | --- 127 | 128 | Ready to build something beautiful? [Browse the widgets](/widgets) now! 129 | -------------------------------------------------------------------------------- /lib/widgets/navigations/motion_tabs_demo.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'motion_tabs.dart'; 3 | 4 | class MotionTabsDemo extends StatefulWidget { 5 | const MotionTabsDemo({super.key}); 6 | 7 | @override 8 | State createState() => _MotionTabsDemoState(); 9 | } 10 | 11 | class _MotionTabsDemoState extends State { 12 | int _index = 0; 13 | 14 | static const _titles = [ 15 | 'Crafting bold landing pages', 16 | 'Polishing motion details', 17 | 'Shipping API handoffs', 18 | 'Preparing beta invites', 19 | ]; 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | return Container( 24 | decoration: const BoxDecoration( 25 | gradient: LinearGradient( 26 | begin: Alignment.topLeft, 27 | end: Alignment.bottomRight, 28 | colors: [Color(0xFF0B1229), Color(0xFF0E1636)], 29 | ), 30 | ), 31 | child: Center( 32 | child: ConstrainedBox( 33 | constraints: const BoxConstraints(maxWidth: 560), 34 | child: Padding( 35 | padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), 36 | child: Column( 37 | mainAxisSize: MainAxisSize.min, 38 | crossAxisAlignment: CrossAxisAlignment.start, 39 | children: [ 40 | Text( 41 | 'Workflow', 42 | style: Theme.of(context).textTheme.titleSmall?.copyWith( 43 | color: Colors.white70, 44 | letterSpacing: 0.6, 45 | ), 46 | ), 47 | const SizedBox(height: 12), 48 | MotionTabs( 49 | initialIndex: _index, 50 | onChanged: (i) => setState(() => _index = i), 51 | backgroundColor: const Color(0xFF5A5FFF), 52 | overlayColor: const Color(0x33FFFFFF), 53 | selectedColor: Colors.white, 54 | textColor: Colors.white.withValues(alpha: 0.86), 55 | selectedTextColor: const Color(0xFF1C1F3E), 56 | height: 58, 57 | borderRadius: 18, 58 | gap: 10, 59 | items: const [ 60 | MotionTabItem( 61 | label: 'Overview', 62 | icon: Icon(Icons.blur_on_rounded), 63 | ), 64 | MotionTabItem( 65 | label: 'Design', 66 | icon: Icon(Icons.auto_fix_high_rounded), 67 | ), 68 | MotionTabItem( 69 | label: 'Develop', 70 | icon: Icon(Icons.code_rounded), 71 | ), 72 | MotionTabItem( 73 | label: 'Launch', 74 | icon: Icon(Icons.rocket_launch_rounded), 75 | ), 76 | ], 77 | ), 78 | const SizedBox(height: 18), 79 | AnimatedSwitcher( 80 | duration: const Duration(milliseconds: 250), 81 | child: Row( 82 | key: ValueKey(_index), 83 | children: [ 84 | Container( 85 | width: 10, 86 | height: 10, 87 | decoration: const BoxDecoration( 88 | color: Color(0xFF8BE1FF), 89 | shape: BoxShape.circle, 90 | ), 91 | ), 92 | const SizedBox(width: 10), 93 | Text( 94 | _titles[_index], 95 | style: Theme.of(context).textTheme.titleMedium 96 | ?.copyWith( 97 | color: Colors.white, 98 | fontWeight: FontWeight.w600, 99 | ), 100 | ), 101 | ], 102 | ), 103 | ), 104 | ], 105 | ), 106 | ), 107 | ), 108 | ), 109 | ); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /lib/pages/docs/docs_search_delegate.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:google_fonts/google_fonts.dart'; 4 | import '../../services/widget_loader.dart'; 5 | import '../../models/widget_metadata.dart'; 6 | 7 | class DocsSearchDelegate extends SearchDelegate { 8 | @override 9 | List? buildActions(BuildContext context) { 10 | return [ 11 | if (query.isNotEmpty) 12 | IconButton( 13 | icon: const Icon(Icons.clear), 14 | onPressed: () { 15 | query = ''; 16 | showSuggestions(context); 17 | }, 18 | ), 19 | ]; 20 | } 21 | 22 | @override 23 | Widget? buildLeading(BuildContext context) { 24 | return IconButton( 25 | icon: const Icon(Icons.arrow_back), 26 | onPressed: () => close(context, null), 27 | ); 28 | } 29 | 30 | @override 31 | Widget buildResults(BuildContext context) { 32 | return _buildSearchResults(context); 33 | } 34 | 35 | @override 36 | Widget buildSuggestions(BuildContext context) { 37 | return _buildSearchResults(context); 38 | } 39 | 40 | Widget _buildSearchResults(BuildContext context) { 41 | final results = WidgetLoader.search(query); 42 | 43 | if (results.isEmpty) { 44 | return Center( 45 | child: Column( 46 | mainAxisAlignment: MainAxisAlignment.center, 47 | children: [ 48 | Icon( 49 | Icons.search_off, 50 | size: 64, 51 | color: Theme.of( 52 | context, 53 | ).colorScheme.onSurface.withValues(alpha: 0.5), 54 | ), 55 | const SizedBox(height: 16), 56 | Text( 57 | 'No results found for "$query"', 58 | style: GoogleFonts.inter( 59 | fontSize: 16, 60 | color: Theme.of( 61 | context, 62 | ).colorScheme.onSurface.withValues(alpha: 0.7), 63 | ), 64 | ), 65 | ], 66 | ), 67 | ); 68 | } 69 | 70 | return ListView.separated( 71 | padding: const EdgeInsets.all(16), 72 | itemCount: results.length, 73 | separatorBuilder: (context, index) => const Divider(height: 1), 74 | itemBuilder: (context, index) { 75 | final widget = results[index]; 76 | return ListTile( 77 | title: Text( 78 | widget.name, 79 | style: GoogleFonts.inter(fontWeight: FontWeight.w600), 80 | ), 81 | subtitle: Text( 82 | widget.description, 83 | maxLines: 1, 84 | overflow: TextOverflow.ellipsis, 85 | style: GoogleFonts.inter( 86 | fontSize: 12, 87 | color: Theme.of( 88 | context, 89 | ).colorScheme.onSurface.withValues(alpha: 0.7), 90 | ), 91 | ), 92 | trailing: Icon( 93 | Icons.chevron_right, 94 | size: 16, 95 | color: Theme.of( 96 | context, 97 | ).colorScheme.onSurface.withValues(alpha: 0.5), 98 | ), 99 | onTap: () { 100 | close(context, widget); 101 | context.go('/widgets/${widget.identifier.replaceAll('_', '-')}'); 102 | }, 103 | ); 104 | }, 105 | ); 106 | } 107 | 108 | @override 109 | ThemeData appBarTheme(BuildContext context) { 110 | final theme = Theme.of(context); 111 | return theme.copyWith( 112 | appBarTheme: theme.appBarTheme.copyWith( 113 | backgroundColor: theme.scaffoldBackgroundColor, 114 | elevation: 0, 115 | iconTheme: theme.iconTheme.copyWith(color: theme.colorScheme.onSurface), 116 | titleTextStyle: GoogleFonts.inter( 117 | color: theme.colorScheme.onSurface, 118 | fontSize: 18, 119 | fontWeight: FontWeight.w500, 120 | ), 121 | ), 122 | inputDecorationTheme: InputDecorationTheme( 123 | border: InputBorder.none, 124 | hintStyle: GoogleFonts.inter( 125 | color: theme.colorScheme.onSurface.withValues(alpha: 0.5), 126 | ), 127 | ), 128 | ); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /docs/widgets/navigations/dock.md: -------------------------------------------------------------------------------- 1 | A macOS-style dock with smooth magnification effect on hover. Icons scale up as the cursor approaches, creating an iconic and intuitive interaction pattern. 2 | 3 | ## Preview 4 | 5 | @{WidgetPreview:navigations/dock} 6 | 7 | ## Features 8 | 9 | - Smooth proximity-based magnification effect 10 | - Customizable icon and dock appearance 11 | - Support for separators between icon groups 12 | - Horizontal and vertical layouts 13 | - Configurable scale and distance parameters 14 | - Zero external dependencies 15 | 16 | ## Properties 17 | 18 | ### Dock 19 | 20 | | Property | Type | Default | Description | 21 | |----------|------|---------|-------------| 22 | | `items` | `List` | *required* | List of `DockIcon` and `DockSeparator` widgets | 23 | | `itemSize` | `double` | `48.0` | Base size of each dock item | 24 | | `maxScale` | `double` | `2.0` | Maximum scale factor when cursor is closest | 25 | | `itemScale` | `double` | `1.0` | Scale factor applied to icon content | 26 | | `distance` | `double` | `100.0` | Distance in pixels for magnification effect | 27 | | `direction` | `Axis` | `Axis.horizontal` | Layout direction of the dock | 28 | | `gap` | `double` | `8.0` | Gap between dock items | 29 | | `padding` | `EdgeInsetsGeometry?` | `EdgeInsets.all(8)` | Padding inside the dock container | 30 | | `decoration` | `BoxDecoration?` | Dark rounded box | Decoration for the dock container | 31 | | `itemDecoration` | `BoxDecoration?` | Dark rounded box | Default decoration for dock icons | 32 | 33 | ### DockIcon 34 | 35 | | Property | Type | Default | Description | 36 | |----------|------|---------|-------------| 37 | | `child` | `Widget` | *required* | The icon or content to display | 38 | | `decoration` | `BoxDecoration?` | `null` | Custom decoration (overrides dock default) | 39 | | `padding` | `EdgeInsetsGeometry?` | `EdgeInsets.zero` | Padding around the icon | 40 | | `onTap` | `VoidCallback?` | `null` | Callback when icon is tapped | 41 | 42 | ### DockSeparator 43 | 44 | | Property | Type | Default | Description | 45 | |----------|------|---------|-------------| 46 | | `width` | `double` | `1.0` | Width of the separator line | 47 | | `height` | `double` | `32.0` | Height of the separator line | 48 | | `color` | `Color?` | `Color(0x40FFFFFF)` | Color of the separator | 49 | | `margin` | `EdgeInsetsGeometry?` | `EdgeInsets.symmetric(horizontal: 4)` | Margin around the separator | 50 | 51 | ## Usage 52 | 53 | ```dart 54 | import 'widgets/navigations/dock.dart'; 55 | 56 | Dock( 57 | items: [ 58 | DockIcon( 59 | child: Icon(Icons.home, color: Colors.white), 60 | onTap: () => print('Home'), 61 | ), 62 | DockIcon( 63 | child: Icon(Icons.search, color: Colors.white), 64 | onTap: () => print('Search'), 65 | ), 66 | DockSeparator(), 67 | DockIcon( 68 | child: Icon(Icons.settings, color: Colors.white), 69 | onTap: () => print('Settings'), 70 | ), 71 | ], 72 | ) 73 | ``` 74 | 75 | ## Examples 76 | 77 | ### Custom Styling 78 | 79 | ```dart 80 | Dock( 81 | decoration: BoxDecoration( 82 | color: Colors.white.withOpacity(0.9), 83 | borderRadius: BorderRadius.circular(20), 84 | ), 85 | itemDecoration: BoxDecoration( 86 | color: Colors.blue, 87 | borderRadius: BorderRadius.circular(12), 88 | ), 89 | items: [ 90 | DockIcon(child: Icon(Icons.star, color: Colors.white)), 91 | DockIcon(child: Icon(Icons.favorite, color: Colors.white)), 92 | ], 93 | ) 94 | ``` 95 | 96 | ### Larger Magnification 97 | 98 | ```dart 99 | Dock( 100 | itemSize: 56.0, 101 | maxScale: 2.5, 102 | distance: 150.0, 103 | items: [...], 104 | ) 105 | ``` 106 | 107 | ### Subtle Effect 108 | 109 | ```dart 110 | Dock( 111 | maxScale: 1.3, 112 | distance: 80.0, 113 | gap: 4.0, 114 | items: [...], 115 | ) 116 | ``` 117 | 118 | ### Custom Icon Design 119 | 120 | ```dart 121 | DockIcon( 122 | decoration: BoxDecoration( 123 | gradient: LinearGradient( 124 | colors: [Colors.purple, Colors.blue], 125 | ), 126 | borderRadius: BorderRadius.circular(12), 127 | ), 128 | child: Icon(Icons.apps, color: Colors.white), 129 | onTap: () {}, 130 | ) 131 | ``` 132 | 133 | ## Tips 134 | 135 | - Use `DockSeparator` to visually group related icons 136 | - Keep 5-9 items for optimal usability 137 | - Adjust `distance` based on dock size for natural feel 138 | - Use consistent icon colors for a cohesive appearance 139 | 140 | ## Source Code 141 | 142 | @{WidgetCode:navigations/dock} -------------------------------------------------------------------------------- /docs/widgets/cards/three-d-card.md: -------------------------------------------------------------------------------- 1 | An interactive 3D card that responds to mouse/pointer movement with realistic perspective transforms. Perfect for product showcases, portfolios, and interactive UI elements. 2 | 3 | ## Preview 4 | 5 | @{WidgetPreview:cards/three_d_card} 6 | 7 | ## Features 8 | 9 | - Real-time mouse/pointer tracking with 3D perspective 10 | - Smooth tilt animation with configurable sensitivity 11 | - Hover state decoration support 12 | - Nested `ThreeDCardItem` for layered parallax effects 13 | - Throttled updates for optimal performance 14 | - Zero external dependencies 15 | 16 | ## Properties 17 | 18 | ### ThreeDCard 19 | 20 | | Property | Type | Default | Description | 21 | |----------|------|---------|-------------| 22 | | `child` | `Widget` | *required* | The card content | 23 | | `sensitivity` | `double` | `25.0` | Mouse movement sensitivity (higher = less tilt) | 24 | | `duration` | `Duration` | `200ms` | Animation duration for tilt transitions | 25 | | `decoration` | `BoxDecoration?` | `null` | Base decoration for the card | 26 | | `hoverDecoration` | `BoxDecoration?` | `null` | Decoration applied when hovering | 27 | | `padding` | `EdgeInsetsGeometry?` | `null` | Padding inside the card | 28 | | `maxRotationX` | `double` | `25.0` | Maximum rotation on X axis (degrees) | 29 | | `maxRotationY` | `double` | `25.0` | Maximum rotation on Y axis (degrees) | 30 | | `onHoverStart` | `VoidCallback?` | `null` | Callback when hover begins | 31 | | `onHoverEnd` | `VoidCallback?` | `null` | Callback when hover ends | 32 | | `curve` | `Curve` | `Curves.easeOut` | Animation curve | 33 | | `enabled` | `bool` | `true` | Whether the 3D effect is enabled | 34 | 35 | ### ThreeDCardItem 36 | 37 | Use inside `ThreeDCard` to create parallax layers that move independently. 38 | 39 | | Property | Type | Default | Description | 40 | |----------|------|---------|-------------| 41 | | `child` | `Widget` | *required* | The item content | 42 | | `translateX` | `double` | `0` | Horizontal translation on hover | 43 | | `translateY` | `double` | `0` | Vertical translation on hover | 44 | | `translateZ` | `double` | `0` | Depth translation on hover | 45 | | `rotateX` | `double` | `0` | X-axis rotation on hover (degrees) | 46 | | `rotateY` | `double` | `0` | Y-axis rotation on hover (degrees) | 47 | | `rotateZ` | `double` | `0` | Z-axis rotation on hover (degrees) | 48 | | `duration` | `Duration` | `500ms` | Animation duration | 49 | 50 | ## Usage 51 | 52 | ```dart 53 | import 'widgets/cards/three_d_card.dart'; 54 | 55 | ThreeDCard( 56 | decoration: BoxDecoration( 57 | color: Colors.white, 58 | borderRadius: BorderRadius.circular(16), 59 | boxShadow: [ 60 | BoxShadow( 61 | color: Colors.black26, 62 | blurRadius: 20, 63 | offset: Offset(0, 10), 64 | ), 65 | ], 66 | ), 67 | child: Container( 68 | width: 300, 69 | height: 400, 70 | padding: EdgeInsets.all(24), 71 | child: Column( 72 | children: [ 73 | Text('Product Name'), 74 | Text('\$99.00'), 75 | ], 76 | ), 77 | ), 78 | ) 79 | ``` 80 | 81 | ## Examples 82 | 83 | ### With Hover Shadow 84 | 85 | ```dart 86 | ThreeDCard( 87 | decoration: BoxDecoration( 88 | color: Colors.white, 89 | borderRadius: BorderRadius.circular(12), 90 | boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10)], 91 | ), 92 | hoverDecoration: BoxDecoration( 93 | boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 30)], 94 | ), 95 | child: YourContent(), 96 | ) 97 | ``` 98 | 99 | ### Subtle Tilt Effect 100 | 101 | ```dart 102 | ThreeDCard( 103 | sensitivity: 40.0, 104 | maxRotationX: 10.0, 105 | maxRotationY: 10.0, 106 | child: YourContent(), 107 | ) 108 | ``` 109 | 110 | ### Parallax Layers 111 | 112 | ```dart 113 | ThreeDCard( 114 | child: Stack( 115 | children: [ 116 | // Background layer - moves less 117 | ThreeDCardItem( 118 | translateZ: 20, 119 | child: Image.asset('background.png'), 120 | ), 121 | // Foreground layer - moves more 122 | ThreeDCardItem( 123 | translateZ: 50, 124 | translateY: -10, 125 | child: Text('Floating Title'), 126 | ), 127 | ], 128 | ), 129 | ) 130 | ``` 131 | 132 | ## Tips 133 | 134 | - Use `sensitivity` between 20-40 for natural feel 135 | - Combine with `hoverDecoration` for enhanced shadows on hover 136 | - Nest `ThreeDCardItem` widgets for parallax depth effects 137 | - Keep card size between 200-500px for best visual impact 138 | 139 | ## Source Code 140 | 141 | @{WidgetCode:cards/three_d_card} -------------------------------------------------------------------------------- /lib/components/widget_code.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:google_fonts/google_fonts.dart'; 4 | 5 | /// A component that displays widget source code with syntax highlighting and copy functionality. 6 | class WidgetCode extends StatelessWidget { 7 | /// The source code to display 8 | final String code; 9 | 10 | /// Optional title for the code block 11 | final String? title; 12 | 13 | /// Whether to show the header with title and copy button 14 | final bool showHeader; 15 | 16 | const WidgetCode({ 17 | super.key, 18 | required this.code, 19 | this.title, 20 | this.showHeader = true, 21 | }); 22 | 23 | /// Static method to copy code to clipboard with feedback 24 | static Future copyToClipboard(BuildContext context, String code) async { 25 | await Clipboard.setData(ClipboardData(text: code)); 26 | if (context.mounted) { 27 | ScaffoldMessenger.of(context).showSnackBar( 28 | const SnackBar( 29 | content: Text('Code copied to clipboard!'), 30 | duration: Duration(seconds: 2), 31 | ), 32 | ); 33 | } 34 | } 35 | 36 | @override 37 | Widget build(BuildContext context) { 38 | final isDark = Theme.of(context).brightness == Brightness.dark; 39 | 40 | return Container( 41 | decoration: BoxDecoration( 42 | color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5), 43 | borderRadius: showHeader ? BorderRadius.circular(12) : null, 44 | border: showHeader 45 | ? Border.all( 46 | color: isDark ? Colors.grey[800]! : Colors.grey[300]!, 47 | width: 1, 48 | ) 49 | : null, 50 | ), 51 | child: Column( 52 | crossAxisAlignment: CrossAxisAlignment.stretch, 53 | children: [ 54 | // Header with title and copy button (optional) 55 | if (showHeader) 56 | Container( 57 | padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 58 | decoration: BoxDecoration( 59 | color: isDark 60 | ? const Color(0xFF252525) 61 | : const Color(0xFFEEEEEE), 62 | borderRadius: const BorderRadius.only( 63 | topLeft: Radius.circular(11), 64 | topRight: Radius.circular(11), 65 | ), 66 | border: Border( 67 | bottom: BorderSide( 68 | color: isDark ? Colors.grey[800]! : Colors.grey[300]!, 69 | width: 1, 70 | ), 71 | ), 72 | ), 73 | child: Row( 74 | children: [ 75 | if (title != null) ...[ 76 | Expanded( 77 | child: Text( 78 | title!, 79 | style: GoogleFonts.inter( 80 | fontSize: 13, 81 | fontWeight: FontWeight.w500, 82 | color: isDark ? Colors.grey[400] : Colors.grey[600], 83 | ), 84 | overflow: TextOverflow.ellipsis, 85 | ), 86 | ), 87 | ], 88 | if (title == null) const Spacer(), 89 | IconButton( 90 | icon: Icon( 91 | Icons.copy_rounded, 92 | size: 18, 93 | color: isDark ? Colors.grey[400] : Colors.grey[600], 94 | ), 95 | onPressed: () => copyToClipboard(context, code), 96 | tooltip: 'Copy code', 97 | padding: EdgeInsets.zero, 98 | constraints: const BoxConstraints(), 99 | splashRadius: 16, 100 | ), 101 | ], 102 | ), 103 | ), 104 | // Code content 105 | Expanded( 106 | child: SingleChildScrollView( 107 | padding: const EdgeInsets.all(16), 108 | child: SelectableText( 109 | code, 110 | style: GoogleFonts.firaCode( 111 | fontSize: 13, 112 | height: 1.6, 113 | color: isDark ? Colors.grey[300] : Colors.grey[800], 114 | ), 115 | ), 116 | ), 117 | ), 118 | ], 119 | ), 120 | ); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /lib/widgets/backgrounds/flickering_grid.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | import 'package:flutter/widgets.dart'; 3 | 4 | class FlickeringGrid extends StatefulWidget { 5 | final double squareSize; 6 | final double gridGap; 7 | final double flickerChance; 8 | final Color color; 9 | final double maxOpacity; 10 | final Duration duration; 11 | final Widget? child; 12 | 13 | const FlickeringGrid({ 14 | super.key, 15 | this.squareSize = 4.0, 16 | this.gridGap = 6.0, 17 | this.flickerChance = 0.3, 18 | this.color = const Color(0xFF000000), 19 | this.maxOpacity = 0.3, 20 | this.duration = const Duration(seconds: 1), 21 | this.child, 22 | }); 23 | 24 | @override 25 | State createState() => _FlickeringGridState(); 26 | } 27 | 28 | class _RepaintNotifier extends ChangeNotifier { 29 | void notify() => notifyListeners(); 30 | } 31 | 32 | class _FlickeringGridState extends State 33 | with SingleTickerProviderStateMixin { 34 | late final controller = AnimationController( 35 | vsync: this, 36 | duration: widget.duration, 37 | ); 38 | final random = Random(); 39 | final repaintNotifier = _RepaintNotifier(); 40 | 41 | Size? size; 42 | int cols = 0; 43 | int rows = 0; 44 | List squares = List.empty(); 45 | 46 | @override 47 | void initState() { 48 | super.initState(); 49 | controller 50 | ..repeat() 51 | ..addListener(tick); 52 | } 53 | 54 | void tick() { 55 | if (squares.isEmpty) return; 56 | 57 | const dt = 0.016; 58 | for (int i = 0; i < squares.length; i++) { 59 | if (random.nextDouble() < widget.flickerChance * dt) { 60 | squares[i] = random.nextDouble() * widget.maxOpacity; 61 | } 62 | } 63 | repaintNotifier.notify(); 64 | } 65 | 66 | void maybeSetup(Size size) { 67 | if (size != this.size) { 68 | this.size = size; 69 | cols = (size.width / (widget.squareSize + widget.gridGap)).floor(); 70 | rows = (size.height / (widget.squareSize + widget.gridGap)).floor(); 71 | squares = List.generate( 72 | cols * rows, 73 | (_) => random.nextDouble() * widget.maxOpacity, 74 | ); 75 | } 76 | } 77 | 78 | @override 79 | void dispose() { 80 | controller.dispose(); 81 | repaintNotifier.dispose(); 82 | super.dispose(); 83 | } 84 | 85 | @override 86 | Widget build(BuildContext context) { 87 | return LayoutBuilder( 88 | builder: (context, constraints) { 89 | final size = Size(constraints.maxWidth, constraints.maxHeight); 90 | maybeSetup(size); 91 | 92 | return CustomPaint( 93 | size: size, 94 | painter: _FlickeringGridPainter( 95 | squares: squares, 96 | cols: cols, 97 | rows: rows, 98 | squareSize: widget.squareSize, 99 | gridGap: widget.gridGap, 100 | color: widget.color, 101 | repaint: repaintNotifier, 102 | ), 103 | child: widget.child, 104 | ); 105 | }, 106 | ); 107 | } 108 | } 109 | 110 | class _FlickeringGridPainter extends CustomPainter { 111 | final Iterable squares; 112 | final int cols; 113 | final int rows; 114 | final double squareSize; 115 | final double gridGap; 116 | final Color color; 117 | 118 | // Reusable paint object to avoid memory allocations 119 | static final Paint _paint = Paint()..style = PaintingStyle.fill; 120 | 121 | _FlickeringGridPainter({ 122 | required this.squares, 123 | required this.cols, 124 | required this.rows, 125 | required this.squareSize, 126 | required this.gridGap, 127 | required this.color, 128 | super.repaint, 129 | }); 130 | 131 | @override 132 | void paint(Canvas canvas, Size size) { 133 | for (int i = 0; i < cols; i++) { 134 | for (int j = 0; j < rows; j++) { 135 | final index = i * rows + j; 136 | if (index >= squares.length) continue; 137 | 138 | final x = i * (squareSize + gridGap); 139 | final y = j * (squareSize + gridGap); 140 | 141 | _paint.color = color.withValues(alpha: squares.elementAt(index)); 142 | canvas.drawRect(Rect.fromLTWH(x, y, squareSize, squareSize), _paint); 143 | } 144 | } 145 | } 146 | 147 | @override 148 | bool shouldRepaint(covariant _FlickeringGridPainter oldDelegate) { 149 | return cols != oldDelegate.cols || 150 | rows != oldDelegate.rows || 151 | squareSize != oldDelegate.squareSize || 152 | gridGap != oldDelegate.gridGap || 153 | color != oldDelegate.color; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /docs/widgets/games/space-shooter.md: -------------------------------------------------------------------------------- 1 | A minimalist space shooter game inspired by 1KB game challenges and classic arcade shooters. Control your ship and destroy enemies before they escape! 2 | 3 | ## Preview 4 | 5 | @{WidgetPreview:games/space_shooter} 6 | 7 | ## Features 8 | 9 | - 🎮 **Simple Controls**: Move your ship with mouse or touch 10 | - 🚀 **Dual Bullets**: Automatically fires two bullets upward 11 | - 👾 **Enemy Waves**: Enemies spawn from the top at random positions 12 | - 📊 **Score System**: +1 for destroying enemies, -1 for missing them 13 | - 💀 **Game Over**: Score starts at configurable value, game ends when it reaches 0 14 | - 🔄 **Restart**: Click restart button to play again 15 | - ⚙️ **Customizable**: Adjust difficulty with spawn rates and speeds 16 | 17 | ## Properties 18 | 19 | | Property | Type | Default | Description | 20 | |----------|------|---------|-------------| 21 | | `playerColor` | `Color` | `Color(0xFF00FF00)` | Color of the player's ship | 22 | | `enemyColor` | `Color` | `Color(0xFFFF0000)` | Color of enemy ships | 23 | | `bulletColor` | `Color` | `Color(0xFFFFFF00)` | Color of bullets | 24 | | `backgroundColor` | `Color` | `Color(0xFF000000)` | Background color | 25 | | `textColor` | `Color` | `Color(0xFFFFFFFF)` | Text color for score display | 26 | | `initialScore` | `int` | `10` | Starting score (game over when reaches 0) | 27 | | `enemySpawnInterval` | `int` | `1500` | How often enemies spawn (milliseconds) | 28 | | `bulletFireInterval` | `int` | `300` | How often bullets fire (milliseconds) | 29 | | `enemySpeed` | `double` | `2.0` | Speed of enemy movement (pixels per frame) | 30 | | `bulletSpeed` | `double` | `5.0` | Speed of bullet movement (pixels per frame) | 31 | | `autoStart` | `bool` | `false` | Whether to start game automatically | 32 | | `onGameOver` | `VoidCallback?` | `null` | Callback when game is over | 33 | | `onScoreChanged` | `ValueChanged?` | `null` | Callback when score changes | 34 | 35 | ## Usage 36 | 37 | ### Basic Example 38 | 39 | ```dart 40 | import 'package:arcade/widgets/games/space_shooter.dart'; 41 | 42 | SpaceShooter( 43 | autoStart: true, 44 | ) 45 | ``` 46 | 47 | ### Full-Screen Game 48 | 49 | ```dart 50 | Scaffold( 51 | body: SpaceShooter( 52 | autoStart: true, 53 | ), 54 | ) 55 | ``` 56 | 57 | ### With Custom Colors 58 | 59 | ```dart 60 | SpaceShooter( 61 | playerColor: Colors.cyan, 62 | enemyColor: Colors.purple, 63 | bulletColor: Colors.orange, 64 | backgroundColor: Color(0xFF0A0E27), 65 | ) 66 | ``` 67 | 68 | ### Hard Mode Configuration 69 | 70 | ```dart 71 | SpaceShooter( 72 | initialScore: 15, 73 | enemySpawnInterval: 800, // Faster spawning 74 | enemySpeed: 3.5, // Faster enemies 75 | bulletSpeed: 4.0, // Slower bullets 76 | onScoreChanged: (score) { 77 | print('Score: $score'); 78 | }, 79 | onGameOver: () { 80 | print('Game Over!'); 81 | }, 82 | ) 83 | ``` 84 | 85 | ## Game Rules 86 | 87 | 1. **Movement**: Your ship follows your mouse cursor or touch position 88 | 2. **Shooting**: Bullets are fired automatically in dual streams 89 | 3. **Enemies**: Spawn at random positions from the top and move downward 90 | 4. **Scoring**: 91 | - Hit an enemy: +1 point 92 | - Enemy escapes (reaches bottom): -1 point 93 | 5. **Game Over**: When score reaches 0 94 | 6. **Controls**: Click "PLAY" to start, click "RESTART" after game over 95 | 96 | ## Examples 97 | 98 | ### Easy Mode 99 | 100 | Perfect for beginners - slower enemies, more starting lives: 101 | 102 | ```dart 103 | SpaceShooter( 104 | initialScore: 20, 105 | enemySpawnInterval: 2000, 106 | enemySpeed: 1.5, 107 | bulletSpeed: 6.0, 108 | ) 109 | ``` 110 | 111 | ### Nightmare Mode 112 | 113 | For experienced players - fast enemies, quick spawning: 114 | 115 | ```dart 116 | SpaceShooter( 117 | initialScore: 10, 118 | enemySpawnInterval: 600, 119 | enemySpeed: 4.0, 120 | bulletSpeed: 4.5, 121 | ) 122 | ``` 123 | 124 | ### Retro Arcade Style 125 | 126 | Classic arcade game appearance: 127 | 128 | ```dart 129 | SpaceShooter( 130 | playerColor: Color(0xFF00FFFF), 131 | enemyColor: Color(0xFFFF00FF), 132 | bulletColor: Color(0xFFFFFF00), 133 | backgroundColor: Color(0xFF000033), 134 | initialScore: 15, 135 | ) 136 | ``` 137 | 138 | ## Performance 139 | 140 | The game runs at 60 FPS using Flutter's Ticker API and CustomPainter for efficient rendering. All game logic and rendering is contained in a single file (~400 lines) for easy copying and customization. 141 | 142 | ## Tips 143 | 144 | - Start with default settings and adjust difficulty as needed 145 | - Lower `enemySpawnInterval` for more challenge 146 | - Increase `enemySpeed` for faster-paced gameplay 147 | - Higher `initialScore` gives more margin for error 148 | - Balance `bulletSpeed` and `enemySpeed` for fair gameplay 149 | 150 | ## Source Code 151 | 152 | @{WidgetCode:games/space_shooter} 153 | -------------------------------------------------------------------------------- /lib/widgets/cards/three_d_card_demo.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'three_d_card.dart'; 3 | 4 | /// Demo widget for ThreeDCard showcasing an "Apple Vision Pro" style card 5 | class ThreeDCardDemo extends StatelessWidget { 6 | const ThreeDCardDemo({super.key}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | final surfaceColor = Theme.of(context).colorScheme.surface; 11 | final primaryColor = Theme.of(context).primaryColor; 12 | 13 | return Center( 14 | child: ThreeDCard( 15 | decoration: BoxDecoration( 16 | color: surfaceColor, 17 | borderRadius: BorderRadius.circular(20), 18 | border: Border.all(color: primaryColor.withValues(alpha: 0.1)), 19 | boxShadow: [ 20 | BoxShadow( 21 | color: primaryColor.withValues(alpha: 0.2), 22 | blurRadius: 20, 23 | spreadRadius: 5, 24 | ), 25 | ], 26 | ), 27 | hoverDecoration: BoxDecoration( 28 | boxShadow: [ 29 | BoxShadow( 30 | color: primaryColor.withValues(alpha: 0.2), 31 | blurRadius: 30, 32 | spreadRadius: 2, 33 | ), 34 | ], 35 | ), 36 | padding: const EdgeInsets.all(24), 37 | child: SizedBox( 38 | width: 320, 39 | child: Column( 40 | mainAxisSize: MainAxisSize.min, 41 | crossAxisAlignment: CrossAxisAlignment.start, 42 | children: [ 43 | // Title 44 | ThreeDCardItem( 45 | translateZ: 40, 46 | child: const Text( 47 | 'Apple Vision Pro', 48 | style: TextStyle( 49 | fontSize: 24, 50 | fontWeight: FontWeight.bold, 51 | letterSpacing: 0.5, 52 | ), 53 | ), 54 | ), 55 | const SizedBox(height: 6), 56 | // Description 57 | ThreeDCardItem( 58 | translateZ: 50, 59 | child: Text( 60 | 'Welcome to the era of spatial computing.', 61 | style: TextStyle(fontSize: 14, height: 1.5), 62 | ), 63 | ), 64 | const SizedBox(height: 16), 65 | // Image 66 | ThreeDCardItem( 67 | translateZ: 80, 68 | child: Container( 69 | height: 160, 70 | decoration: BoxDecoration( 71 | borderRadius: BorderRadius.circular(12), 72 | boxShadow: [ 73 | BoxShadow( 74 | color: Colors.black.withValues(alpha: 0.3), 75 | blurRadius: 10, 76 | offset: const Offset(0, 5), 77 | ), 78 | ], 79 | ), 80 | child: ClipRRect( 81 | borderRadius: BorderRadius.circular(12), 82 | child: Image.network( 83 | 'https://images.unsplash.com/photo-1713869820987-519844949a8a?q=80&w=3500&auto=format&fit=crop', 84 | width: double.infinity, 85 | fit: BoxFit.cover, 86 | ), 87 | ), 88 | ), 89 | ), 90 | const SizedBox(height: 16), 91 | // Buttons 92 | Row( 93 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 94 | children: [ 95 | ThreeDCardItem( 96 | translateZ: 30, 97 | child: TextButton( 98 | onPressed: () {}, 99 | style: TextButton.styleFrom( 100 | padding: const EdgeInsets.symmetric(horizontal: 16), 101 | ), 102 | child: const Text('Learn more →'), 103 | ), 104 | ), 105 | ThreeDCardItem( 106 | translateZ: 40, 107 | child: FilledButton.icon( 108 | onPressed: () {}, 109 | style: FilledButton.styleFrom( 110 | shape: const StadiumBorder(), 111 | padding: const EdgeInsets.symmetric( 112 | horizontal: 24, 113 | vertical: 12, 114 | ), 115 | ), 116 | icon: const Icon(Icons.shopping_cart), 117 | label: const Text( 118 | 'Buy', 119 | style: TextStyle(fontWeight: FontWeight.w600), 120 | ), 121 | ), 122 | ), 123 | ], 124 | ), 125 | ], 126 | ), 127 | ), 128 | ), 129 | ); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /lib/pages/docs/widgets_list_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:google_fonts/google_fonts.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import '../../services/widget_loader.dart'; 5 | import '../../models/widget_metadata.dart'; 6 | 7 | /// Widgets list page (/docs/widgets) 8 | class WidgetsListPage extends StatefulWidget { 9 | const WidgetsListPage({super.key}); 10 | 11 | @override 12 | State createState() => _WidgetsListPageState(); 13 | } 14 | 15 | class _WidgetsListPageState extends State { 16 | String? _selectedGroup; 17 | String _searchQuery = ''; 18 | 19 | List get _filteredWidgets { 20 | var widgets = WidgetLoader.widgets; 21 | 22 | if (_selectedGroup != null) { 23 | widgets = widgets.where((w) => w.group == _selectedGroup).toList(); 24 | } 25 | 26 | if (_searchQuery.isNotEmpty) { 27 | widgets = WidgetLoader.search(_searchQuery); 28 | } 29 | 30 | return widgets; 31 | } 32 | 33 | @override 34 | Widget build(BuildContext context) { 35 | final groups = WidgetLoader.groups; 36 | 37 | return Scaffold( 38 | appBar: AppBar( 39 | title: Text( 40 | 'Widget Gallery', 41 | style: GoogleFonts.inter(fontWeight: FontWeight.w600), 42 | ), 43 | ), 44 | body: Column( 45 | children: [ 46 | // Search and filter bar 47 | Padding( 48 | padding: const EdgeInsets.all(16), 49 | child: Row( 50 | children: [ 51 | Expanded( 52 | child: TextField( 53 | decoration: InputDecoration( 54 | hintText: 'Search widgets...', 55 | prefixIcon: const Icon(Icons.search), 56 | border: OutlineInputBorder( 57 | borderRadius: BorderRadius.circular(8), 58 | ), 59 | ), 60 | onChanged: (value) { 61 | setState(() => _searchQuery = value); 62 | }, 63 | ), 64 | ), 65 | const SizedBox(width: 16), 66 | DropdownButton( 67 | value: _selectedGroup, 68 | hint: const Text('All Categories'), 69 | items: [ 70 | const DropdownMenuItem(value: null, child: Text('All')), 71 | ...groups.map( 72 | (group) => DropdownMenuItem( 73 | value: group, 74 | child: Text(_capitalize(group)), 75 | ), 76 | ), 77 | ], 78 | onChanged: (value) { 79 | setState(() => _selectedGroup = value); 80 | }, 81 | ), 82 | ], 83 | ), 84 | ), 85 | // Widgets grid 86 | Expanded( 87 | child: GridView.builder( 88 | padding: const EdgeInsets.all(16), 89 | gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( 90 | maxCrossAxisExtent: 350, 91 | childAspectRatio: 1.2, 92 | crossAxisSpacing: 16, 93 | mainAxisSpacing: 16, 94 | ), 95 | itemCount: _filteredWidgets.length, 96 | itemBuilder: (context, index) { 97 | final widget = _filteredWidgets[index]; 98 | return _WidgetCard(widget: widget); 99 | }, 100 | ), 101 | ), 102 | ], 103 | ), 104 | ); 105 | } 106 | 107 | String _capitalize(String input) { 108 | if (input.isEmpty) return input; 109 | return input[0].toUpperCase() + input.substring(1); 110 | } 111 | } 112 | 113 | class _WidgetCard extends StatelessWidget { 114 | final WidgetMetadata widget; 115 | 116 | const _WidgetCard({required this.widget}); 117 | 118 | @override 119 | Widget build(BuildContext context) { 120 | return Card( 121 | elevation: 2, 122 | clipBehavior: Clip.antiAlias, 123 | child: InkWell( 124 | onTap: () { 125 | context.go('/docs/${widget.group}/${_toKebabCase(widget.name)}'); 126 | }, 127 | child: Padding( 128 | padding: const EdgeInsets.all(20), 129 | child: Column( 130 | crossAxisAlignment: CrossAxisAlignment.start, 131 | children: [ 132 | // Category badge 133 | Container( 134 | padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 135 | decoration: BoxDecoration( 136 | color: Theme.of(context).colorScheme.primaryContainer, 137 | borderRadius: BorderRadius.circular(4), 138 | ), 139 | child: Text( 140 | widget.categoryName, 141 | style: TextStyle( 142 | fontSize: 12, 143 | fontWeight: FontWeight.w600, 144 | color: Theme.of(context).colorScheme.onPrimaryContainer, 145 | ), 146 | ), 147 | ), 148 | const SizedBox(height: 12), 149 | // Widget name 150 | Text( 151 | widget.name, 152 | style: GoogleFonts.inter( 153 | fontSize: 20, 154 | fontWeight: FontWeight.bold, 155 | ), 156 | ), 157 | const SizedBox(height: 8), 158 | // Description 159 | Expanded( 160 | child: Text( 161 | widget.description, 162 | style: TextStyle( 163 | fontSize: 14, 164 | color: Colors.grey[600], 165 | height: 1.4, 166 | ), 167 | maxLines: 3, 168 | overflow: TextOverflow.ellipsis, 169 | ), 170 | ), 171 | const SizedBox(height: 12), 172 | // Tags 173 | Wrap( 174 | spacing: 4, 175 | runSpacing: 4, 176 | children: widget.tags.take(3).map((tag) { 177 | return Chip( 178 | label: Text(tag, style: const TextStyle(fontSize: 11)), 179 | padding: EdgeInsets.zero, 180 | visualDensity: VisualDensity.compact, 181 | ); 182 | }).toList(), 183 | ), 184 | ], 185 | ), 186 | ), 187 | ), 188 | ); 189 | } 190 | 191 | String _toKebabCase(String input) { 192 | return input 193 | .replaceAllMapped( 194 | RegExp(r'[A-Z]'), 195 | (match) => '-${match.group(0)!.toLowerCase()}', 196 | ) 197 | .replaceFirst(RegExp(r'^-'), ''); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /lib/pages/docs/docs_index_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:google_fonts/google_fonts.dart'; 4 | import '../../services/widget_loader.dart'; 5 | import '../../models/widget_metadata.dart'; 6 | 7 | /// Documentation index page (/docs) 8 | class DocsIndexPage extends StatelessWidget { 9 | const DocsIndexPage({super.key}); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | // Group widgets by category 14 | final groups = >{}; 15 | for (var widget in WidgetLoader.widgets) { 16 | if (!groups.containsKey(widget.group)) { 17 | groups[widget.group] = []; 18 | } 19 | groups[widget.group]!.add(widget); 20 | } 21 | 22 | return Title( 23 | title: 'Widget Index - Flutter Arcade UI', 24 | color: Theme.of(context).primaryColor, 25 | child: SingleChildScrollView( 26 | padding: const EdgeInsets.all(40), 27 | child: Column( 28 | crossAxisAlignment: CrossAxisAlignment.start, 29 | children: [ 30 | Text( 31 | 'Widgets', 32 | style: GoogleFonts.inter( 33 | fontSize: 16, 34 | fontWeight: FontWeight.w500, 35 | color: Theme.of(context).colorScheme.primary, 36 | ), 37 | ), 38 | const SizedBox(height: 32), 39 | Text( 40 | 'Widget Index', 41 | style: GoogleFonts.outfit( 42 | fontSize: 48, 43 | fontWeight: FontWeight.bold, 44 | height: 1.1, 45 | ), 46 | ), 47 | const SizedBox(height: 16), 48 | Text( 49 | 'List of all the widgets provided by Arcade UI.', 50 | style: GoogleFonts.inter( 51 | fontSize: 18, 52 | color: Theme.of( 53 | context, 54 | ).colorScheme.onSurface.withValues(alpha: 0.7), 55 | ), 56 | ), 57 | const SizedBox(height: 48), 58 | // All Widgets Section 59 | Text( 60 | 'All widgets', 61 | style: GoogleFonts.outfit( 62 | fontSize: 32, 63 | fontWeight: FontWeight.bold, 64 | ), 65 | ), 66 | const SizedBox(height: 32), 67 | 68 | ...groups.entries.map((entry) { 69 | return Column( 70 | crossAxisAlignment: CrossAxisAlignment.start, 71 | children: [ 72 | Padding( 73 | padding: const EdgeInsets.symmetric(vertical: 16), 74 | child: Row( 75 | children: [ 76 | Text( 77 | '${entry.key[0].toUpperCase()}${entry.key.substring(1)}', 78 | style: GoogleFonts.inter( 79 | fontSize: 20, 80 | fontWeight: FontWeight.w600, 81 | ), 82 | ), 83 | const SizedBox(width: 8), 84 | Text( 85 | '(${entry.value.length})', 86 | style: GoogleFonts.inter( 87 | fontSize: 16, 88 | color: Theme.of( 89 | context, 90 | ).colorScheme.onSurface.withValues(alpha: 0.5), 91 | ), 92 | ), 93 | ], 94 | ), 95 | ), 96 | Container( 97 | decoration: BoxDecoration( 98 | border: Border.all( 99 | color: Theme.of( 100 | context, 101 | ).dividerColor.withValues(alpha: 0.2), 102 | ), 103 | borderRadius: BorderRadius.circular(12), 104 | ), 105 | child: Column( 106 | children: entry.value.asMap().entries.map((widgetEntry) { 107 | final index = widgetEntry.key; 108 | final widget = widgetEntry.value; 109 | final isLast = index == entry.value.length - 1; 110 | 111 | return _ComponentListItem( 112 | widget: widget, 113 | showDivider: !isLast, 114 | ); 115 | }).toList(), 116 | ), 117 | ), 118 | const SizedBox(height: 32), 119 | ], 120 | ); 121 | }), 122 | ], 123 | ), 124 | ), 125 | ); 126 | } 127 | } 128 | 129 | class _ComponentListItem extends StatelessWidget { 130 | final WidgetMetadata widget; 131 | final bool showDivider; 132 | 133 | const _ComponentListItem({required this.widget, this.showDivider = true}); 134 | 135 | @override 136 | Widget build(BuildContext context) { 137 | return InkWell( 138 | onTap: () => 139 | context.go('/widgets/${widget.identifier.replaceAll('_', '-')}'), 140 | child: Container( 141 | padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), 142 | decoration: BoxDecoration( 143 | border: showDivider 144 | ? Border( 145 | bottom: BorderSide( 146 | color: Theme.of( 147 | context, 148 | ).dividerColor.withValues(alpha: 0.1), 149 | ), 150 | ) 151 | : null, 152 | ), 153 | child: Row( 154 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 155 | children: [ 156 | Text( 157 | widget.name, 158 | style: GoogleFonts.inter( 159 | fontSize: 16, 160 | fontWeight: FontWeight.w500, 161 | ), 162 | ), 163 | Row( 164 | children: [ 165 | Text( 166 | 'View', 167 | style: GoogleFonts.inter( 168 | fontSize: 14, 169 | fontWeight: FontWeight.w500, 170 | color: Theme.of( 171 | context, 172 | ).colorScheme.onSurface.withValues(alpha: 0.7), 173 | ), 174 | ), 175 | const SizedBox(width: 4), 176 | Icon( 177 | Icons.arrow_forward, 178 | size: 16, 179 | color: Theme.of( 180 | context, 181 | ).colorScheme.onSurface.withValues(alpha: 0.7), 182 | ), 183 | ], 184 | ), 185 | ], 186 | ), 187 | ), 188 | ); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /lib/widgets/cards/three_d_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | 3 | const double _kDegreesToRadians = 0.0174533; 4 | 5 | class ThreeDCardData extends InheritedWidget { 6 | final bool isHovered; 7 | 8 | const ThreeDCardData({ 9 | super.key, 10 | required this.isHovered, 11 | required super.child, 12 | }); 13 | 14 | static ThreeDCardData? of(BuildContext context) { 15 | return context.dependOnInheritedWidgetOfExactType(); 16 | } 17 | 18 | @override 19 | bool updateShouldNotify(ThreeDCardData oldWidget) { 20 | return oldWidget.isHovered != isHovered; 21 | } 22 | } 23 | 24 | class ThreeDCard extends StatefulWidget { 25 | final Widget child; 26 | final double sensitivity; 27 | final Duration duration; 28 | final BoxDecoration? decoration; 29 | final BoxDecoration? hoverDecoration; 30 | final EdgeInsetsGeometry? padding; 31 | final double maxRotationX; 32 | final double maxRotationY; 33 | final VoidCallback? onHoverStart; 34 | final VoidCallback? onHoverEnd; 35 | final Curve curve; 36 | final bool enabled; 37 | 38 | const ThreeDCard({ 39 | super.key, 40 | required this.child, 41 | this.sensitivity = 25.0, 42 | this.duration = const Duration(milliseconds: 200), 43 | this.decoration, 44 | this.hoverDecoration, 45 | this.padding, 46 | this.maxRotationX = 25.0, 47 | this.maxRotationY = 25.0, 48 | this.onHoverStart, 49 | this.onHoverEnd, 50 | this.curve = Curves.easeOut, 51 | this.enabled = true, 52 | }) : assert(sensitivity > 0, 'sensitivity must be greater than 0'), 53 | assert(maxRotationX >= 0, 'maxRotationX must be non-negative'), 54 | assert(maxRotationY >= 0, 'maxRotationY must be non-negative'); 55 | 56 | @override 57 | State createState() => _ThreeDCardState(); 58 | } 59 | 60 | class _ThreeDCardState extends State { 61 | bool _isHovered = false; 62 | double _rotationX = 0.0; 63 | double _rotationY = 0.0; 64 | DateTime _lastHoverUpdate = DateTime.now(); 65 | static const _hoverThrottleMs = 16; 66 | 67 | void _handleHoverStart() { 68 | if (!widget.enabled) return; 69 | setState(() => _isHovered = true); 70 | widget.onHoverStart?.call(); 71 | } 72 | 73 | void _handleHoverEnd() { 74 | if (!widget.enabled) return; 75 | setState(() { 76 | _isHovered = false; 77 | _rotationX = 0.0; 78 | _rotationY = 0.0; 79 | }); 80 | widget.onHoverEnd?.call(); 81 | } 82 | 83 | void _handleHover(PointerEvent event) { 84 | if (!widget.enabled) return; 85 | 86 | final now = DateTime.now(); 87 | if (now.difference(_lastHoverUpdate).inMilliseconds < _hoverThrottleMs) { 88 | return; 89 | } 90 | _lastHoverUpdate = now; 91 | 92 | final size = context.size; 93 | if (size == null) return; 94 | 95 | final centerX = size.width / 2; 96 | final centerY = size.height / 2; 97 | 98 | final x = (event.localPosition.dx - centerX) / widget.sensitivity; 99 | final y = (event.localPosition.dy - centerY) / widget.sensitivity; 100 | 101 | setState(() { 102 | _rotationX = (-y).clamp(-widget.maxRotationX, widget.maxRotationX); 103 | _rotationY = x.clamp(-widget.maxRotationY, widget.maxRotationY); 104 | }); 105 | } 106 | 107 | @override 108 | Widget build(BuildContext context) { 109 | return MouseRegion( 110 | onEnter: (_) => _handleHoverStart(), 111 | onExit: (_) => _handleHoverEnd(), 112 | onHover: _handleHover, 113 | child: ThreeDCardData( 114 | isHovered: _isHovered, 115 | child: TweenAnimationBuilder( 116 | tween: Tween(begin: 0, end: _rotationX), 117 | duration: widget.duration, 118 | curve: widget.curve, 119 | builder: (context, rx, _) { 120 | return TweenAnimationBuilder( 121 | tween: Tween(begin: 0, end: _rotationY), 122 | duration: widget.duration, 123 | curve: widget.curve, 124 | builder: (context, ry, child) { 125 | return Transform( 126 | transform: Matrix4.identity() 127 | ..setEntry(3, 2, 0.001) 128 | ..rotateX(rx * _kDegreesToRadians) 129 | ..rotateY(ry * _kDegreesToRadians), 130 | alignment: Alignment.center, 131 | child: AnimatedContainer( 132 | duration: widget.duration, 133 | curve: widget.curve, 134 | decoration: _isHovered && widget.hoverDecoration != null 135 | ? _mergeDecorations( 136 | widget.decoration, 137 | widget.hoverDecoration!, 138 | ) 139 | : widget.decoration, 140 | padding: widget.padding, 141 | child: widget.child, 142 | ), 143 | ); 144 | }, 145 | ); 146 | }, 147 | ), 148 | ), 149 | ); 150 | } 151 | 152 | BoxDecoration? _mergeDecorations(BoxDecoration? base, BoxDecoration hover) { 153 | if (base == null) return hover; 154 | 155 | return base.copyWith( 156 | color: hover.color ?? base.color, 157 | image: hover.image ?? base.image, 158 | border: hover.border ?? base.border, 159 | borderRadius: hover.borderRadius ?? base.borderRadius, 160 | boxShadow: hover.boxShadow ?? base.boxShadow, 161 | gradient: hover.gradient ?? base.gradient, 162 | backgroundBlendMode: 163 | hover.backgroundBlendMode ?? base.backgroundBlendMode, 164 | shape: hover.shape != BoxShape.rectangle ? hover.shape : base.shape, 165 | ); 166 | } 167 | } 168 | 169 | class ThreeDCardItem extends StatelessWidget { 170 | final Widget child; 171 | final double translateX; 172 | final double translateY; 173 | final double translateZ; 174 | final double rotateX; 175 | final double rotateY; 176 | final double rotateZ; 177 | final Duration duration; 178 | 179 | const ThreeDCardItem({ 180 | super.key, 181 | required this.child, 182 | this.translateX = 0, 183 | this.translateY = 0, 184 | this.translateZ = 0, 185 | this.rotateX = 0, 186 | this.rotateY = 0, 187 | this.rotateZ = 0, 188 | this.duration = const Duration(milliseconds: 500), 189 | }); 190 | 191 | @override 192 | Widget build(BuildContext context) { 193 | final isHovered = ThreeDCardData.of(context)?.isHovered ?? false; 194 | 195 | return AnimatedContainer( 196 | duration: duration, 197 | curve: Curves.easeInOut, 198 | transform: isHovered 199 | ? (Matrix4.identity() 200 | ..setTranslationRaw(translateX, translateY, -translateZ) 201 | ..rotateX(rotateX * _kDegreesToRadians) 202 | ..rotateY(rotateY * _kDegreesToRadians) 203 | ..rotateZ(rotateZ * _kDegreesToRadians)) 204 | : Matrix4.identity(), 205 | child: child, 206 | ); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /lib/pages/docs/docs_shell.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:google_fonts/google_fonts.dart'; 4 | import 'package:url_launcher/url_launcher.dart'; 5 | import '../../services/widget_loader.dart'; 6 | import '../../models/widget_metadata.dart'; 7 | import 'docs_search_delegate.dart'; 8 | 9 | class DocsShell extends StatelessWidget { 10 | final Widget child; 11 | 12 | const DocsShell({super.key, required this.child}); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final isDesktop = MediaQuery.of(context).size.width >= 900; 17 | 18 | return Scaffold( 19 | appBar: AppBar( 20 | title: Row( 21 | children: [ 22 | const Icon(Icons.widgets, size: 24), 23 | const SizedBox(width: 12), 24 | Text( 25 | 'Arcade UI', 26 | style: GoogleFonts.inter( 27 | fontWeight: FontWeight.bold, 28 | fontSize: 20, 29 | ), 30 | ), 31 | const SizedBox(width: 32), 32 | const SizedBox(width: 32), 33 | ], 34 | ), 35 | actions: [ 36 | IconButton( 37 | icon: const Icon(Icons.search), 38 | onPressed: () { 39 | showSearch(context: context, delegate: DocsSearchDelegate()); 40 | }, 41 | ), 42 | const SizedBox(width: 8), 43 | IconButton( 44 | icon: const Icon(Icons.code), 45 | onPressed: () => launchUrl( 46 | Uri.parse('https://github.com/medz/flutter-arcade-ui'), 47 | ), 48 | ), 49 | const SizedBox(width: 16), 50 | ], 51 | backgroundColor: Theme.of(context).scaffoldBackgroundColor, 52 | elevation: 0, 53 | bottom: PreferredSize( 54 | preferredSize: const Size.fromHeight(1), 55 | child: Container( 56 | color: Theme.of(context).dividerColor.withValues(alpha: 0.1), 57 | height: 1, 58 | ), 59 | ), 60 | ), 61 | drawer: !isDesktop ? const Drawer(child: DocsSidebar()) : null, 62 | body: Row( 63 | children: [ 64 | if (isDesktop) 65 | Container( 66 | width: 280, 67 | decoration: BoxDecoration( 68 | border: Border( 69 | right: BorderSide( 70 | color: Theme.of( 71 | context, 72 | ).dividerColor.withValues(alpha: 0.1), 73 | ), 74 | ), 75 | ), 76 | child: const DocsSidebar(), 77 | ), 78 | Expanded(child: child), 79 | ], 80 | ), 81 | ); 82 | } 83 | } 84 | 85 | class DocsSidebar extends StatelessWidget { 86 | const DocsSidebar({super.key}); 87 | 88 | @override 89 | Widget build(BuildContext context) { 90 | final currentPath = GoRouterState.of(context).uri.path; 91 | 92 | return Container( 93 | width: 280, 94 | decoration: BoxDecoration( 95 | border: Border( 96 | right: BorderSide(color: Theme.of(context).dividerColor), 97 | ), 98 | ), 99 | child: Column( 100 | children: [ 101 | Expanded( 102 | child: SingleChildScrollView( 103 | padding: const EdgeInsets.all(24), 104 | child: Column( 105 | crossAxisAlignment: CrossAxisAlignment.start, 106 | children: [ 107 | _SidebarLink( 108 | title: 'Home', 109 | icon: Icons.home_outlined, 110 | isSelected: currentPath == '/', 111 | onTap: () => context.go('/'), 112 | ), 113 | const SizedBox(height: 8), 114 | _SidebarLink( 115 | title: 'Getting Started', 116 | icon: Icons.rocket_launch_outlined, 117 | isSelected: currentPath == '/get-started', 118 | onTap: () => context.go('/get-started'), 119 | ), 120 | const SizedBox(height: 8), 121 | _SidebarLink( 122 | title: 'Widgets', 123 | icon: Icons.widgets_outlined, 124 | isSelected: currentPath == '/widgets', 125 | onTap: () => context.go('/widgets'), 126 | ), 127 | const SizedBox(height: 16), 128 | const Divider(height: 1), 129 | const SizedBox(height: 16), 130 | ...WidgetLoader.groups.map((group) { 131 | final widgets = WidgetLoader.getWidgetsByGroup(group); 132 | return Column( 133 | crossAxisAlignment: CrossAxisAlignment.start, 134 | children: [ 135 | Padding( 136 | padding: const EdgeInsets.symmetric( 137 | vertical: 8, 138 | horizontal: 12, 139 | ), 140 | child: Text( 141 | WidgetMetadata.capitalize(group), 142 | style: GoogleFonts.inter( 143 | fontSize: 14, 144 | fontWeight: FontWeight.w600, 145 | color: Theme.of(context).colorScheme.primary, 146 | ), 147 | ), 148 | ), 149 | ...widgets.map((widget) { 150 | final path = 151 | '/widgets/${widget.identifier.replaceAll('_', '-')}'; 152 | return _SidebarLink( 153 | title: widget.name, 154 | isSelected: currentPath == path, 155 | isSubItem: true, 156 | onTap: () => context.go(path), 157 | ); 158 | }), 159 | const SizedBox(height: 16), 160 | ], 161 | ); 162 | }), 163 | ], 164 | ), 165 | ), 166 | ), 167 | ], 168 | ), 169 | ); 170 | } 171 | } 172 | 173 | class _SidebarLink extends StatelessWidget { 174 | final String title; 175 | final IconData? icon; 176 | final bool isSelected; 177 | final bool isSubItem; 178 | final VoidCallback onTap; 179 | 180 | const _SidebarLink({ 181 | required this.title, 182 | this.icon, 183 | this.isSelected = false, 184 | this.isSubItem = false, 185 | required this.onTap, 186 | }); 187 | 188 | @override 189 | Widget build(BuildContext context) { 190 | final colorScheme = Theme.of(context).colorScheme; 191 | final textColor = isSelected 192 | ? colorScheme.primary 193 | : colorScheme.onSurface.withValues(alpha: 0.7); 194 | 195 | return InkWell( 196 | onTap: onTap, 197 | borderRadius: BorderRadius.circular(6), 198 | child: Container( 199 | padding: EdgeInsets.symmetric( 200 | vertical: 8, 201 | horizontal: isSubItem ? 12 : 12, 202 | ), 203 | decoration: BoxDecoration( 204 | color: isSelected 205 | ? colorScheme.primary.withValues(alpha: 0.1) 206 | : Colors.transparent, 207 | borderRadius: BorderRadius.circular(6), 208 | ), 209 | child: Row( 210 | children: [ 211 | if (isSubItem) const SizedBox(width: 8), 212 | if (icon != null) ...[ 213 | Icon( 214 | icon, 215 | size: 20, 216 | color: isSelected 217 | ? colorScheme.primary 218 | : colorScheme.onSurface.withValues(alpha: 0.7), 219 | ), 220 | const SizedBox(width: 12), 221 | ], 222 | Expanded( 223 | child: Text( 224 | title, 225 | style: GoogleFonts.inter( 226 | fontSize: 14, 227 | fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, 228 | color: textColor, 229 | ), 230 | ), 231 | ), 232 | ], 233 | ), 234 | ), 235 | ); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /lib/widgets/navigations/floating_dock.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | 3 | class FloatingDock extends StatefulWidget { 4 | final List items; 5 | final double baseItemSize; 6 | final double maxItemSize; 7 | final double distance; 8 | final double gap; 9 | final EdgeInsetsGeometry? padding; 10 | final BoxDecoration? decoration; 11 | final BoxDecoration? itemDecoration; 12 | 13 | const FloatingDock({ 14 | super.key, 15 | required this.items, 16 | this.baseItemSize = 48.0, 17 | this.maxItemSize = 68.0, 18 | this.distance = 150.0, 19 | this.gap = 12.0, 20 | this.padding, 21 | this.decoration, 22 | this.itemDecoration, 23 | }); 24 | 25 | @override 26 | State createState() => _FloatingDockState(); 27 | } 28 | 29 | class _FloatingDockState extends State { 30 | double? _mouseX; 31 | final _keys = []; 32 | 33 | static const _defaultDecoration = BoxDecoration( 34 | color: Color(0xFFF5F5F5), 35 | borderRadius: BorderRadius.all(Radius.circular(20)), 36 | ); 37 | static const _defaultItemDecoration = BoxDecoration(color: Color(0xFFE8E8E8)); 38 | 39 | @override 40 | void initState() { 41 | super.initState(); 42 | _syncKeys(); 43 | } 44 | 45 | @override 46 | void didUpdateWidget(FloatingDock oldWidget) { 47 | super.didUpdateWidget(oldWidget); 48 | if (oldWidget.items.length != widget.items.length) _syncKeys(); 49 | } 50 | 51 | void _syncKeys() { 52 | _keys 53 | ..clear() 54 | ..addAll(List.generate(widget.items.length, (_) => GlobalKey())); 55 | } 56 | 57 | @override 58 | Widget build(BuildContext context) { 59 | final deco = _merge(_defaultDecoration, widget.decoration); 60 | final itemDeco = _merge(_defaultItemDecoration, widget.itemDecoration); 61 | 62 | return MouseRegion( 63 | onHover: (e) => setState(() => _mouseX = e.position.dx), 64 | onExit: (_) => setState(() => _mouseX = null), 65 | child: DecoratedBox( 66 | decoration: deco, 67 | child: Padding( 68 | padding: 69 | widget.padding ?? 70 | const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 71 | child: Row( 72 | mainAxisSize: MainAxisSize.min, 73 | crossAxisAlignment: CrossAxisAlignment.end, 74 | children: [ 75 | for (var i = 0; i < widget.items.length; i++) ...[ 76 | _DockIcon( 77 | key: _keys[i], 78 | gKey: _keys[i], 79 | item: widget.items[i], 80 | mouseX: _mouseX, 81 | baseSize: widget.baseItemSize, 82 | maxSize: widget.maxItemSize, 83 | distance: widget.distance, 84 | decoration: _merge(itemDeco, widget.items[i].decoration), 85 | ), 86 | if (i < widget.items.length - 1) SizedBox(width: widget.gap), 87 | ], 88 | ], 89 | ), 90 | ), 91 | ), 92 | ); 93 | } 94 | } 95 | 96 | class _DockIcon extends StatefulWidget { 97 | final GlobalKey gKey; 98 | final FloatingDockItem item; 99 | final double? mouseX; 100 | final double baseSize, maxSize, distance; 101 | final BoxDecoration decoration; 102 | 103 | const _DockIcon({ 104 | super.key, 105 | required this.gKey, 106 | required this.item, 107 | required this.mouseX, 108 | required this.baseSize, 109 | required this.maxSize, 110 | required this.distance, 111 | required this.decoration, 112 | }); 113 | 114 | @override 115 | State<_DockIcon> createState() => _DockIconState(); 116 | } 117 | 118 | class _DockIconState extends State<_DockIcon> { 119 | bool _hovered = false; 120 | 121 | double get _scale { 122 | if (widget.mouseX == null) return 1.0; 123 | final box = widget.gKey.currentContext?.findRenderObject() as RenderBox?; 124 | if (box == null) return 1.0; 125 | final center = box.localToGlobal(Offset.zero).dx + box.size.width / 2; 126 | final dist = (widget.mouseX! - center).abs(); 127 | if (dist > widget.distance) return 1.0; 128 | return 1.0 + 129 | (widget.maxSize / widget.baseSize - 1.0) * (1 - dist / widget.distance); 130 | } 131 | 132 | @override 133 | Widget build(BuildContext context) { 134 | return MouseRegion( 135 | onEnter: (_) => setState(() => _hovered = true), 136 | onExit: (_) => setState(() => _hovered = false), 137 | child: GestureDetector( 138 | onTap: widget.item.onTap, 139 | child: TweenAnimationBuilder( 140 | duration: const Duration(milliseconds: 200), 141 | curve: Curves.easeOutCubic, 142 | tween: Tween(begin: widget.baseSize, end: widget.baseSize * _scale), 143 | builder: (context, s, _) { 144 | final offset = widget.baseSize - s; 145 | return SizedBox( 146 | width: s, 147 | height: widget.baseSize, 148 | child: OverflowBox( 149 | maxWidth: s, 150 | maxHeight: s, 151 | child: Stack( 152 | clipBehavior: Clip.none, 153 | children: [ 154 | Transform.translate( 155 | offset: Offset(0, offset), 156 | child: DecoratedBox( 157 | decoration: widget.decoration.copyWith( 158 | borderRadius: 159 | widget.decoration.borderRadius ?? 160 | BorderRadius.circular(s * 0.25), 161 | ), 162 | child: SizedBox( 163 | width: s, 164 | height: s, 165 | child: FractionallySizedBox( 166 | widthFactor: 0.5, 167 | heightFactor: 0.5, 168 | child: FittedBox(child: widget.item.icon), 169 | ), 170 | ), 171 | ), 172 | ), 173 | if (widget.item.title != null) 174 | Positioned( 175 | bottom: s - offset + 6, 176 | left: s / 2, 177 | child: _Tooltip(widget.item.title!, _hovered), 178 | ), 179 | ], 180 | ), 181 | ), 182 | ); 183 | }, 184 | ), 185 | ), 186 | ); 187 | } 188 | } 189 | 190 | class _Tooltip extends StatelessWidget { 191 | final String title; 192 | final bool visible; 193 | const _Tooltip(this.title, this.visible); 194 | 195 | @override 196 | Widget build(BuildContext context) { 197 | return TweenAnimationBuilder( 198 | duration: const Duration(milliseconds: 150), 199 | tween: Tween(begin: 0, end: visible ? 1.0 : 0.0), 200 | builder: (context, v, child) { 201 | if (v == 0) return const SizedBox.shrink(); 202 | return FractionalTranslation( 203 | translation: Offset(-0.5, (1 - v) * 0.5), 204 | child: Opacity(opacity: v, child: child), 205 | ); 206 | }, 207 | child: DecoratedBox( 208 | decoration: BoxDecoration( 209 | color: const Color(0xFFF5F5F5), 210 | borderRadius: BorderRadius.circular(6), 211 | border: Border.all(color: const Color(0xFFE0E0E0), width: 1), 212 | ), 213 | child: Padding( 214 | padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 215 | child: Text( 216 | title, 217 | style: const TextStyle(fontSize: 12, color: Color(0xFF404040)), 218 | ), 219 | ), 220 | ), 221 | ); 222 | } 223 | } 224 | 225 | class FloatingDockItem { 226 | final Widget icon; 227 | final String? title; 228 | final BoxDecoration? decoration; 229 | final VoidCallback? onTap; 230 | 231 | const FloatingDockItem({ 232 | required this.icon, 233 | this.title, 234 | this.decoration, 235 | this.onTap, 236 | }); 237 | } 238 | 239 | BoxDecoration _merge(BoxDecoration base, BoxDecoration? o) { 240 | if (o == null) return base; 241 | return BoxDecoration( 242 | color: o.color ?? base.color, 243 | image: o.image ?? base.image, 244 | border: o.border ?? base.border, 245 | borderRadius: o.borderRadius ?? base.borderRadius, 246 | boxShadow: o.boxShadow ?? base.boxShadow, 247 | gradient: o.gradient ?? base.gradient, 248 | backgroundBlendMode: o.backgroundBlendMode ?? base.backgroundBlendMode, 249 | shape: o.shape, 250 | ); 251 | } 252 | -------------------------------------------------------------------------------- /lib/components/widget_preview.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:google_fonts/google_fonts.dart'; 4 | import '../services/widget_loader.dart'; 5 | import '../models/widget_metadata.dart'; 6 | 7 | /// A component that displays a widget preview with tabs for Preview and Code view. 8 | /// Supports responsive sizing: square on mobile, 16:9 on desktop. 9 | class WidgetPreview extends StatefulWidget { 10 | /// Widget identifier (e.g., "cards/three_d_card") 11 | final String identifier; 12 | 13 | /// Optional custom preview widget 14 | final Widget? previewWidget; 15 | 16 | const WidgetPreview({ 17 | super.key, 18 | required this.identifier, 19 | this.previewWidget, 20 | }); 21 | 22 | @override 23 | State createState() => _WidgetPreviewState(); 24 | } 25 | 26 | class _WidgetPreviewState extends State 27 | with SingleTickerProviderStateMixin { 28 | late TabController _tabController; 29 | WidgetMetadata? _metadata; 30 | String? _sourceCode; 31 | bool _isLoading = true; 32 | bool _isCodeTabSelected = false; 33 | bool _copied = false; 34 | 35 | @override 36 | void initState() { 37 | super.initState(); 38 | _tabController = TabController(length: 2, vsync: this); 39 | _tabController.addListener(_onTabChanged); 40 | _loadWidget(); 41 | } 42 | 43 | @override 44 | void dispose() { 45 | _tabController.removeListener(_onTabChanged); 46 | _tabController.dispose(); 47 | super.dispose(); 48 | } 49 | 50 | void _onTabChanged() { 51 | if (!mounted) return; 52 | setState(() { 53 | _isCodeTabSelected = _tabController.index == 1; 54 | }); 55 | } 56 | 57 | Future _loadWidget() async { 58 | if (!mounted) return; 59 | setState(() => _isLoading = true); 60 | 61 | final metadata = WidgetLoader.findByIdentifier(widget.identifier); 62 | if (metadata != null) { 63 | // Load DEMO code instead of source code for the Code tab 64 | final code = await WidgetLoader.loadDemoCode(metadata); 65 | if (!mounted) return; 66 | setState(() { 67 | _metadata = metadata; 68 | _sourceCode = code; 69 | _isLoading = false; 70 | }); 71 | } else { 72 | if (!mounted) return; 73 | setState(() { 74 | _isLoading = false; 75 | }); 76 | } 77 | } 78 | 79 | Future _copyToClipboard() async { 80 | if (_sourceCode == null) return; 81 | await Clipboard.setData(ClipboardData(text: _sourceCode!)); 82 | if (mounted) { 83 | setState(() => _copied = true); 84 | Future.delayed(const Duration(seconds: 2), () { 85 | if (mounted) { 86 | setState(() => _copied = false); 87 | } 88 | }); 89 | } 90 | } 91 | 92 | @override 93 | Widget build(BuildContext context) { 94 | if (_isLoading) { 95 | return const Center(child: CircularProgressIndicator()); 96 | } 97 | 98 | if (_metadata == null) { 99 | return Card( 100 | child: Padding( 101 | padding: const EdgeInsets.all(24), 102 | child: Text('Widget not found: ${widget.identifier}'), 103 | ), 104 | ); 105 | } 106 | 107 | final isDark = Theme.of(context).brightness == Brightness.dark; 108 | 109 | return Material( 110 | type: MaterialType.transparency, 111 | shape: RoundedRectangleBorder( 112 | borderRadius: BorderRadius.circular(12), 113 | side: BorderSide(color: Theme.of(context).dividerColor), 114 | ), 115 | clipBehavior: Clip.antiAlias, 116 | child: SizedBox( 117 | height: 400, 118 | child: Column( 119 | crossAxisAlignment: CrossAxisAlignment.stretch, 120 | children: [ 121 | // Tab bar with copy button 122 | Container( 123 | decoration: BoxDecoration( 124 | color: Theme.of(context).colorScheme.surface, 125 | border: Border( 126 | bottom: BorderSide(color: Theme.of(context).dividerColor), 127 | ), 128 | ), 129 | child: Row( 130 | children: [ 131 | // Tabs on the left 132 | TabBar( 133 | controller: _tabController, 134 | isScrollable: true, 135 | tabAlignment: TabAlignment.start, 136 | dividerColor: Colors.transparent, 137 | tabs: const [ 138 | Tab(text: 'Preview'), 139 | Tab(text: 'Code'), 140 | ], 141 | ), 142 | const Spacer(), 143 | // Copy button on the right (only visible when Code tab is selected) 144 | AnimatedOpacity( 145 | opacity: _isCodeTabSelected ? 1.0 : 0.0, 146 | duration: const Duration(milliseconds: 200), 147 | child: AnimatedSlide( 148 | offset: _isCodeTabSelected 149 | ? Offset.zero 150 | : const Offset(0.2, 0), 151 | duration: const Duration(milliseconds: 200), 152 | child: Padding( 153 | padding: const EdgeInsets.only(right: 12), 154 | child: InkWell( 155 | onTap: _isCodeTabSelected ? _copyToClipboard : null, 156 | borderRadius: BorderRadius.circular(6), 157 | child: Padding( 158 | padding: const EdgeInsets.symmetric( 159 | horizontal: 10, 160 | vertical: 6, 161 | ), 162 | child: Row( 163 | mainAxisSize: MainAxisSize.min, 164 | children: [ 165 | Icon( 166 | _copied 167 | ? Icons.check_rounded 168 | : Icons.copy_rounded, 169 | size: 16, 170 | color: _copied 171 | ? Colors.green 172 | : (isDark 173 | ? Colors.grey[400] 174 | : Colors.grey[600]), 175 | ), 176 | const SizedBox(width: 6), 177 | Text( 178 | _copied ? 'Copied!' : 'Copy', 179 | style: GoogleFonts.inter( 180 | fontSize: 13, 181 | fontWeight: FontWeight.w500, 182 | color: _copied 183 | ? Colors.green 184 | : (isDark 185 | ? Colors.grey[400] 186 | : Colors.grey[600]), 187 | ), 188 | ), 189 | ], 190 | ), 191 | ), 192 | ), 193 | ), 194 | ), 195 | ), 196 | ], 197 | ), 198 | ), 199 | // Tab content 200 | Expanded( 201 | child: TabBarView( 202 | controller: _tabController, 203 | physics: const NeverScrollableScrollPhysics(), 204 | children: [ 205 | // Preview tab 206 | _buildPreviewTab(), 207 | // Code tab 208 | _buildCodeTab(), 209 | ], 210 | ), 211 | ), 212 | ], 213 | ), 214 | ), 215 | ); 216 | } 217 | 218 | Widget _buildPreviewTab() { 219 | return Container( 220 | color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.1), 221 | child: Center( 222 | child: 223 | widget.previewWidget ?? 224 | const Text( 225 | 'Preview widget not provided.\nPass previewWidget to WidgetPreview.', 226 | textAlign: TextAlign.center, 227 | ), 228 | ), 229 | ); 230 | } 231 | 232 | Widget _buildCodeTab() { 233 | if (_sourceCode == null) { 234 | return const Center(child: Text('Source code not available')); 235 | } 236 | 237 | final isDark = Theme.of(context).brightness == Brightness.dark; 238 | 239 | // Display code directly without header 240 | return Container( 241 | color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5), 242 | child: SingleChildScrollView( 243 | padding: const EdgeInsets.all(16), 244 | child: SelectableText( 245 | _sourceCode!, 246 | style: GoogleFonts.firaCode( 247 | fontSize: 13, 248 | height: 1.6, 249 | color: isDark ? Colors.grey[300] : Colors.grey[800], 250 | ), 251 | ), 252 | ), 253 | ); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /lib/widgets/navigations/dock.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | 3 | class Dock extends StatefulWidget { 4 | final List items; 5 | final double itemSize; 6 | final double maxScale; 7 | final double itemScale; 8 | final double distance; 9 | final Axis direction; 10 | final double gap; 11 | final EdgeInsetsGeometry? padding; 12 | final BoxDecoration? decoration; 13 | final BoxDecoration? itemDecoration; 14 | 15 | const Dock({ 16 | super.key, 17 | required this.items, 18 | this.itemSize = 48.0, 19 | this.maxScale = 2.0, 20 | this.itemScale = 1.0, 21 | this.distance = 100.0, 22 | this.direction = Axis.horizontal, 23 | this.gap = 8.0, 24 | this.padding, 25 | this.decoration, 26 | this.itemDecoration, 27 | }); 28 | 29 | @override 30 | State createState() => _DockState(); 31 | } 32 | 33 | class _DockState extends State { 34 | final _mousePositionNotifier = ValueNotifier(null); 35 | 36 | static const _defaultDecoration = BoxDecoration( 37 | color: Color(0x80000000), 38 | borderRadius: BorderRadius.all(Radius.circular(16)), 39 | ); 40 | 41 | static const _defaultItemDecoration = BoxDecoration( 42 | color: Color(0xFF2A2A2A), 43 | borderRadius: BorderRadius.all(Radius.circular(12)), 44 | ); 45 | 46 | @override 47 | void dispose() { 48 | _mousePositionNotifier.dispose(); 49 | super.dispose(); 50 | } 51 | 52 | @override 53 | Widget build(BuildContext context) { 54 | final decoration = _mergeDecoration(_defaultDecoration, widget.decoration); 55 | final itemDecoration = _mergeDecoration( 56 | _defaultItemDecoration, 57 | widget.itemDecoration, 58 | ); 59 | 60 | return MouseRegion( 61 | onHover: (event) { 62 | final renderBox = context.findRenderObject() as RenderBox?; 63 | if (renderBox != null) { 64 | final center = renderBox.localToGlobal( 65 | renderBox.size.center(Offset.zero), 66 | ); 67 | _mousePositionNotifier.value = event.position - center; 68 | } 69 | }, 70 | onExit: (_) => _mousePositionNotifier.value = null, 71 | child: DecoratedBox( 72 | decoration: decoration, 73 | child: Padding( 74 | padding: widget.padding ?? const EdgeInsets.all(8.0), 75 | child: _DockConfig( 76 | notifier: _mousePositionNotifier, 77 | itemDecoration: itemDecoration, 78 | child: Row( 79 | mainAxisSize: MainAxisSize.min, 80 | crossAxisAlignment: CrossAxisAlignment.center, 81 | children: _buildItems(context), 82 | ), 83 | ), 84 | ), 85 | ), 86 | ); 87 | } 88 | 89 | List _buildItems(BuildContext context) { 90 | final children = []; 91 | final isHorizontal = widget.direction == Axis.horizontal; 92 | final itemWidths = []; 93 | var totalWidth = 0.0; 94 | 95 | for (final item in widget.items) { 96 | final width = item is DockSeparator 97 | ? (isHorizontal ? item.width : item.height) + 98 | (item.margin?.resolve(Directionality.of(context)) ?? 99 | const EdgeInsets.symmetric(horizontal: 4.0)) 100 | .horizontal 101 | : widget.itemSize; 102 | itemWidths.add(width); 103 | totalWidth += width; 104 | } 105 | 106 | totalWidth += (widget.items.length - 1) * widget.gap; 107 | var currentOffset = -totalWidth / 2; 108 | 109 | for (var i = 0; i < widget.items.length; i++) { 110 | final item = widget.items[i]; 111 | final width = itemWidths[i]; 112 | final centerOffset = currentOffset + width / 2; 113 | 114 | if (item is DockSeparator) { 115 | children.add(item); 116 | } else { 117 | children.add( 118 | _DockItem( 119 | key: ValueKey(i), 120 | itemSize: widget.itemSize, 121 | maxScale: widget.maxScale, 122 | itemScale: widget.itemScale, 123 | distance: widget.distance, 124 | direction: widget.direction, 125 | baseOffset: centerOffset, 126 | child: item, 127 | ), 128 | ); 129 | } 130 | 131 | if (i < widget.items.length - 1) { 132 | children.add( 133 | SizedBox( 134 | width: isHorizontal ? widget.gap : 0, 135 | height: isHorizontal ? 0 : widget.gap, 136 | ), 137 | ); 138 | } 139 | 140 | currentOffset += width + widget.gap; 141 | } 142 | 143 | return children; 144 | } 145 | } 146 | 147 | class _DockConfig extends InheritedWidget { 148 | final ValueNotifier notifier; 149 | final BoxDecoration itemDecoration; 150 | 151 | const _DockConfig({ 152 | required this.notifier, 153 | required this.itemDecoration, 154 | required super.child, 155 | }); 156 | 157 | static _DockConfig? of(BuildContext context) { 158 | return context.dependOnInheritedWidgetOfExactType<_DockConfig>(); 159 | } 160 | 161 | @override 162 | bool updateShouldNotify(_DockConfig oldWidget) => 163 | notifier != oldWidget.notifier || 164 | itemDecoration != oldWidget.itemDecoration; 165 | } 166 | 167 | class _DockItem extends StatelessWidget { 168 | final double itemSize; 169 | final double maxScale; 170 | final double itemScale; 171 | final double distance; 172 | final Axis direction; 173 | final double baseOffset; 174 | final Widget child; 175 | 176 | const _DockItem({ 177 | super.key, 178 | required this.itemSize, 179 | required this.maxScale, 180 | required this.itemScale, 181 | required this.distance, 182 | required this.direction, 183 | required this.baseOffset, 184 | required this.child, 185 | }); 186 | 187 | double _calcScale(Offset? mouseOffset, double targetScale) { 188 | if (mouseOffset == null) return 1.0; 189 | final itemCenter = direction == Axis.horizontal 190 | ? Offset(baseOffset, 0) 191 | : Offset(0, baseOffset); 192 | final dist = (mouseOffset - itemCenter).distance; 193 | if (dist > distance) return 1.0; 194 | return 1.0 + (targetScale - 1.0) * (1 - dist / distance); 195 | } 196 | 197 | @override 198 | Widget build(BuildContext context) { 199 | final config = _DockConfig.of(context)!; 200 | 201 | return ValueListenableBuilder( 202 | valueListenable: config.notifier, 203 | builder: (context, mouseOffset, _) { 204 | final scale = _calcScale(mouseOffset, maxScale); 205 | final childScale = _calcScale(mouseOffset, itemScale); 206 | final scaledSize = itemSize * scale; 207 | 208 | return TweenAnimationBuilder( 209 | duration: const Duration(milliseconds: 200), 210 | curve: Curves.easeOutCubic, 211 | tween: Tween(begin: 1.0, end: scale), 212 | builder: (context, animatedScale, _) { 213 | final size = itemSize * animatedScale; 214 | return SizedBox( 215 | width: direction == Axis.horizontal ? scaledSize : itemSize, 216 | height: direction == Axis.vertical ? scaledSize : itemSize, 217 | child: Align( 218 | alignment: direction == Axis.horizontal 219 | ? Alignment.bottomCenter 220 | : Alignment.centerLeft, 221 | child: _DockItemScaleProvider( 222 | scale: childScale, 223 | child: SizedBox(width: size, height: size, child: child), 224 | ), 225 | ), 226 | ); 227 | }, 228 | ); 229 | }, 230 | ); 231 | } 232 | } 233 | 234 | class _DockItemScaleProvider extends InheritedWidget { 235 | final double scale; 236 | 237 | const _DockItemScaleProvider({required this.scale, required super.child}); 238 | 239 | static double of(BuildContext context) { 240 | return context 241 | .dependOnInheritedWidgetOfExactType<_DockItemScaleProvider>() 242 | ?.scale ?? 243 | 1.0; 244 | } 245 | 246 | @override 247 | bool updateShouldNotify(_DockItemScaleProvider oldWidget) => 248 | scale != oldWidget.scale; 249 | } 250 | 251 | class DockIcon extends StatelessWidget { 252 | final Widget child; 253 | final BoxDecoration? decoration; 254 | final EdgeInsetsGeometry? padding; 255 | final VoidCallback? onTap; 256 | 257 | const DockIcon({ 258 | super.key, 259 | required this.child, 260 | this.decoration, 261 | this.padding, 262 | this.onTap, 263 | }); 264 | 265 | @override 266 | Widget build(BuildContext context) { 267 | final config = _DockConfig.of(context); 268 | final baseDecoration = config?.itemDecoration ?? const BoxDecoration(); 269 | final mergedDecoration = _mergeDecoration(baseDecoration, decoration); 270 | 271 | return GestureDetector( 272 | onTap: onTap, 273 | child: Padding( 274 | padding: padding ?? EdgeInsets.zero, 275 | child: DecoratedBox( 276 | decoration: mergedDecoration, 277 | child: Center( 278 | child: Builder( 279 | builder: (context) { 280 | final scale = _DockItemScaleProvider.of(context); 281 | return Transform.scale(scale: scale, child: child); 282 | }, 283 | ), 284 | ), 285 | ), 286 | ), 287 | ); 288 | } 289 | } 290 | 291 | class DockSeparator extends StatelessWidget { 292 | final double width; 293 | final double height; 294 | final Color? color; 295 | final EdgeInsetsGeometry? margin; 296 | 297 | const DockSeparator({ 298 | super.key, 299 | this.width = 1.0, 300 | this.height = 32.0, 301 | this.color, 302 | this.margin, 303 | }); 304 | 305 | @override 306 | Widget build(BuildContext context) { 307 | return Padding( 308 | padding: margin ?? const EdgeInsets.symmetric(horizontal: 4.0), 309 | child: DecoratedBox( 310 | decoration: BoxDecoration( 311 | color: color ?? const Color(0x40FFFFFF), 312 | borderRadius: BorderRadius.circular(width / 2), 313 | ), 314 | child: SizedBox(width: width, height: height), 315 | ), 316 | ); 317 | } 318 | } 319 | 320 | BoxDecoration _mergeDecoration(BoxDecoration base, BoxDecoration? override) { 321 | if (override == null) return base; 322 | return BoxDecoration( 323 | color: override.color ?? base.color, 324 | image: override.image ?? base.image, 325 | border: override.border ?? base.border, 326 | borderRadius: override.borderRadius ?? base.borderRadius, 327 | boxShadow: override.boxShadow ?? base.boxShadow, 328 | gradient: override.gradient ?? base.gradient, 329 | backgroundBlendMode: 330 | override.backgroundBlendMode ?? base.backgroundBlendMode, 331 | shape: override.shape, 332 | ); 333 | } 334 | -------------------------------------------------------------------------------- /lib/widgets/games/space_shooter_demo.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'space_shooter.dart'; 3 | 4 | /// Demo widget showcasing the SpaceShooter game with difficulty controls 5 | class SpaceShooterDemo extends StatefulWidget { 6 | const SpaceShooterDemo({super.key}); 7 | 8 | @override 9 | State createState() => _SpaceShooterDemoState(); 10 | } 11 | 12 | class _SpaceShooterDemoState extends State { 13 | // Difficulty settings 14 | int _initialScore = 10; 15 | int _enemySpawnInterval = 1500; 16 | double _enemySpeed = 2.0; 17 | double _bulletSpeed = 5.0; 18 | 19 | // Presets 20 | static const Map> _presets = { 21 | 'Easy': { 22 | 'score': 20, 23 | 'spawnInterval': 2000, 24 | 'enemySpeed': 1.5, 25 | 'bulletSpeed': 6.0, 26 | }, 27 | 'Normal': { 28 | 'score': 10, 29 | 'spawnInterval': 1500, 30 | 'enemySpeed': 2.0, 31 | 'bulletSpeed': 5.0, 32 | }, 33 | 'Hard': { 34 | 'score': 10, 35 | 'spawnInterval': 1000, 36 | 'enemySpeed': 3.0, 37 | 'bulletSpeed': 4.5, 38 | }, 39 | 'Nightmare': { 40 | 'score': 5, 41 | 'spawnInterval': 600, 42 | 'enemySpeed': 4.0, 43 | 'bulletSpeed': 4.0, 44 | }, 45 | }; 46 | 47 | String _selectedPreset = 'Normal'; 48 | bool _showSettings = true; 49 | 50 | void _applyPreset(String preset) { 51 | final settings = _presets[preset]!; 52 | setState(() { 53 | _selectedPreset = preset; 54 | _initialScore = settings['score'] as int; 55 | _enemySpawnInterval = settings['spawnInterval'] as int; 56 | _enemySpeed = settings['enemySpeed'] as double; 57 | _bulletSpeed = settings['bulletSpeed'] as double; 58 | }); 59 | } 60 | 61 | @override 62 | Widget build(BuildContext context) { 63 | return Container( 64 | decoration: BoxDecoration( 65 | border: Border.all( 66 | color: Colors.white.withValues(alpha: 0.2), 67 | width: 1, 68 | ), 69 | borderRadius: BorderRadius.circular(8), 70 | ), 71 | clipBehavior: Clip.antiAlias, 72 | child: Stack( 73 | children: [ 74 | // Game (full size) 75 | SpaceShooter( 76 | key: ValueKey( 77 | '$_selectedPreset-$_initialScore-$_enemySpawnInterval-$_enemySpeed-$_bulletSpeed', 78 | ), 79 | initialScore: _initialScore, 80 | enemySpawnInterval: _enemySpawnInterval, 81 | enemySpeed: _enemySpeed, 82 | bulletSpeed: _bulletSpeed, 83 | ), 84 | 85 | // Settings overlay (top-right) 86 | Positioned( 87 | top: 8, 88 | right: 8, 89 | child: Column( 90 | crossAxisAlignment: CrossAxisAlignment.end, 91 | children: [ 92 | // Toggle settings button 93 | IconButton( 94 | icon: Icon( 95 | _showSettings ? Icons.close : Icons.settings, 96 | color: Colors.white.withValues(alpha: 0.9), 97 | ), 98 | onPressed: () { 99 | setState(() { 100 | _showSettings = !_showSettings; 101 | }); 102 | }, 103 | style: IconButton.styleFrom( 104 | backgroundColor: Colors.black.withValues(alpha: 0.5), 105 | ), 106 | ), 107 | 108 | // Settings panel 109 | if (_showSettings) ...[ 110 | const SizedBox(height: 8), 111 | Container( 112 | padding: const EdgeInsets.all(16), 113 | decoration: BoxDecoration( 114 | color: Colors.black.withValues(alpha: 0.85), 115 | borderRadius: BorderRadius.circular(8), 116 | border: Border.all( 117 | color: Colors.white.withValues(alpha: 0.2), 118 | ), 119 | ), 120 | child: Column( 121 | crossAxisAlignment: CrossAxisAlignment.start, 122 | mainAxisSize: MainAxisSize.min, 123 | children: [ 124 | Text( 125 | 'Difficulty', 126 | style: TextStyle( 127 | color: Colors.white.withValues(alpha: 0.9), 128 | fontSize: 14, 129 | fontWeight: FontWeight.bold, 130 | ), 131 | ), 132 | const SizedBox(height: 12), 133 | 134 | // Preset buttons 135 | ..._presets.keys.map((preset) { 136 | final isSelected = _selectedPreset == preset; 137 | return Padding( 138 | padding: const EdgeInsets.only(bottom: 8), 139 | child: SizedBox( 140 | width: 120, 141 | child: ElevatedButton( 142 | onPressed: () => _applyPreset(preset), 143 | style: ElevatedButton.styleFrom( 144 | backgroundColor: isSelected 145 | ? const Color(0xFF00FF00) 146 | : Colors.grey.shade800, 147 | foregroundColor: isSelected 148 | ? Colors.black 149 | : Colors.white, 150 | padding: const EdgeInsets.symmetric( 151 | horizontal: 16, 152 | vertical: 8, 153 | ), 154 | ), 155 | child: Text( 156 | preset, 157 | style: const TextStyle( 158 | fontSize: 12, 159 | fontWeight: FontWeight.bold, 160 | ), 161 | ), 162 | ), 163 | ), 164 | ); 165 | }), 166 | 167 | const Divider(color: Colors.white24, height: 24), 168 | 169 | // Custom settings 170 | _buildSlider( 171 | 'Lives', 172 | _initialScore.toDouble(), 173 | 5, 174 | 30, 175 | (value) { 176 | setState(() { 177 | _initialScore = value.round(); 178 | _selectedPreset = 'Custom'; 179 | }); 180 | }, 181 | _initialScore.toString(), 182 | ), 183 | 184 | _buildSlider( 185 | 'Spawn Rate', 186 | _enemySpawnInterval.toDouble(), 187 | 500, 188 | 3000, 189 | (value) { 190 | setState(() { 191 | _enemySpawnInterval = value.round(); 192 | _selectedPreset = 'Custom'; 193 | }); 194 | }, 195 | '${_enemySpawnInterval}ms', 196 | ), 197 | 198 | _buildSlider( 199 | 'Enemy Speed', 200 | _enemySpeed, 201 | 1.0, 202 | 5.0, 203 | (value) { 204 | setState(() { 205 | _enemySpeed = value; 206 | _selectedPreset = 'Custom'; 207 | }); 208 | }, 209 | _enemySpeed.toStringAsFixed(1), 210 | ), 211 | 212 | _buildSlider( 213 | 'Bullet Speed', 214 | _bulletSpeed, 215 | 3.0, 216 | 8.0, 217 | (value) { 218 | setState(() { 219 | _bulletSpeed = value; 220 | _selectedPreset = 'Custom'; 221 | }); 222 | }, 223 | _bulletSpeed.toStringAsFixed(1), 224 | ), 225 | ], 226 | ), 227 | ), 228 | ], 229 | ], 230 | ), 231 | ), 232 | ], 233 | ), 234 | ); 235 | } 236 | 237 | Widget _buildSlider( 238 | String label, 239 | double value, 240 | double min, 241 | double max, 242 | ValueChanged onChanged, 243 | String displayValue, 244 | ) { 245 | return Padding( 246 | padding: const EdgeInsets.only(bottom: 12), 247 | child: Column( 248 | crossAxisAlignment: CrossAxisAlignment.start, 249 | children: [ 250 | Row( 251 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 252 | children: [ 253 | Text( 254 | label, 255 | style: TextStyle( 256 | color: Colors.white.withValues(alpha: 0.7), 257 | fontSize: 11, 258 | ), 259 | ), 260 | Text( 261 | displayValue, 262 | style: TextStyle( 263 | color: Colors.white.withValues(alpha: 0.9), 264 | fontSize: 11, 265 | fontWeight: FontWeight.bold, 266 | fontFamily: 'monospace', 267 | ), 268 | ), 269 | ], 270 | ), 271 | const SizedBox(height: 4), 272 | SizedBox( 273 | width: 120, 274 | child: SliderTheme( 275 | data: SliderThemeData( 276 | trackHeight: 2, 277 | thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), 278 | overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), 279 | ), 280 | child: Slider( 281 | value: value, 282 | min: min, 283 | max: max, 284 | activeColor: const Color(0xFF00FF00), 285 | inactiveColor: Colors.grey.shade700, 286 | onChanged: onChanged, 287 | ), 288 | ), 289 | ), 290 | ], 291 | ), 292 | ); 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | args: 5 | dependency: transitive 6 | description: 7 | name: args 8 | sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 9 | url: "https://pub.dev" 10 | source: hosted 11 | version: "2.7.0" 12 | async: 13 | dependency: transitive 14 | description: 15 | name: async 16 | sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" 17 | url: "https://pub.dev" 18 | source: hosted 19 | version: "2.13.0" 20 | characters: 21 | dependency: transitive 22 | description: 23 | name: characters 24 | sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 25 | url: "https://pub.dev" 26 | source: hosted 27 | version: "1.4.0" 28 | collection: 29 | dependency: transitive 30 | description: 31 | name: collection 32 | sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" 33 | url: "https://pub.dev" 34 | source: hosted 35 | version: "1.19.1" 36 | crypto: 37 | dependency: transitive 38 | description: 39 | name: crypto 40 | sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf 41 | url: "https://pub.dev" 42 | source: hosted 43 | version: "3.0.7" 44 | ffi: 45 | dependency: transitive 46 | description: 47 | name: ffi 48 | sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" 49 | url: "https://pub.dev" 50 | source: hosted 51 | version: "2.1.4" 52 | flutter: 53 | dependency: "direct main" 54 | description: flutter 55 | source: sdk 56 | version: "0.0.0" 57 | flutter_lints: 58 | dependency: "direct dev" 59 | description: 60 | name: flutter_lints 61 | sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" 62 | url: "https://pub.dev" 63 | source: hosted 64 | version: "6.0.0" 65 | flutter_markdown_plus: 66 | dependency: "direct main" 67 | description: 68 | name: flutter_markdown_plus 69 | sha256: "7f349c075157816da399216a4127096108fd08e1ac931e34e72899281db4113c" 70 | url: "https://pub.dev" 71 | source: hosted 72 | version: "1.0.5" 73 | flutter_web_plugins: 74 | dependency: "direct main" 75 | description: flutter 76 | source: sdk 77 | version: "0.0.0" 78 | go_router: 79 | dependency: "direct main" 80 | description: 81 | name: go_router 82 | sha256: c92d18e1fe994cb06d48aa786c46b142a5633067e8297cff6b5a3ac742620104 83 | url: "https://pub.dev" 84 | source: hosted 85 | version: "17.0.0" 86 | google_fonts: 87 | dependency: "direct main" 88 | description: 89 | name: google_fonts 90 | sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c" 91 | url: "https://pub.dev" 92 | source: hosted 93 | version: "6.3.2" 94 | http: 95 | dependency: transitive 96 | description: 97 | name: http 98 | sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" 99 | url: "https://pub.dev" 100 | source: hosted 101 | version: "1.6.0" 102 | http_parser: 103 | dependency: transitive 104 | description: 105 | name: http_parser 106 | sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" 107 | url: "https://pub.dev" 108 | source: hosted 109 | version: "4.1.2" 110 | lints: 111 | dependency: transitive 112 | description: 113 | name: lints 114 | sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 115 | url: "https://pub.dev" 116 | source: hosted 117 | version: "6.0.0" 118 | logging: 119 | dependency: transitive 120 | description: 121 | name: logging 122 | sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 123 | url: "https://pub.dev" 124 | source: hosted 125 | version: "1.3.0" 126 | markdown: 127 | dependency: transitive 128 | description: 129 | name: markdown 130 | sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" 131 | url: "https://pub.dev" 132 | source: hosted 133 | version: "7.3.0" 134 | material_color_utilities: 135 | dependency: transitive 136 | description: 137 | name: material_color_utilities 138 | sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec 139 | url: "https://pub.dev" 140 | source: hosted 141 | version: "0.11.1" 142 | meta: 143 | dependency: transitive 144 | description: 145 | name: meta 146 | sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" 147 | url: "https://pub.dev" 148 | source: hosted 149 | version: "1.17.0" 150 | path: 151 | dependency: transitive 152 | description: 153 | name: path 154 | sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" 155 | url: "https://pub.dev" 156 | source: hosted 157 | version: "1.9.1" 158 | path_provider: 159 | dependency: transitive 160 | description: 161 | name: path_provider 162 | sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" 163 | url: "https://pub.dev" 164 | source: hosted 165 | version: "2.1.5" 166 | path_provider_android: 167 | dependency: transitive 168 | description: 169 | name: path_provider_android 170 | sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e 171 | url: "https://pub.dev" 172 | source: hosted 173 | version: "2.2.22" 174 | path_provider_foundation: 175 | dependency: transitive 176 | description: 177 | name: path_provider_foundation 178 | sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" 179 | url: "https://pub.dev" 180 | source: hosted 181 | version: "2.5.1" 182 | path_provider_linux: 183 | dependency: transitive 184 | description: 185 | name: path_provider_linux 186 | sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 187 | url: "https://pub.dev" 188 | source: hosted 189 | version: "2.2.1" 190 | path_provider_platform_interface: 191 | dependency: transitive 192 | description: 193 | name: path_provider_platform_interface 194 | sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" 195 | url: "https://pub.dev" 196 | source: hosted 197 | version: "2.1.2" 198 | path_provider_windows: 199 | dependency: transitive 200 | description: 201 | name: path_provider_windows 202 | sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 203 | url: "https://pub.dev" 204 | source: hosted 205 | version: "2.3.0" 206 | platform: 207 | dependency: transitive 208 | description: 209 | name: platform 210 | sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" 211 | url: "https://pub.dev" 212 | source: hosted 213 | version: "3.1.6" 214 | plugin_platform_interface: 215 | dependency: transitive 216 | description: 217 | name: plugin_platform_interface 218 | sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" 219 | url: "https://pub.dev" 220 | source: hosted 221 | version: "2.1.8" 222 | sky_engine: 223 | dependency: transitive 224 | description: flutter 225 | source: sdk 226 | version: "0.0.0" 227 | source_span: 228 | dependency: transitive 229 | description: 230 | name: source_span 231 | sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" 232 | url: "https://pub.dev" 233 | source: hosted 234 | version: "1.10.1" 235 | string_scanner: 236 | dependency: transitive 237 | description: 238 | name: string_scanner 239 | sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" 240 | url: "https://pub.dev" 241 | source: hosted 242 | version: "1.4.1" 243 | term_glyph: 244 | dependency: transitive 245 | description: 246 | name: term_glyph 247 | sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" 248 | url: "https://pub.dev" 249 | source: hosted 250 | version: "1.2.2" 251 | typed_data: 252 | dependency: transitive 253 | description: 254 | name: typed_data 255 | sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 256 | url: "https://pub.dev" 257 | source: hosted 258 | version: "1.4.0" 259 | url_launcher: 260 | dependency: "direct main" 261 | description: 262 | name: url_launcher 263 | sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 264 | url: "https://pub.dev" 265 | source: hosted 266 | version: "6.3.2" 267 | url_launcher_android: 268 | dependency: transitive 269 | description: 270 | name: url_launcher_android 271 | sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" 272 | url: "https://pub.dev" 273 | source: hosted 274 | version: "6.3.28" 275 | url_launcher_ios: 276 | dependency: transitive 277 | description: 278 | name: url_launcher_ios 279 | sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad 280 | url: "https://pub.dev" 281 | source: hosted 282 | version: "6.3.6" 283 | url_launcher_linux: 284 | dependency: transitive 285 | description: 286 | name: url_launcher_linux 287 | sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a 288 | url: "https://pub.dev" 289 | source: hosted 290 | version: "3.2.2" 291 | url_launcher_macos: 292 | dependency: transitive 293 | description: 294 | name: url_launcher_macos 295 | sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" 296 | url: "https://pub.dev" 297 | source: hosted 298 | version: "3.2.5" 299 | url_launcher_platform_interface: 300 | dependency: transitive 301 | description: 302 | name: url_launcher_platform_interface 303 | sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" 304 | url: "https://pub.dev" 305 | source: hosted 306 | version: "2.3.2" 307 | url_launcher_web: 308 | dependency: transitive 309 | description: 310 | name: url_launcher_web 311 | sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" 312 | url: "https://pub.dev" 313 | source: hosted 314 | version: "2.4.1" 315 | url_launcher_windows: 316 | dependency: transitive 317 | description: 318 | name: url_launcher_windows 319 | sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" 320 | url: "https://pub.dev" 321 | source: hosted 322 | version: "3.1.5" 323 | vector_math: 324 | dependency: transitive 325 | description: 326 | name: vector_math 327 | sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b 328 | url: "https://pub.dev" 329 | source: hosted 330 | version: "2.2.0" 331 | web: 332 | dependency: transitive 333 | description: 334 | name: web 335 | sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" 336 | url: "https://pub.dev" 337 | source: hosted 338 | version: "1.1.1" 339 | xdg_directories: 340 | dependency: transitive 341 | description: 342 | name: xdg_directories 343 | sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" 344 | url: "https://pub.dev" 345 | source: hosted 346 | version: "1.1.0" 347 | sdks: 348 | dart: ">=3.10.1 <4.0.0" 349 | flutter: ">=3.35.0" 350 | -------------------------------------------------------------------------------- /lib/widgets/navigations/motion_tabs.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' as math; 2 | import 'package:flutter/widgets.dart'; 3 | 4 | class MotionTabs extends StatefulWidget { 5 | final List items; 6 | final int initialIndex; 7 | final ValueChanged? onChanged; 8 | 9 | final Color backgroundColor; 10 | final Color overlayColor; 11 | final Color selectedColor; 12 | final Color textColor; 13 | final Color selectedTextColor; 14 | 15 | final double height; 16 | final double borderRadius; 17 | final EdgeInsets padding; 18 | final double gap; 19 | final Duration animationDuration; 20 | final Curve animationCurve; 21 | 22 | const MotionTabs({ 23 | super.key, 24 | required this.items, 25 | this.initialIndex = 0, 26 | this.onChanged, 27 | this.backgroundColor = const Color(0xFF2F4BFF), 28 | this.overlayColor = const Color(0x24FFFFFF), 29 | this.selectedColor = const Color(0xffffffff), 30 | this.textColor = const Color(0xffffffff), 31 | this.selectedTextColor = const Color(0xFF1C1F3E), 32 | this.height = 56, 33 | this.borderRadius = 18, 34 | this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 10), 35 | this.gap = 8, 36 | this.animationDuration = const Duration(milliseconds: 220), 37 | this.animationCurve = Curves.easeOutCubic, 38 | }) : assert(items.length > 0, 'items cannot be empty'); 39 | 40 | @override 41 | State createState() => _MotionTabsState(); 42 | } 43 | 44 | class _MotionTabsState extends State { 45 | late int _selectedIndex; 46 | int? _hoveredIndex; 47 | 48 | @override 49 | void initState() { 50 | super.initState(); 51 | _selectedIndex = _clampIndex(widget.initialIndex); 52 | } 53 | 54 | @override 55 | void didUpdateWidget(covariant MotionTabs oldWidget) { 56 | super.didUpdateWidget(oldWidget); 57 | if (widget.initialIndex != oldWidget.initialIndex) { 58 | _selectedIndex = _clampIndex(widget.initialIndex); 59 | } 60 | if (widget.items.length != oldWidget.items.length) { 61 | _selectedIndex = _clampIndex(_selectedIndex); 62 | _hoveredIndex = null; 63 | } 64 | } 65 | 66 | int _clampIndex(int index) { 67 | return index.clamp(0, widget.items.length - 1); 68 | } 69 | 70 | void _handleTap(int index) { 71 | if (_selectedIndex == index) { 72 | widget.items[index].onTap?.call(); 73 | return; 74 | } 75 | 76 | setState(() => _selectedIndex = index); 77 | widget.onChanged?.call(index); 78 | widget.items[index].onTap?.call(); 79 | } 80 | 81 | void _handleHover(int index) { 82 | if (_hoveredIndex == index) return; 83 | setState(() => _hoveredIndex = index); 84 | } 85 | 86 | void _clearHover() { 87 | if (_hoveredIndex == null) return; 88 | setState(() => _hoveredIndex = null); 89 | } 90 | 91 | @override 92 | Widget build(BuildContext context) { 93 | final radius = BorderRadius.circular(widget.borderRadius); 94 | 95 | return MouseRegion( 96 | onExit: (_) => _clearHover(), 97 | child: ClipRRect( 98 | borderRadius: radius, 99 | child: AnimatedContainer( 100 | duration: widget.animationDuration, 101 | decoration: BoxDecoration( 102 | color: widget.backgroundColor, 103 | gradient: LinearGradient( 104 | begin: Alignment.topLeft, 105 | end: Alignment.bottomRight, 106 | colors: [ 107 | widget.backgroundColor, 108 | widget.backgroundColor.withValues(alpha: 0.78), 109 | ], 110 | ), 111 | ), 112 | child: SizedBox( 113 | height: widget.height, 114 | child: LayoutBuilder( 115 | builder: (context, constraints) { 116 | final fallbackWidth = 117 | widget.items.length * 120 + 118 | widget.gap * (widget.items.length - 1) + 119 | widget.padding.horizontal; 120 | final availableWidth = constraints.maxWidth.isFinite 121 | ? constraints.maxWidth 122 | : fallbackWidth.toDouble(); 123 | 124 | final rawContentWidth = math.max( 125 | 0.0, 126 | availableWidth - widget.padding.horizontal, 127 | ); 128 | final totalGapWidth = 129 | widget.gap * (widget.items.length - 1).toDouble(); 130 | final minContentWidth = 131 | totalGapWidth + widget.items.length.toDouble(); 132 | final contentWidth = math.max( 133 | rawContentWidth, 134 | minContentWidth.toDouble(), 135 | ); 136 | final tabWidth = 137 | (contentWidth - totalGapWidth) / widget.items.length; 138 | 139 | final overlayWidth = _hoveredIndex == null 140 | ? contentWidth 141 | : tabWidth; 142 | final overlayLeft = 143 | widget.padding.left + 144 | (_hoveredIndex ?? 0) * (tabWidth + widget.gap); 145 | final itemHeight = math.max( 146 | 0.0, 147 | widget.height - widget.padding.vertical, 148 | ); 149 | final selectedLeft = 150 | widget.padding.left + 151 | _selectedIndex * (tabWidth + widget.gap); 152 | 153 | return Stack( 154 | alignment: Alignment.centerLeft, 155 | children: [ 156 | Positioned.fill( 157 | child: Container( 158 | decoration: BoxDecoration( 159 | borderRadius: radius, 160 | color: widget.backgroundColor, 161 | ), 162 | ), 163 | ), 164 | AnimatedPositioned( 165 | duration: widget.animationDuration, 166 | curve: widget.animationCurve, 167 | left: selectedLeft, 168 | top: widget.padding.top, 169 | bottom: widget.padding.bottom, 170 | width: tabWidth, 171 | child: IgnorePointer( 172 | child: DecoratedBox( 173 | decoration: BoxDecoration( 174 | color: widget.selectedColor, 175 | borderRadius: BorderRadius.circular( 176 | math.max(0.0, widget.borderRadius - 2), 177 | ), 178 | ), 179 | ), 180 | ), 181 | ), 182 | AnimatedPositioned( 183 | duration: widget.animationDuration, 184 | curve: widget.animationCurve, 185 | left: overlayLeft, 186 | top: widget.padding.top, 187 | bottom: widget.padding.bottom, 188 | width: overlayWidth, 189 | child: IgnorePointer( 190 | child: AnimatedContainer( 191 | duration: widget.animationDuration, 192 | curve: widget.animationCurve, 193 | decoration: BoxDecoration( 194 | color: widget.overlayColor, 195 | borderRadius: BorderRadius.circular( 196 | math.max(0.0, widget.borderRadius - 2), 197 | ), 198 | ), 199 | ), 200 | ), 201 | ), 202 | Padding( 203 | padding: widget.padding, 204 | child: Row( 205 | children: [ 206 | for (var i = 0; i < widget.items.length; i++) ...[ 207 | _TabButton( 208 | width: tabWidth, 209 | height: itemHeight, 210 | item: widget.items[i], 211 | selected: i == _selectedIndex, 212 | textColor: widget.textColor, 213 | selectedTextColor: widget.selectedTextColor, 214 | selectedColor: widget.selectedColor, 215 | borderRadius: widget.borderRadius, 216 | duration: widget.animationDuration, 217 | curve: widget.animationCurve, 218 | onTap: () => _handleTap(i), 219 | onHover: () => _handleHover(i), 220 | ), 221 | if (i != widget.items.length - 1) 222 | SizedBox(width: widget.gap), 223 | ], 224 | ], 225 | ), 226 | ), 227 | ], 228 | ); 229 | }, 230 | ), 231 | ), 232 | ), 233 | ), 234 | ); 235 | } 236 | } 237 | 238 | class _TabButton extends StatelessWidget { 239 | final double width; 240 | final double height; 241 | final MotionTabItem item; 242 | final bool selected; 243 | final Color textColor; 244 | final Color selectedTextColor; 245 | final Color selectedColor; 246 | final double borderRadius; 247 | final Duration duration; 248 | final Curve curve; 249 | final VoidCallback onTap; 250 | final VoidCallback onHover; 251 | 252 | const _TabButton({ 253 | required this.width, 254 | required this.height, 255 | required this.item, 256 | required this.selected, 257 | required this.textColor, 258 | required this.selectedTextColor, 259 | required this.selectedColor, 260 | required this.borderRadius, 261 | required this.duration, 262 | required this.curve, 263 | required this.onTap, 264 | required this.onHover, 265 | }); 266 | 267 | @override 268 | Widget build(BuildContext context) { 269 | final effectiveRadius = math.max(0.0, borderRadius - 4); 270 | final iconColor = selected ? selectedTextColor : textColor; 271 | 272 | return MouseRegion( 273 | onEnter: (_) => onHover(), 274 | child: GestureDetector( 275 | behavior: HitTestBehavior.opaque, 276 | onTap: onTap, 277 | child: AnimatedContainer( 278 | duration: duration, 279 | curve: curve, 280 | width: width, 281 | height: height, 282 | decoration: BoxDecoration( 283 | color: const Color(0x00ffffff), 284 | borderRadius: BorderRadius.circular(effectiveRadius), 285 | ), 286 | padding: const EdgeInsets.symmetric(horizontal: 12), 287 | child: Row( 288 | mainAxisAlignment: MainAxisAlignment.center, 289 | mainAxisSize: MainAxisSize.min, 290 | children: [ 291 | if (item.icon != null) ...[ 292 | TweenAnimationBuilder( 293 | tween: ColorTween(begin: iconColor, end: iconColor), 294 | duration: duration, 295 | curve: curve, 296 | builder: (context, color, child) => IconTheme( 297 | data: IconThemeData(color: color, size: 18), 298 | child: child!, 299 | ), 300 | child: SizedBox( 301 | height: 18, 302 | width: 18, 303 | child: FittedBox(child: item.icon), 304 | ), 305 | ), 306 | const SizedBox(width: 8), 307 | ], 308 | AnimatedDefaultTextStyle( 309 | duration: duration, 310 | curve: curve, 311 | style: TextStyle( 312 | color: selected ? selectedTextColor : textColor, 313 | fontWeight: FontWeight.w600, 314 | letterSpacing: 0.2, 315 | ), 316 | child: Text(item.label, overflow: TextOverflow.ellipsis), 317 | ), 318 | ], 319 | ), 320 | ), 321 | ), 322 | ); 323 | } 324 | } 325 | 326 | class MotionTabItem { 327 | final String label; 328 | final Widget? icon; 329 | final VoidCallback? onTap; 330 | 331 | const MotionTabItem({required this.label, this.icon, this.onTap}); 332 | } 333 | -------------------------------------------------------------------------------- /lib/widgets/backgrounds/black_hole_background.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' as math; 2 | import 'dart:ui' as ui; 3 | import 'package:flutter/widgets.dart'; 4 | import 'package:flutter/scheduler.dart'; 5 | 6 | class BlackHoleBackground extends StatefulWidget { 7 | final Color strokeColor; 8 | final int numberOfLines; 9 | final int numberOfDiscs; 10 | final Color particleColor; 11 | final Widget? child; 12 | 13 | const BlackHoleBackground({ 14 | super.key, 15 | this.strokeColor = const Color(0x33737373), 16 | this.numberOfLines = 50, 17 | this.numberOfDiscs = 50, 18 | this.particleColor = const Color(0x33FFFFFF), 19 | this.child, 20 | }); 21 | 22 | @override 23 | State createState() => _BlackHoleBackgroundState(); 24 | } 25 | 26 | class _RepaintNotifier extends ChangeNotifier { 27 | void notify() => notifyListeners(); 28 | } 29 | 30 | class _BlackHoleBackgroundState extends State 31 | with SingleTickerProviderStateMixin { 32 | late Ticker _ticker; 33 | final _repaintNotifier = _RepaintNotifier(); 34 | 35 | List<_Disc> _discs = []; 36 | List> _lines = []; 37 | List<_Particle> _particles = []; 38 | _Clip _clip = _Clip(); 39 | _Disc _startDisc = _Disc(p: 0, x: 0, y: 0, w: 0, h: 0); 40 | _Disc _endDisc = _Disc(p: 0, x: 0, y: 0, w: 0, h: 0); 41 | Size _rect = Size.zero; 42 | _ParticleArea _particleArea = _ParticleArea(); 43 | ui.Picture? _linesPicture; 44 | 45 | final math.Random _random = math.Random(); 46 | 47 | @override 48 | void initState() { 49 | super.initState(); 50 | _ticker = createTicker(_tick)..start(); 51 | } 52 | 53 | @override 54 | void dispose() { 55 | _ticker.dispose(); 56 | _repaintNotifier.dispose(); 57 | _linesPicture?.dispose(); 58 | _linesPicture = null; 59 | super.dispose(); 60 | } 61 | 62 | double _easeInExpo(double p) { 63 | return p == 0 ? 0 : math.pow(2, 10 * (p - 1)).toDouble(); 64 | } 65 | 66 | double _tweenValue( 67 | double start, 68 | double end, 69 | double p, { 70 | bool inExpo = false, 71 | }) { 72 | final delta = end - start; 73 | final easeVal = inExpo ? _easeInExpo(p) : p; 74 | return start + delta * easeVal; 75 | } 76 | 77 | void _tweenDisc(_Disc disc) { 78 | disc.x = _tweenValue(_startDisc.x, _endDisc.x, disc.p); 79 | disc.y = _tweenValue(_startDisc.y, _endDisc.y, disc.p, inExpo: true); 80 | disc.w = _tweenValue(_startDisc.w, _endDisc.w, disc.p); 81 | disc.h = _tweenValue(_startDisc.h, _endDisc.h, disc.p); 82 | } 83 | 84 | void _setSize(Size size) { 85 | if (_rect == size) return; 86 | _rect = size; 87 | _init(); 88 | } 89 | 90 | void _init() { 91 | _setDiscs(); 92 | _setLines(); 93 | _setParticles(); 94 | } 95 | 96 | void _setDiscs() { 97 | final width = _rect.width; 98 | final height = _rect.height; 99 | if (width <= 0 || height <= 0) return; 100 | 101 | _discs = []; 102 | _startDisc = _Disc( 103 | p: 0, 104 | x: width * 0.5, 105 | y: height * 0.45, 106 | w: width * 0.75, 107 | h: height * 0.7, 108 | ); 109 | _endDisc = _Disc(p: 0, x: width * 0.5, y: height * 0.95, w: 0, h: 0); 110 | 111 | double prevBottom = height; 112 | _clip = _Clip(); 113 | 114 | for (int i = 0; i < widget.numberOfDiscs; i++) { 115 | final p = i / widget.numberOfDiscs; 116 | final disc = _Disc(p: p, x: 0, y: 0, w: 0, h: 0); 117 | _tweenDisc(disc); 118 | final bottom = disc.y + disc.h; 119 | if (bottom <= prevBottom) { 120 | _clip = _Clip( 121 | disc: _Disc(p: disc.p, x: disc.x, y: disc.y, w: disc.w, h: disc.h), 122 | i: i, 123 | ); 124 | } 125 | prevBottom = bottom; 126 | _discs.add(disc); 127 | } 128 | 129 | if (_clip.disc != null) { 130 | final disc = _clip.disc!; 131 | final clipPath = Path(); 132 | clipPath.addOval( 133 | Rect.fromCenter( 134 | center: Offset(disc.x, disc.y), 135 | width: disc.w * 2, 136 | height: disc.h * 2, 137 | ), 138 | ); 139 | clipPath.addRect(Rect.fromLTWH(disc.x - disc.w, 0, disc.w * 2, disc.y)); 140 | _clip.path = clipPath; 141 | } 142 | } 143 | 144 | void _setLines() { 145 | final width = _rect.width; 146 | final height = _rect.height; 147 | if (width <= 0 || height <= 0) return; 148 | 149 | _lines = []; 150 | final linesAngle = (math.pi * 2) / widget.numberOfLines; 151 | for (int i = 0; i < widget.numberOfLines; i++) { 152 | _lines.add([]); 153 | } 154 | 155 | for (var disc in _discs) { 156 | for (int i = 0; i < widget.numberOfLines; i++) { 157 | final angle = i * linesAngle; 158 | final point = _Point( 159 | x: disc.x + math.cos(angle) * disc.w, 160 | y: disc.y + math.sin(angle) * disc.h, 161 | ); 162 | _lines[i].add(point); 163 | } 164 | } 165 | 166 | // Dispose old picture before creating new one 167 | _linesPicture?.dispose(); 168 | _linesPicture = null; 169 | 170 | if (_clip.path == null) { 171 | return; 172 | } 173 | 174 | final recorder = ui.PictureRecorder(); 175 | final canvas = Canvas(recorder); 176 | final paint = Paint() 177 | ..color = widget.strokeColor 178 | ..style = PaintingStyle.stroke 179 | ..strokeWidth = 2; 180 | 181 | for (var line in _lines) { 182 | canvas.save(); 183 | bool lineIsIn = false; 184 | for (int j = 0; j < line.length; j++) { 185 | if (j == 0) continue; 186 | final p0 = line[j - 1]; 187 | final p1 = line[j]; 188 | if (!lineIsIn && _isPointInClipPath(p1.x, p1.y)) { 189 | lineIsIn = true; 190 | } else if (lineIsIn) { 191 | canvas.clipPath(_clip.path!); 192 | } 193 | canvas.drawLine(Offset(p0.x, p0.y), Offset(p1.x, p1.y), paint); 194 | } 195 | canvas.restore(); 196 | } 197 | 198 | _linesPicture = recorder.endRecording(); 199 | } 200 | 201 | bool _isPointInClipPath(double x, double y) { 202 | if (_clip.disc == null) return false; 203 | final disc = _clip.disc!; 204 | 205 | final dx = (x - disc.x) / disc.w; 206 | final dy = (y - disc.y) / disc.h; 207 | final ellipseValue = dx * dx + dy * dy; 208 | final sqrtV = math.sqrt(ellipseValue); 209 | 210 | if (sqrtV <= 1.0) return true; 211 | final distToEdge = (sqrtV - 1.0) * math.min(disc.w, disc.h); 212 | if (distToEdge <= 0.2) return true; 213 | 214 | if (y < disc.y && x >= disc.x - disc.w && x <= disc.x + disc.w) { 215 | return true; 216 | } 217 | 218 | return false; 219 | } 220 | 221 | _Particle _initParticle({bool start = false}) { 222 | final sx = 223 | (_particleArea.sx ?? 0) + 224 | (_particleArea.sw ?? 0) * _random.nextDouble(); 225 | final ex = 226 | (_particleArea.ex ?? 0) + 227 | (_particleArea.ew ?? 0) * _random.nextDouble(); 228 | final dx = ex - sx; 229 | final y = start 230 | ? (_particleArea.h ?? 0) * _random.nextDouble() 231 | : (_particleArea.h ?? 0); 232 | final r = 0.5 + _random.nextDouble() * 4; 233 | final vy = 0.5 + _random.nextDouble(); 234 | return _Particle( 235 | x: sx, 236 | sx: sx, 237 | dx: dx, 238 | y: y, 239 | vy: vy, 240 | p: 0, 241 | r: r, 242 | opacity: _random.nextDouble(), 243 | ); 244 | } 245 | 246 | void _setParticles() { 247 | final width = _rect.width; 248 | final height = _rect.height; 249 | _particles = []; 250 | final disc = _clip.disc; 251 | if (disc == null) return; 252 | _particleArea = _ParticleArea( 253 | sw: disc.w * 0.5, 254 | ew: disc.w * 2, 255 | h: height * 0.85, 256 | ); 257 | _particleArea.sx = (width - (_particleArea.sw ?? 0)) / 2; 258 | _particleArea.ex = (width - (_particleArea.ew ?? 0)) / 2; 259 | for (int i = 0; i < 100; i++) { 260 | _particles.add(_initParticle(start: true)); 261 | } 262 | } 263 | 264 | void _moveDiscs() { 265 | for (var disc in _discs) { 266 | disc.p = (disc.p + 0.001) % 1; 267 | _tweenDisc(disc); 268 | } 269 | } 270 | 271 | void _moveParticles() { 272 | for (int i = 0; i < _particles.length; i++) { 273 | final particle = _particles[i]; 274 | particle.p = 1 - particle.y / (_particleArea.h ?? 1); 275 | particle.x = particle.sx + particle.dx * particle.p; 276 | particle.y -= particle.vy; 277 | if (particle.y < 0) { 278 | _particles[i] = _initParticle(); 279 | } 280 | } 281 | } 282 | 283 | void _tick(Duration elapsed) { 284 | if (_rect == Size.zero) return; 285 | _moveDiscs(); 286 | _moveParticles(); 287 | _repaintNotifier.notify(); 288 | } 289 | 290 | @override 291 | Widget build(BuildContext context) { 292 | return LayoutBuilder( 293 | builder: (context, constraints) { 294 | _setSize(Size(constraints.maxWidth, constraints.maxHeight)); 295 | 296 | return Stack( 297 | fit: StackFit.expand, 298 | children: [ 299 | CustomPaint( 300 | painter: _BlackHolePainter( 301 | discs: _discs, 302 | linesPicture: _linesPicture, 303 | particles: _particles, 304 | clip: _clip, 305 | startDisc: _startDisc, 306 | strokeColor: widget.strokeColor, 307 | particleColor: widget.particleColor, 308 | repaint: _repaintNotifier, 309 | ), 310 | ), 311 | if (widget.child != null) widget.child!, 312 | ], 313 | ); 314 | }, 315 | ); 316 | } 317 | } 318 | 319 | class _BlackHolePainter extends CustomPainter { 320 | final List<_Disc> discs; 321 | final ui.Picture? linesPicture; 322 | final List<_Particle> particles; 323 | final _Clip clip; 324 | final _Disc startDisc; 325 | final Color strokeColor; 326 | final Color particleColor; 327 | 328 | // Reusable paint objects to avoid memory allocations 329 | late final Paint _strokePaint = Paint() 330 | ..style = PaintingStyle.stroke 331 | ..strokeWidth = 2; 332 | late final Paint _particlePaint = Paint(); 333 | 334 | _BlackHolePainter({ 335 | required this.discs, 336 | this.linesPicture, 337 | required this.particles, 338 | required this.clip, 339 | required this.startDisc, 340 | required this.strokeColor, 341 | required this.particleColor, 342 | super.repaint, 343 | }); 344 | 345 | @override 346 | void paint(Canvas canvas, Size size) { 347 | _drawDiscs(canvas); 348 | _drawLines(canvas); 349 | _drawParticles(canvas); 350 | } 351 | 352 | void _drawDiscs(Canvas canvas) { 353 | _strokePaint.color = strokeColor; 354 | 355 | canvas.drawOval( 356 | Rect.fromCenter( 357 | center: Offset(startDisc.x, startDisc.y), 358 | width: startDisc.w * 2, 359 | height: startDisc.h * 2, 360 | ), 361 | _strokePaint, 362 | ); 363 | 364 | for (int i = 0; i < discs.length; i++) { 365 | if (i % 5 != 0) continue; 366 | final disc = discs[i]; 367 | if (disc.w < (clip.disc?.w ?? 0) - 5) { 368 | canvas.save(); 369 | if (clip.path != null) { 370 | canvas.clipPath(clip.path!); 371 | } 372 | } 373 | canvas.drawOval( 374 | Rect.fromCenter( 375 | center: Offset(disc.x, disc.y), 376 | width: disc.w * 2, 377 | height: disc.h * 2, 378 | ), 379 | _strokePaint, 380 | ); 381 | if (disc.w < (clip.disc?.w ?? 0) - 5) { 382 | canvas.restore(); 383 | } 384 | } 385 | } 386 | 387 | void _drawLines(Canvas canvas) { 388 | if (linesPicture != null) { 389 | canvas.drawPicture(linesPicture!); 390 | } 391 | } 392 | 393 | void _drawParticles(Canvas canvas) { 394 | if (clip.path == null) return; 395 | canvas.save(); 396 | canvas.clipPath(clip.path!); 397 | for (var particle in particles) { 398 | _particlePaint.color = particleColor.withValues(alpha: particle.opacity); 399 | canvas.drawRect( 400 | Rect.fromLTWH(particle.x, particle.y, particle.r, particle.r), 401 | _particlePaint, 402 | ); 403 | } 404 | canvas.restore(); 405 | } 406 | 407 | @override 408 | bool shouldRepaint(covariant _BlackHolePainter oldDelegate) { 409 | return strokeColor != oldDelegate.strokeColor || 410 | particleColor != oldDelegate.particleColor || 411 | linesPicture != oldDelegate.linesPicture; 412 | } 413 | } 414 | 415 | class _Disc { 416 | double p; 417 | double x; 418 | double y; 419 | double w; 420 | double h; 421 | 422 | _Disc({ 423 | required this.p, 424 | required this.x, 425 | required this.y, 426 | required this.w, 427 | required this.h, 428 | }); 429 | } 430 | 431 | class _Point { 432 | double x; 433 | double y; 434 | 435 | _Point({required this.x, required this.y}); 436 | } 437 | 438 | class _Particle { 439 | double x; 440 | double sx; 441 | double dx; 442 | double y; 443 | double vy; 444 | double p; 445 | double r; 446 | double opacity; 447 | 448 | _Particle({ 449 | required this.x, 450 | required this.sx, 451 | required this.dx, 452 | required this.y, 453 | required this.vy, 454 | required this.p, 455 | required this.r, 456 | required this.opacity, 457 | }); 458 | } 459 | 460 | class _Clip { 461 | _Disc? disc; 462 | int? i; 463 | Path? path; 464 | 465 | _Clip({this.disc, this.i}); 466 | } 467 | 468 | class _ParticleArea { 469 | double? sw; 470 | double? ew; 471 | double? h; 472 | double? sx; 473 | double? ex; 474 | 475 | _ParticleArea({this.sw, this.ew, this.h}); 476 | } 477 | -------------------------------------------------------------------------------- /lib/components/markdown_renderer.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; 4 | import 'package:google_fonts/google_fonts.dart'; 5 | import 'package:url_launcher/url_launcher.dart'; 6 | import 'widget_preview.dart'; 7 | import 'widget_code.dart'; 8 | import '../services/widget_loader.dart'; 9 | 10 | /// Enhanced markdown renderer with support for custom widget embedding. 11 | /// Supports: 12 | /// - @{WidgetPreview:identifier} - Embeds a widget preview 13 | /// - @{WidgetCode:identifier} - Embeds widget source code 14 | /// - Code blocks with copy functionality 15 | class MarkdownRenderer extends StatelessWidget { 16 | /// The markdown content to render 17 | final String markdown; 18 | 19 | /// Custom preview widgets to use (keyed by identifier) 20 | final Map? previewWidgets; 21 | 22 | /// Map of heading titles to GlobalKeys for scrolling 23 | final Map? headingKeys; 24 | 25 | const MarkdownRenderer({ 26 | super.key, 27 | required this.markdown, 28 | this.previewWidgets, 29 | this.headingKeys, 30 | }); 31 | 32 | @override 33 | Widget build(BuildContext context) { 34 | // Parse markdown and extract custom directives and code blocks 35 | final parsed = _parseContent(markdown); 36 | 37 | return Column( 38 | crossAxisAlignment: CrossAxisAlignment.stretch, 39 | children: parsed.expand((segment) { 40 | if (segment is _MarkdownSegment) { 41 | // Split segment by headings to properly attach keys 42 | return _splitByHeadings(segment.content, context); 43 | } else if (segment is _WidgetPreviewSegment) { 44 | return [ 45 | Padding( 46 | padding: const EdgeInsets.symmetric(vertical: 16), 47 | child: WidgetPreview( 48 | identifier: segment.identifier, 49 | previewWidget: previewWidgets?[segment.identifier], 50 | ), 51 | ), 52 | ]; 53 | } else if (segment is _WidgetCodeSegment) { 54 | return [ 55 | Padding( 56 | padding: const EdgeInsets.symmetric(vertical: 16), 57 | child: FutureBuilder( 58 | future: _loadCode(segment.identifier), 59 | builder: (context, snapshot) { 60 | if (snapshot.connectionState == ConnectionState.waiting) { 61 | return const Center(child: CircularProgressIndicator()); 62 | } 63 | return SizedBox( 64 | height: 400, 65 | child: WidgetCode( 66 | code: snapshot.data ?? '// Error loading code', 67 | title: '${segment.identifier}.dart', 68 | ), 69 | ); 70 | }, 71 | ), 72 | ), 73 | ]; 74 | } else if (segment is _CodeBlockSegment) { 75 | return [ 76 | Padding( 77 | padding: const EdgeInsets.symmetric(vertical: 8), 78 | child: _CodeBlockWidget( 79 | code: segment.code, 80 | language: segment.language, 81 | ), 82 | ), 83 | ]; 84 | } 85 | 86 | return [const SizedBox.shrink()]; 87 | }).toList(), 88 | ); 89 | } 90 | 91 | /// Split markdown content by headings and attach GlobalKeys 92 | List _splitByHeadings(String content, BuildContext context) { 93 | final widgets = []; 94 | final lines = content.split('\n'); 95 | final buffer = StringBuffer(); 96 | String? currentHeading; 97 | 98 | for (var i = 0; i < lines.length; i++) { 99 | final line = lines[i]; 100 | 101 | if (line.trim().startsWith('## ')) { 102 | // Save previous section 103 | if (buffer.isNotEmpty) { 104 | widgets.add( 105 | _buildMarkdownWidget( 106 | buffer.toString(), 107 | context, 108 | headingKey: currentHeading != null && headingKeys != null 109 | ? headingKeys![currentHeading] 110 | : null, 111 | ), 112 | ); 113 | buffer.clear(); 114 | } 115 | 116 | // Start new section with this heading 117 | currentHeading = line.trim().substring(3).trim(); 118 | buffer.writeln(line); 119 | } else { 120 | buffer.writeln(line); 121 | } 122 | } 123 | 124 | // Add remaining content 125 | if (buffer.isNotEmpty) { 126 | widgets.add( 127 | _buildMarkdownWidget( 128 | buffer.toString(), 129 | context, 130 | headingKey: currentHeading != null && headingKeys != null 131 | ? headingKeys![currentHeading] 132 | : null, 133 | ), 134 | ); 135 | } 136 | 137 | return widgets.isEmpty ? [const SizedBox.shrink()] : widgets; 138 | } 139 | 140 | /// Build a markdown widget with optional key 141 | Widget _buildMarkdownWidget( 142 | String content, 143 | BuildContext context, { 144 | GlobalKey? headingKey, 145 | }) { 146 | final markdownBody = MarkdownBody( 147 | data: content, 148 | selectable: true, 149 | onTapLink: (text, href, title) { 150 | if (href != null) { 151 | launchUrl(Uri.parse(href)); 152 | } 153 | }, 154 | styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith( 155 | blockSpacing: 16, 156 | // Heading styles 157 | h1: Theme.of(context).textTheme.headlineMedium, 158 | h1Padding: const EdgeInsets.only(top: 24, bottom: 8), 159 | h2: Theme.of(context).textTheme.headlineSmall, 160 | h2Padding: const EdgeInsets.only(top: 32, bottom: 8), 161 | h3: Theme.of(context).textTheme.titleLarge, 162 | h3Padding: const EdgeInsets.only(top: 24, bottom: 8), 163 | ), 164 | ); 165 | 166 | if (headingKey != null) { 167 | return Container(key: headingKey, child: markdownBody); 168 | } 169 | 170 | return markdownBody; 171 | } 172 | 173 | Future _loadCode(String identifier) async { 174 | final metadata = WidgetLoader.findByIdentifier(identifier); 175 | if (metadata != null) { 176 | return await WidgetLoader.loadSourceCode(metadata); 177 | } 178 | return '// Widget not found: $identifier'; 179 | } 180 | 181 | /// Parse content and extract custom syntax, code blocks 182 | List _parseContent(String content) { 183 | final segments = []; 184 | 185 | // Combined regex for custom directives and code blocks 186 | final customDirectiveRegex = RegExp( 187 | r'@\{(WidgetPreview|WidgetCode):([^}]+)\}', 188 | ); 189 | final codeBlockRegex = RegExp(r'```(\w*)\n([\s\S]*?)```', multiLine: true); 190 | 191 | // Find all matches and sort by position 192 | final matches = <_Match>[]; 193 | 194 | for (final match in customDirectiveRegex.allMatches(content)) { 195 | matches.add(_Match(match.start, match.end, 'directive', match)); 196 | } 197 | 198 | for (final match in codeBlockRegex.allMatches(content)) { 199 | matches.add(_Match(match.start, match.end, 'codeblock', match)); 200 | } 201 | 202 | matches.sort((a, b) => a.start.compareTo(b.start)); 203 | 204 | int lastIndex = 0; 205 | for (final m in matches) { 206 | // Add markdown before this match 207 | if (m.start > lastIndex) { 208 | final mdContent = content.substring(lastIndex, m.start); 209 | if (mdContent.trim().isNotEmpty) { 210 | segments.add(_MarkdownSegment(mdContent)); 211 | } 212 | } 213 | 214 | if (m.type == 'directive') { 215 | final match = m.match; 216 | final type = match.group(1); 217 | final identifier = match.group(2)!.trim(); 218 | 219 | if (type == 'WidgetPreview') { 220 | segments.add(_WidgetPreviewSegment(identifier)); 221 | } else if (type == 'WidgetCode') { 222 | segments.add(_WidgetCodeSegment(identifier)); 223 | } 224 | } else if (m.type == 'codeblock') { 225 | final match = m.match; 226 | final language = match.group(1) ?? ''; 227 | final code = match.group(2) ?? ''; 228 | segments.add(_CodeBlockSegment(code.trimRight(), language)); 229 | } 230 | 231 | lastIndex = m.end; 232 | } 233 | 234 | // Add remaining markdown 235 | if (lastIndex < content.length) { 236 | final mdContent = content.substring(lastIndex); 237 | if (mdContent.trim().isNotEmpty) { 238 | segments.add(_MarkdownSegment(mdContent)); 239 | } 240 | } 241 | 242 | // If no content found, return single markdown segment 243 | if (segments.isEmpty) { 244 | segments.add(_MarkdownSegment(content)); 245 | } 246 | 247 | return segments; 248 | } 249 | } 250 | 251 | // Helper class for sorting matches 252 | class _Match { 253 | final int start; 254 | final int end; 255 | final String type; 256 | final RegExpMatch match; 257 | 258 | _Match(this.start, this.end, this.type, this.match); 259 | } 260 | 261 | // Helper classes for parsed segments 262 | class _MarkdownSegment { 263 | final String content; 264 | _MarkdownSegment(this.content); 265 | } 266 | 267 | class _WidgetPreviewSegment { 268 | final String identifier; 269 | _WidgetPreviewSegment(this.identifier); 270 | } 271 | 272 | class _WidgetCodeSegment { 273 | final String identifier; 274 | _WidgetCodeSegment(this.identifier); 275 | } 276 | 277 | class _CodeBlockSegment { 278 | final String code; 279 | final String language; 280 | _CodeBlockSegment(this.code, this.language); 281 | } 282 | 283 | /// Code block widget with copy functionality 284 | class _CodeBlockWidget extends StatefulWidget { 285 | final String code; 286 | final String language; 287 | 288 | const _CodeBlockWidget({required this.code, this.language = ''}); 289 | 290 | @override 291 | State<_CodeBlockWidget> createState() => _CodeBlockWidgetState(); 292 | } 293 | 294 | class _CodeBlockWidgetState extends State<_CodeBlockWidget> { 295 | bool _copied = false; 296 | 297 | Future _copyToClipboard() async { 298 | await Clipboard.setData(ClipboardData(text: widget.code)); 299 | if (mounted) { 300 | setState(() => _copied = true); 301 | Future.delayed(const Duration(seconds: 2), () { 302 | if (mounted) { 303 | setState(() => _copied = false); 304 | } 305 | }); 306 | } 307 | } 308 | 309 | @override 310 | Widget build(BuildContext context) { 311 | final isDark = Theme.of(context).brightness == Brightness.dark; 312 | final lang = widget.language.isNotEmpty ? widget.language : 'code'; 313 | 314 | return Container( 315 | width: double.infinity, 316 | decoration: BoxDecoration( 317 | color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5), 318 | borderRadius: BorderRadius.circular(12), 319 | border: Border.all( 320 | color: isDark ? Colors.grey[800]! : Colors.grey[300]!, 321 | width: 1, 322 | ), 323 | ), 324 | child: Column( 325 | crossAxisAlignment: CrossAxisAlignment.stretch, 326 | children: [ 327 | // Header with language label and copy button 328 | Container( 329 | padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 330 | decoration: BoxDecoration( 331 | color: isDark ? const Color(0xFF252525) : const Color(0xFFEEEEEE), 332 | borderRadius: const BorderRadius.only( 333 | topLeft: Radius.circular(11), 334 | topRight: Radius.circular(11), 335 | ), 336 | border: Border( 337 | bottom: BorderSide( 338 | color: isDark ? Colors.grey[800]! : Colors.grey[300]!, 339 | width: 1, 340 | ), 341 | ), 342 | ), 343 | child: Row( 344 | children: [ 345 | Text( 346 | lang, 347 | style: GoogleFonts.firaCode( 348 | fontSize: 12, 349 | color: isDark ? Colors.grey[500] : Colors.grey[600], 350 | ), 351 | ), 352 | const Spacer(), 353 | InkWell( 354 | onTap: _copyToClipboard, 355 | borderRadius: BorderRadius.circular(4), 356 | child: Padding( 357 | padding: const EdgeInsets.symmetric( 358 | horizontal: 8, 359 | vertical: 4, 360 | ), 361 | child: Row( 362 | mainAxisSize: MainAxisSize.min, 363 | children: [ 364 | Icon( 365 | _copied ? Icons.check_rounded : Icons.copy_rounded, 366 | size: 14, 367 | color: _copied 368 | ? Colors.green 369 | : (isDark ? Colors.grey[400] : Colors.grey[600]), 370 | ), 371 | const SizedBox(width: 4), 372 | Text( 373 | _copied ? 'Copied!' : 'Copy', 374 | style: GoogleFonts.inter( 375 | fontSize: 12, 376 | color: _copied 377 | ? Colors.green 378 | : (isDark 379 | ? Colors.grey[400] 380 | : Colors.grey[600]), 381 | ), 382 | ), 383 | ], 384 | ), 385 | ), 386 | ), 387 | ], 388 | ), 389 | ), 390 | // Code content 391 | Padding( 392 | padding: const EdgeInsets.all(16), 393 | child: SelectableText( 394 | widget.code, 395 | style: GoogleFonts.firaCode( 396 | fontSize: 13, 397 | height: 1.6, 398 | color: isDark ? Colors.grey[300] : Colors.grey[800], 399 | ), 400 | ), 401 | ), 402 | ], 403 | ), 404 | ); 405 | } 406 | } 407 | --------------------------------------------------------------------------------