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/CHANGELOG.md b/CHANGELOG.md index 44c85d5..61ce976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.25 +* **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..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: @@ -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/example/lib/main.dart b/example/lib/main.dart index 61ccb6e..420abe9 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'; @@ -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/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 b41da47..273e002 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.25" sqflite: dependency: transitive description: diff --git a/lib/models/particle.dart b/lib/models/particle.dart index 0c48220..7cca1da 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,14 @@ 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; + // 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/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..5fd6437 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 @@ -64,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 = []; @@ -210,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(); @@ -228,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(); @@ -343,13 +336,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; + Duration delta; + if (last == null) { + delta = elapsed; + } else 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) { + // Fallback to a 60fps frame duration if delta is not positive. + delta = const Duration(microseconds: 16667); + } + + 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 +408,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/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 = []; diff --git a/pubspec.yaml b/pubspec.yaml index 61243b0..061d230 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,8 @@ 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 screenshots: @@ -22,10 +23,10 @@ platforms: topics: - spoiler - - text + - animation - particle - - hidden - - hide + - text + - shader dependencies: flutter: 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" diff --git a/test/particle_update_interval_test.dart b/test/particle_update_interval_test.dart new file mode 100644 index 0000000..3eadd20 --- /dev/null +++ b/test/particle_update_interval_test.dart @@ -0,0 +1,82 @@ +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()); + + 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); + + controller.dispose(); + await tester.pump(); + }); + + testWidgets('particle updates run every frame when interval is zero', + (tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + + final controller = TestSpoilerController(vsync: const TestVSync()); + + 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); + + controller.dispose(); + await tester.pump(); + }); +}