From 599986e674f6e142dbfc7900cb2b283498fd5488 Mon Sep 17 00:00:00 2001 From: Argen Zhukeev Date: Fri, 26 Dec 2025 22:37:14 +0600 Subject: [PATCH 1/5] feat: particle update interval --- CHANGELOG.md | 8 +++++ CONTRIBUTING.md | 20 +++++++++++ README.md | 2 ++ lib/models/particle.dart | 15 ++++++--- lib/models/particle_config.dart | 25 +++++++++++++- lib/models/spoiler_controller.dart | 43 +++++++++++++++++++++++- lib/models/spoiler_drawing_strategy.dart | 4 ++- scripts/release_check.sh | 35 +++++++++++++++++++ scripts/setup_hooks.sh | 8 +++++ 9 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100755 scripts/release_check.sh create mode 100755 scripts/setup_hooks.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c85d5..a70c52f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.24 +* **Performance:** + * Added `ParticleConfig.updateInterval` to throttle particle updates. +* **DevX:** + * Added `CONTRIBUTING.md` and release check scripts. +* **Docs:** + * Expanded configuration model documentation. + ## 1.0.24 * **Packaging:** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fbf4630 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,20 @@ +# Contributing + +Thanks for helping improve `spoiler_widget`! + +## Setup + +1. Install Flutter (or use FVM). +2. Run `scripts/setup_hooks.sh` to enable git hooks. +3. Run `flutter pub get`. + +## Development workflow + +- Format: `dart format .` +- Analyze: `flutter analyze` and `flutter analyze` inside `example/` +- Tests: `flutter test` and `flutter test test/golden_test.dart` + +## Release checks + +Use `scripts/release_check.sh` to run format, analyze, tests, goldens, and +`dart pub publish --dry-run` in one go. diff --git a/README.md b/README.md index 80bcf07..2342c81 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,7 @@ These fields are kept for backward compatibility; prefer `particleConfig` and `f | `enableWaves` | bool | Enables ripple waves that push particles. | | `maxWaveRadius` | double | Wave radius limit in pixels. | | `maxWaveCount` | int | Maximum number of simultaneous waves. | +| `updateInterval` | double | Minimum seconds between particle updates (0 = every frame). | #### TextSpoilerConfig @@ -345,6 +346,7 @@ SpoilerTextWrapper( ### Contributing Contributions are welcome! Whether it’s bug fixes, new features, or documentation improvements, open a [Pull Request](https://github.com/zhukeev/spoiler_widget/pulls) or [Issue](https://github.com/zhukeev/spoiler_widget/issues). +See [CONTRIBUTING.md](https://github.com/zhukeev/spoiler_widget/blob/main/CONTRIBUTING.md) for setup and release checks. --- diff --git a/lib/models/particle.dart b/lib/models/particle.dart index 0c48220..5caf7e4 100644 --- a/lib/models/particle.dart +++ b/lib/models/particle.dart @@ -62,8 +62,10 @@ class Particle extends Offset { /// /// This method is used to move the particle. /// It calculates the next position of the particle based on the current position, speed, and angle. - Particle moveToRandomAngle() { - return moveWithAngle(angle).copyWith( + /// + /// [timeScale] is a multiplier where 1.0 represents a single 60fps frame. + Particle moveToRandomAngle([double timeScale = 1.0]) { + return moveWithAngle(angle, timeScale).copyWith( // Random angle angle: angle + (Random().nextDouble() - 0.5), ); @@ -72,10 +74,13 @@ class Particle extends Offset { /// Move the particle /// /// This method is used to move the particle to given angle. - Particle moveWithAngle(double angle) { - final next = this + Offset.fromDirection(angle, speed); + /// + /// [timeScale] is a multiplier where 1.0 represents a single 60fps frame. + Particle moveWithAngle(double angle, [double timeScale = 1.0]) { + final clampedScale = timeScale <= 0 ? 0.0 : timeScale; + final next = this + Offset.fromDirection(angle, speed * clampedScale); - final lifetime = life - 0.01; + final lifetime = life - (0.01 * clampedScale); final color = this.color.withValues(alpha: lifetime.clamp(0, 1)); diff --git a/lib/models/particle_config.dart b/lib/models/particle_config.dart index c933677..8efc88d 100644 --- a/lib/models/particle_config.dart +++ b/lib/models/particle_config.dart @@ -40,18 +40,36 @@ double _estimatePathAreaFactor(Path path, {int samples = 48}) { return areaFactor; } +/// Configuration for particle appearance, motion, and update cadence. @immutable class ParticleConfig { /// Fraction of area covered by particles (0..1 => 0%..100%). final double density; + + /// Particle speed in logical pixels per frame. final double speed; + + /// Base color used for particle rendering. final Color color; + + /// Particle diameter in logical pixels. final double maxParticleSize; + + /// Optional preset shape used by both atlas and shader rendering. final ParticlePathPreset? shapePreset; + + /// Enables ripple waves that push particles. final bool enableWaves; + + /// Maximum radius for a single wave animation. final double maxWaveRadius; + + /// Maximum number of simultaneous wave animations. final int maxWaveCount; + /// Minimum seconds between particle updates. Use 0 for every frame. + final double updateInterval; + const ParticleConfig({ required this.density, required this.speed, @@ -61,6 +79,7 @@ class ParticleConfig { this.enableWaves = false, this.maxWaveRadius = 0.0, this.maxWaveCount = 3, + this.updateInterval = 0.0, }); factory ParticleConfig.defaultConfig() => ParticleConfig( @@ -69,8 +88,10 @@ class ParticleConfig { color: Colors.white, maxParticleSize: 1.0, shapePreset: ParticlePathPreset.circle, + updateInterval: 0.0, ); + /// Area normalization factor derived from [shapePreset]. double get areaFactor { if (shapePreset == null) return 1.0; final presetArea = shapePreset!.areaFactor; @@ -87,6 +108,7 @@ class ParticleConfig { enableWaves, maxWaveRadius, maxWaveCount, + updateInterval, ); @override @@ -100,5 +122,6 @@ class ParticleConfig { shapePreset == other.shapePreset && enableWaves == other.enableWaves && maxWaveRadius == other.maxWaveRadius && - maxWaveCount == other.maxWaveCount; + maxWaveCount == other.maxWaveCount && + updateInterval == other.updateInterval; } diff --git a/lib/models/spoiler_controller.dart b/lib/models/spoiler_controller.dart index 36eb5a3..9470ae7 100644 --- a/lib/models/spoiler_controller.dart +++ b/lib/models/spoiler_controller.dart @@ -54,6 +54,8 @@ class SpoilerController extends ChangeNotifier { // --------------------------- Path? _cachedClipPath; bool _isDisposed = false; + Duration? _lastParticleElapsed; + double _particleAccumulator = 0.0; // --------------------------- // Visual Assets & Bounds @@ -343,13 +345,50 @@ class SpoilerController extends ChangeNotifier { /// If _isEnabled is true, start or repeat the particle animation loop. void _startParticleAnimationIfNeeded() { if (_isEnabled) { + _lastParticleElapsed = null; + _particleAccumulator = 0.0; _particleCtrl.repeat(); } } /// Called each frame (via [_particleCtrl]) to move or re-spawn particles. void _onParticleFrameTick() { - _drawer.update(0.016); // ~60fps increment + final elapsed = _particleCtrl.lastElapsedDuration; + if (elapsed == null) return; + + final last = _lastParticleElapsed; + if (last == null) { + _lastParticleElapsed = elapsed; + return; + } + + Duration delta; + if (elapsed < last) { + final duration = _particleCtrl.duration; + if (duration == null || duration == Duration.zero) { + _lastParticleElapsed = elapsed; + return; + } + delta = (duration - last) + elapsed; + } else { + delta = elapsed - last; + } + + _lastParticleElapsed = elapsed; + if (delta <= Duration.zero) return; + + var dt = delta.inMicroseconds / 1e6; + final minStep = _config.particleConfig.updateInterval; + if (minStep > 0) { + _particleAccumulator += dt; + if (_particleAccumulator < minStep) { + return; + } + dt = _particleAccumulator; + _particleAccumulator = 0.0; + } + + _drawer.update(dt); notifyListeners(); } @@ -378,6 +417,8 @@ class SpoilerController extends ChangeNotifier { _isEnabled = false; _fadeRadius = 0; _cachedClipPath = null; // Invalidate cache + _lastParticleElapsed = null; + _particleAccumulator = 0.0; _particleCtrl.reset(); notifyListeners(); } diff --git a/lib/models/spoiler_drawing_strategy.dart b/lib/models/spoiler_drawing_strategy.dart index a28f1c9..0965312 100644 --- a/lib/models/spoiler_drawing_strategy.dart +++ b/lib/models/spoiler_drawing_strategy.dart @@ -202,6 +202,7 @@ int _colorToArgb(Color color) { /// Strategy for drawing particles using Flutter's drawRawAtlas (CPU/hybrid). class AtlasSpoilerDrawer implements SpoilerDrawer { static const double _lifeSizeMin = 0.6; + static const double _baseFrameSeconds = 1 / 60; AtlasSpoilerDrawer(); // Particle state @@ -282,12 +283,13 @@ class AtlasSpoilerDrawer implements SpoilerDrawer { @override void update(double dt) { if (_particles.isEmpty) return; + final timeScale = dt <= 0 ? 0.0 : dt / _baseFrameSeconds; for (int i = 0; i < _particles.length; i++) { final p = _particles[i]; _particles[i] = (p.life <= 0.1) ? _createRandomParticlePath(p.path) - : p.moveToRandomAngle(); + : p.moveToRandomAngle(timeScale); } } diff --git a/scripts/release_check.sh b/scripts/release_check.sh new file mode 100755 index 0000000..2545fea --- /dev/null +++ b/scripts/release_check.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +local_home="$repo_root/.dart_tool_home" +mkdir -p "$local_home" + +flutter_cmd=() +dart_cmd=() + +local_flutter="$repo_root/.fvm/flutter_sdk/bin/flutter" +local_dart="$repo_root/.fvm/flutter_sdk/bin/dart" + +if [[ -x "$local_flutter" && -x "$local_dart" ]]; then + flutter_cmd=("$local_flutter") + dart_cmd=("$local_dart") +elif command -v fvm >/dev/null 2>&1; then + flutter_cmd=(fvm flutter) + dart_cmd=(fvm dart) +else + flutter_cmd=(flutter) + dart_cmd=(dart) +fi + +export HOME="$local_home" +export DART_DISABLE_TELEMETRY=1 +export FLUTTER_SUPPRESS_ANALYTICS=true + +"${dart_cmd[@]}" format --output=none --set-exit-if-changed . +"${flutter_cmd[@]}" pub get +"${flutter_cmd[@]}" analyze +(cd "$repo_root/example" && "${flutter_cmd[@]}" analyze) +"${flutter_cmd[@]}" test +"${flutter_cmd[@]}" test test/golden_test.dart +"${dart_cmd[@]}" pub publish --dry-run diff --git a/scripts/setup_hooks.sh b/scripts/setup_hooks.sh new file mode 100755 index 0000000..d8f63e6 --- /dev/null +++ b/scripts/setup_hooks.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) + +chmod +x "$repo_root/.githooks/pre-commit" +git -C "$repo_root" config core.hooksPath .githooks +echo "Git hooks installed at .githooks" From 1382ba1f19054a4b55715a9a661ea0ec476ebacf Mon Sep 17 00:00:00 2001 From: Argen Zhukeev Date: Fri, 26 Dec 2025 23:00:10 +0600 Subject: [PATCH 2/5] feat: spoiler performance page --- example/lib/main.dart | 4 +- .../lib/pages/spoiler_performance_page.dart | 133 ++++++++++++++++++ pubspec.yaml | 7 +- test/particle_update_interval_test.dart | 78 ++++++++++ 4 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 example/lib/pages/spoiler_performance_page.dart create mode 100644 test/particle_update_interval_test.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 61ccb6e..55bff86 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'pages/spoiler_overlay_page.dart'; +import 'pages/spoiler_performance_page.dart'; import 'pages/spoiler_text_field_page.dart'; import 'pages/spoiler_text_page.dart'; import 'pages/spoiler_text_wrapper_page.dart'; @@ -19,7 +20,7 @@ class _MainAppState extends State { @override Widget build(BuildContext context) { return const MaterialApp( - showPerformanceOverlay: false, + showPerformanceOverlay: true, home: _DemoListPage(), ); } @@ -37,6 +38,7 @@ class _DemoListPage extends StatelessWidget { _DemoEntry('SpoilerOverlay', () => const SpoilerOverlayPage()), _DemoEntry('SpoilerOverlay Full', () => const SpoilerOverlayPage(fullPage: true)), + _DemoEntry('Performance', () => const SpoilerPerformancePage()), ]; return Scaffold( diff --git a/example/lib/pages/spoiler_performance_page.dart b/example/lib/pages/spoiler_performance_page.dart new file mode 100644 index 0000000..8cf188d --- /dev/null +++ b/example/lib/pages/spoiler_performance_page.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:spoiler_widget/spoiler_widget.dart'; + +class SpoilerPerformancePage extends StatefulWidget { + const SpoilerPerformancePage({super.key}); + + @override + State createState() => _SpoilerPerformancePageState(); +} + +class _SpoilerPerformancePageState extends State { + double _density = 0.1; + double _updateInterval = 0.0; + double _maxParticleSize = 1.0; + + Widget _buildSlider({ + required String label, + required double value, + required double min, + required double max, + required int divisions, + required ValueChanged onChanged, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$label: ${value.toStringAsFixed(2)}', + style: const TextStyle(color: Colors.white), + ), + Slider( + value: value, + min: min, + max: max, + divisions: divisions, + label: value.toStringAsFixed(2), + onChanged: onChanged, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final config = TextSpoilerConfig( + isEnabled: true, + enableGestureReveal: true, + fadeConfig: const FadeConfig(padding: 10.0, edgeThickness: 20.0), + particleConfig: ParticleConfig( + density: _density, + speed: 0.25, + color: Colors.white, + maxParticleSize: _maxParticleSize, + updateInterval: _updateInterval, + ), + ); + + return Scaffold( + appBar: AppBar(title: const Text('Performance')), + backgroundColor: Colors.black, + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + _buildSlider( + label: 'Density', + value: _density, + min: 0.0, + max: 1.0, + divisions: 20, + onChanged: (value) { + setState(() { + _density = value; + }); + }, + ), + _buildSlider( + label: 'Update interval (s)', + value: _updateInterval, + min: 0.0, + max: 0.5, + divisions: 10, + onChanged: (value) { + setState(() { + _updateInterval = value; + }); + }, + ), + _buildSlider( + label: 'Max particle size', + value: _maxParticleSize, + min: 1.0, + max: 6.0, + divisions: 10, + onChanged: (value) { + setState(() { + _maxParticleSize = value; + }); + }, + ), + ], + ), + ), + Expanded( + child: Center( + child: SpoilerTextWrapper( + config: config, + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Performance tuning', + style: TextStyle(fontSize: 28, color: Colors.white), + ), + SizedBox(height: 12), + Text( + 'Adjust density, update interval, and particle size.', + style: TextStyle(fontSize: 16, color: Colors.white70), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 61243b0..abea0c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,6 +2,7 @@ name: spoiler_widget description: A Flutter package to create spoiler animations similar to the one used in Telegram, allowing you to hide sensitive or spoiler-filled content until it's tapped or clicked. version: 1.0.24 homepage: https://github.com/zhukeev/spoiler_widget +repository: https://github.com/zhukeev/spoiler_widget issue_tracker: https://github.com/zhukeev/spoiler_widget/issues screenshots: @@ -22,10 +23,10 @@ platforms: topics: - spoiler - - text + - animation - particle - - hidden - - hide + - text + - shader dependencies: flutter: diff --git a/test/particle_update_interval_test.dart b/test/particle_update_interval_test.dart new file mode 100644 index 0000000..5441d54 --- /dev/null +++ b/test/particle_update_interval_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spoiler_widget/models/particle.dart'; +import 'package:spoiler_widget/models/spoiler_configs.dart'; +import 'package:spoiler_widget/models/spoiler_controller.dart'; + +class TestSpoilerController extends SpoilerController { + TestSpoilerController({required super.vsync}); + + List get debugParticles => particles; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + SpoilerConfig buildConfig({required double updateInterval}) { + return SpoilerConfig( + isEnabled: true, + enableGestureReveal: false, + fadeConfig: const FadeConfig(padding: 1.0, edgeThickness: 1.0), + particleConfig: ParticleConfig( + density: 0.05, + speed: 1.0, + color: Colors.white, + maxParticleSize: 2.0, + updateInterval: updateInterval, + ), + ); + } + + testWidgets('particle updates throttle by updateInterval', (tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + + final controller = TestSpoilerController(vsync: const TestVSync()); + addTearDown(controller.dispose); + + controller.initializeParticles( + Path()..addRect(const Rect.fromLTWH(0, 0, 100, 100)), + buildConfig(updateInterval: 0.5), + ); + + expect(controller.debugParticles, isNotEmpty); + final initial = controller.debugParticles.first; + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + + final afterShort = controller.debugParticles.first; + expect(identical(afterShort, initial), isTrue); + + await tester.pump(const Duration(milliseconds: 400)); + + final afterThreshold = controller.debugParticles.first; + expect(identical(afterThreshold, initial), isFalse); + }); + + testWidgets('particle updates run every frame when interval is zero', + (tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + + final controller = TestSpoilerController(vsync: const TestVSync()); + addTearDown(controller.dispose); + + controller.initializeParticles( + Path()..addRect(const Rect.fromLTWH(0, 0, 100, 100)), + buildConfig(updateInterval: 0.0), + ); + + expect(controller.debugParticles, isNotEmpty); + final initial = controller.debugParticles.first; + + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + + final afterTick = controller.debugParticles.first; + expect(identical(afterTick, initial), isFalse); + }); +} From 96a5adcd464f695d1ec2487e8fd51343add2dba9 Mon Sep 17 00:00:00 2001 From: Argen Zhukeev Date: Fri, 26 Dec 2025 23:24:27 +0600 Subject: [PATCH 3/5] feat: update tests and ci test trigger files --- .github/workflows/ci.yml | 11 ++++----- example/pubspec.lock | 2 +- lib/models/spoiler_controller.dart | 32 +++++++++---------------- test/particle_update_interval_test.dart | 8 +++++-- 4 files changed, 23 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc19782..ece5482 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,14 +19,13 @@ jobs: with: filters: | code: - - "lib/**" - - "test/**" - - "example/**" + - "lib/spoiler_*.dart" + - "lib/widgets/**" + - "lib/models/**" + - "lib/utils/**" + - "lib/extension/**" - "shaders/**" - - "assets/**" - "pubspec.yaml" - - "analysis_options.yaml" - - "analysis_options.yml" - name: Setup Flutter uses: subosito/flutter-action@v2 diff --git a/example/pubspec.lock b/example/pubspec.lock index b41da47..0a0b1ed 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -318,7 +318,7 @@ packages: path: ".." relative: true source: path - version: "1.0.18" + version: "1.0.24" sqflite: dependency: transitive description: diff --git a/lib/models/spoiler_controller.dart b/lib/models/spoiler_controller.dart index 9470ae7..b32643f 100644 --- a/lib/models/spoiler_controller.dart +++ b/lib/models/spoiler_controller.dart @@ -66,11 +66,6 @@ class SpoilerController extends ChangeNotifier { /// A Path describing the spoiler region (may be multiple rectangles). final Path _spoilerPath = Path(); - /// Cached list of sub-paths (individual text blocks) for per-rect shader rendering. - // TODO: Deprecate/remove if _spoilerRects replaces this completely. - // Keeping for fallback or if Path based logic is needed elsewhere. - List _encapsulatedPaths = []; - /// Explicit list of rectangles for per-rect shader rendering. List _spoilerRects = []; @@ -212,12 +207,11 @@ class SpoilerController extends ChangeNotifier { _spoilerBounds = _spoilerPath.getBounds(); } - // If rects weren't provided, try to approximate them from path bounds + final subPaths = _spoilerPath.subPaths.toList(); + + // If rects weren't provided, try to approximate them from path bounds. if (_spoilerRects.isEmpty) { - // Fallback: use subPaths derived rects - final subPaths = _spoilerPath.subPaths; - _encapsulatedPaths = subPaths.toList(); - _spoilerRects = _encapsulatedPaths.map((p) => p.getBounds()).toList(); + _spoilerRects = subPaths.map((p) => p.getBounds()).toList(); } _initFadeIfNeeded(); @@ -230,10 +224,7 @@ class SpoilerController extends ChangeNotifier { } } - // Ensure we are using Atlas drawer initially or if config changes - final subPaths = _spoilerPath.subPaths; - _encapsulatedPaths = subPaths.toList(); - + // Ensure we are using Atlas drawer initially or if config changes. if (_drawer is! ShaderSpoilerDrawer) { if (_drawer is! AtlasSpoilerDrawer) { _drawer = AtlasSpoilerDrawer(); @@ -357,13 +348,10 @@ class SpoilerController extends ChangeNotifier { if (elapsed == null) return; final last = _lastParticleElapsed; - if (last == null) { - _lastParticleElapsed = elapsed; - return; - } - Duration delta; - if (elapsed < last) { + if (last == null) { + delta = elapsed; + } else if (elapsed < last) { final duration = _particleCtrl.duration; if (duration == null || duration == Duration.zero) { _lastParticleElapsed = elapsed; @@ -375,7 +363,9 @@ class SpoilerController extends ChangeNotifier { } _lastParticleElapsed = elapsed; - if (delta <= Duration.zero) return; + if (delta <= Duration.zero) { + delta = const Duration(microseconds: 16667); + } var dt = delta.inMicroseconds / 1e6; final minStep = _config.particleConfig.updateInterval; diff --git a/test/particle_update_interval_test.dart b/test/particle_update_interval_test.dart index 5441d54..3eadd20 100644 --- a/test/particle_update_interval_test.dart +++ b/test/particle_update_interval_test.dart @@ -32,7 +32,6 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); final controller = TestSpoilerController(vsync: const TestVSync()); - addTearDown(controller.dispose); controller.initializeParticles( Path()..addRect(const Rect.fromLTWH(0, 0, 100, 100)), @@ -52,6 +51,9 @@ void main() { final afterThreshold = controller.debugParticles.first; expect(identical(afterThreshold, initial), isFalse); + + controller.dispose(); + await tester.pump(); }); testWidgets('particle updates run every frame when interval is zero', @@ -59,7 +61,6 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); final controller = TestSpoilerController(vsync: const TestVSync()); - addTearDown(controller.dispose); controller.initializeParticles( Path()..addRect(const Rect.fromLTWH(0, 0, 100, 100)), @@ -74,5 +75,8 @@ void main() { final afterTick = controller.debugParticles.first; expect(identical(afterTick, initial), isFalse); + + controller.dispose(); + await tester.pump(); }); } From 21732f150105e77bba55cf307fe4e4047509e7bc Mon Sep 17 00:00:00 2001 From: Argen Zhukeev Date: Fri, 26 Dec 2025 23:31:19 +0600 Subject: [PATCH 4/5] fix: linter issues --- CHANGELOG.md | 2 +- README.md | 2 +- example/lib/main.dart | 2 +- lib/models/particle.dart | 1 + lib/models/spoiler_controller.dart | 1 + pubspec.yaml | 2 +- 6 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a70c52f..61ce976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.24 +## 1.0.25 * **Performance:** * Added `ParticleConfig.updateInterval` to throttle particle updates. * **DevX:** diff --git a/README.md b/README.md index 2342c81..e7f1448 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ In your `pubspec.yaml`: ```yaml dependencies: - spoiler_widget: ^1.0.24 + spoiler_widget: ^1.0.25 ``` Then run: diff --git a/example/lib/main.dart b/example/lib/main.dart index 55bff86..420abe9 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -20,7 +20,7 @@ class _MainAppState extends State { @override Widget build(BuildContext context) { return const MaterialApp( - showPerformanceOverlay: true, + showPerformanceOverlay: false, home: _DemoListPage(), ); } diff --git a/lib/models/particle.dart b/lib/models/particle.dart index 5caf7e4..7cca1da 100644 --- a/lib/models/particle.dart +++ b/lib/models/particle.dart @@ -80,6 +80,7 @@ class Particle extends Offset { final clampedScale = timeScale <= 0 ? 0.0 : timeScale; final next = this + Offset.fromDirection(angle, speed * clampedScale); + // Lifetime decreases by 0.01 per 60fps frame, resulting in a ~1.67s lifespan. final lifetime = life - (0.01 * clampedScale); final color = this.color.withValues(alpha: lifetime.clamp(0, 1)); diff --git a/lib/models/spoiler_controller.dart b/lib/models/spoiler_controller.dart index b32643f..5fd6437 100644 --- a/lib/models/spoiler_controller.dart +++ b/lib/models/spoiler_controller.dart @@ -364,6 +364,7 @@ class SpoilerController extends ChangeNotifier { _lastParticleElapsed = elapsed; if (delta <= Duration.zero) { + // Fallback to a 60fps frame duration if delta is not positive. delta = const Duration(microseconds: 16667); } diff --git a/pubspec.yaml b/pubspec.yaml index abea0c7..061d230 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: spoiler_widget description: A Flutter package to create spoiler animations similar to the one used in Telegram, allowing you to hide sensitive or spoiler-filled content until it's tapped or clicked. -version: 1.0.24 +version: 1.0.25 homepage: https://github.com/zhukeev/spoiler_widget repository: https://github.com/zhukeev/spoiler_widget issue_tracker: https://github.com/zhukeev/spoiler_widget/issues From f21ac0c77f8d60998c9e4a84aec4cdff20c8f211 Mon Sep 17 00:00:00 2001 From: Argen Zhukeev Date: Wed, 11 Feb 2026 18:50:18 +0600 Subject: [PATCH 5/5] Refactor: Adjust `SpoilerRenderObject` painting logic for `_rectsDirty` state and `SpoilerPaintingContext` to improve rendering. --- .../Flutter/GeneratedPluginRegistrant.swift | 2 +- example/pubspec.lock | 2 +- lib/widgets/spoiler_render_object.dart | 19 +++++++++++++++---- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 2bfe7e4..252c004 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,7 +6,7 @@ import FlutterMacOS import Foundation import path_provider_foundation -import sqflite +import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) diff --git a/example/pubspec.lock b/example/pubspec.lock index 0a0b1ed..273e002 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -318,7 +318,7 @@ packages: path: ".." relative: true source: path - version: "1.0.24" + version: "1.0.25" sqflite: dependency: transitive description: diff --git a/lib/widgets/spoiler_render_object.dart b/lib/widgets/spoiler_render_object.dart index 0046c27..adcc28b 100644 --- a/lib/widgets/spoiler_render_object.dart +++ b/lib/widgets/spoiler_render_object.dart @@ -209,11 +209,14 @@ class RenderSpoiler extends RenderProxyBox { ); _updateCachedRects(collected); _lastSelection = selection; - _rectsDirty = false; + // Note: _rectsDirty is reset at the end of paint() to ensure + // SpoilerPaintingContext created this frame still sees it as dirty. } final clipPath = _normalizeClipPath(_onClipPath?.call(size)); + final internalOffset = isRepaintBoundary ? Offset.zero : offset; + if (_enableOverlay) { super.paint(context, offset); @@ -222,6 +225,7 @@ class RenderSpoiler extends RenderProxyBox { _onAfterPaint == null && _imageFilter == null) { _syncEditableOffsets(visitedOffsets); + _rectsDirty = false; return; } @@ -242,7 +246,9 @@ class RenderSpoiler extends RenderProxyBox { final spoilerContext = layerContext is SpoilerPaintingContext ? layerContext : SpoilerPaintingContext( - layer: layer!, + layer: layerContext is SpoilerPaintingContext + ? layerContext.storageLayer + : layer!, estimatedBounds: paintBounds.shift(layerOffset), calculateRects: _rectsDirty, ); @@ -294,11 +300,13 @@ class RenderSpoiler extends RenderProxyBox { calculateRects: _rectsDirty, ); - paintSpoilerLayer(rootSpoilerContext, offset); + paintSpoilerLayer(rootSpoilerContext, internalOffset); + // Ensure we finalize our custom context's recording // ignore: invalid_use_of_protected_member rootSpoilerContext.stopRecordingIfNeeded(); + _rectsDirty = false; _syncEditableOffsets(visitedOffsets); } @@ -506,7 +514,10 @@ class SpoilerPaintingContext extends PaintingContext { required ContainerLayer layer, required Rect estimatedBounds, required this.calculateRects, - }) : super(layer, estimatedBounds); + }) : storageLayer = layer, + super(layer, estimatedBounds); + + final ContainerLayer storageLayer; final bool calculateRects; final List spoilerRects = [];