diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ef78a08..3744d3c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,11 +8,31 @@ on: jobs: publish: + name: Publish to pub.dev permissions: contents: read id-token: write - uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 - with: - # Specify the github actions deployment environment - environment: pub.dev - # working-directory: path/to/package/within/repository + runs-on: ubuntu-latest + environment: pub.dev + + steps: + - uses: actions/checkout@v6 + + - name: Set up Dart OIDC + uses: dart-lang/setup-dart@v1 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.7.12" + channel: stable + cache: true + + - name: Install dependencies + run: dart pub get + + - name: Publish - dry run + run: dart pub publish --dry-run + + - name: Publish to pub.dev + run: dart pub publish --force diff --git a/CHANGELOG.md b/CHANGELOG.md index a82a176..d608d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,14 @@ # CHANGELOG -## 0.2.4-dev.1 +## 0.2.4 * Expand Dart SDK compatibility to allow Dart 3. * Add CI coverage for both Flutter 3.7.12 and the latest stable Flutter. * Avoid deprecated color opacity APIs on current Flutter. +* Add dartdoc coverage for the main public API. +* Replace the reusable publish workflow with Node 24 compatible publish steps. +* Expand README usage guidance with minimal examples, parameter tables, and common errors. +* Refresh the example app with a Material 3 generator, live performance metrics, and copyable code output. ## 0.2.3 diff --git a/README.md b/README.md index 609284e..8026b9a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![Pub](https://img.shields.io/pub/v/wave.svg?logo=flutter&style=flat-square)](https://pub.dev/packages/wave) ![GitHub](https://img.shields.io/github/license/mashape/apistatus.svg?longCache=true&style=flat-square) -A Flutter package for displaying waves. +A Flutter package for displaying animated wave backgrounds. ## Demo @@ -16,82 +16,149 @@ A Flutter package for displaying waves. | -: | -: | | Web | [wave.glorylab.xyz](https://wave.glorylab.xyz "The demo page of the wave package.") | +The web demo is an interactive generator. Adjust the config mode, palette, +layer count, height, amplitude, speed, and loop behavior, then copy the +generated `WaveWidget` code into your app. + +Wave Generator web demo with live preview, controls, performance metrics, and generated code dialog entry + ## Deployment The web demo is built from `example/` and deployed with Cloudflare Workers Static Assets. See [docs/cloudflare-workers.md](docs/cloudflare-workers.md) for the GitHub Actions and migration setup. +## Install +```yaml +dependencies: + wave: ^0.2.4 +``` -## Getting Started +## Minimal Example -``` Dart +```dart +import 'package:flutter/material.dart'; +import 'package:wave/wave.dart'; -static const _backgroundColor = Color(0xFFF15BB5); +class WaveBackground extends StatelessWidget { + const WaveBackground({Key? key}) : super(key: key); -static const _colors = [ - Color(0xFFFEE440), - Color(0xFF00BBF9), -]; + @override + Widget build(BuildContext context) { + return WaveWidget( + config: SingleConfig( + color: Color(0xFF00BBF9), + layers: 3, + ), + size: Size(double.infinity, double.infinity), + ); + } +} +``` -static const _durations = [ - 5000, - 4000, -]; +Use `WaveWidget` anywhere a normal widget can be painted. Give it a bounded +parent, such as `SizedBox.expand`, `Positioned.fill`, or a fixed-height +container. -static const _heightPercentages = [ - 0.65, - 0.66, -]; +## Config Modes +### Single color + +`SingleConfig` creates layered waves from one base color. + +```dart WaveWidget( - config: CustomConfig( - colors: _colors, - durations: _durations, - heightPercentages: _heightPercentages, - ), - backgroundColor: _backgroundColor, - size: Size(double.infinity, double.infinity), - waveAmplitude: 0, -), + config: SingleConfig( + color: const Color(0xFF00BBF9), + layers: 3, + ), + size: const Size(double.infinity, double.infinity), +) ``` -## Config modes +### Seeded random colors -`CustomConfig` is the most explicit mode. Use it when you want full control over -each wave layer's colors or gradients, duration, and height. +`RandomConfig` creates generated colors for each layer. Provide `seed` when +deterministic output is useful for tests, demos, or copyable examples. Omit +`seed` only when you really want a new generated palette for each config +construction. -``` Dart +```dart WaveWidget( - config: CustomConfig( - gradients: [ - [Color(0xFF00BBF9), Color(0xFF9B5DE5)], - [Color(0xFFFEE440), Color(0xFFF15BB5)], - ], - durations: [5000, 4000], - heightPercentages: [0.65, 0.66], - ), - size: Size(double.infinity, double.infinity), + config: RandomConfig( + seed: 7, + layers: 4, + ), + size: const Size(double.infinity, double.infinity), ) ``` -`SingleConfig` creates layered waves from one color. `RandomConfig` creates -layer colors for you, with an optional `seed` when deterministic output is -useful for tests or demos. +### Custom colors or gradients -``` Dart +`CustomConfig` is the most explicit mode. Use it when you want full control over +each layer's color or gradient, duration, and height. + +```dart WaveWidget( - config: SingleConfig( - color: Color(0xFF00BBF9), - layers: 3, - ), - size: Size(double.infinity, double.infinity), + config: CustomConfig( + gradients: const [ + [Color(0xFF00BBF9), Color(0xFF9B5DE5)], + [Color(0xFFFEE440), Color(0xFFF15BB5)], + ], + durations: const [5000, 4000], + heightPercentages: const [0.65, 0.66], + ), + size: const Size(double.infinity, double.infinity), ) +``` -WaveWidget( - config: RandomConfig( - seed: 7, - layers: 4, - ), - size: Size(double.infinity, double.infinity), +## Parameters + +### WaveWidget + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `config` | `Config` | required | Layer colors, durations, heights, and blur. | +| `size` | `Size` | required | Paint size for the wave stack. | +| `waveAmplitude` | `double` | `20.0` | Base vertical movement of the wave path. | +| `wavePhase` | `double` | `10.0` | Initial phase offset for the animation. | +| `waveFrequency` | `double` | `1.6` | Horizontal frequency of the wave path. | +| `duration` | `int?` | `6000` | Total runtime in milliseconds when `isLoop` is false. | +| `backgroundColor` | `Color?` | `null` | Background color behind the waves. | +| `backgroundImage` | `DecorationImage?` | `null` | Background image behind the waves. | +| `isLoop` | `bool` | `true` | Whether animations repeat indefinitely. | + +### Config classes + +| Class | Best for | Key parameters | +| --- | --- | --- | +| `SingleConfig` | One-color layered waves | `color`, `layers`, `opacityPercentages`, `durations`, `heightPercentages` | +| `RandomConfig` | Fast generated palettes | `layers`, `seed`, `colors`, `durations`, `heightPercentages` | +| `CustomConfig` | Exact colors or gradients | `colors` or `gradients`, `durations`, `heightPercentages`, `gradientBegin`, `gradientEnd` | + +## Common Errors + +`CustomConfig` requires exactly one of `colors` or `gradients`. + +```dart +CustomConfig( + colors: const [Color(0xFF00BBF9)], + gradients: const [ + [Color(0xFF00BBF9), Color(0xFF9B5DE5)], + ], + durations: const [5000], + heightPercentages: const [0.65], ) ``` + +All per-layer lists must have the same length. + +```dart +CustomConfig( + colors: const [Color(0xFF00BBF9), Color(0xFF9B5DE5)], + durations: const [5000], + heightPercentages: const [0.65, 0.66], +) +``` + +`heightPercentages` and `opacityPercentages` values must be between `0` and +`1`, and every `duration` must be positive. diff --git a/assets/wave_generator_demo.png b/assets/wave_generator_demo.png new file mode 100644 index 0000000..dbd5811 Binary files /dev/null and b/assets/wave_generator_demo.png differ diff --git a/example/README.md b/example/README.md index 01d7969..e90a2ce 100644 --- a/example/README.md +++ b/example/README.md @@ -1,14 +1,20 @@ -# Wave +# Wave Generator Example -Flutter package: tm - WAVE +This example is an interactive generator for the `wave` package. ---- +Use the controls to adjust: -[![Awesome: Flutter](https://img.shields.io/badge/⌐◨─◨-AwesomeFlutter-blue.svg?logo=flutter&longCache=true&style=flat-square)](https://github.com/Solido/awesome-flutter#effect) -[![Pub](https://img.shields.io/pub/v/wave.svg?logo=flutter&style=flat-square)](https://pub.dev/packages/wave) -![GitHub](https://img.shields.io/github/license/mashape/apistatus.svg?longCache=true&style=flat-square) +- config mode: `SingleConfig`, seeded `RandomConfig`, or `CustomConfig` +- palette +- layer count +- wave height +- amplitude +- animation speed +- loop behavior -A Flutter package for displaying waves. +The preview updates immediately, and the top bar shows live frame metrics for +the current animation. Use **Code** to inspect and copy the complete generated +Flutter widget from a dialog. ## Demo @@ -16,37 +22,9 @@ A Flutter package for displaying waves. | -: | -: | | Web | [wave.glorylab.xyz](https://wave.glorylab.xyz "The demo page of the wave package.") | +## Run Locally - -## Getting Started - -``` Dart - -static const _backgroundColor = Color(0xFFF15BB5); - -static const _colors = [ - Color(0xFFFEE440), - Color(0xFF00BBF9), -]; - -static const _durations = [ - 5000, - 4000, -]; - -static const _heightPercentages = [ - 0.65, - 0.66, -]; - -WaveWidget( - config: CustomConfig( - colors: _colors, - durations: _durations, - heightPercentages: _heightPercentages, - ), - backgroundColor: _backgroundColor, - size: Size(double.infinity, double.infinity), - waveAmplitude: 0, -), +```sh +flutter pub get +flutter run -d chrome ``` diff --git a/example/lib/generated_plugin_registrant.dart b/example/lib/generated_plugin_registrant.dart deleted file mode 100644 index 40a0d00..0000000 --- a/example/lib/generated_plugin_registrant.dart +++ /dev/null @@ -1,17 +0,0 @@ -// -// Generated file. Do not edit. -// - -// ignore_for_file: directives_ordering -// ignore_for_file: lines_longer_than_80_chars -// ignore_for_file: depend_on_referenced_packages - -import 'package:url_launcher_web/url_launcher_web.dart'; - -import 'package:flutter_web_plugins/flutter_web_plugins.dart'; - -// ignore: public_member_api_docs -void registerPlugins(Registrar registrar) { - UrlLauncherPlugin.registerWith(registrar); - registrar.registerMessageHandler(); -} diff --git a/example/lib/main.dart b/example/lib/main.dart index 3be7d7f..9d3fd56 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,231 +1,1486 @@ +import 'dart:async'; +import 'dart:ui'; + import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:wave/wave.dart'; -void main() => runApp(const WaveDemoApp()); +void main() => runApp(const WaveGeneratorApp()); + +const String _appTitle = 'Wave Generator'; +const String _logoAsset = 'web/icons/Icon-192.png'; +final Uri _repoUri = Uri.parse('https://github.com/glorylab/wave'); + +enum _ConfigMode { single, random, custom } -const String appName = 'Demo WAVE'; -const String repoURL = 'https://github.com/glorylab/wave'; +const double _layersMin = 1; +const double _layersMax = 5; +const double _heightMin = 0.18; +const double _heightMax = 0.72; +const double _amplitudeMin = 0; +const double _amplitudeMax = 28; +const double _speedMin = 0.6; +const double _speedMax = 1.8; -class WaveDemoApp extends StatelessWidget { - const WaveDemoApp({Key? key}) : super(key: key); +const _palettes = <_Palette>[ + _Palette( + name: 'Ocean', + background: 0xFFECFEFF, + seed: 7, + colors: [0xFF0891B2, 0xFF22D3EE, 0xFF38BDF8, 0xFF2563EB], + ), + _Palette( + name: 'Sunset', + background: 0xFFFFF7ED, + seed: 12, + colors: [0xFFF97316, 0xFFFB7185, 0xFFFACC15, 0xFF7C2D12], + ), + _Palette( + name: 'Mint', + background: 0xFFF0FDFA, + seed: 21, + colors: [0xFF0F766E, 0xFF14B8A6, 0xFF5EEAD4, 0xFFCCFBF1], + ), + _Palette( + name: 'Violet', + background: 0xFFFAF5FF, + seed: 34, + colors: [0xFF6D28D9, 0xFF8B5CF6, 0xFFC084FC, 0xFFF0ABFC], + ), +]; + +class WaveGeneratorApp extends StatelessWidget { + const WaveGeneratorApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( - title: appName, + debugShowCheckedModeBanner: false, + title: _appTitle, theme: ThemeData( - primarySwatch: Colors.indigo, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF0891B2), + ), useMaterial3: true, + scaffoldBackgroundColor: const Color(0xFFF6F8FB), ), - home: const WaveDemoHomePage(title: appName), + home: const WaveGeneratorHome(), ); } } -class WaveDemoHomePage extends StatefulWidget { - const WaveDemoHomePage({Key? key, this.title}) : super(key: key); - - final String? title; +class WaveGeneratorHome extends StatefulWidget { + const WaveGeneratorHome({Key? key}) : super(key: key); @override - State createState() => _WaveDemoHomePageState(); + State createState() => _WaveGeneratorHomeState(); } -class _WaveDemoHomePageState extends State { - _buildCard({ - required Config config, - Color? backgroundColor = Colors.transparent, - DecorationImage? backgroundImage, - double height = 152.0, - }) { - return SizedBox( - height: height, - width: double.infinity, - child: Card( - elevation: 12.0, - margin: EdgeInsets.only( - right: marginHorizontal, left: marginHorizontal, bottom: 16.0), - clipBehavior: Clip.antiAlias, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(16.0))), - child: WaveWidget( - config: config, - backgroundColor: backgroundColor, - backgroundImage: backgroundImage, - size: const Size(double.infinity, double.infinity), - waveAmplitude: 0, +class _WaveGeneratorHomeState extends State { + _ConfigMode _mode = _ConfigMode.custom; + int _paletteIndex = 0; + int _layers = 3; + double _height = 0.42; + double _amplitude = 12; + double _speed = 1; + bool _isLoop = true; + + _Palette get _palette => _palettes[_paletteIndex]; + + Future _openRepository() async { + await launchUrl(_repoUri, mode: LaunchMode.externalApplication); + } + + Future _copyCode() async { + await Clipboard.setData(ClipboardData(text: _generatedCode())); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Code copied')), + ); + } + + Future _showCodeDialog() async { + await showDialog( + context: context, + builder: (context) { + return _CodeDialog( + code: _generatedCode(), + onCopy: _copyCode, + ); + }, + ); + } + + void _setMode(_ConfigMode mode) { + setState(() { + _mode = mode; + }); + } + + void _setPaletteIndex(int index) { + setState(() { + _paletteIndex = index; + }); + } + + void _setLayers(double value) { + setState(() { + _layers = _clampSliderValue(value, _layersMin, _layersMax).round(); + }); + } + + void _setHeight(double value) { + setState(() { + _height = _clampSliderValue(value, _heightMin, _heightMax); + }); + } + + void _setAmplitude(double value) { + setState(() { + _amplitude = _clampSliderValue(value, _amplitudeMin, _amplitudeMax); + }); + } + + void _setSpeed(double value) { + setState(() { + _speed = _clampSliderValue(value, _speedMin, _speedMax); + }); + } + + void _setLoop(bool value) { + setState(() { + _isLoop = value; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= 980; + if (isDesktop) { + return _DesktopConsole(state: this); + } + + return _StackedConsole(state: this); + }, ), ), ); } - double marginHorizontal = 16.0; + Config _config() { + switch (_mode) { + case _ConfigMode.single: + return SingleConfig( + color: Color(_palette.colors.first), + layers: _layers, + opacityPercentages: _opacityPercentages(), + durations: _durations(), + heightPercentages: _heightPercentages(), + ); + case _ConfigMode.random: + return RandomConfig( + seed: _palette.seed, + layers: _layers, + durations: _durations(), + heightPercentages: _heightPercentages(), + ); + case _ConfigMode.custom: + return CustomConfig( + gradients: _gradients(), + durations: _durations(), + heightPercentages: _heightPercentages(), + gradientBegin: Alignment.bottomLeft, + gradientEnd: Alignment.topRight, + ); + } + } + + List _durations() { + return List.generate(_layers, (index) { + final rawDuration = (18000 - index * 2600) / _speed; + return rawDuration.round().clamp(3200, 30000).toInt(); + }, growable: false); + } + + List _heightPercentages() { + return List.generate(_layers, (index) { + return (_height + index * 0.035).clamp(0.16, 0.92).toDouble(); + }, growable: false); + } + + List _opacityPercentages() { + return List.generate(_layers, (index) { + return (0.42 - index * 0.06).clamp(0.14, 0.48).toDouble(); + }, growable: false); + } - void _launchUrl(url) async { - if (!await launchUrl(Uri.parse(url))) throw 'Could not launch $url'; + List> _gradients() { + return List>.generate(_layers, (index) { + return [ + Color(_palette.colors[index % _palette.colors.length]), + Color(_palette.colors[(index + 1) % _palette.colors.length]), + ]; + }, growable: false); } + String _generatedCode() { + final configCode = _configCode(); + final loopLine = _isLoop ? '' : ' isLoop: false,\n'; + final durationLine = _isLoop ? '' : ' duration: 8000,\n'; + return ''' +import 'package:flutter/material.dart'; +import 'package:wave/wave.dart'; + +class GeneratedWave extends StatelessWidget { + const GeneratedWave({Key? key}) : super(key: key); + @override Widget build(BuildContext context) { - marginHorizontal = 16.0 + - (MediaQuery.of(context).size.width > 512 - ? (MediaQuery.of(context).size.width - 512) / 2 - : 0); - return Scaffold( - appBar: AppBar( - title: Text(widget.title!), - elevation: 2.0, - actions: [ - IconButton( - onPressed: () { - _launchUrl(repoURL); - }, - icon: Image.asset( - 'icons/ic_github.png', - package: 'web3_icons', - width: 32.0, - height: 32.0, - ), - ) + return SizedBox.expand( + child: WaveWidget( +$configCode + backgroundColor: const ${_colorLiteral(_palette.background)}, + size: const Size(double.infinity, double.infinity), + waveAmplitude: ${_amplitude.toStringAsFixed(1)}, +$durationLine$loopLine ), + ); + } +} +'''; + } + + String _configCode() { + switch (_mode) { + case _ConfigMode.single: + return ''' + config: SingleConfig( + color: const ${_colorLiteral(_palette.colors.first)}, + layers: $_layers, + opacityPercentages: const ${_doubleListLiteral(_opacityPercentages())}, + durations: const ${_intListLiteral(_durations())}, + heightPercentages: const ${_doubleListLiteral(_heightPercentages())}, + ),'''; + case _ConfigMode.random: + return ''' + config: RandomConfig( + seed: ${_palette.seed}, + layers: $_layers, + durations: const ${_intListLiteral(_durations())}, + heightPercentages: const ${_doubleListLiteral(_heightPercentages())}, + ),'''; + case _ConfigMode.custom: + return ''' + config: CustomConfig( + gradients: const ${_gradientListLiteral()}, + durations: const ${_intListLiteral(_durations())}, + heightPercentages: const ${_doubleListLiteral(_heightPercentages())}, + gradientBegin: Alignment.bottomLeft, + gradientEnd: Alignment.topRight, + ),'''; + } + } + + String _gradientListLiteral() { + final rows = List.generate(_layers, (index) { + final first = _palette.colors[index % _palette.colors.length]; + final second = _palette.colors[(index + 1) % _palette.colors.length]; + return '[${_colorLiteral(first)}, ${_colorLiteral(second)}]'; + }, growable: false); + return '[\n ${rows.join(',\n ')},\n ]'; + } + + String _intListLiteral(List values) { + return '[${values.join(', ')}]'; + } + + String _doubleListLiteral(List values) { + return '[${values.map((value) => value.toStringAsFixed(2)).join(', ')}]'; + } + + String _colorLiteral(int value) { + return 'Color(0x${value.toRadixString(16).padLeft(8, '0').toUpperCase()})'; + } +} + +class _DesktopConsole extends StatelessWidget { + const _DesktopConsole({Key? key, required this.state}) : super(key: key); + + final _WaveGeneratorHomeState state; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: const Color(0xFFF3F6FA), + child: Column( + children: [ + _ConsoleTopBar(state: state), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: _PreviewPanel( + state: state, + fillAvailableSpace: true, + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 360, + child: _ControlsPanel( + state: state, + fillAvailableSpace: true, + ), + ), + ], + ), + ), + ), ], ), - body: Column( + ); + } +} + +class _StackedConsole extends StatelessWidget { + const _StackedConsole({Key? key, required this.state}) : super(key: key); + + final _WaveGeneratorHomeState state; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: const Color(0xFFF3F6FA), + child: ListView( + padding: EdgeInsets.zero, children: [ - Expanded( - child: ListView( - children: [ - const SizedBox(height: 16.0), - _buildCard( - backgroundColor: Colors.purpleAccent, - config: CustomConfig( - gradients: [ - [Colors.red, const Color(0xEEF44336)], - [Colors.red[800]!, const Color(0x77E57373)], - [Colors.orange, const Color(0x66FF9800)], - [Colors.yellow, const Color(0x55FFEB3B)] + _ConsoleTopBar(state: state), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + children: [ + _PreviewPanel(state: state), + const SizedBox(height: 16), + _ControlsPanel(state: state), + ], + ), + ), + ], + ), + ); + } +} + +class _ConsoleTopBar extends StatelessWidget { + const _ConsoleTopBar({Key? key, required this.state}) : super(key: key); + + final _WaveGeneratorHomeState state; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 720; + return Container( + height: compact ? 72 : 64, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: const BoxDecoration( + color: Color(0xFFF8FAFC), + border: Border( + bottom: BorderSide(color: Color(0xFFDDE5EF)), + ), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(9), + child: Image.asset( + _logoAsset, + width: 40, + height: 40, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + _appTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Color(0xFF0F172A), + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + '${_modeLabel(state._mode)} / ${_presetLabel(state)} / ${state._layers} layers', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFF64748B), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + if (!compact) ...[ + _NavbarPerformanceMonitor( + compact: constraints.maxWidth < 1160, + ), + const SizedBox(width: 12), + ], + if (compact) ...[ + IconButton( + tooltip: 'View code', + onPressed: state._showCodeDialog, + icon: const Icon(Icons.code), + ), + ] else ...[ + ElevatedButton.icon( + onPressed: state._showCodeDialog, + icon: const Icon(Icons.code, size: 18), + label: const Text('Code'), + ), + ], + IconButton( + tooltip: 'GitHub', + onPressed: state._openRepository, + icon: const Icon(Icons.open_in_new), + ), + ], + ), + ); + }, + ); + } +} + +class _PanelCard extends StatelessWidget { + const _PanelCard({Key? key, required this.child}) : super(key: key); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Card( + clipBehavior: Clip.antiAlias, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: Color(0xFFE3E8EF)), + ), + child: child, + ); + } +} + +class _PreviewPanel extends StatelessWidget { + const _PreviewPanel({ + Key? key, + required this.state, + this.fillAvailableSpace = false, + }) : super(key: key); + + final _WaveGeneratorHomeState state; + final bool fillAvailableSpace; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final preview = ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + fit: StackFit.expand, + children: [ + WaveWidget( + config: state._config(), + backgroundColor: Color(state._palette.background), + size: const Size(double.infinity, double.infinity), + waveAmplitude: state._amplitude, + isLoop: state._isLoop, + duration: 8000, + ), + const _PreviewComposition(), + ], + ), + ); + + return _PanelCard( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Live preview', + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + if (fillAvailableSpace) + Expanded(child: preview) + else + AspectRatio( + aspectRatio: 16 / 10, + child: preview, + ), + ], + ), + ), + ); + } +} + +class _PreviewComposition extends StatelessWidget { + const _PreviewComposition({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: LayoutBuilder( + builder: (context, constraints) { + final roomy = + constraints.maxWidth >= 640 && constraints.maxHeight >= 420; + return Padding( + padding: EdgeInsets.all(roomy ? 28 : 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PreviewSkeletonBlock( + width: roomy ? constraints.maxWidth * 0.42 : 260, + height: roomy ? 92 : 72, + lines: roomy ? 3 : 2, + ), + if (roomy) ...[ + const SizedBox(height: 16), + Row( + children: [ + _PreviewSkeletonBlock( + width: constraints.maxWidth * 0.24, + height: 118, + lines: 3, + ), + const SizedBox(width: 16), + _PreviewSkeletonBlock( + width: constraints.maxWidth * 0.28, + height: 118, + lines: 4, + ), ], - durations: [35000, 19440, 10800, 6000], - heightPercentages: [0.20, 0.23, 0.25, 0.30], - gradientBegin: Alignment.bottomLeft, - gradientEnd: Alignment.topRight, ), + ], + ], + ), + ); + }, + ), + ); + } +} + +class _PreviewSkeletonBlock extends StatelessWidget { + const _PreviewSkeletonBlock({ + Key? key, + required this.width, + required this.height, + required this.lines, + }) : super(key: key); + + final double width; + final double height; + final int lines; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xEFFFFFFF), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xCCFFFFFF)), + boxShadow: const [ + BoxShadow( + color: Color(0x170F172A), + blurRadius: 24, + offset: Offset(0, 12), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(lines, (index) { + final factor = index == 0 ? 0.74 : (index.isEven ? 0.58 : 0.86); + return Padding( + padding: EdgeInsets.only(top: index == 0 ? 0 : 10), + child: FractionallySizedBox( + widthFactor: factor, + alignment: Alignment.centerLeft, + child: Container( + height: index == 0 ? 12 : 8, + decoration: BoxDecoration( + color: index == 0 + ? const Color(0xFF0E7490) + : const Color(0xFFCBD5E1), + borderRadius: BorderRadius.circular(999), ), - _buildCard( - height: 256.0, - backgroundImage: const DecorationImage( - image: NetworkImage( - 'https://images.unsplash.com/photo-1554779147-a2a22d816042?crop=entropy&cs=tinysrgb&fm=jpg&ixlib=rb-1.2.1&q=80&raw_url=true&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3540', + ), + ), + ); + }), + ), + ); + } +} + +class _ControlsPanel extends StatelessWidget { + const _ControlsPanel({ + Key? key, + required this.state, + this.fillAvailableSpace = false, + }) : super(key: key); + + final _WaveGeneratorHomeState state; + final bool fillAvailableSpace; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final header = Row( + children: [ + Expanded( + child: Text( + 'Controls', + style: textTheme.titleLarge?.copyWith( + color: const Color(0xFF0F172A), + fontWeight: FontWeight.w700, + ), + ), + ), + _StatusDot(isActive: state._isLoop), + ], + ); + final controlBody = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ModeSelector(state: state), + const SizedBox(height: 18), + _SectionLabel( + label: state._mode == _ConfigMode.random ? 'Seed preset' : 'Palette', + ), + const SizedBox(height: 10), + _PaletteSelector(state: state), + const SizedBox(height: 16), + const Divider(height: 1), + const SizedBox(height: 8), + _SliderControl( + label: 'Layers', + valueLabel: '${state._layers}', + value: state._layers.toDouble(), + min: _layersMin, + max: _layersMax, + divisions: 4, + onChanged: state._setLayers, + ), + _SliderControl( + label: 'Height', + valueLabel: state._height.toStringAsFixed(2), + value: state._height, + min: _heightMin, + max: _heightMax, + divisions: 14, + onChanged: state._setHeight, + ), + _SliderControl( + label: 'Amplitude', + valueLabel: state._amplitude.toStringAsFixed(1), + value: state._amplitude, + min: _amplitudeMin, + max: _amplitudeMax, + divisions: 14, + onChanged: state._setAmplitude, + ), + _SliderControl( + label: 'Speed', + valueLabel: '${state._speed.toStringAsFixed(1)}x', + value: state._speed, + min: _speedMin, + max: _speedMax, + divisions: 12, + onChanged: state._setSpeed, + ), + const SizedBox(height: 6), + _LoopToggle( + value: state._isLoop, + onChanged: state._setLoop, + ), + ], + ); + + if (fillAvailableSpace) { + return _PanelCard( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + header, + const SizedBox(height: 14), + Expanded( + child: Scrollbar( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(right: 2), + child: controlBody, ), - fit: BoxFit.cover, - colorFilter: - ColorFilter.mode(Colors.white, BlendMode.softLight), ), - config: CustomConfig( - colors: [ - Colors.pink[400]!, - Colors.pink[300]!, - Colors.pink[200]!, - Colors.pink[100]! - ], - durations: [18000, 8000, 5000, 12000], - heightPercentages: [0.85, 0.86, 0.88, 0.90], + ), + ), + ], + ), + ), + ); + } + + return _PanelCard( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + header, + const SizedBox(height: 14), + controlBody, + ], + ), + ), + ); + } +} + +class _ModeSelector extends StatelessWidget { + const _ModeSelector({Key? key, required this.state}) : super(key: key); + + final _WaveGeneratorHomeState state; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final itemWidth = (constraints.maxWidth / _ConfigMode.values.length) + .clamp(84.0, 104.0) + .toDouble(); + return ToggleButtons( + constraints: BoxConstraints( + minHeight: 38, + minWidth: itemWidth, + ), + borderRadius: BorderRadius.circular(8), + isSelected: _ConfigMode.values + .map((mode) => mode == state._mode) + .toList(growable: false), + onPressed: (index) { + state._setMode(_ConfigMode.values[index]); + }, + children: _ConfigMode.values + .map( + (mode) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text(_modeLabel(mode)), + ), + ) + .toList(growable: false), + ); + }, + ); + } +} + +class _StatusDot extends StatelessWidget { + const _StatusDot({Key? key, required this.isActive}) : super(key: key); + + final bool isActive; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + decoration: BoxDecoration( + color: isActive ? const Color(0xFFE0F2FE) : const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: isActive ? const Color(0xFFBAE6FD) : const Color(0xFFE2E8F0), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: + isActive ? const Color(0xFF0891B2) : const Color(0xFF94A3B8), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + isActive ? 'Loop' : 'Static', + style: const TextStyle( + color: Color(0xFF334155), + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _NavbarPerformanceMonitor extends StatefulWidget { + const _NavbarPerformanceMonitor({ + Key? key, + required this.compact, + }) : super(key: key); + + final bool compact; + + @override + State<_NavbarPerformanceMonitor> createState() => + _NavbarPerformanceMonitorState(); +} + +class _NavbarPerformanceMonitorState extends State<_NavbarPerformanceMonitor> { + Timer? _timer; + int _sampleCount = 0; + int _slowFrames = 0; + double _totalFrameMs = 0; + double _maxFrameMs = 0; + _PerformanceStats _stats = const _PerformanceStats.empty(); + + @override + void initState() { + super.initState(); + SchedulerBinding.instance.addTimingsCallback(_handleTimings); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + _publishStats(); + }); + } + + @override + void dispose() { + _timer?.cancel(); + SchedulerBinding.instance.removeTimingsCallback(_handleTimings); + super.dispose(); + } + + void _handleTimings(List timings) { + for (final timing in timings) { + final frameMs = timing.totalSpan.inMicroseconds / 1000; + _sampleCount += 1; + _totalFrameMs += frameMs; + if (frameMs > _maxFrameMs) { + _maxFrameMs = frameMs; + } + if (frameMs > 16.7) { + _slowFrames += 1; + } + } + } + + void _publishStats() { + if (!mounted) return; + if (_sampleCount == 0) { + setState(() { + _stats = const _PerformanceStats.empty(); + }); + return; + } + + final averageFrameMs = _totalFrameMs / _sampleCount; + final slowFramePercent = _slowFrames * 100 / _sampleCount; + setState(() { + _stats = _PerformanceStats( + framesPerSecond: _sampleCount, + averageFrameMs: averageFrameMs, + maxFrameMs: _maxFrameMs, + slowFramePercent: slowFramePercent, + ); + }); + + _sampleCount = 0; + _slowFrames = 0; + _totalFrameMs = 0; + _maxFrameMs = 0; + } + + @override + Widget build(BuildContext context) { + return Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0xFFDDE5EF)), + boxShadow: const [ + BoxShadow( + color: Color(0x0D0F172A), + blurRadius: 16, + offset: Offset(0, 8), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.speed, color: Color(0xFF0E7490), size: 18), + const SizedBox(width: 8), + _PerformanceMetric( + label: 'FPS', + value: _stats.hasData ? '${_stats.framesPerSecond}' : '--', + valueWidth: 24, + ), + const SizedBox(width: 10), + _PerformanceMetric( + label: 'Frame', + value: _stats.hasData + ? '${_stats.averageFrameMs.toStringAsFixed(1)} ms' + : '--', + valueWidth: 52, + ), + if (!widget.compact) ...[ + const SizedBox(width: 10), + _PerformanceMetric( + label: 'Worst', + value: _stats.hasData + ? '${_stats.maxFrameMs.toStringAsFixed(1)} ms' + : '--', + valueWidth: 52, + ), + const SizedBox(width: 10), + _PerformanceMetric( + label: 'Slow', + value: _stats.hasData + ? '${_stats.slowFramePercent.toStringAsFixed(0)}%' + : '--', + valueWidth: 34, + ), + ], + ], + ), + ); + } +} + +class _PerformanceMetric extends StatelessWidget { + const _PerformanceMetric({ + Key? key, + required this.label, + required this.value, + required this.valueWidth, + }) : super(key: key); + + final String label; + final String value; + final double valueWidth; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: const TextStyle( + color: Color(0xFF64748B), + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(width: 4), + SizedBox( + width: valueWidth, + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.clip, + textAlign: TextAlign.right, + style: const TextStyle( + color: Color(0xFF0F172A), + fontSize: 13, + fontWeight: FontWeight.w800, + fontFamily: 'monospace', + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + ], + ); + } +} + +class _PerformanceStats { + const _PerformanceStats({ + required this.framesPerSecond, + required this.averageFrameMs, + required this.maxFrameMs, + required this.slowFramePercent, + }); + + const _PerformanceStats.empty() + : framesPerSecond = 0, + averageFrameMs = 0, + maxFrameMs = 0, + slowFramePercent = 0; + + final int framesPerSecond; + final double averageFrameMs; + final double maxFrameMs; + final double slowFramePercent; + + bool get hasData => framesPerSecond > 0; +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({Key? key, required this.label}) : super(key: key); + + final String label; + + @override + Widget build(BuildContext context) { + return Text( + label, + style: const TextStyle( + color: Color(0xFF475569), + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ); + } +} + +class _PaletteSelector extends StatelessWidget { + const _PaletteSelector({Key? key, required this.state}) : super(key: key); + + final _WaveGeneratorHomeState state; + + @override + Widget build(BuildContext context) { + final usesSeedPresets = state._mode == _ConfigMode.random; + return LayoutBuilder( + builder: (context, constraints) { + final itemWidth = constraints.maxWidth >= 300 + ? (constraints.maxWidth - 8) / 2 + : 156.0; + return Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate(_palettes.length, (index) { + final palette = _palettes[index]; + return SizedBox( + width: itemWidth, + child: _PaletteOption( + palette: palette, + usesSeedPreset: usesSeedPresets, + selected: state._paletteIndex == index, + onTap: () { + state._setPaletteIndex(index); + }, + ), + ); + }), + ); + }, + ); + } +} + +class _PaletteOption extends StatelessWidget { + const _PaletteOption({ + Key? key, + required this.palette, + required this.usesSeedPreset, + required this.selected, + required this.onTap, + }) : super(key: key); + + final _Palette palette; + final bool usesSeedPreset; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: selected ? const Color(0xFFE0F2FE) : Colors.white, + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + selected ? const Color(0xFF0891B2) : const Color(0xFFDDE5EF), + ), + ), + child: Row( + children: [ + if (usesSeedPreset) + const Icon(Icons.tag, color: Color(0xFF0E7490), size: 18) + else + _PaletteSwatches(colors: palette.colors), + const SizedBox(width: 9), + Expanded( + child: Text( + usesSeedPreset ? 'Seed ${palette.seed}' : palette.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFF0F172A), + fontSize: 13, + fontWeight: FontWeight.w700, ), ), - _buildCard( - config: CustomConfig( - colors: [ - Colors.white70, - Colors.white54, - Colors.white30, - Colors.white24, - ], - durations: [32000, 21000, 18000, 5000], - heightPercentages: [0.25, 0.26, 0.28, 0.31], - ), - backgroundColor: Colors.blue[600]), - Align( - child: Container( - height: 128, - width: 128, - decoration: - const BoxDecoration(shape: BoxShape.circle, boxShadow: [ - BoxShadow( - color: Color(0xFF9B5DE5), - blurRadius: 2.0, - spreadRadius: -5.0, - offset: Offset(0.0, 8.0), + ), + if (selected) + const Icon( + Icons.check, + color: Color(0xFF0891B2), + size: 16, + ), + ], + ), + ), + ), + ); + } +} + +class _PaletteSwatches extends StatelessWidget { + const _PaletteSwatches({Key? key, required this.colors}) : super(key: key); + + final List colors; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 34, + height: 18, + child: Stack( + children: List.generate(colors.length, (index) { + return Positioned( + left: index * 8.0, + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: Color(colors[index]), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + ), + ); + }), + ), + ); + } +} + +class _LoopToggle extends StatelessWidget { + const _LoopToggle({ + Key? key, + required this.value, + required this.onChanged, + }) : super(key: key); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + height: 50, + padding: const EdgeInsets.only(left: 12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFDDE5EF)), + ), + child: Row( + children: [ + const Icon(Icons.repeat, color: Color(0xFF475569), size: 18), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'Loop animation', + style: TextStyle( + color: Color(0xFF0F172A), + fontWeight: FontWeight.w600, + ), + ), + ), + Switch(value: value, onChanged: onChanged), + ], + ), + ); + } +} + +class _SliderControl extends StatelessWidget { + const _SliderControl({ + Key? key, + required this.label, + required this.valueLabel, + required this.value, + required this.min, + required this.max, + required this.divisions, + required this.onChanged, + }) : super(key: key); + + final String label; + final String valueLabel; + final double value; + final double min; + final double max; + final int divisions; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final sliderValue = _clampSliderValue(value, min, max); + + return Padding( + padding: const EdgeInsets.only(top: 11), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle( + color: Color(0xFF0F172A), + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + valueLabel, + style: const TextStyle( + color: Color(0xFF334155), + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 3, + overlayShape: const RoundSliderOverlayShape(overlayRadius: 18), + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), + ), + child: Slider( + value: sliderValue, + min: min, + max: max, + divisions: divisions, + label: valueLabel, + onChanged: onChanged, + ), + ), + ], + ), + ); + } +} + +double _clampSliderValue(double value, double min, double max) { + return value.clamp(min, max).toDouble(); +} + +class _CodeDialog extends StatelessWidget { + const _CodeDialog({ + Key? key, + required this.code, + required this.onCopy, + }) : super(key: key); + + final String code; + final VoidCallback onCopy; + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + return Dialog( + insetPadding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 900, + maxHeight: size.height * 0.86, + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 12, 14), + child: Row( + children: [ + const Icon(Icons.code, color: Color(0xFF0E7490)), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'Generated Flutter code', + style: TextStyle( + color: Color(0xFF0F172A), + fontSize: 18, + fontWeight: FontWeight.w700, ), - ]), - child: ClipOval( - child: WaveWidget( - config: CustomConfig( - colors: [ - const Color(0xFFFEE440), - const Color(0xFF00BBF9), - ], - durations: [ - 5000, - 4000, - ], - heightPercentages: [ - 0.65, - 0.66, - ], + ), + ), + IconButton( + tooltip: 'Close', + onPressed: () { + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.close), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: Container( + width: double.infinity, + color: const Color(0xFF0F172A), + child: Scrollbar( + child: SingleChildScrollView( + padding: const EdgeInsets.all(18), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SelectableText( + code, + style: const TextStyle( + color: Color(0xFFE5E7EB), + fontFamily: 'monospace', + fontSize: 13, + height: 1.45, ), - backgroundColor: const Color(0xFFF15BB5), - size: const Size(double.infinity, double.infinity), - waveAmplitude: 0, ), ), ), ), - const SizedBox( - height: 88, - ), - Container( - alignment: Alignment.center, - margin: const EdgeInsets.all(16), - child: Column( - children: [ - Image.asset( - 'icons/ic_glory_lab.png', - package: 'web3_icons', - width: 32.0, - height: 32.0, - ), - const SizedBox(height: 8), - Text( - 'Made in Glory Lab', - style: GoogleFonts.robotoMono( - color: Colors.grey[500], - ), - ) - ], - )), - SizedBox( - height: 48, - child: WaveWidget( - config: CustomConfig( - colors: [ - Colors.indigo[400]!, - Colors.indigo[300]!, - Colors.indigo[200]!, - Colors.indigo[100]! - ], - durations: [18000, 8000, 5000, 12000], - heightPercentages: [0.65, 0.66, 0.68, 0.70], + ), + ), + Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + decoration: const BoxDecoration( + color: Color(0xFFF8FAFC), + border: Border( + top: BorderSide(color: Color(0xFFDDE5EF)), + ), + ), + child: Row( + children: [ + const Expanded( + child: Text( + 'Copy this widget into any Flutter layout.', + style: TextStyle( + color: Color(0xFF475569), + fontWeight: FontWeight.w500, + ), ), - size: const Size(double.infinity, double.infinity), - waveAmplitude: 0, ), - ), - ], + const SizedBox(width: 12), + OutlinedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Close'), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: onCopy, + icon: const Icon(Icons.copy_outlined, size: 18), + label: const Text('Copy code'), + ), + ], + ), ), - ), - ], + ], + ), ), ); } } + +String _modeLabel(_ConfigMode mode) { + switch (mode) { + case _ConfigMode.single: + return 'Single'; + case _ConfigMode.random: + return 'Seeded'; + case _ConfigMode.custom: + return 'Custom'; + } +} + +String _presetLabel(_WaveGeneratorHomeState state) { + if (state._mode == _ConfigMode.random) { + return 'seed ${state._palette.seed}'; + } + return state._palette.name; +} + +class _Palette { + const _Palette({ + required this.name, + required this.background, + required this.seed, + required this.colors, + }); + + final String name; + final int background; + final int seed; + final List colors; +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 5cb3cdb..e486feb 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -20,8 +20,6 @@ dependencies: wave: path: ../ - web3_icons: ^0.0.8 - google_fonts: ^4.0.3 url_launcher: ^6.1.10 dev_dependencies: @@ -41,10 +39,8 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - web/icons/Icon-192.png # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.io/assets-and-images/#resolution-aware. diff --git a/lib/src/config.dart b/lib/src/config.dart index 8b70369..33d33ba 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -2,22 +2,27 @@ import 'dart:math'; import 'package:flutter/widgets.dart'; +/// Selects how wave layer colors are generated. enum ColorMode { - /// Waves with *single* **color** but different **alpha** and **amplitude**. + /// Uses one base color with per-layer opacity and amplitude values. single, - /// Waves using *random* **color**, **alpha** and **amplitude**. + /// Generates random colors for each layer. random, - /// Waves' colors must be set, and [colors]'s length must equal with [layers] + /// Uses explicit colors or gradients supplied by [CustomConfig]. custom, } +/// Base class for wave color and layer configuration. abstract class Config { + /// The color generation mode represented by this config. final ColorMode? colorMode; + /// Creates a wave configuration with the selected [colorMode]. const Config({this.colorMode}); + /// Throws a consistent error for missing required config values. static void throwNullError(String colorModeStr, String configStr) { throw FlutterError( 'When using `ColorMode.$colorModeStr`, `$configStr` must be set.'); @@ -142,15 +147,34 @@ abstract class Config { } } +/// Configuration for waves with explicit per-layer colors or gradients. class CustomConfig extends Config { + /// Per-layer solid colors. final List? colors; + + /// Per-layer gradients. final List>? gradients; + + /// Alignment where each gradient begins. final Alignment? gradientBegin; + + /// Alignment where each gradient ends. final Alignment? gradientEnd; + + /// Per-layer animation durations in milliseconds. final List? durations; + + /// Per-layer vertical offsets, expressed as fractions from 0 to 1. final List? heightPercentages; + + /// Optional blur mask applied to each wave layer. final MaskFilter? blur; + /// Creates explicit wave layers from [colors] or [gradients]. + /// + /// Provide exactly one of [colors] or [gradients]. The configured layer + /// lists must have equal lengths, and each gradient must contain at least + /// two colors. CustomConfig({ List? colors, List>? gradients, @@ -205,12 +229,25 @@ class CustomConfig extends Config { } } +/// Configuration for waves with generated random colors. class RandomConfig extends Config { + /// Generated or supplied per-layer colors. final List colors; + + /// Per-layer animation durations in milliseconds. final List durations; + + /// Per-layer vertical offsets, expressed as fractions from 0 to 1. final List heightPercentages; + + /// Optional blur mask applied to each wave layer. final MaskFilter? blur; + /// Creates randomly colored wave layers. + /// + /// Use [layers] to control the number of generated layers and [seed] for + /// deterministic color generation. Any supplied lists must have equal + /// lengths. RandomConfig({ int? layers, int? seed, @@ -269,13 +306,27 @@ class RandomConfig extends Config { } } +/// Configuration for waves derived from one base color. class SingleConfig extends Config { + /// Base color used for every wave layer. final Color color; + + /// Per-layer opacity values from 0 to 1. final List opacityPercentages; + + /// Per-layer animation durations in milliseconds. final List durations; + + /// Per-layer vertical offsets, expressed as fractions from 0 to 1. final List heightPercentages; + + /// Optional blur mask applied to each wave layer. final MaskFilter? blur; + /// Creates wave layers from a single [color]. + /// + /// Use [layers] to control generated default list lengths. Any supplied + /// lists must have equal lengths. SingleConfig({ int? layers, this.color = const Color(0xFF2196F3), diff --git a/lib/src/widget.dart b/lib/src/widget.dart index a333f01..af31065 100644 --- a/lib/src/widget.dart +++ b/lib/src/widget.dart @@ -207,18 +207,40 @@ import 'package:flutter/widgets.dart'; import 'config.dart'; +/// Paints animated wave layers with the supplied [config]. class WaveWidget extends StatefulWidget { + /// Color, duration, height, and blur configuration for each wave layer. final Config config; + + /// Fixed paint size for each wave layer. final Size size; + + /// Base vertical amplitude of the wave path. final double waveAmplitude; + + /// Initial phase offset for the wave path. final double wavePhase; + + /// Horizontal frequency of the wave path. final double waveFrequency; + + /// Default vertical offset used by the painter when a layer config does not + /// provide its own height percentage. final double heightPercentage; + + /// Total animation duration in milliseconds when [isLoop] is false. final int? duration; + + /// Background color behind the animated waves. final Color? backgroundColor; + + /// Background image behind the animated waves. final DecorationImage? backgroundImage; + + /// Whether wave animations repeat indefinitely. final bool isLoop; + /// Creates an animated wave widget. WaveWidget({ required this.config, required this.size, diff --git a/lib/wave.dart b/lib/wave.dart index 3e82d71..511ea20 100644 --- a/lib/wave.dart +++ b/lib/wave.dart @@ -1,2 +1,5 @@ +/// Animated wave backgrounds and configuration helpers for Flutter widgets. +library wave; + export 'src/config.dart'; export 'src/widget.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 18fbda0..84d968f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: wave description: Widget for displaying waves with custom color, duration, floating and blur effects. -version: 0.2.4-dev.1 +version: 0.2.4 homepage: https://github.com/glorylab/wave environment: