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 @@
[](https://pub.dev/packages/wave)

-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.
+
+
+
## 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
-
+This example is an interactive generator for the `wave` package.
----
+Use the controls to adjust:
-[](https://github.com/Solido/awesome-flutter#effect)
-[](https://pub.dev/packages/wave)
-
+- 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: