diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..36e7d81 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + - develop + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.7.12" + channel: stable + cache: true + + - run: flutter pub get + - run: flutter analyze + - run: flutter test diff --git a/.pubignore b/.pubignore new file mode 100644 index 0000000..8f86a45 --- /dev/null +++ b/.pubignore @@ -0,0 +1,13 @@ +.dart_tool/ +.fvm/ +.fvmrc +.github/ +.idea/ +.vscode/ + +build/ +docs/ +wrangler.jsonc +*.iml + +PULL_REQUEST_TEMPLATE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 72f6742..dbee35d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## 0.2.3-dev.1 + +* Add runtime validation for custom gradient and layer configuration. +* Add default rendering support for `SingleConfig` and `RandomConfig`. +* Improve animation lifecycle handling in `WaveWidget`. +* Add CI coverage for analyze and tests on Flutter 3.7. + ## 0.2.2 Update banner. diff --git a/README.md b/README.md index 7774b22..609284e 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,9 @@ A Flutter package for displaying waves. ## Demo -| Platform | Branch | URL | -| -: | -: | -: | -| Web | `master` | [wave.glorylab.xyz](https://wave.glorylab.xyz "The demo page of the wave package.") | -| Web | `develop` | [dev.wave.glorylab.xyz](https://dev.wave.glorylab.xyz "The demo page of the wave package's develop branch.") | +| Platform | URL | +| -: | -: | +| Web | [wave.glorylab.xyz](https://wave.glorylab.xyz "The demo page of the wave package.") | ## Deployment @@ -55,3 +54,44 @@ WaveWidget( waveAmplitude: 0, ), ``` + +## Config modes + +`CustomConfig` is the most explicit mode. Use it when you want full control over +each wave layer's colors or gradients, duration, and height. + +``` 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), +) +``` + +`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. + +``` Dart +WaveWidget( + config: SingleConfig( + color: Color(0xFF00BBF9), + layers: 3, + ), + size: Size(double.infinity, double.infinity), +) + +WaveWidget( + config: RandomConfig( + seed: 7, + layers: 4, + ), + size: Size(double.infinity, double.infinity), +) +``` diff --git a/example/README.md b/example/README.md index 4f3f0a9..01d7969 100644 --- a/example/README.md +++ b/example/README.md @@ -12,10 +12,9 @@ A Flutter package for displaying waves. ## Demo -| Platform | Branch | URL | -| -: | -: | -: | -| Web | `master` | [wave.glorylab.xyz](https://wave.glorylab.xyz "The demo page of the wave package.") | -| Web | `develop` | [dev.wave.glorylab.xyz](https://dev.wave.glorylab.xyz "The demo page of the wave package's develop branch.") | +| Platform | URL | +| -: | -: | +| Web | [wave.glorylab.xyz](https://wave.glorylab.xyz "The demo page of the wave package.") | @@ -42,7 +41,7 @@ static const _heightPercentages = [ WaveWidget( config: CustomConfig( - colors: colors, + colors: _colors, durations: _durations, heightPercentages: _heightPercentages, ), @@ -50,4 +49,4 @@ WaveWidget( size: Size(double.infinity, double.infinity), waveAmplitude: 0, ), -``` \ No newline at end of file +``` diff --git a/example/lib/main.dart b/example/lib/main.dart index 0e66e6b..3be7d7f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -3,12 +3,14 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:wave/wave.dart'; -void main() => runApp(WaveDemoApp()); +void main() => runApp(const WaveDemoApp()); -final String appName = 'Demo WAVE'; -final String repoURL = 'https://github.com/glorylab/wave'; +const String appName = 'Demo WAVE'; +const String repoURL = 'https://github.com/glorylab/wave'; class WaveDemoApp extends StatelessWidget { + const WaveDemoApp({Key? key}) : super(key: key); + @override Widget build(BuildContext context) { return MaterialApp( @@ -17,18 +19,18 @@ class WaveDemoApp extends StatelessWidget { primarySwatch: Colors.indigo, useMaterial3: true, ), - home: WaveDemoHomePage(title: appName), + home: const WaveDemoHomePage(title: appName), ); } } class WaveDemoHomePage extends StatefulWidget { - WaveDemoHomePage({Key? key, this.title}) : super(key: key); + const WaveDemoHomePage({Key? key, this.title}) : super(key: key); final String? title; @override - _WaveDemoHomePageState createState() => _WaveDemoHomePageState(); + State createState() => _WaveDemoHomePageState(); } class _WaveDemoHomePageState extends State { @@ -38,7 +40,7 @@ class _WaveDemoHomePageState extends State { DecorationImage? backgroundImage, double height = 152.0, }) { - return Container( + return SizedBox( height: height, width: double.infinity, child: Card( @@ -46,13 +48,13 @@ class _WaveDemoHomePageState extends State { margin: EdgeInsets.only( right: marginHorizontal, left: marginHorizontal, bottom: 16.0), clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder( + shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(16.0))), child: WaveWidget( config: config, backgroundColor: backgroundColor, backgroundImage: backgroundImage, - size: Size(double.infinity, double.infinity), + size: const Size(double.infinity, double.infinity), waveAmplitude: 0, ), ), @@ -94,15 +96,15 @@ class _WaveDemoHomePageState extends State { Expanded( child: ListView( children: [ - SizedBox(height: 16.0), + const SizedBox(height: 16.0), _buildCard( backgroundColor: Colors.purpleAccent, config: CustomConfig( gradients: [ - [Colors.red, Color(0xEEF44336)], - [Colors.red[800]!, Color(0x77E57373)], - [Colors.orange, Color(0x66FF9800)], - [Colors.yellow, Color(0x55FFEB3B)] + [Colors.red, const Color(0xEEF44336)], + [Colors.red[800]!, const Color(0x77E57373)], + [Colors.orange, const Color(0x66FF9800)], + [Colors.yellow, const Color(0x55FFEB3B)] ], durations: [35000, 19440, 10800, 6000], heightPercentages: [0.20, 0.23, 0.25, 0.30], @@ -112,7 +114,7 @@ class _WaveDemoHomePageState extends State { ), _buildCard( height: 256.0, - backgroundImage: DecorationImage( + 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', ), @@ -148,7 +150,7 @@ class _WaveDemoHomePageState extends State { height: 128, width: 128, decoration: - BoxDecoration(shape: BoxShape.circle, boxShadow: [ + const BoxDecoration(shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Color(0xFF9B5DE5), blurRadius: 2.0, @@ -160,8 +162,8 @@ class _WaveDemoHomePageState extends State { child: WaveWidget( config: CustomConfig( colors: [ - Color(0xFFFEE440), - Color(0xFF00BBF9), + const Color(0xFFFEE440), + const Color(0xFF00BBF9), ], durations: [ 5000, @@ -172,8 +174,8 @@ class _WaveDemoHomePageState extends State { 0.66, ], ), - backgroundColor: Color(0xFFF15BB5), - size: Size(double.infinity, double.infinity), + backgroundColor: const Color(0xFFF15BB5), + size: const Size(double.infinity, double.infinity), waveAmplitude: 0, ), ), @@ -202,7 +204,7 @@ class _WaveDemoHomePageState extends State { ) ], )), - Container( + SizedBox( height: 48, child: WaveWidget( config: CustomConfig( @@ -215,7 +217,7 @@ class _WaveDemoHomePageState extends State { durations: [18000, 8000, 5000, 12000], heightPercentages: [0.65, 0.66, 0.68, 0.70], ), - size: Size(double.infinity, double.infinity), + size: const Size(double.infinity, double.infinity), waveAmplitude: 0, ), ), diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 9f8e57a..e5cb278 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,6 +1,6 @@ name: wave_example description: An example application for Wave. -homepage: https://github.com/i-protoss/wave/tree/master/example +homepage: https://github.com/glorylab/wave/tree/master/example publish_to: none # The following defines the version and build number for your application. @@ -27,6 +27,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + flutter_lints: ^2.0.0 # For information on the generic Dart part of this file, see the diff --git a/lib/src/config.dart b/lib/src/config.dart index 09fc5f5..8b70369 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/widgets.dart'; enum ColorMode { @@ -14,12 +16,130 @@ enum ColorMode { abstract class Config { final ColorMode? colorMode; - Config({this.colorMode}); + const Config({this.colorMode}); static void throwNullError(String colorModeStr, String configStr) { throw FlutterError( 'When using `ColorMode.$colorModeStr`, `$configStr` must be set.'); } + + static int _resolveLayerCount( + String configName, { + int? layers, + List? colors, + List? gradients, + List? durations, + List? heightPercentages, + List? opacityPercentages, + }) { + final lengths = {}; + if (layers != null) { + if (layers <= 0) { + throw FlutterError('`layers` must be greater than zero.'); + } + lengths['layers'] = layers; + } + if (colors != null) lengths['colors'] = colors.length; + if (gradients != null) lengths['gradients'] = gradients.length; + if (durations != null) lengths['durations'] = durations.length; + if (heightPercentages != null) { + lengths['heightPercentages'] = heightPercentages.length; + } + if (opacityPercentages != null) { + lengths['opacityPercentages'] = opacityPercentages.length; + } + + if (lengths.isEmpty) return 3; + + final expected = lengths.values.first; + if (expected == 0) { + throw FlutterError('`$configName` must define at least one layer.'); + } + for (final entry in lengths.entries) { + if (entry.value != expected) { + throw FlutterError( + 'Length of ${lengths.keys.map((name) => '`$name`').join(', ')} ' + 'must be equal.', + ); + } + } + + return expected; + } + + static List _durationsOrDefault(List? values, int layers) { + final durations = values ?? + List.generate( + layers, + (index) => 5000 + (layers - index) * 2000, + ); + _validateDurations(durations); + return List.unmodifiable(durations); + } + + static void _validateDurations(List durations) { + if (durations.isEmpty) { + throw FlutterError('`durations` must define at least one layer.'); + } + for (final duration in durations) { + if (duration <= 0) { + throw FlutterError('Every value in `durations` must be positive.'); + } + } + } + + static List _heightPercentagesOrDefault( + List? values, + int layers, + ) { + final heightPercentages = values ?? + List.generate( + layers, + (index) => min(0.9, 0.2 + index * 0.03), + ); + _validateHeightPercentages(heightPercentages); + return List.unmodifiable(heightPercentages); + } + + static void _validateHeightPercentages(List heightPercentages) { + if (heightPercentages.isEmpty) { + throw FlutterError('`heightPercentages` must define at least one layer.'); + } + for (final heightPercentage in heightPercentages) { + if (heightPercentage < 0 || heightPercentage > 1) { + throw FlutterError( + 'Every value in `heightPercentages` must be between 0 and 1.', + ); + } + } + } + + static List _opacityPercentagesOrDefault( + List? values, + int layers, + ) { + final opacityPercentages = values ?? + List.generate( + layers, + (index) => layers == 1 ? 0.35 : 0.45 - (0.25 * index / (layers - 1)), + ); + _validateOpacityPercentages(opacityPercentages); + return List.unmodifiable(opacityPercentages); + } + + static void _validateOpacityPercentages(List opacityPercentages) { + if (opacityPercentages.isEmpty) { + throw FlutterError( + '`opacityPercentages` must define at least one layer.'); + } + for (final opacityPercentage in opacityPercentages) { + if (opacityPercentage < 0 || opacityPercentage > 1) { + throw FlutterError( + 'Every value in `opacityPercentages` must be between 0 and 1.', + ); + } + } + } } class CustomConfig extends Config { @@ -32,62 +152,166 @@ class CustomConfig extends Config { final MaskFilter? blur; CustomConfig({ - this.colors, - this.gradients, + List? colors, + List>? gradients, this.gradientBegin, this.gradientEnd, - required this.durations, - required this.heightPercentages, + required List? durations, + required List? heightPercentages, this.blur, - }) : assert(() { - if (colors == null && gradients == null) { - Config.throwNullError('custom', 'colors` or `gradients'); - } - return true; - }()), - assert(() { - if (gradients == null && - (gradientBegin != null || gradientEnd != null)) { - throw FlutterError( - 'You set a gradient direction but forgot setting `gradients`.'); - } - return true; - }()), - assert(() { - if (durations == null) { - Config.throwNullError('custom', 'durations'); - } - return true; - }()), - assert(() { - if (heightPercentages == null) { - Config.throwNullError('custom', 'heightPercentages'); - } - return true; - }()), - assert(() { - if (colors != null && - durations != null && - heightPercentages != null) { - if (colors.length != durations.length || - colors.length != heightPercentages.length) { - throw FlutterError( - 'Length of `colors`, `durations` and `heightPercentages` must be equal.'); - } - } - return true; - }()), - assert(colors == null || gradients == null, - 'Cannot provide both colors and gradients.'), - super(colorMode: ColorMode.custom); + }) : colors = colors, + gradients = gradients, + durations = durations, + heightPercentages = heightPercentages, + super(colorMode: ColorMode.custom) { + if (colors == null && gradients == null) { + Config.throwNullError('custom', 'colors` or `gradients'); + } + if (colors != null && gradients != null) { + throw FlutterError('Cannot provide both `colors` and `gradients`.'); + } + if (durations == null) { + Config.throwNullError('custom', 'durations'); + } + if (heightPercentages == null) { + Config.throwNullError('custom', 'heightPercentages'); + } + final resolvedDurations = durations!; + final resolvedHeightPercentages = heightPercentages!; + Config._resolveLayerCount( + 'CustomConfig', + colors: colors, + gradients: gradients, + durations: resolvedDurations, + heightPercentages: resolvedHeightPercentages, + ); + Config._validateDurations(resolvedDurations); + Config._validateHeightPercentages(resolvedHeightPercentages); + if (gradients == null && (gradientBegin != null || gradientEnd != null)) { + throw FlutterError( + 'You set a gradient direction but forgot setting `gradients`.'); + } + if (colors != null && colors.isEmpty) { + throw FlutterError('`colors` must define at least one layer.'); + } + if (gradients != null) { + for (final gradient in gradients) { + if (gradient.length < 2) { + throw FlutterError( + 'Every gradient in `gradients` must have at least two colors.'); + } + } + } + } } -/// todo class RandomConfig extends Config { - RandomConfig() : super(colorMode: ColorMode.random); + final List colors; + final List durations; + final List heightPercentages; + final MaskFilter? blur; + + RandomConfig({ + int? layers, + int? seed, + List? colors, + List? durations, + List? heightPercentages, + this.blur, + }) : colors = colors == null + ? _buildRandomColors( + Config._resolveLayerCount( + 'RandomConfig', + layers: layers, + durations: durations, + heightPercentages: heightPercentages, + ), + seed, + ) + : List.unmodifiable(colors), + durations = Config._durationsOrDefault( + durations, + Config._resolveLayerCount( + 'RandomConfig', + layers: layers, + colors: colors, + durations: durations, + heightPercentages: heightPercentages, + ), + ), + heightPercentages = Config._heightPercentagesOrDefault( + heightPercentages, + Config._resolveLayerCount( + 'RandomConfig', + layers: layers, + colors: colors, + durations: durations, + heightPercentages: heightPercentages, + ), + ), + super(colorMode: ColorMode.random) { + if (this.colors.isEmpty) { + throw FlutterError('`colors` must define at least one layer.'); + } + } + + static List _buildRandomColors(int layers, int? seed) { + final random = seed == null ? Random() : Random(seed); + return List.unmodifiable( + List.generate(layers, (index) { + final hue = random.nextDouble() * 360; + final saturation = 0.55 + random.nextDouble() * 0.35; + final value = 0.65 + random.nextDouble() * 0.25; + final alpha = 0.22 + random.nextDouble() * 0.38; + return HSVColor.fromAHSV(alpha, hue, saturation, value).toColor(); + }), + ); + } } -/// todo class SingleConfig extends Config { - SingleConfig() : super(colorMode: ColorMode.single); + final Color color; + final List opacityPercentages; + final List durations; + final List heightPercentages; + final MaskFilter? blur; + + SingleConfig({ + int? layers, + this.color = const Color(0xFF2196F3), + List? opacityPercentages, + List? durations, + List? heightPercentages, + this.blur, + }) : opacityPercentages = Config._opacityPercentagesOrDefault( + opacityPercentages, + Config._resolveLayerCount( + 'SingleConfig', + layers: layers, + durations: durations, + heightPercentages: heightPercentages, + opacityPercentages: opacityPercentages, + ), + ), + durations = Config._durationsOrDefault( + durations, + Config._resolveLayerCount( + 'SingleConfig', + layers: layers, + durations: durations, + heightPercentages: heightPercentages, + opacityPercentages: opacityPercentages, + ), + ), + heightPercentages = Config._heightPercentagesOrDefault( + heightPercentages, + Config._resolveLayerCount( + 'SingleConfig', + layers: layers, + durations: durations, + heightPercentages: heightPercentages, + opacityPercentages: opacityPercentages, + ), + ), + super(colorMode: ColorMode.single); } diff --git a/lib/src/widget.dart b/lib/src/widget.dart index 13ec321..787d4af 100644 --- a/lib/src/widget.dart +++ b/lib/src/widget.dart @@ -204,7 +204,8 @@ import 'dart:async'; import 'dart:math'; import 'package:flutter/widgets.dart'; -import 'package:wave/wave.dart'; + +import 'config.dart'; class WaveWidget extends StatefulWidget { final Config config; @@ -236,96 +237,143 @@ class WaveWidget extends StatefulWidget { } class _WaveWidgetState extends State with TickerProviderStateMixin { - late List _waveControllers; - late List> _wavePhaseValues; - - List _waveAmplitudes = []; - Map, AnimationController>? valueList; + List<_WaveLayerAnimation> _layers = <_WaveLayerAnimation>[]; Timer? _endAnimationTimer; - _initAnimations() { - if (widget.config.colorMode == ColorMode.custom) { - _waveControllers = - (widget.config as CustomConfig).durations!.map((duration) { - _waveAmplitudes.add(widget.waveAmplitude + 10); - return AnimationController( - vsync: this, duration: Duration(milliseconds: duration)); - }).toList(); - - _wavePhaseValues = _waveControllers.map((controller) { - CurvedAnimation _curve = - CurvedAnimation(parent: controller, curve: Curves.easeInOut); - Animation value = Tween( - begin: widget.wavePhase, - end: 360 + widget.wavePhase, - ).animate( - _curve, - ); - value.addStatusListener((status) { - switch (status) { - case AnimationStatus.completed: - controller.reverse(); - break; - case AnimationStatus.dismissed: - controller.forward(); - break; - default: - break; - } - }); - controller.forward(); - return value; - }).toList(); - - // If isLoop is false, stop the animation after the specified duration. - if (!widget.isLoop) { - _endAnimationTimer = - Timer(Duration(milliseconds: widget.duration!), () { - for (AnimationController waveController in _waveControllers) { - waveController.stop(); - } - }); - } + void _initAnimations() { + final specs = _buildLayerSpecs(widget.config); + _layers = specs.map((spec) { + final controller = AnimationController( + vsync: this, + duration: Duration(milliseconds: spec.duration), + ); + final curve = CurvedAnimation( + parent: controller, + curve: Curves.easeInOut, + ); + final phaseValue = Tween( + begin: widget.wavePhase, + end: 360 + widget.wavePhase, + ).animate(curve); + + controller.repeat(reverse: true); + return _WaveLayerAnimation( + controller: controller, + phaseValue: phaseValue, + ); + }).toList(growable: false); + + if (!widget.isLoop) { + _endAnimationTimer = Timer( + Duration(milliseconds: widget.duration ?? 6000), + _stopAnimations, + ); } } - _buildPaints() { - List paints = []; - if (widget.config.colorMode == ColorMode.custom) { - List? _colors = (widget.config as CustomConfig).colors; - List>? _gradients = (widget.config as CustomConfig).gradients; - Alignment? begin = (widget.config as CustomConfig).gradientBegin; - Alignment? end = (widget.config as CustomConfig).gradientEnd; - for (int i = 0; i < _wavePhaseValues.length; i++) { - paints.add( - Container( - child: CustomPaint( - painter: _CustomWavePainter( - color: _colors == null ? null : _colors[i], - gradient: _gradients == null ? null : _gradients[i], - gradientBegin: begin, - gradientEnd: end, - heightPercentage: - (widget.config as CustomConfig).heightPercentages![i], - repaint: _waveControllers[i], - waveFrequency: widget.waveFrequency, - wavePhaseValue: _wavePhaseValues[i], - waveAmplitude: _waveAmplitudes[i], - blur: (widget.config as CustomConfig).blur, - ), - size: widget.size, - ), + List<_WaveLayerSpec> _buildLayerSpecs(Config config) { + if (config.colorMode == ColorMode.custom) { + final customConfig = config as CustomConfig; + return List<_WaveLayerSpec>.generate( + customConfig.durations!.length, + (index) => _WaveLayerSpec( + color: + customConfig.colors == null ? null : customConfig.colors![index], + gradient: customConfig.gradients == null + ? null + : customConfig.gradients![index], + gradientBegin: customConfig.gradientBegin, + gradientEnd: customConfig.gradientEnd, + duration: customConfig.durations![index], + heightPercentage: customConfig.heightPercentages![index], + amplitude: widget.waveAmplitude + 10, + blur: customConfig.blur, + ), + growable: false, + ); + } + if (config.colorMode == ColorMode.random) { + final randomConfig = config as RandomConfig; + return List<_WaveLayerSpec>.generate( + randomConfig.durations.length, + (index) => _WaveLayerSpec( + color: randomConfig.colors[index], + duration: randomConfig.durations[index], + heightPercentage: randomConfig.heightPercentages[index], + amplitude: _amplitudeForLayer(index), + blur: randomConfig.blur, + ), + growable: false, + ); + } + if (config.colorMode == ColorMode.single) { + final singleConfig = config as SingleConfig; + return List<_WaveLayerSpec>.generate( + singleConfig.durations.length, + (index) => _WaveLayerSpec( + color: singleConfig.color.withOpacity( + singleConfig.opacityPercentages[index], ), - ); - } + duration: singleConfig.durations[index], + heightPercentage: singleConfig.heightPercentages[index], + amplitude: _amplitudeForLayer(index), + blur: singleConfig.blur, + ), + growable: false, + ); + } + + throw FlutterError('Unsupported or missing `ColorMode` in `config`.'); + } + + double _amplitudeForLayer(int index) { + return widget.waveAmplitude + 8 + index * 2; + } + + List _buildPaints() { + final specs = _buildLayerSpecs(widget.config); + return _layers.asMap().entries.map((entry) { + final layer = entry.value; + final spec = specs[entry.key]; + return CustomPaint( + painter: _CustomWavePainter( + color: spec.color, + gradient: spec.gradient, + gradientBegin: spec.gradientBegin, + gradientEnd: spec.gradientEnd, + heightPercentage: spec.heightPercentage, + repaint: layer.controller, + waveFrequency: widget.waveFrequency, + wavePhaseValue: layer.phaseValue, + waveAmplitude: spec.amplitude, + blur: spec.blur, + ), + size: widget.size, + ); + }).toList(growable: false); + } + + void _stopAnimations() { + for (final layer in _layers) { + layer.controller.stop(); + } + } + + void _disposeAnimations() { + _endAnimationTimer?.cancel(); + _endAnimationTimer = null; + for (final layer in _layers) { + layer.controller.dispose(); } - return paints; + _layers = <_WaveLayerAnimation>[]; } - _disposeAnimations() { - _waveControllers.forEach((controller) { - controller.dispose(); - }); + bool _shouldRecreateAnimations(WaveWidget oldWidget) { + return oldWidget.config != widget.config || + oldWidget.waveAmplitude != widget.waveAmplitude || + oldWidget.wavePhase != widget.wavePhase || + oldWidget.isLoop != widget.isLoop || + oldWidget.duration != widget.duration; } @override @@ -334,10 +382,18 @@ class _WaveWidgetState extends State with TickerProviderStateMixin { _initAnimations(); } + @override + void didUpdateWidget(WaveWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (_shouldRecreateAnimations(oldWidget)) { + _disposeAnimations(); + _initAnimations(); + } + } + @override void dispose() { _disposeAnimations(); - _endAnimationTimer?.cancel(); super.dispose(); } @@ -355,6 +411,38 @@ class _WaveWidgetState extends State with TickerProviderStateMixin { } } +class _WaveLayerSpec { + final Color? color; + final List? gradient; + final Alignment? gradientBegin; + final Alignment? gradientEnd; + final int duration; + final double heightPercentage; + final double amplitude; + final MaskFilter? blur; + + const _WaveLayerSpec({ + this.color, + this.gradient, + this.gradientBegin, + this.gradientEnd, + required this.duration, + required this.heightPercentage, + required this.amplitude, + this.blur, + }); +} + +class _WaveLayerAnimation { + final AnimationController controller; + final Animation phaseValue; + + const _WaveLayerAnimation({ + required this.controller, + required this.phaseValue, + }); +} + /// Meta data of layer class Layer { final Color? color; @@ -375,109 +463,117 @@ class Layer { } class _CustomWavePainter extends CustomPainter { - final ColorMode? colorMode; final Color? color; final List? gradient; final Alignment? gradientBegin; final Alignment? gradientEnd; final MaskFilter? blur; + final double waveAmplitude; + final Animation wavePhaseValue; + final double waveFrequency; + final double heightPercentage; - double? waveAmplitude; - - Animation? wavePhaseValue; - - double? waveFrequency; - - double? heightPercentage; - - double _tempA = 0.0; - double _tempB = 0.0; - double viewWidth = 0.0; - Paint _paint = Paint(); - - _CustomWavePainter( - {this.colorMode, - this.color, - this.gradient, - this.gradientBegin, - this.gradientEnd, - this.blur, - this.heightPercentage, - this.waveFrequency, - this.wavePhaseValue, - this.waveAmplitude, - Listenable? repaint}) - : super(repaint: repaint); + final Paint _paint = Paint(); - _setPaths(double viewCenterY, Size size, Canvas canvas) { - Layer _layer = Layer( + _CustomWavePainter({ + this.color, + this.gradient, + this.gradientBegin, + this.gradientEnd, + this.blur, + required this.heightPercentage, + required this.waveFrequency, + required this.wavePhaseValue, + required this.waveAmplitude, + Listenable? repaint, + }) : super(repaint: repaint); + + void _setPaths(double viewCenterY, Size size, Canvas canvas) { + final layer = Layer( path: Path(), color: color, gradient: gradient, blur: blur, - amplitude: (-1.6 + 0.8) * waveAmplitude!, - phase: wavePhaseValue!.value * 2 + 30, + amplitude: -0.8 * waveAmplitude, + phase: wavePhaseValue.value * 2 + 30, ); - _layer.path!.reset(); - _layer.path!.moveTo( - 0.0, - viewCenterY + - _layer.amplitude! * _getSinY(_layer.phase!, waveFrequency!, -1)); + layer.path!.reset(); + layer.path!.moveTo( + 0.0, + viewCenterY + + layer.amplitude! * _getSinY(layer.phase!, waveFrequency, -1, size), + ); for (int i = 1; i < size.width + 1; i++) { - _layer.path!.lineTo( - i.toDouble(), - viewCenterY + - _layer.amplitude! * _getSinY(_layer.phase!, waveFrequency!, i)); + layer.path!.lineTo( + i.toDouble(), + viewCenterY + + layer.amplitude! * _getSinY(layer.phase!, waveFrequency, i, size), + ); } - _layer.path!.lineTo(size.width, size.height); - _layer.path!.lineTo(0.0, size.height); - _layer.path!.close(); - if (_layer.color != null) { - _paint.color = _layer.color!; + layer.path!.lineTo(size.width, size.height); + layer.path!.lineTo(0.0, size.height); + layer.path!.close(); + if (layer.color != null) { + _paint.color = layer.color!; + _paint.shader = null; } - if (_layer.gradient != null) { - var rect = Offset.zero & - Size(size.width, size.height - viewCenterY * heightPercentage!); + if (layer.gradient != null) { + final rect = Offset.zero & + Size(size.width, size.height - viewCenterY * heightPercentage); _paint.shader = LinearGradient( - begin: gradientBegin == null - ? Alignment.bottomCenter - : gradientBegin!, - end: gradientEnd == null ? Alignment.topCenter : gradientEnd!, - colors: _layer.gradient!) - .createShader(rect); - } - if (_layer.blur != null) { - _paint.maskFilter = _layer.blur; + begin: gradientBegin ?? Alignment.bottomCenter, + end: gradientEnd ?? Alignment.topCenter, + colors: layer.gradient!, + ).createShader(rect); } - + _paint.maskFilter = layer.blur; _paint.style = PaintingStyle.fill; - canvas.drawPath(_layer.path!, _paint); + canvas.drawPath(layer.path!, _paint); } @override void paint(Canvas canvas, Size size) { - double viewCenterY = size.height * (heightPercentage! + 0.1); - viewWidth = size.width; + if (size.width <= 0 || size.height <= 0) { + return; + } + final viewCenterY = size.height * (heightPercentage + 0.1); _setPaths(viewCenterY, size, canvas); } @override - bool shouldRepaint(CustomPainter oldDelegate) { - return false; + bool shouldRepaint(covariant _CustomWavePainter oldDelegate) { + return oldDelegate.color != color || + !_listEquals(oldDelegate.gradient, gradient) || + oldDelegate.gradientBegin != gradientBegin || + oldDelegate.gradientEnd != gradientEnd || + oldDelegate.blur != blur || + oldDelegate.waveAmplitude != waveAmplitude || + oldDelegate.waveFrequency != waveFrequency || + oldDelegate.heightPercentage != heightPercentage; } double _getSinY( - double startradius, double waveFrequency, int currentposition) { - if (_tempA == 0) { - _tempA = pi / viewWidth; - } - if (_tempB == 0) { - _tempB = 2 * pi / 360.0; - } + double startRadius, + double waveFrequency, + int currentPosition, + Size size, + ) { + final scale = pi / size.width; + final phaseScale = 2 * pi / 360.0; + + return sin( + scale * waveFrequency * (currentPosition + 1) + startRadius * phaseScale, + ); + } - return (sin( - _tempA * waveFrequency * (currentposition + 1) + startradius * _tempB)); + bool _listEquals(List? first, List? second) { + if (first == null) return second == null; + if (second == null || first.length != second.length) return false; + for (int i = 0; i < first.length; i++) { + if (first[i] != second[i]) return false; + } + return true; } } diff --git a/pubspec.yaml b/pubspec.yaml index 6aef9c1..9aa8eaf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: wave description: Widget for displaying waves with custom color, duration, floating and blur effects. version: 0.2.3-dev.1 -homepage: https://github.com/i-protoss/wave +homepage: https://github.com/glorylab/wave environment: sdk: '>=2.12.0 <3.0.0' diff --git a/test/wave_test.dart b/test/wave_test.dart index db46d30..614aa16 100644 --- a/test/wave_test.dart +++ b/test/wave_test.dart @@ -3,48 +3,99 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:wave/wave.dart'; void main() { + group('Config', () { + test('rejects mismatched custom gradient lengths', () { + expect( + () => CustomConfig( + gradients: [ + [Colors.red, Colors.blue], + ], + durations: [1000, 2000], + heightPercentages: [0.2, 0.3], + ), + throwsA(isA()), + ); + }); + + test('rejects gradients with fewer than two colors', () { + expect( + () => CustomConfig( + gradients: [ + [Colors.red], + ], + durations: [1000], + heightPercentages: [0.2], + ), + throwsA(isA()), + ); + }); + }); + group('Wave widget', () { testWidgets('isLoop true (default)', (WidgetTester tester) async { await tester.pumpWidget(getWaveWidget()); - try { - /* - If isLoop is true, - animations will not stopped and therefore the pumpAndSettle() will throw error. - */ - await tester.pumpAndSettle(); - } catch (e) { - expect(e, isFlutterError); - } + await tester.pump(const Duration(milliseconds: 100)); + + expect(tester.takeException(), isNull); }); testWidgets('isLoop false', (WidgetTester tester) async { int duration = 5000; await tester.pumpWidget(getWaveWidget(duration: duration, isLoop: false)); + await tester.pump(Duration(milliseconds: duration + 1)); int count = await tester.pumpAndSettle(const Duration(milliseconds: 1)); + // Animations should be stopped after the specified duration. - expect(count, duration); + expect(count, lessThan(10)); + }); + + testWidgets('SingleConfig renders', (WidgetTester tester) async { + await tester.pumpWidget(getWaveWidget( + config: SingleConfig(), + duration: 10, + isLoop: false, + )); + await tester.pump(const Duration(milliseconds: 11)); + + expect(tester.takeException(), isNull); + }); + + testWidgets('RandomConfig renders', (WidgetTester tester) async { + await tester.pumpWidget(getWaveWidget( + config: RandomConfig(seed: 7), + duration: 10, + isLoop: false, + )); + await tester.pump(const Duration(milliseconds: 11)); + + expect(tester.takeException(), isNull); }); }); } -Widget getWaveWidget({int? duration, bool isLoop = true}) { +Widget getWaveWidget({ + Config? config, + int? duration, + bool isLoop = true, +}) { return MaterialApp( home: Container( child: WaveWidget( backgroundColor: Colors.white, - config: CustomConfig( - blur: MaskFilter.blur( - BlurStyle.solid, - 0.0, - ), - colors: [ - Colors.white54, - Colors.white30, - Colors.white, - ], - durations: [21000, 18000, 5000], - heightPercentages: [0.26, 0.28, 0.31], - ), + config: config ?? + CustomConfig( + blur: MaskFilter.blur( + BlurStyle.solid, + 0.0, + ), + colors: [ + Colors.white54, + Colors.white30, + Colors.white, + ], + durations: [21000, 18000, 5000], + heightPercentages: [0.26, 0.28, 0.31], + ), duration: duration, isLoop: isLoop, size: Size(