diff --git a/.gitignore b/.gitignore index df4d551..13bb4b9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ pubspec.lock # If you don't generate documentation locally you can remove this line. doc/api/ +# Coverage output +/coverage/ + # dotenv environment variables file .env* diff --git a/CHANGELOG.md b/CHANGELOG.md index aa32ef9..077060a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,29 @@ +## 0.1.0 + +Complete rewrite — `wave_screen` is now a composable, GPU-shader-driven wave +library. **Breaking:** the entire public API changed and the `WaveSkeletonizer` +module was removed. + +### Added + +- Composable `Wave` built from swappable traits: `WaveShape`, `WaveStyle`, + `WaveMotion`, and `WaveEffect`. +- `WaveShape.sine`, `WaveShape.gerstner` (sharpened ocean crests), and + `WaveShape.metaball` (gooey merging blobs) — each a pure, testable height field + mirrored by the fragment shader. +- `WaveMotion.drift`, `WaveMotion.pingPong` (smooth oscillation), and + `WaveMotion.still`. +- `WaveField` — GPU fragment-shader compositor with a CPU fallback. +- `PointerRippleEffect` + interactive `WaveField`: taps and drags spawn ripples + that displace the surface, with an `onRipple` callback. +- `WaveScreen` + a broad `WavePresets` gallery (14 curated presets including the + classic `violet` theme, reachable via `WavePresets.byName` / `.all`). + +### Removed + +- `WaveSkeletonizer` and the `skeletonizer` dependency. +- The previous `Wave`/`WaveScreen` recipe/adaptation/palette API. + ## 0.0.1 -- Initial release of `wave_screen`. -- Added the public `Wave`, `WaveScreen`, and `WaveSkeletonizer` exports. -- Added geometry-aware skeletonizer effects, including the tide-pool style ping-pong wave behavior. -- Added the example application and GitHub Pages workflow for publishing the web demo. +- Initial release of `wave_screen` (legacy API). diff --git a/README.md b/README.md index 6c44535..dfd3705 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,112 @@ -# wave-screen +# wave_screen [![Deploy Example To GitHub Pages](https://github.com/Pathverse/wave-screen/actions/workflows/deploy-example-pages.yml/badge.svg)](https://github.com/Pathverse/wave-screen/actions/workflows/deploy-example-pages.yml) -`wave_screen` is a Flutter package for animated wave-driven UI surfaces. It currently exports three main pieces: +`wave_screen` renders **composable, GPU-shader-driven animated wave surfaces** for +Flutter. A `Wave` is built from swappable traits — a **shape** (geometry), a +**style** (appearance), a **motion** (time evolution), and optional **effects** +(interaction) — and composited on the GPU via a fragment shader (with a CPU +fallback so it never blanks). -- `Wave` for standalone animated wave treatments. -- `WaveScreen` for layered screen backgrounds and surface styling. -- `WaveSkeletonizer` for geometry-aware loading states, including the tide-pool style ping-pong effect used in the example app. +## Install -## Package Exports +```yaml +dependencies: + wave_screen: ^0.1.0 +``` -The public entrypoint is [lib/wave_screen.dart](lib/wave_screen.dart), which exports: +## Quick start -- [lib/src/wave/wave.dart](lib/src/wave/wave.dart) -- [lib/src/screen/screen.dart](lib/src/screen/screen.dart) -- [lib/src/skeletonizer.dart](lib/src/skeletonizer.dart) +Drop in a curated preset as a full-bleed background: -## Run The Example Locally +```dart +import 'package:wave_screen/wave_screen.dart'; -From the repository root: +WaveScreen(preset: WavePresets.ocean); +``` -```bash -cd example -flutter pub get -flutter run -d chrome +Or compose a field from raw traits: + +```dart +WaveField( + waves: [ + Wave( + shape: WaveShape.gerstner(amplitude: 0.06, frequency: 1.0, steepness: 1.1), + style: const WaveStyle(fill: Color(0xFF2384A0)), + motion: WaveMotion.drift(speed: 0.3), + ), + Wave( + shape: WaveShape.metaball(blobCount: 4, radius: 0.2, amplitude: 0.12), + style: const WaveStyle(fill: Color(0xFF57C4D6)), + motion: WaveMotion.pingPong(sway: 1.2, period: 6), + ), + ], +); +``` + +## The trait model + +| Trait | Options | +|---|---| +| `WaveShape` | `.sine(amplitude, frequency, baseline)` · `.gerstner(…, steepness)` (sharpened ocean crests) · `.metaball(blobCount, radius, amplitude)` (gooey merging blobs) | +| `WaveStyle` | `WaveStyle(fill: Color, opacity)` | +| `WaveMotion` | `.drift(speed)` · `.pingPong(sway, period)` (smooth back-and-forth) · `.still()` | +| `WaveEffect` | `PointerRippleEffect(strength, decay, speed, wavelength)` | + +Every shape is a pure height field: `shape.sampleAt(x, phase)` (and +`wave.heightAt(x, t)`) returns the exact value the shader renders, so behavior is +testable without a GPU. + +## Interaction + +Give any wave a `PointerRippleEffect` and its enclosing `WaveField` becomes +interactive — taps and drags send ripples across the surface: + +```dart +WaveField( + waves: [ + Wave( + shape: WaveShape.sine(amplitude: 0.05, frequency: 0.9), + style: const WaveStyle(fill: Color(0xFF1C6E8C)), + motion: WaveMotion.drift(speed: 0.25), + effects: const [PointerRippleEffect()], + ), + ], + onRipple: (normalizedPosition) { /* optional */ }, +); ``` -## GitHub Pages Example +## Presets -The example app in [example](example) is deployed to GitHub Pages through [.github/workflows/deploy-example-pages.yml](.github/workflows/deploy-example-pages.yml). +`WavePresets` ships a broad curated gallery — sine, Gerstner and metaball themes +plus interactive presets. Access them by name: -- Trigger: push to `main` or manual `workflow_dispatch` -- Build target: `example/build/web` -- Published URL: `https://pathverse.github.io/wave-screen/` +```dart +WaveScreen(preset: WavePresets.byName['tidepool']!); + +for (final preset in WavePresets.all) { /* … */ } +``` -Before the first deployment, set the repository Pages source to `GitHub Actions` in the GitHub repository settings. +Themes include `aurora`, `violet`, `dusk`, `sunset`, `neon`, `mist`, `abyss`, +`ocean`, `lagoon`, `ember`, `jelly`, `lava`, `tidepool`, and `pulse`. + +## Rendering + +Waves render on the GPU through `shaders/wave.frag`. If the shader cannot be +loaded (some platforms, or a stale build) the widget transparently falls back to +an equivalent CPU painter, so the surface always shows. + +## Run the example + +```bash +cd example +flutter pub get +flutter run -d chrome +``` -## Development Notes +The example is a gallery of the milestones: **Foundation**, **Shapes**, +**Interaction**, and the full **Preset Gallery**. -- The example depends on the local package by path, so build and deployment should always run from [example/pubspec.yaml](example/pubspec.yaml). -- The Pages workflow passes `--base-href "/wave-screen/"` so the Flutter web build serves correctly from the repository subpath. +## License +Apache-2.0. See [LICENSE](LICENSE). diff --git a/REVAMP_PLAN.md b/REVAMP_PLAN.md new file mode 100644 index 0000000..a609f75 --- /dev/null +++ b/REVAMP_PLAN.md @@ -0,0 +1,106 @@ +# wave_screen — Revamp Plan (v0.1.0, breaking) + +> Durable decision record for the complete revamp of `wave_screen`. +> Reference (old): `../wave-screen`. New implementation: this repo (`wave-screen-2`). +> Built under Pathverse **flow-v1b** governance. This document is the pre-Phase-1 +> scope lock; it is the source of truth for reconstructing the decisions below. + +## Governance + +- Constitution: pathverse-agent-governance `docs/constitution.md` (factors 2,3,7,8,10,12). +- Flow: `CLARIFY (BDD) → IMPLEMENT (TDD) → STRUCTURE → COVERAGE → COMMIT (zmem)`. +- Stack: `dart-flutter` (skills: `good-bdd-dart-flutter`, `good-tdd-dart-flutter`). +- Behavior is proven by behave scenarios bound to Flutter integration proofs + (`@proof:` ↔ `proof_`-tagged tests), never by inspecting source text. + +## Locked decisions (from scope-lock Q&A) + +1. **Rendering engine:** GPU **fragment shader** (`FragmentProgram`). The shader + receives uniforms (time, resolution, per-layer shape params, pointer) and + evaluates a per-pixel height field → SDF fill. Metaball/gooey and Gerstner are + natural fits; pointer ripple = extra uniforms. +2. **Public API model:** maximum granularity — a **`Wave` is an object composed of + swappable traits**: `WaveShape` (geometry), `WaveStyle` (appearance), + `WaveMotion` (time evolution), `List` (interaction/decoration). +3. **Compatibility:** clean-slate, breaking. No back-compat with old API. +4. **Module scope:** `Wave` + `WaveScreen` revamped. **`WaveSkeletonizer` REMOVED** + (no adoption) — delete `lib/src/skeletonizer/*`, its export, the `skeletonizer` + dependency, and its example pages/tests. +5. **New visual capabilities (all in scope):** expanded named preset library, + gooey/metaball waves, pointer-interactive ripples, Gerstner/realistic ocean. +6. **Naming vocabulary (confirmed):** `Wave`, `WaveShape`, `WaveStyle`, + `WaveMotion`, `WaveEffect`, `WaveField`, `WaveScreen`. +7. **Execution:** milestone-by-milestone. Each milestone passes the FULL governance + gate (scenarios → TDD → structure → coverage → zmem commit) and stops for review + before the next. +8. **Example requirement:** every milestone **appends a full showcase** to the + example app (cumulative, never replacing prior showcases) demonstrating that + milestone's capabilities running live. This is a per-milestone exit criterion. + +## Target public API (illustrative) + +```dart +class Wave { + final WaveShape shape; + final WaveStyle style; + final WaveMotion motion; + final List effects; +} + +// SHAPE — height-field generator fed to the shader +WaveShape.sine({layers, amplitude, frequency, ...}); // current-style look +WaveShape.gerstner({steepness, wavelength, ...}); // realistic ocean (M2) +WaveShape.metaball({blobs, radius, gooeyness, ...}); // gooey/fluid (M2) + +// STYLE — pure appearance +WaveStyle({fill: Color|Gradient, stroke?, blur?, crestHighlight?, opacity}); + +// MOTION — time evolution +WaveMotion.drift({speed, direction}); +WaveMotion.pingPong({period, pause}); +WaveMotion.still(); + +// EFFECTS — composable overlays (M3) +PointerRippleEffect({strength, decay}); +FoamEffect({...}); + +// Widgets +WaveField(waves: [Wave(...), ...]); // composites via shader +WaveScreen(preset: WavePresets.lagoon); // curated arrangement +WaveScreen(background: ..., waves: [Wave(...), ...]); // full control +``` + +## Proposed module structure + +``` +lib/wave_screen.dart → public exports (Wave, traits, WaveField, WaveScreen, presets) +lib/src/wave/ → Wave object + trait types + WaveField widget +lib/src/wave/shader/ → .frag source + FragmentProgram loader/uniform packing +lib/src/screen/ → WaveScreen widget + WavePresets +shaders/ → compiled shader assets (pubspec `flutter: shaders:`) +``` + +## Milestones + +Each milestone = full governance flow + appended example showcase + zmem commit. + +- **M1 Foundation** — shader core + trait model (`Wave`, `WaveShape.sine`, + `WaveStyle`, `WaveMotion.drift`/`still`), `WaveField`, `WaveScreen` with a few + presets. Reaches parity with today's Wave/WaveScreen, on GPU. Remove the + skeletonizer module + dependency. Example: append a "Foundation" showcase page. +- **M2 Shapes** — add `WaveShape.gerstner` + `WaveShape.metaball` (gooey). + Example: append a "Shapes" showcase. +- **M3 Interaction** — pointer ripple + `WaveEffect` system + `WaveMotion.pingPong`. + Example: append an "Interaction" showcase. +- **M4 Presets & release** — expanded `WavePresets` library, docs, README/CHANGELOG, + publish prep. Example: append a "Preset Gallery" showcase. + +## Open implementation risks (resolved during build, not now) + +- Uniform-count vs. layer-count limits → may need multi-pass draws or texture-packed + params. Decided in M1 IMPLEMENT. +- Fragment-shader availability/fallback on all targets (web/Impeller vs others) → + confirm during M1; the reference already disabled blur on web, so web parity is a + known sensitivity. +- behave→Flutter proof harness bring-up (no `.zmem`/behave scaffold exists yet) → + established in M1 CLARIFY. diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..ffe4751 --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,10 @@ +tags: + proof_wave_field_renders_and_animates: + proof_wave_traits_drive_surface: + proof_wave_screen_preset_renders: + proof_gerstner_sharpens_crest: + proof_metaball_blobs_merge: + proof_pingpong_reverses: + proof_ripple_decays_and_propagates: + proof_pointer_spawns_ripple: + proof_preset_gallery_is_valid: diff --git a/example/lib/src/app_shell.dart b/example/lib/src/app_shell.dart index 0cdd9a4..63bc864 100644 --- a/example/lib/src/app_shell.dart +++ b/example/lib/src/app_shell.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; -import 'pages/ping_pong_skeletonizer_page.dart'; -import 'pages/skeletonizer_page.dart'; -import 'pages/waves_page.dart'; - -enum ExampleDestination { waves, skeletonizer, pingPong } +import 'example_pages.dart'; +/// Hosts the milestone showcase pages. Navigation is derived from +/// [examplePages]; a single page renders on its own, and destinations appear +/// once a later milestone appends more pages. class ExampleAppShell extends StatefulWidget { const ExampleAppShell({super.key}); @@ -14,42 +13,24 @@ class ExampleAppShell extends StatefulWidget { } class _ExampleAppShellState extends State { - ExampleDestination _destination = ExampleDestination.waves; + int _index = 0; + + void _select(int index) => setState(() => _index = index); @override Widget build(BuildContext context) { - final destinations = const [ - NavigationDestination( - icon: Icon(Icons.waves_outlined), - selectedIcon: Icon(Icons.waves), - label: 'Waves', - ), - NavigationDestination( - icon: Icon(Icons.view_agenda_outlined), - selectedIcon: Icon(Icons.view_agenda), - label: 'Skeletonizer', - ), - NavigationDestination( - icon: Icon(Icons.swap_horiz_outlined), - selectedIcon: Icon(Icons.swap_horiz), - label: 'Ping Pong', - ), - ]; - - final pages = [ - const WavesPage(), - const SkeletonizerPage(), - const PingPongSkeletonizerPage(), - ]; + final pages = examplePages; + final hasNav = pages.length >= 2; return LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth >= 900; + final body = pages[_index].builder(context); return Scaffold( backgroundColor: const Color(0xFF111324), body: SafeArea( - child: isWide + child: hasNav && isWide ? Row( children: [ Padding( @@ -57,50 +38,42 @@ class _ExampleAppShellState extends State { child: NavigationRail( backgroundColor: const Color(0x12000000), indicatorColor: const Color(0xFF3A34C7), - selectedIndex: _destination.index, + selectedIndex: _index, onDestinationSelected: _select, labelType: NavigationRailLabelType.all, - destinations: const [ - NavigationRailDestination( - icon: Icon(Icons.waves_outlined), - selectedIcon: Icon(Icons.waves), - label: Text('Waves'), - ), - NavigationRailDestination( - icon: Icon(Icons.view_agenda_outlined), - selectedIcon: Icon(Icons.view_agenda), - label: Text('Skeletonizer'), - ), - NavigationRailDestination( - icon: Icon(Icons.swap_horiz_outlined), - selectedIcon: Icon(Icons.swap_horiz), - label: Text('Ping Pong'), - ), + destinations: [ + for (final page in pages) + NavigationRailDestination( + icon: Icon(page.icon), + selectedIcon: Icon(page.selectedIcon), + label: Text(page.label), + ), ], ), ), - Expanded(child: pages[_destination.index]), + Expanded(child: body), ], ) - : pages[_destination.index], + : body, ), - bottomNavigationBar: isWide - ? null - : NavigationBar( + bottomNavigationBar: hasNav && !isWide + ? NavigationBar( backgroundColor: const Color(0xFF171A31), indicatorColor: const Color(0xFF3A34C7), - selectedIndex: _destination.index, + selectedIndex: _index, onDestinationSelected: _select, - destinations: destinations, - ), + destinations: [ + for (final page in pages) + NavigationDestination( + icon: Icon(page.icon), + selectedIcon: Icon(page.selectedIcon), + label: page.label, + ), + ], + ) + : null, ); }, ); } - - void _select(int index) { - setState(() { - _destination = ExampleDestination.values[index]; - }); - } -} \ No newline at end of file +} diff --git a/example/lib/src/example_pages.dart b/example/lib/src/example_pages.dart new file mode 100644 index 0000000..327305f --- /dev/null +++ b/example/lib/src/example_pages.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import 'pages/foundation_page.dart'; +import 'pages/gallery_page.dart'; +import 'pages/interaction_page.dart'; +import 'pages/shapes_page.dart'; + +/// One entry in the example's milestone gallery. Each milestone appends its own +/// [ExamplePage] here, so the navigation grows without touching the shell. +class ExamplePage { + final String label; + final IconData icon; + final IconData selectedIcon; + final WidgetBuilder builder; + + const ExamplePage({ + required this.label, + required this.icon, + required this.selectedIcon, + required this.builder, + }); +} + +/// The milestone showcases, in order. M1 ships the Foundation page; later +/// milestones append Shapes, Interaction, and the Preset Gallery. +final List examplePages = [ + ExamplePage( + label: 'Foundation', + icon: Icons.waves_outlined, + selectedIcon: Icons.waves, + builder: (_) => const FoundationPage(), + ), + ExamplePage( + label: 'Shapes', + icon: Icons.blur_on_outlined, + selectedIcon: Icons.blur_on, + builder: (_) => const ShapesPage(), + ), + ExamplePage( + label: 'Interaction', + icon: Icons.touch_app_outlined, + selectedIcon: Icons.touch_app, + builder: (_) => const InteractionPage(), + ), + ExamplePage( + label: 'Gallery', + icon: Icons.grid_view_outlined, + selectedIcon: Icons.grid_view, + builder: (_) => const GalleryPage(), + ), +]; diff --git a/example/lib/src/pages/foundation_page.dart b/example/lib/src/pages/foundation_page.dart new file mode 100644 index 0000000..8385abd --- /dev/null +++ b/example/lib/src/pages/foundation_page.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:wave_screen/wave_screen.dart'; + +import '../widgets/example_page_frame.dart'; +import '../widgets/example_tile.dart'; + +/// M1 Foundation showcase: the fresh curated presets plus a wave composed +/// directly from swappable shape / style / motion traits. +class FoundationPage extends StatelessWidget { + const FoundationPage({super.key}); + + @override + Widget build(BuildContext context) { + final tiles = [ + ExampleTile( + title: 'Aurora (preset)', + child: WaveScreen(preset: WavePresets.aurora), + ), + ExampleTile( + title: 'Dusk (preset)', + child: WaveScreen(preset: WavePresets.dusk), + ), + ExampleTile( + title: 'Abyss (preset)', + child: WaveScreen(preset: WavePresets.abyss), + ), + ExampleTile( + title: 'Composed from traits', + child: WaveScreen.custom( + background: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF10131F), Color(0xFF1B2138)], + ), + waves: [ + Wave( + shape: WaveShape.sine( + amplitude: 0.05, + frequency: 0.9, + baseline: 0.55, + ), + style: const WaveStyle(fill: Color(0xFF3A7BD5)), + motion: WaveMotion.drift(speed: 0.35), + ), + Wave( + shape: WaveShape.sine( + amplitude: 0.07, + frequency: 1.3, + baseline: 0.72, + ), + style: const WaveStyle(fill: Color(0xFF00D2A0), opacity: 0.9), + motion: WaveMotion.drift(speed: -0.28), + ), + Wave( + shape: WaveShape.sine( + amplitude: 0.05, + frequency: 1.1, + baseline: 0.88, + ), + style: const WaveStyle(fill: Color(0xFF9C7BE8)), + motion: WaveMotion.still(), + ), + ], + ), + ), + ]; + + return ExamplePageFrame( + title: 'Foundation', + description: + 'GPU shader-driven waves composed from swappable shape, style, and ' + 'motion traits. Curated presets, plus a surface built inline from ' + 'raw Wave traits.', + child: LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 900 ? 2 : 1; + return GridView.count( + crossAxisCount: columns, + childAspectRatio: 16 / 10, + mainAxisSpacing: 20, + crossAxisSpacing: 20, + children: tiles, + ); + }, + ), + ); + } +} diff --git a/example/lib/src/pages/gallery_page.dart b/example/lib/src/pages/gallery_page.dart new file mode 100644 index 0000000..033534f --- /dev/null +++ b/example/lib/src/pages/gallery_page.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:wave_screen/wave_screen.dart'; + +import '../widgets/example_page_frame.dart'; +import '../widgets/example_tile.dart'; + +/// M4 Preset Gallery: every curated preset in the library, each reachable by +/// name via WavePresets.byName. +class GalleryPage extends StatelessWidget { + const GalleryPage({super.key}); + + @override + Widget build(BuildContext context) { + final entries = WavePresets.byName.entries.toList(); + + return ExamplePageFrame( + title: 'Preset Gallery', + description: + 'The full curated library — sine, Gerstner and metaball themes plus ' + 'interactive ripple presets. Each is reachable by name via ' + 'WavePresets.byName.', + child: LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 1100 + ? 3 + : constraints.maxWidth >= 700 + ? 2 + : 1; + return GridView.count( + crossAxisCount: columns, + childAspectRatio: 16 / 11, + mainAxisSpacing: 18, + crossAxisSpacing: 18, + children: [ + for (final entry in entries) + ExampleTile( + title: entry.key, + child: WaveScreen(preset: entry.value), + ), + ], + ); + }, + ), + ); + } +} diff --git a/example/lib/src/pages/interaction_page.dart b/example/lib/src/pages/interaction_page.dart new file mode 100644 index 0000000..17d7ef8 --- /dev/null +++ b/example/lib/src/pages/interaction_page.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:wave_screen/wave_screen.dart'; + +import '../widgets/example_page_frame.dart'; +import '../widgets/example_tile.dart'; + +/// M3 Interaction showcase: tap or drag to send ripples across the surface, and +/// a ping-pong wave that eases back and forth. +class InteractionPage extends StatelessWidget { + const InteractionPage({super.key}); + + @override + Widget build(BuildContext context) { + final tiles = [ + ExampleTile( + title: 'Tap & drag to ripple', + child: Stack( + fit: StackFit.expand, + children: [ + WaveScreen.custom( + background: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF061A2B), Color(0xFF0C2E44)], + ), + waves: [ + Wave( + shape: WaveShape.sine( + amplitude: 0.05, + frequency: 0.9, + baseline: 0.58, + ), + style: const WaveStyle(fill: Color(0xFF1C6E8C)), + motion: WaveMotion.drift(speed: 0.25), + effects: const [PointerRippleEffect()], + ), + Wave( + shape: WaveShape.sine( + amplitude: 0.06, + frequency: 1.2, + baseline: 0.74, + ), + style: const WaveStyle(fill: Color(0xFF3CA0BE)), + motion: WaveMotion.drift(speed: -0.2), + effects: const [PointerRippleEffect(strength: 0.1)], + ), + ], + ), + const IgnorePointer( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.only(bottom: 16), + child: Text( + 'tap or drag anywhere', + style: TextStyle(color: Color(0x99FFFFFF), fontSize: 13), + ), + ), + ), + ), + ], + ), + ), + ExampleTile( + title: 'Ping-pong sway', + child: WaveScreen.custom( + background: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF241535), Color(0xFF3A2050)], + ), + waves: [ + Wave( + shape: WaveShape.sine( + amplitude: 0.07, + frequency: 1.0, + baseline: 0.6, + ), + style: const WaveStyle(fill: Color(0xFF7A5FCF)), + motion: WaveMotion.pingPong(sway: 1.4, period: 5.0), + ), + Wave( + shape: WaveShape.sine( + amplitude: 0.06, + frequency: 1.3, + baseline: 0.8, + ), + style: const WaveStyle(fill: Color(0xFFA07CE8)), + motion: WaveMotion.pingPong(sway: 1.0, period: 7.0), + ), + ], + ), + ), + ]; + + return ExamplePageFrame( + title: 'Interaction', + description: + 'Pointer-driven ripples and oscillating motion. Tap or drag the first ' + 'tile to send ripples through the surface; the second uses ping-pong ' + 'motion that eases back and forth.', + child: LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 900 ? 2 : 1; + return GridView.count( + crossAxisCount: columns, + childAspectRatio: 16 / 10, + mainAxisSpacing: 20, + crossAxisSpacing: 20, + children: tiles, + ); + }, + ), + ); + } +} diff --git a/example/lib/src/pages/ping_pong_skeletonizer_page.dart b/example/lib/src/pages/ping_pong_skeletonizer_page.dart deleted file mode 100644 index 0295eff..0000000 --- a/example/lib/src/pages/ping_pong_skeletonizer_page.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:wave_screen/wave_screen.dart'; - -import '../widgets/example_page_frame.dart'; -import '../widgets/skeletonizer_showcase.dart'; - -class PingPongSkeletonizerPage extends StatelessWidget { - const PingPongSkeletonizerPage({super.key}); - - @override - Widget build(BuildContext context) { - return ExamplePageFrame( - title: 'Ping Pong Skeletonizer', - description: - 'A tide-pool scaffold swell that grows as it rolls in, compresses at the wall, reflects into ripples, then settles as it recedes.', - child: const SkeletonizerShowcase( - demoTitle: 'Traveling crest demo', - demoDescription: - 'One shoreline-like swell builds across the scaffold shapes, hits the wall, reflects into ripples, and settles on the way back. The motion stays on the loading geometry only.', - effect: PingPongWaveEffect( - baseColor: Color(0xFFF7171F), - highlightColor: Color(0xFFFF8D93), - duration: Duration(milliseconds: 5200), - pauseDuration: Duration(milliseconds: 2200), - deformation: WaveDeformation.topEdge, - synchronization: WaveSynchronization.global, - spread: 0.3, - amplitudeFactor: 0.27, - frequency: 1.08, - ), - ), - ); - } -} \ No newline at end of file diff --git a/example/lib/src/pages/shapes_page.dart b/example/lib/src/pages/shapes_page.dart new file mode 100644 index 0000000..50c56de --- /dev/null +++ b/example/lib/src/pages/shapes_page.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:wave_screen/wave_screen.dart'; + +import '../widgets/example_page_frame.dart'; +import '../widgets/example_tile.dart'; + +/// M2 Shapes showcase: the new Gerstner (sharp ocean crests) and metaball +/// (gooey merging blobs) geometries, alongside a sine for comparison. +class ShapesPage extends StatelessWidget { + const ShapesPage({super.key}); + + static Wave _wave({ + required WaveShape shape, + required Color color, + required double speed, + double opacity = 1.0, + }) { + return Wave( + shape: shape, + style: WaveStyle(fill: color, opacity: opacity), + motion: WaveMotion.drift(speed: speed), + ); + } + + @override + Widget build(BuildContext context) { + final tiles = [ + ExampleTile( + title: 'Gerstner ocean', + child: WaveScreen.custom( + background: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF04121F), Color(0xFF0A2540)], + ), + waves: [ + _wave( + shape: WaveShape.gerstner( + amplitude: 0.06, + frequency: 0.8, + steepness: 0.9, + baseline: 0.52, + ), + color: const Color(0xFF1C6E8C), + speed: 0.25, + ), + _wave( + shape: WaveShape.gerstner( + amplitude: 0.07, + frequency: 1.1, + steepness: 1.2, + baseline: 0.70, + ), + color: const Color(0xFF2E97B7), + speed: -0.2, + ), + _wave( + shape: WaveShape.gerstner( + amplitude: 0.06, + frequency: 0.95, + steepness: 0.8, + baseline: 0.86, + ), + color: const Color(0xFF57C4D6), + speed: 0.3, + ), + ], + ), + ), + ExampleTile( + title: 'Gooey metaballs', + child: WaveScreen.custom( + background: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF2A123F), Color(0xFF45204E)], + ), + waves: [ + _wave( + shape: WaveShape.metaball( + blobCount: 4, + radius: 0.18, + amplitude: 0.12, + baseline: 0.60, + ), + color: const Color(0xFFC85C9C), + speed: 0.18, + ), + _wave( + shape: WaveShape.metaball( + blobCount: 3, + radius: 0.24, + amplitude: 0.14, + baseline: 0.82, + ), + color: const Color(0xFFE87AB0), + speed: -0.14, + ), + ], + ), + ), + ExampleTile( + title: 'Sine vs Gerstner', + child: WaveScreen.custom( + backgroundColor: const Color(0xFF10131F), + waves: [ + _wave( + shape: WaveShape.sine( + amplitude: 0.07, + frequency: 1.0, + baseline: 0.55, + ), + color: const Color(0xFF3A7BD5), + speed: 0.25, + opacity: 0.85, + ), + _wave( + shape: WaveShape.gerstner( + amplitude: 0.07, + frequency: 1.0, + steepness: 1.4, + baseline: 0.75, + ), + color: const Color(0xFF00D2A0), + speed: 0.25, + ), + ], + ), + ), + ]; + + return ExamplePageFrame( + title: 'Shapes', + description: + 'New WaveShape geometries: Gerstner sharpens crests toward an ocean ' + 'look, and metaball builds gooey merging blobs — all still composed ' + 'from the same trait model.', + child: LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 900 ? 2 : 1; + return GridView.count( + crossAxisCount: columns, + childAspectRatio: 16 / 10, + mainAxisSpacing: 20, + crossAxisSpacing: 20, + children: tiles, + ); + }, + ), + ); + } +} diff --git a/example/lib/src/pages/skeletonizer_page.dart b/example/lib/src/pages/skeletonizer_page.dart deleted file mode 100644 index 57ecd77..0000000 --- a/example/lib/src/pages/skeletonizer_page.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../widgets/example_page_frame.dart'; -import '../widgets/skeletonizer_showcase.dart'; - -class SkeletonizerPage extends StatelessWidget { - const SkeletonizerPage({super.key}); - - @override - Widget build(BuildContext context) { - return ExamplePageFrame( - title: 'Skeletonizer', - description: - 'A live WaveSkeletonizer preview using real widgets so the wave effect can be toggled and extended later.', - child: const SkeletonizerShowcase(), - ); - } -} diff --git a/example/lib/src/pages/waves_page.dart b/example/lib/src/pages/waves_page.dart deleted file mode 100644 index 5d038f5..0000000 --- a/example/lib/src/pages/waves_page.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:wave_screen/wave_screen.dart'; - -import '../widgets/example_page_frame.dart'; -import '../widgets/example_tile.dart'; - -class WavesPage extends StatelessWidget { - const WavesPage({super.key}); - - @override - Widget build(BuildContext context) { - final children = [ - ExampleTile( - title: 'Violet Drift', - child: WaveScreen(style: WaveScreenStyle.violet(seed: 25)), - ), - ExampleTile( - title: 'Sunset Melt', - child: WaveScreen(style: WaveScreenStyle.sunset(seed: 15)), - ), - ExampleTile( - title: 'Lagoon Glass', - child: WaveScreen(style: WaveScreenStyle.lagoon(seed: 23)), - ), - ]; - - return ExamplePageFrame( - title: 'Wave Examples', - description: - 'Seeded wave screens with configurable palettes, adaptation, and width-aware layer actions.', - child: LayoutBuilder( - builder: (context, constraints) { - final isWide = constraints.maxWidth >= 900; - - return isWide - ? Row( - children: [ - for (int index = 0; index < children.length; index++) ...[ - Expanded(child: children[index]), - if (index != children.length - 1) - const SizedBox(width: 20), - ], - ], - ) - : ListView.separated( - itemBuilder: (context, index) => SizedBox( - height: 260, - child: children[index], - ), - separatorBuilder: (context, index) => - const SizedBox(height: 20), - itemCount: children.length, - ); - }, - ), - ); - } -} \ No newline at end of file diff --git a/example/lib/src/widgets/skeletonizer_showcase.dart b/example/lib/src/widgets/skeletonizer_showcase.dart deleted file mode 100644 index aef0d63..0000000 --- a/example/lib/src/widgets/skeletonizer_showcase.dart +++ /dev/null @@ -1,345 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:wave_screen/wave_screen.dart'; - -const Color _loadedTitleColor = Color(0xFF111827); -const Color _loadedBodyColor = Color(0xFF4B5563); -const Color _loadedCardColor = Color(0xFFF3F4F6); -const Color _loadedCardBorderColor = Color(0x120F172A); -const Color _loadedIconColor = Color(0xFF4F46E5); - -class SkeletonizerShowcase extends StatefulWidget { - final String demoTitle; - final String demoDescription; - final SkeletonizerEffect effect; - - const SkeletonizerShowcase({ - super.key, - this.demoTitle = 'Live demo', - this.demoDescription = - 'The scaffold shapes carry the wave motion. No animated background.', - this.effect = const WaveEffect( - baseColor: Color(0xFFF7171F), - highlightColor: Color(0xFFFF6368), - duration: Duration(milliseconds: 1200), - deformation: WaveDeformation.topEdge, - synchronization: WaveSynchronization.global, - ), - }); - - @override - State createState() => _SkeletonizerShowcaseState(); -} - -class _SkeletonizerShowcaseState extends State { - bool _enabled = true; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: const Color(0xFFF7F7F9), - borderRadius: BorderRadius.circular(28), - boxShadow: const [ - BoxShadow( - color: Color(0x33000000), - blurRadius: 24, - offset: Offset(0, 16), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(28), - child: Padding( - padding: const EdgeInsets.all(24), - child: ListView( - children: [ - _DemoHeader( - title: widget.demoTitle, - description: widget.demoDescription, - enabled: _enabled, - onChanged: (value) { - setState(() { - _enabled = value; - }); - }, - ), - const SizedBox(height: 18), - _DemoSurface(enabled: _enabled, effect: widget.effect), - ], - ), - ), - ), - ); - } -} - -class _DemoHeader extends StatelessWidget { - final String title; - final String description; - final bool enabled; - final ValueChanged onChanged; - - const _DemoHeader({ - required this.title, - required this.description, - required this.enabled, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - color: Color(0xFF1A1A1A), - fontSize: 20, - fontWeight: FontWeight.w700, - ), - ), - SizedBox(height: 6), - Text( - description, - style: TextStyle( - color: Color(0xCC1A1A1A), - fontSize: 14, - height: 1.4, - ), - ), - ], - ), - ), - const SizedBox(width: 16), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - enabled ? 'Skeleton' : 'Loaded', - style: const TextStyle(color: Color(0xFF1A1A1A)), - ), - Switch( - key: const ValueKey('skeletonizer-toggle'), - value: enabled, - onChanged: onChanged, - ), - ], - ), - ], - ); - } -} - -class _DemoSurface extends StatelessWidget { - final bool enabled; - final SkeletonizerEffect effect; - - const _DemoSurface({required this.enabled, required this.effect}); - - @override - Widget build(BuildContext context) { - return ClipRRect( - borderRadius: BorderRadius.circular(24), - child: WaveSkeletonizer( - enabled: enabled, - effect: effect, - ignoreContainers: true, - enableSwitchAnimation: true, - child: Container( - decoration: BoxDecoration( - color: const Color(0xFFFFFFFF), - border: Border.all(color: const Color(0x1A000000), width: 2), - ), - padding: const EdgeInsets.all(20), - child: const SingleChildScrollView( - child: _DemoContent(), - ), - ), - ), - ); - } -} - -class _DemoContent extends StatelessWidget { - const _DemoContent(); - - @override - Widget build(BuildContext context) { - return const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _ProfileCard(), - SizedBox(height: 20), - _SummaryCard(), - SizedBox(height: 20), - _TaskGroup(), - ], - ); - } -} - -class _ProfileCard extends StatelessWidget { - const _ProfileCard(); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - const CircleAvatar( - radius: 30, - backgroundColor: Color(0xFF6E79F7), - child: Text( - 'M', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: 16), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Mina Lawson', - style: TextStyle( - color: _loadedTitleColor, - fontSize: 18, - fontWeight: FontWeight.w700, - ), - ), - SizedBox(height: 4), - Text( - 'Design systems lead', - style: TextStyle(color: _loadedBodyColor), - ), - ], - ), - ), - FilledButton( - onPressed: () {}, - child: const Text('Review'), - ), - ], - ); - } -} - -class _SummaryCard extends StatelessWidget { - const _SummaryCard(); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: _loadedCardColor, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: _loadedCardBorderColor), - ), - child: const Padding( - padding: EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Designing the wave effect seam', - style: TextStyle( - color: _loadedTitleColor, - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), - SizedBox(height: 10), - Text( - 'The widget layer stays simple while the effect seam remains open for future stars, sparks, or other motion systems.', - style: TextStyle(color: _loadedBodyColor, height: 1.45), - ), - ], - ), - ), - ); - } -} - -class _TaskGroup extends StatelessWidget { - const _TaskGroup(); - - @override - Widget build(BuildContext context) { - return Column( - children: const [ - _TaskRow( - title: 'Audit loading states', - subtitle: 'Text, avatars, cards, and grouped layouts', - ), - SizedBox(height: 12), - _TaskRow( - title: 'Swap visual effects later', - subtitle: 'Keep the widget stable while changing the motion system', - ), - SizedBox(height: 12), - _TaskRow( - title: 'Preserve transition quality', - subtitle: 'Switch smoothly between skeleton and loaded content', - ), - ], - ); - } -} - -class _TaskRow extends StatelessWidget { - final String title; - final String subtitle; - - const _TaskRow({required this.title, required this.subtitle}); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: const Color(0x226E79F7), - borderRadius: BorderRadius.circular(14), - ), - alignment: Alignment.center, - child: const Icon( - Icons.auto_awesome, - color: _loadedIconColor, - size: 20, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - color: _loadedTitleColor, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: const TextStyle( - color: _loadedBodyColor, - height: 1.35, - ), - ), - ], - ), - ), - ], - ); - } -} \ No newline at end of file diff --git a/example/test/app_shell_test.dart b/example/test/app_shell_test.dart index 2ca1dc3..aaac3de 100644 --- a/example/test/app_shell_test.dart +++ b/example/test/app_shell_test.dart @@ -3,21 +3,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:wave_screen_example/src/app_shell.dart'; void main() { - testWidgets('ExampleAppShell exposes a ping-pong skeletonizer tab', ( - tester, - ) async { - await tester.pumpWidget( - const MaterialApp( - home: ExampleAppShell(), - ), - ); + testWidgets('ExampleAppShell shows the Foundation showcase', (tester) async { + await tester.pumpWidget(const MaterialApp(home: ExampleAppShell())); + await tester.pump(const Duration(milliseconds: 16)); - expect(find.text('Ping Pong'), findsOneWidget); - - await tester.tap(find.text('Ping Pong')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 250)); - - expect(find.text('Ping Pong Skeletonizer'), findsOneWidget); + expect(find.text('Foundation'), findsWidgets); }); -} \ No newline at end of file +} diff --git a/example/test/ping_pong_skeletonizer_page_test.dart b/example/test/ping_pong_skeletonizer_page_test.dart deleted file mode 100644 index ef4be8e..0000000 --- a/example/test/ping_pong_skeletonizer_page_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:wave_screen/wave_screen.dart'; -import 'package:wave_screen_example/src/pages/ping_pong_skeletonizer_page.dart'; - -void main() { - testWidgets('PingPongSkeletonizerPage hosts a ping-pong WaveSkeletonizer demo', ( - tester, - ) async { - await tester.pumpWidget( - const MaterialApp( - home: Scaffold( - body: PingPongSkeletonizerPage(), - ), - ), - ); - - expect(find.byType(WaveSkeletonizer), findsOneWidget); - expect(find.text('Traveling crest demo'), findsOneWidget); - expect(find.text('Mina Lawson'), findsOneWidget); - - final skeletonizer = tester.widget( - find.byType(WaveSkeletonizer), - ); - - expect(skeletonizer.effect, isA()); - expect(skeletonizer.enabled, isTrue); - - await tester.tap(find.byKey(const ValueKey('skeletonizer-toggle'))); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 250)); - - expect( - tester.widget(find.text('Mina Lawson').first).style?.color, - const Color(0xFF111827), - ); - expect( - tester.widget(find.text('Design systems lead').first).style?.color, - const Color(0xFF4B5563), - ); - }); -} \ No newline at end of file diff --git a/example/test/skeletonizer_page_test.dart b/example/test/skeletonizer_page_test.dart deleted file mode 100644 index 6c6e579..0000000 --- a/example/test/skeletonizer_page_test.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:wave_screen/wave_screen.dart'; -import 'package:wave_screen_example/src/pages/skeletonizer_page.dart'; - -void main() { - testWidgets('SkeletonizerPage hosts a live WaveSkeletonizer demo', ( - tester, - ) async { - await tester.pumpWidget( - const MaterialApp( - home: Scaffold( - body: SkeletonizerPage(), - ), - ), - ); - - expect(find.byType(WaveSkeletonizer), findsOneWidget); - expect(find.byType(WaveScreen), findsNothing); - expect(find.text('Live demo'), findsOneWidget); - expect(find.text('Mina Lawson'), findsOneWidget); - - var skeletonizer = tester.widget( - find.byType(WaveSkeletonizer), - ); - expect(skeletonizer.enabled, isTrue); - - await tester.tap(find.byKey(const ValueKey('skeletonizer-toggle'))); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 250)); - - skeletonizer = tester.widget(find.byType(WaveSkeletonizer)); - expect(skeletonizer.enabled, isFalse); - - expect( - tester.widget(find.text('Mina Lawson').first).style?.color, - const Color(0xFF111827), - ); - expect( - tester.widget(find.text('Designing the wave effect seam').first).style?.color, - const Color(0xFF111827), - ); - expect( - tester.widget(find.text('Design systems lead').first).style?.color, - const Color(0xFF4B5563), - ); - expect( - tester.widget(find.text('Audit loading states').first).style?.color, - const Color(0xFF111827), - ); - }); -} \ No newline at end of file diff --git a/features/foundation.feature b/features/foundation.feature new file mode 100644 index 0000000..81241a7 --- /dev/null +++ b/features/foundation.feature @@ -0,0 +1,26 @@ +Feature: M1 Foundation — composable shader-driven waves + As a Flutter developer + I want to compose a Wave from swappable traits and render it on the GPU + So that I can place configurable animated wave surfaces and screens. + + # Proof contract (M1): each scenario is bound to a real `flutter test` that + # mounts the actual widgets and/or exercises the real Wave height-field + # computation and asserts on observed outcomes. No shader pixels are asserted. + + @proof_wave_field_renders_and_animates + Scenario: a developer places a WaveField and it fills its box and animates + Given the wave field journey is bound to an integration proof + When the bound integration proof is executed + Then it passes, proving the WaveField fills its constraints and animates over time + + @proof_wave_traits_drive_surface + Scenario: shape and motion traits determine the wave surface over time + Given the wave traits journey is bound to an integration proof + When the bound integration proof is executed + Then it passes, proving a drifting wave's surface changes while a still wave's holds + + @proof_wave_screen_preset_renders + Scenario: a developer uses a preset and gets a full-bleed animated screen + Given the wave screen preset journey is bound to an integration proof + When the bound integration proof is executed + Then it passes, proving WaveScreen renders the preset's waves across its constraints diff --git a/features/interaction.feature b/features/interaction.feature new file mode 100644 index 0000000..d1c3cc7 --- /dev/null +++ b/features/interaction.feature @@ -0,0 +1,25 @@ +Feature: M3 Interaction — ping-pong motion and pointer ripples + As a Flutter developer + I want oscillating motion and pointer-driven ripples + So that wave surfaces feel alive and respond to touch. + + # Motion and ripple math stay pure functions (computed-model proofs). The + # pointer wiring is proven at the widget layer via an observable callback. + + @proof_pingpong_reverses + Scenario: ping-pong motion eases forward then back + Given the pingpong motion journey is bound to an integration proof + When the bound integration proof is executed + Then it passes, proving the phase oscillates through zero and reverses sign over a period + + @proof_ripple_decays_and_propagates + Scenario: a pointer ripple propagates outward and decays + Given the ripple effect journey is bound to an integration proof + When the bound integration proof is executed + Then it passes, proving the ripple peaks at its wavefront, moves outward with age, and fades away + + @proof_pointer_spawns_ripple + Scenario: tapping and dragging a field spawns ripples at the pointer + Given the pointer ripple journey is bound to an integration proof + When the bound integration proof is executed + Then it passes, proving a tap and a drag each emit ripples at the normalized pointer position diff --git a/features/presets.feature b/features/presets.feature new file mode 100644 index 0000000..29efb02 --- /dev/null +++ b/features/presets.feature @@ -0,0 +1,10 @@ +Feature: M4 Presets — a curated gallery + As a Flutter developer + I want a broad library of ready-made wave presets + So that I can drop in a polished animated background without tuning traits. + + @proof_preset_gallery_is_valid + Scenario: the preset gallery exposes many valid, uniquely named presets + Given the preset gallery journey is bound to an integration proof + When the bound integration proof is executed + Then it passes, proving every gallery preset is renderable and reachable by a unique name diff --git a/features/shapes.feature b/features/shapes.feature new file mode 100644 index 0000000..c329258 --- /dev/null +++ b/features/shapes.feature @@ -0,0 +1,19 @@ +Feature: M2 Shapes — Gerstner and metaball surfaces + As a Flutter developer + I want richer wave geometries beyond a plain sine + So that surfaces can look like sharp ocean crests or gooey merging blobs. + + # Both new shapes stay in the height-field model (sampleAt), so proofs exercise + # the real Dart computation the shader mirrors — no shader pixels asserted. + + @proof_gerstner_sharpens_crest + Scenario: a Gerstner surface sharpens crests relative to a sine + Given the gerstner shape journey is bound to an integration proof + When the bound integration proof is executed + Then it passes, proving the Gerstner crest peaks like a sine but flattens between crests + + @proof_metaball_blobs_merge + Scenario: metaball blobs bulge at their centers and merge as radius grows + Given the metaball shape journey is bound to an integration proof + When the bound integration proof is executed + Then it passes, proving nearby blobs bridge into one gooey crest while far blobs stay separate diff --git a/features/steps/binding_steps.py b/features/steps/binding_steps.py new file mode 100644 index 0000000..c1f6d21 --- /dev/null +++ b/features/steps/binding_steps.py @@ -0,0 +1,59 @@ +"""Conformance steps proving the scenario<->proof binding is a bijection. + +This is the static half of Gate C (binding integrity). Scanning tags is correct +HERE because this is the conformance tier, not the executable behavior loop: +- every `@proof_` scenario resolves to exactly one `proof_`-tagged Dart + test, +- every such Dart test is claimed by exactly one scenario, +- no proof id is duplicated on either side. +""" + +import re +from pathlib import Path + +from behave import given, when, then + +PACKAGE_ROOT = Path(__file__).resolve().parents[2] +FEATURES_DIR = PACKAGE_ROOT / "features" +TEST_DIR = PACKAGE_ROOT / "test" + +_FEATURE_TAG = re.compile(r"@(proof_[A-Za-z0-9_]+)") +_DART_TAG = re.compile(r"@Tags\(\[\s*'(proof_[A-Za-z0-9_]+)'\s*\]\)") + + +def _scan(paths, pattern): + ids = [] + for path in paths: + text = path.read_text(encoding="utf-8") + ids.extend(pattern.findall(text)) + return ids + + +@given('the feature suite and the Flutter proof tests') +def step_collect(context): + context.feature_ids = _scan(FEATURES_DIR.rglob("*.feature"), _FEATURE_TAG) + context.dart_ids = ( + _scan(TEST_DIR.rglob("*.dart"), _DART_TAG) if TEST_DIR.exists() else [] + ) + + +@when('the proof bindings are cross-checked') +def step_cross_check(context): + context.feature_set = set(context.feature_ids) + context.dart_set = set(context.dart_ids) + + +@then('every scenario proof id resolves to exactly one tagged Flutter test') +def step_scenarios_resolve(context): + dupes = [i for i in context.feature_set if context.feature_ids.count(i) > 1] + assert not dupes, f"duplicate proof ids across scenarios: {sorted(dupes)}" + orphans = sorted(context.feature_set - context.dart_set) + assert not orphans, f"scenarios bound to missing Flutter proofs: {orphans}" + + +@then('every tagged Flutter test is claimed by exactly one scenario') +def step_tests_claimed(context): + dupes = [i for i in context.dart_set if context.dart_ids.count(i) > 1] + assert not dupes, f"duplicate proof tags across Dart tests: {sorted(dupes)}" + unclaimed = sorted(context.dart_set - context.feature_set) + assert not unclaimed, f"Flutter proofs not claimed by any scenario: {unclaimed}" diff --git a/features/steps/proof_steps.py b/features/steps/proof_steps.py new file mode 100644 index 0000000..f605acf --- /dev/null +++ b/features/steps/proof_steps.py @@ -0,0 +1,57 @@ +"""Generic behave steps that bind a scenario to a Flutter integration proof. + +behave (Python) cannot drive the Flutter app in-process, so the real system is +reached by executing a bound `flutter test --tags proof_` and inheriting its +verdict. No Dart source is inspected here: the `@proof_` tag on the scenario +is the single source of truth for the binding. +""" + +import shutil +import subprocess +from pathlib import Path + +from behave import given, when, then + +# features/steps/proof_steps.py -> parents[2] == repo root (the Flutter package). +PACKAGE_ROOT = Path(__file__).resolve().parents[2] + + +def _flutter_executable(): + # Resolve the real launcher (flutter.bat on Windows) so subprocess can find + # it without a shell — a bare "flutter" raises FileNotFoundError on Windows. + resolved = shutil.which("flutter") + assert resolved, "flutter not found on PATH — required for verify-mode" + return resolved + + +def _proof_tag(context): + tags = [t for t in context.scenario.tags if t.startswith("proof_")] + assert len(tags) == 1, ( + f"scenario must carry exactly one @proof_ tag, found {tags or 'none'}" + ) + return tags[0] + + +@given('the {journey} is bound to an integration proof') +def step_bound(context, journey): + # Binding must be declared: resolve (and validate) the proof tag now. + context.proof_tag = _proof_tag(context) + + +@when('the bound integration proof is executed') +def step_execute(context): + context.result = subprocess.run( + [_flutter_executable(), "test", "--tags", context.proof_tag], + cwd=str(PACKAGE_ROOT), + capture_output=True, + text=True, + ) + + +@then('it passes, proving {claim}') +def step_passes(context, claim): + result = context.result + assert result.returncode == 0, ( + f"bound proof '{context.proof_tag}' failed (exit {result.returncode}) " + f"while proving: {claim}\n{result.stdout}\n{result.stderr}" + ) diff --git a/features/system/binding_integrity.feature b/features/system/binding_integrity.feature new file mode 100644 index 0000000..9141582 --- /dev/null +++ b/features/system/binding_integrity.feature @@ -0,0 +1,9 @@ +Feature: BDD proof binding integrity + Every scenario proof id must map one-to-one to a bound Flutter test, so a + scenario can never pass vacuously against a missing or duplicated proof. + + Scenario: every proof tag maps one-to-one to a bound Flutter test + Given the feature suite and the Flutter proof tests + When the proof bindings are cross-checked + Then every scenario proof id resolves to exactly one tagged Flutter test + And every tagged Flutter test is claimed by exactly one scenario diff --git a/lib/src/screen/adaptation.dart b/lib/src/screen/adaptation.dart deleted file mode 100644 index 7a9cdc4..0000000 --- a/lib/src/screen/adaptation.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:flutter/widgets.dart'; - -@immutable -class WaveScreenAdaptation { - final Size referenceSize; - final bool scaleFrequencyToWidth; - final bool scaleAmplitudeToHeight; - final bool scaleSpeedToWidth; - final bool scaleHorizontalShiftToWidth; - final double secondaryStrengthScale; - final double minFrequencyScale; - final double maxFrequencyScale; - final double minAmplitudeScale; - final double maxAmplitudeScale; - final double minSpeedScale; - final double maxSpeedScale; - final double minShiftScale; - final double maxShiftScale; - final double minSecondaryStrengthScale; - final double maxSecondaryStrengthScale; - - const WaveScreenAdaptation({ - required this.referenceSize, - required this.scaleFrequencyToWidth, - required this.scaleAmplitudeToHeight, - required this.scaleSpeedToWidth, - required this.scaleHorizontalShiftToWidth, - required this.secondaryStrengthScale, - required this.minFrequencyScale, - required this.maxFrequencyScale, - required this.minAmplitudeScale, - required this.maxAmplitudeScale, - required this.minSpeedScale, - required this.maxSpeedScale, - required this.minShiftScale, - required this.maxShiftScale, - required this.minSecondaryStrengthScale, - required this.maxSecondaryStrengthScale, - }); - - const WaveScreenAdaptation.none() - : this( - referenceSize: const Size(360, 260), - scaleFrequencyToWidth: false, - scaleAmplitudeToHeight: false, - scaleSpeedToWidth: false, - scaleHorizontalShiftToWidth: false, - secondaryStrengthScale: 1, - minFrequencyScale: 1, - maxFrequencyScale: 1, - minAmplitudeScale: 1, - maxAmplitudeScale: 1, - minSpeedScale: 1, - maxSpeedScale: 1, - minShiftScale: 1, - maxShiftScale: 1, - minSecondaryStrengthScale: 1, - maxSecondaryStrengthScale: 1, - ); - - const WaveScreenAdaptation.referenceSpace({ - this.referenceSize = const Size(360, 260), - this.scaleFrequencyToWidth = true, - this.scaleAmplitudeToHeight = true, - this.scaleSpeedToWidth = true, - this.scaleHorizontalShiftToWidth = true, - this.secondaryStrengthScale = 1, - this.minFrequencyScale = 0.72, - this.maxFrequencyScale = 2.2, - this.minAmplitudeScale = 0.90, - this.maxAmplitudeScale = 1.45, - this.minSpeedScale = 0.72, - this.maxSpeedScale = 1.45, - this.minShiftScale = 0.78, - this.maxShiftScale = 1.35, - this.minSecondaryStrengthScale = 1, - this.maxSecondaryStrengthScale = 1, - }); - - const WaveScreenAdaptation.responsiveGeometry() - : this( - referenceSize: const Size(360, 260), - scaleFrequencyToWidth: true, - scaleAmplitudeToHeight: true, - scaleSpeedToWidth: false, - scaleHorizontalShiftToWidth: true, - secondaryStrengthScale: 1, - minFrequencyScale: 0.72, - maxFrequencyScale: 2.2, - minAmplitudeScale: 0.90, - maxAmplitudeScale: 1.45, - minSpeedScale: 1, - maxSpeedScale: 1, - minShiftScale: 0.78, - maxShiftScale: 1.35, - minSecondaryStrengthScale: 1, - maxSecondaryStrengthScale: 1, - ); -} \ No newline at end of file diff --git a/lib/src/screen/config.dart b/lib/src/screen/config.dart deleted file mode 100644 index d33ae57..0000000 --- a/lib/src/screen/config.dart +++ /dev/null @@ -1,5 +0,0 @@ -export 'adaptation.dart'; -export 'layer_action.dart'; -export 'layer_recipe.dart'; -export 'palette.dart'; -export 'style.dart'; \ No newline at end of file diff --git a/lib/src/screen/layer_action.dart b/lib/src/screen/layer_action.dart deleted file mode 100644 index ec76a0e..0000000 --- a/lib/src/screen/layer_action.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import 'layer_recipe.dart'; - -@immutable -class WaveScreenLayerAction { - final double referenceWidth; - final double widthPerAddedLayer; - final int maxAdditionalLayers; - - const WaveScreenLayerAction({ - required this.referenceWidth, - required this.widthPerAddedLayer, - required this.maxAdditionalLayers, - }); - - const WaveScreenLayerAction.none() - : this( - referenceWidth: double.infinity, - widthPerAddedLayer: double.infinity, - maxAdditionalLayers: 0, - ); - - const WaveScreenLayerAction.growOnWideScreens({ - this.referenceWidth = 360, - this.widthPerAddedLayer = 140, - this.maxAdditionalLayers = 4, - }); - - List apply({ - required List baseLayers, - double? sceneWidth, - }) { - if (_shouldKeepBaseLayers(baseLayers, sceneWidth)) { - return baseLayers; - } - - final extraLayers = _extraLayerCount(sceneWidth!); - if (extraLayers == 0) { - return baseLayers; - } - - final expanded = [...baseLayers]; - for (int index = 0; index < extraLayers; index++) { - expanded.insert( - _insertionIndex(index, expanded.length, extraLayers), - WaveScreenLayer( - zIndex: 0, - recipe: baseLayers[_sourceIndex(index, baseLayers.length, extraLayers)].recipe, - ), - ); - } - - return List.generate(expanded.length, (index) { - return WaveScreenLayer(zIndex: index, recipe: expanded[index].recipe); - }, growable: false); - } - - bool _shouldKeepBaseLayers(List baseLayers, double? sceneWidth) { - return sceneWidth == null || - maxAdditionalLayers <= 0 || - sceneWidth <= referenceWidth || - baseLayers.isEmpty; - } - - int _extraLayerCount(double sceneWidth) { - return ((sceneWidth - referenceWidth) / widthPerAddedLayer) - .floor() - .clamp(0, maxAdditionalLayers); - } - - int _sourceIndex(int index, int baseLayerCount, int extraLayerCount) { - return (((index + 1) * baseLayerCount) / (extraLayerCount + 1)) - .floor() - .clamp(0, baseLayerCount - 1); - } - - int _insertionIndex(int index, int expandedLength, int extraLayerCount) { - return (((index + 1) * expandedLength) / (extraLayerCount + 1)) - .floor() - .clamp(1, expandedLength); - } -} \ No newline at end of file diff --git a/lib/src/screen/layer_recipe.dart b/lib/src/screen/layer_recipe.dart deleted file mode 100644 index acd501a..0000000 --- a/lib/src/screen/layer_recipe.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/widgets.dart'; - -import '../wave/wave.dart'; - -@immutable -class WaveLayerRecipe { - final double amplitudeMin; - final double amplitudeMax; - final double frequencyMin; - final double frequencyMax; - final double speedMin; - final double speedMax; - final WavePlacement placement; - final double slopeMin; - final double slopeMax; - final double horizontalShiftMin; - final double horizontalShiftMax; - final double secondaryStrengthMin; - final double secondaryStrengthMax; - final bool blur; - - const WaveLayerRecipe({ - required this.amplitudeMin, - required this.amplitudeMax, - required this.frequencyMin, - required this.frequencyMax, - required this.speedMin, - required this.speedMax, - required this.placement, - required this.slopeMin, - required this.slopeMax, - required this.horizontalShiftMin, - required this.horizontalShiftMax, - required this.secondaryStrengthMin, - required this.secondaryStrengthMax, - this.blur = false, - }); - - WaveLayer build(Random random, Gradient gradient) { - return WaveLayer.gradient( - gradient: gradient, - amplitude: _between(random, amplitudeMin, amplitudeMax), - frequency: _between(random, frequencyMin, frequencyMax), - speed: _between(random, speedMin, speedMax), - phaseShift: random.nextDouble() * pi * 2, - horizontalShift: _between(random, horizontalShiftMin, horizontalShiftMax), - secondaryStrength: _between( - random, - secondaryStrengthMin, - secondaryStrengthMax, - ), - heightFactor: placement.resolve(random), - slope: _between(random, slopeMin, slopeMax), - blur: blur ? MaskFilter.blur(BlurStyle.normal, 2) : null, - ); - } - - static double _between(Random random, double min, double max) { - return min + (max - min) * random.nextDouble(); - } -} - -@immutable -class WavePlacement { - final double min; - final double max; - - const WavePlacement(this.min, this.max) - : assert(min >= 0 && min <= 1), - assert(max >= 0 && max <= 1), - assert(min <= max); - - const WavePlacement.fixed(double value) : this(value, value); - - double resolve(Random random) { - if (min == max) { - return min; - } - return min + (max - min) * random.nextDouble(); - } -} - -@immutable -class WaveScreenLayer { - final int zIndex; - final WaveLayerRecipe recipe; - - const WaveScreenLayer({required this.zIndex, required this.recipe}); -} \ No newline at end of file diff --git a/lib/src/screen/palette.dart b/lib/src/screen/palette.dart deleted file mode 100644 index 05af22d..0000000 --- a/lib/src/screen/palette.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'package:flutter/widgets.dart'; - -@immutable -class WaveScreenPalette { - final Gradient backgroundGradient; - final List layerGradients; - - const WaveScreenPalette({ - required this.backgroundGradient, - required this.layerGradients, - }); - - static const violet = WaveScreenPalette( - backgroundGradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF3B2CC5), Color(0xFF2522B9)], - ), - layerGradients: [ - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF4233C9), Color(0xFF2E2FB8)], - ), - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF4B36CB), Color(0xFF3430BB)], - ), - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF5C3FD1), Color(0xFF4032BB)], - ), - LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [Color(0xFF6943D2), Color(0xFF4736C0)], - ), - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF7248D6), Color(0xFF4A39C4)], - ), - LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [Color(0xFF7E53DB), Color(0xFF5641CA)], - ), - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF6642D0), Color(0xFF4436BF)], - ), - LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [Color(0xFF583ACE), Color(0xFF3930BB)], - ), - ], - ); - - static const sunset = WaveScreenPalette( - backgroundGradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF672A58), Color(0xFF301D4F)], - ), - layerGradients: [ - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFD25C78), Color(0xFFB34865)], - ), - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFDF6B79), Color(0xFFBD516A)], - ), - LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [Color(0xFFF1836D), Color(0xFFCA5E67)], - ), - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFF79E5F), Color(0xFFD36B5A)], - ), - LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [Color(0xFFFFB85A), Color(0xFFE37D57)], - ), - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFFFCA72), Color(0xFFF09164)], - ), - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFF08A6D), Color(0xFFD16263)], - ), - LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [Color(0xFFE7717A), Color(0xFFB74F6C)], - ), - ], - ); - - static const lagoon = WaveScreenPalette( - backgroundGradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF173E84), Color(0xFF132F69)], - ), - layerGradients: [ - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF2C8AA4), Color(0xFF226D90)], - ), - LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [Color(0xFF35A5B0), Color(0xFF2A7D98)], - ), - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF49C1C1), Color(0xFF3491AE)], - ), - LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [Color(0xFF60D1CC), Color(0xFF3C9DBA)], - ), - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF73DDD8), Color(0xFF4FADD1)], - ), - LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [Color(0xFF89ECE4), Color(0xFF68BDE1)], - ), - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF5CCBC8), Color(0xFF4198C2)], - ), - LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [Color(0xFF42B6BE), Color(0xFF2C7DAB)], - ), - ], - ); -} \ No newline at end of file diff --git a/lib/src/screen/presets.dart b/lib/src/screen/presets.dart deleted file mode 100644 index 947eb26..0000000 --- a/lib/src/screen/presets.dart +++ /dev/null @@ -1,205 +0,0 @@ -import 'layer_recipe.dart'; - -class WaveScreenDefaults { - static const List layers = [ - WaveScreenLayer( - zIndex: 0, - recipe: WaveLayerRecipe( - amplitudeMin: 6, - amplitudeMax: 10, - frequencyMin: 0.44, - frequencyMax: 0.58, - speedMin: 0.16, - speedMax: 0.22, - placement: WavePlacement(0.02, 0.06), - slopeMin: -0.16, - slopeMax: -0.10, - horizontalShiftMin: -0.26, - horizontalShiftMax: -0.06, - secondaryStrengthMin: 0.03, - secondaryStrengthMax: 0.08, - ), - ), - WaveScreenLayer( - zIndex: 1, - recipe: WaveLayerRecipe( - amplitudeMin: 10, - amplitudeMax: 16, - frequencyMin: 0.60, - frequencyMax: 0.74, - speedMin: -0.22, - speedMax: -0.15, - placement: WavePlacement(0.10, 0.16), - slopeMin: 0.06, - slopeMax: 0.12, - horizontalShiftMin: 0.10, - horizontalShiftMax: 0.24, - secondaryStrengthMin: 0.06, - secondaryStrengthMax: 0.12, - ), - ), - WaveScreenLayer( - zIndex: 2, - recipe: WaveLayerRecipe( - amplitudeMin: 12, - amplitudeMax: 18, - frequencyMin: 0.68, - frequencyMax: 0.82, - speedMin: 0.10, - speedMax: 0.18, - placement: WavePlacement(0.20, 0.27), - slopeMin: -0.14, - slopeMax: -0.06, - horizontalShiftMin: -0.18, - horizontalShiftMax: 0.00, - secondaryStrengthMin: 0.10, - secondaryStrengthMax: 0.16, - ), - ), - WaveScreenLayer( - zIndex: 3, - recipe: WaveLayerRecipe( - amplitudeMin: 18, - amplitudeMax: 24, - frequencyMin: 0.76, - frequencyMax: 0.92, - speedMin: -0.18, - speedMax: -0.10, - placement: WavePlacement(0.30, 0.36), - slopeMin: 0.10, - slopeMax: 0.18, - horizontalShiftMin: 0.12, - horizontalShiftMax: 0.26, - secondaryStrengthMin: 0.13, - secondaryStrengthMax: 0.20, - ), - ), - WaveScreenLayer( - zIndex: 4, - recipe: WaveLayerRecipe( - amplitudeMin: 22, - amplitudeMax: 30, - frequencyMin: 0.72, - frequencyMax: 0.88, - speedMin: 0.08, - speedMax: 0.15, - placement: WavePlacement(0.40, 0.47), - slopeMin: -0.12, - slopeMax: -0.04, - horizontalShiftMin: -0.24, - horizontalShiftMax: -0.08, - secondaryStrengthMin: 0.14, - secondaryStrengthMax: 0.22, - ), - ), - WaveScreenLayer( - zIndex: 5, - recipe: WaveLayerRecipe( - amplitudeMin: 20, - amplitudeMax: 28, - frequencyMin: 0.86, - frequencyMax: 1.00, - speedMin: -0.12, - speedMax: -0.05, - placement: WavePlacement(0.50, 0.57), - slopeMin: 0.02, - slopeMax: 0.10, - horizontalShiftMin: 0.02, - horizontalShiftMax: 0.18, - secondaryStrengthMin: 0.15, - secondaryStrengthMax: 0.23, - ), - ), - WaveScreenLayer( - zIndex: 6, - recipe: WaveLayerRecipe( - amplitudeMin: 26, - amplitudeMax: 34, - frequencyMin: 0.78, - frequencyMax: 0.92, - speedMin: 0.07, - speedMax: 0.13, - placement: WavePlacement(0.60, 0.67), - slopeMin: -0.16, - slopeMax: -0.08, - horizontalShiftMin: -0.22, - horizontalShiftMax: -0.04, - secondaryStrengthMin: 0.12, - secondaryStrengthMax: 0.20, - ), - ), - WaveScreenLayer( - zIndex: 7, - recipe: WaveLayerRecipe( - amplitudeMin: 18, - amplitudeMax: 26, - frequencyMin: 0.94, - frequencyMax: 1.08, - speedMin: -0.10, - speedMax: -0.04, - placement: WavePlacement(0.69, 0.75), - slopeMin: 0.08, - slopeMax: 0.16, - horizontalShiftMin: 0.08, - horizontalShiftMax: 0.24, - secondaryStrengthMin: 0.14, - secondaryStrengthMax: 0.22, - ), - ), - WaveScreenLayer( - zIndex: 8, - recipe: WaveLayerRecipe( - amplitudeMin: 28, - amplitudeMax: 38, - frequencyMin: 0.82, - frequencyMax: 0.96, - speedMin: 0.05, - speedMax: 0.10, - placement: WavePlacement(0.77, 0.83), - slopeMin: -0.12, - slopeMax: -0.02, - horizontalShiftMin: -0.20, - horizontalShiftMax: -0.02, - secondaryStrengthMin: 0.12, - secondaryStrengthMax: 0.20, - ), - ), - WaveScreenLayer( - zIndex: 9, - recipe: WaveLayerRecipe( - amplitudeMin: 16, - amplitudeMax: 24, - frequencyMin: 1.02, - frequencyMax: 1.16, - speedMin: -0.08, - speedMax: -0.03, - placement: WavePlacement(0.84, 0.89), - slopeMin: 0.06, - slopeMax: 0.12, - horizontalShiftMin: 0.00, - horizontalShiftMax: 0.14, - secondaryStrengthMin: 0.16, - secondaryStrengthMax: 0.24, - ), - ), - WaveScreenLayer( - zIndex: 10, - recipe: WaveLayerRecipe( - amplitudeMin: 12, - amplitudeMax: 20, - frequencyMin: 1.08, - frequencyMax: 1.22, - speedMin: -0.06, - speedMax: -0.02, - placement: WavePlacement(0.90, 0.95), - slopeMin: -0.08, - slopeMax: 0.02, - horizontalShiftMin: -0.12, - horizontalShiftMax: 0.08, - secondaryStrengthMin: 0.18, - secondaryStrengthMax: 0.26, - blur: true, - ), - ), - ]; -} \ No newline at end of file diff --git a/lib/src/screen/screen.dart b/lib/src/screen/screen.dart deleted file mode 100644 index ad6479f..0000000 --- a/lib/src/screen/screen.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'config.dart'; -export 'widget.dart'; \ No newline at end of file diff --git a/lib/src/screen/style.dart b/lib/src/screen/style.dart deleted file mode 100644 index 78e5afb..0000000 --- a/lib/src/screen/style.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/widgets.dart'; - -import '../wave/wave.dart'; -import 'adaptation.dart'; -import 'layer_action.dart'; -import 'layer_recipe.dart'; -import 'palette.dart'; -import 'presets.dart'; - -@immutable -class WaveScreenStyle { - final WaveScreenPalette palette; - final List layers; - final int seed; - final Duration duration; - final WaveScreenAdaptation adaptation; - final WaveScreenLayerAction layerAction; - - const WaveScreenStyle({ - required this.palette, - required this.layers, - required this.seed, - this.duration = const Duration(seconds: 9), - this.adaptation = const WaveScreenAdaptation.referenceSpace(), - this.layerAction = const WaveScreenLayerAction.growOnWideScreens(), - }); - - List buildLayers({double? sceneWidth}) { - final random = Random(seed); - final sortedLayers = [...layers] - ..sort((left, right) => left.zIndex.compareTo(right.zIndex)); - final resolvedLayers = layerAction.apply( - baseLayers: sortedLayers, - sceneWidth: sceneWidth, - ); - - return List.generate(resolvedLayers.length, (index) { - final gradient = palette.layerGradients[index % palette.layerGradients.length]; - return resolvedLayers[index].recipe.build(random, gradient); - }, growable: false); - } - - factory WaveScreenStyle.violet({ - int seed = 7, - Duration duration = const Duration(seconds: 9), - WaveScreenAdaptation adaptation = - const WaveScreenAdaptation.referenceSpace(), - WaveScreenLayerAction layerAction = - const WaveScreenLayerAction.growOnWideScreens(), - }) { - return WaveScreenStyle( - palette: WaveScreenPalette.violet, - layers: WaveScreenDefaults.layers, - seed: seed, - duration: duration, - adaptation: adaptation, - layerAction: layerAction, - ); - } - - factory WaveScreenStyle.sunset({ - int seed = 17, - Duration duration = const Duration(seconds: 9), - WaveScreenAdaptation adaptation = - const WaveScreenAdaptation.referenceSpace(), - WaveScreenLayerAction layerAction = - const WaveScreenLayerAction.growOnWideScreens(), - }) { - return WaveScreenStyle( - palette: WaveScreenPalette.sunset, - layers: WaveScreenDefaults.layers, - seed: seed, - duration: duration, - adaptation: adaptation, - layerAction: layerAction, - ); - } - - factory WaveScreenStyle.lagoon({ - int seed = 29, - Duration duration = const Duration(seconds: 9), - WaveScreenAdaptation adaptation = - const WaveScreenAdaptation.referenceSpace(), - WaveScreenLayerAction layerAction = - const WaveScreenLayerAction.growOnWideScreens(), - }) { - return WaveScreenStyle( - palette: WaveScreenPalette.lagoon, - layers: WaveScreenDefaults.layers, - seed: seed, - duration: duration, - adaptation: adaptation, - layerAction: layerAction, - ); - } -} \ No newline at end of file diff --git a/lib/src/screen/wave_presets.dart b/lib/src/screen/wave_presets.dart new file mode 100644 index 0000000..11423d6 --- /dev/null +++ b/lib/src/screen/wave_presets.dart @@ -0,0 +1,247 @@ +import 'package:flutter/painting.dart'; + +import '../wave/pointer_ripple_effect.dart'; +import '../wave/wave.dart'; +import '../wave/wave_effect.dart'; +import '../wave/wave_motion.dart'; +import '../wave/wave_shape.dart'; +import '../wave/wave_style.dart'; +import 'wave_screen_preset.dart'; + +enum _Kind { sine, gerstner, metaball } + +/// A broad, curated library of [WaveScreenPreset]s spanning all shapes and the +/// interactive ripple effect. Presets are reachable by name via [byName] or as +/// an ordered list via [all]. +class WavePresets { + WavePresets._(); + + static Gradient _bg(int top, int bottom) => LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(top), Color(bottom)], + ); + + static List _layers({ + required List colors, + _Kind kind = _Kind.sine, + double speed = 0.28, + double amp = 0.06, + double steepness = 0.9, + int blobCount = 4, + double radius = 0.2, + List effects = const [], + }) { + return [ + for (var i = 0; i < colors.length; i++) + Wave( + shape: switch (kind) { + _Kind.sine => WaveShape.sine( + amplitude: amp, + frequency: 0.8 + (i * 0.15), + baseline: 0.42 + (i * 0.12), + ), + _Kind.gerstner => WaveShape.gerstner( + amplitude: amp, + frequency: 0.8 + (i * 0.15), + steepness: steepness, + baseline: 0.42 + (i * 0.12), + ), + _Kind.metaball => WaveShape.metaball( + blobCount: blobCount, + radius: radius, + amplitude: amp + 0.05, + baseline: 0.5 + (i * 0.13), + ), + }, + style: WaveStyle(fill: colors[i]), + motion: WaveMotion.drift( + speed: (i.isEven ? 1 : -1) * (speed - (i * 0.02)), + ), + effects: effects, + ), + ]; + } + + // --- Sine themes --------------------------------------------------------- + + static WaveScreenPreset get aurora => WaveScreenPreset( + background: _bg(0xFF0B1E3B, 0xFF122B4E), + waves: _layers(colors: const [ + Color(0xFF1F7A6E), + Color(0xFF2FA88F), + Color(0xFF48C7A6), + Color(0xFF7A6CD9), + Color(0xFF9C7BE8), + ]), + ); + + /// The classic deep indigo/violet theme carried over from the original + /// `WaveScreenStyle.violet`. + static WaveScreenPreset get violet => WaveScreenPreset( + background: _bg(0xFF3B2CC5, 0xFF2522B9), + waves: _layers(colors: const [ + Color(0xFF4233C9), + Color(0xFF5C3FD1), + Color(0xFF6943D2), + Color(0xFF7248D6), + Color(0xFF7E53DB), + ]), + ); + + static WaveScreenPreset get dusk => WaveScreenPreset( + background: _bg(0xFF3A1D3F, 0xFF5E2A46), + waves: _layers(colors: const [ + Color(0xFFC85C7E), + Color(0xFFE87A6B), + Color(0xFFF3A25E), + Color(0xFFFFC46B), + ]), + ); + + static WaveScreenPreset get sunset => WaveScreenPreset( + background: _bg(0xFF2A0F2E, 0xFF5A2440), + waves: _layers(colors: const [ + Color(0xFFB0446E), + Color(0xFFE06A5C), + Color(0xFFF59A4E), + Color(0xFFFFD36B), + ]), + ); + + static WaveScreenPreset get neon => WaveScreenPreset( + background: _bg(0xFF130A2B, 0xFF241246), + waves: _layers(speed: 0.4, colors: const [ + Color(0xFF6A3DF0), + Color(0xFFB63BE6), + Color(0xFFF03D9E), + Color(0xFF3DE0F0), + ]), + ); + + static WaveScreenPreset get mist => WaveScreenPreset( + background: _bg(0xFF1A2230, 0xFF283645), + waves: _layers(speed: 0.16, amp: 0.04, colors: const [ + Color(0xFF3D5064), + Color(0xFF556A80), + Color(0xFF6E869C), + Color(0xFF90A6BA), + ]), + ); + + // --- Gerstner (sharp ocean) themes -------------------------------------- + + static WaveScreenPreset get abyss => WaveScreenPreset( + background: _bg(0xFF04121F, 0xFF082238), + waves: _layers(kind: _Kind.gerstner, speed: 0.18, steepness: 0.7, colors: const [ + Color(0xFF10465F), + Color(0xFF1B6A86), + Color(0xFF2E90A8), + Color(0xFF49B4C2), + ]), + ); + + static WaveScreenPreset get ocean => WaveScreenPreset( + background: _bg(0xFF05131F, 0xFF0A2A44), + waves: _layers(kind: _Kind.gerstner, steepness: 1.1, colors: const [ + Color(0xFF14607C), + Color(0xFF2384A0), + Color(0xFF35A6BE), + Color(0xFF5AC8D6), + ]), + ); + + static WaveScreenPreset get lagoon => WaveScreenPreset( + background: _bg(0xFF08222B, 0xFF0F3B42), + waves: _layers(kind: _Kind.gerstner, steepness: 0.9, colors: const [ + Color(0xFF1C8A7E), + Color(0xFF2FB39C), + Color(0xFF46D1B4), + Color(0xFF74E6CE), + ]), + ); + + static WaveScreenPreset get ember => WaveScreenPreset( + background: _bg(0xFF200A0A, 0xFF3E1512), + waves: _layers(kind: _Kind.gerstner, steepness: 1.3, speed: 0.2, colors: const [ + Color(0xFF8A2D22), + Color(0xFFC0472E), + Color(0xFFE86F3A), + Color(0xFFFFA24E), + ]), + ); + + // --- Metaball (gooey) themes -------------------------------------------- + + static WaveScreenPreset get jelly => WaveScreenPreset( + background: _bg(0xFF102614, 0xFF1B3F22), + waves: _layers(kind: _Kind.metaball, blobCount: 4, radius: 0.2, speed: 0.16, colors: const [ + Color(0xFF3FA84E), + Color(0xFF6CCB5A), + Color(0xFFA8E86B), + ]), + ); + + static WaveScreenPreset get lava => WaveScreenPreset( + background: _bg(0xFF160404, 0xFF2C0A08), + waves: _layers(kind: _Kind.metaball, blobCount: 3, radius: 0.26, speed: 0.12, colors: const [ + Color(0xFF9A1F14), + Color(0xFFD8471E), + Color(0xFFF5892E), + ]), + ); + + // --- Interactive themes -------------------------------------------------- + + static WaveScreenPreset get tidepool => WaveScreenPreset( + background: _bg(0xFF061A2B, 0xFF0C2E44), + waves: _layers( + kind: _Kind.gerstner, + steepness: 0.8, + speed: 0.22, + effects: const [PointerRippleEffect()], + colors: const [ + Color(0xFF1C6E8C), + Color(0xFF2E97B7), + Color(0xFF57C4D6), + ], + ), + ); + + static WaveScreenPreset get pulse => WaveScreenPreset( + background: _bg(0xFF120A26, 0xFF241542), + waves: _layers( + speed: 0.3, + effects: const [PointerRippleEffect(strength: 0.1)], + colors: const [ + Color(0xFF5A3DD0), + Color(0xFF8E5CE0), + Color(0xFFC77BE8), + ], + ), + ); + + /// All presets keyed by name — the source of truth for the gallery. + static final Map byName = { + 'aurora': aurora, + 'violet': violet, + 'dusk': dusk, + 'sunset': sunset, + 'neon': neon, + 'mist': mist, + 'abyss': abyss, + 'ocean': ocean, + 'lagoon': lagoon, + 'ember': ember, + 'jelly': jelly, + 'lava': lava, + 'tidepool': tidepool, + 'pulse': pulse, + }; + + /// All presets in gallery order. + static List get all => byName.values.toList(growable: false); + + /// All preset names in gallery order. + static List get names => byName.keys.toList(growable: false); +} diff --git a/lib/src/screen/wave_screen.dart b/lib/src/screen/wave_screen.dart new file mode 100644 index 0000000..b9780b3 --- /dev/null +++ b/lib/src/screen/wave_screen.dart @@ -0,0 +1,45 @@ +import 'package:flutter/widgets.dart'; + +import '../wave/wave.dart'; +import '../wave/wave_field.dart'; +import 'wave_screen_preset.dart'; + +/// A full-bleed animated wave background. Supply a curated [WaveScreenPreset] or +/// build one inline with [WaveScreen.custom]. An optional [child] is layered on +/// top of the waves. +class WaveScreen extends StatelessWidget { + final WaveScreenPreset preset; + final Widget? child; + + const WaveScreen({super.key, required this.preset, this.child}); + + /// Build a screen from loose waves without naming a preset. + WaveScreen.custom({ + super.key, + required List waves, + Gradient? background, + Color? backgroundColor, + this.child, + }) : preset = WaveScreenPreset( + waves: waves, + background: background, + backgroundColor: backgroundColor, + ); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: preset.backgroundColor, + gradient: preset.background, + ), + child: Stack( + fit: StackFit.expand, + children: [ + WaveField(waves: preset.waves), + ?child, + ], + ), + ); + } +} diff --git a/lib/src/screen/wave_screen_preset.dart b/lib/src/screen/wave_screen_preset.dart new file mode 100644 index 0000000..7f32866 --- /dev/null +++ b/lib/src/screen/wave_screen_preset.dart @@ -0,0 +1,19 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; + +import '../wave/wave.dart'; + +/// A named, curated arrangement of a background and a stack of [Wave] layers, +/// consumed by `WaveScreen` and produced by `WavePresets`. +@immutable +class WaveScreenPreset { + final List waves; + final Gradient? background; + final Color? backgroundColor; + + const WaveScreenPreset({ + required this.waves, + this.background, + this.backgroundColor, + }); +} diff --git a/lib/src/screen/widget.dart b/lib/src/screen/widget.dart deleted file mode 100644 index 377e61a..0000000 --- a/lib/src/screen/widget.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import '../wave/wave.dart'; -import 'config.dart'; - -class WaveScreen extends StatelessWidget { - final WaveScreenStyle style; - final DecorationImage? backgroundImage; - final double? width; - final double? height; - final Clip clipBehavior; - final bool repeat; - final Widget? child; - - const WaveScreen({ - super.key, - required this.style, - this.backgroundImage, - this.width, - this.height, - this.clipBehavior = Clip.hardEdge, - this.repeat = true, - this.child, - }); - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final resolvedWidth = width ?? - (constraints.maxWidth.isFinite ? constraints.maxWidth : 320.0); - final resolvedHeight = height ?? - (constraints.maxHeight.isFinite ? constraints.maxHeight : 240.0); - - return WaveWidget( - layers: _adaptLayers( - style.buildLayers(sceneWidth: resolvedWidth), - resolvedWidth, - resolvedHeight, - ), - duration: style.duration, - backgroundGradient: style.palette.backgroundGradient, - backgroundImage: backgroundImage, - width: width, - height: height, - clipBehavior: clipBehavior, - repeat: repeat, - child: child, - ); - }, - ); - } - - List _adaptLayers( - List layers, - double sceneWidth, - double sceneHeight, - ) { - final adaptation = style.adaptation; - final widthRatio = sceneWidth / adaptation.referenceSize.width; - final heightRatio = sceneHeight / adaptation.referenceSize.height; - final frequencyScale = adaptation.scaleFrequencyToWidth - ? widthRatio.clamp( - adaptation.minFrequencyScale, - adaptation.maxFrequencyScale, - ) - : 1.0; - final amplitudeScale = adaptation.scaleAmplitudeToHeight - ? heightRatio.clamp( - adaptation.minAmplitudeScale, - adaptation.maxAmplitudeScale, - ) - : 1.0; - final speedScale = adaptation.scaleSpeedToWidth - ? (1 / widthRatio).clamp( - adaptation.minSpeedScale, - adaptation.maxSpeedScale, - ) - : 1.0; - final shiftScale = adaptation.scaleHorizontalShiftToWidth - ? (1 / widthRatio).clamp( - adaptation.minShiftScale, - adaptation.maxShiftScale, - ) - : 1.0; - final harmonicScale = adaptation.secondaryStrengthScale.clamp( - adaptation.minSecondaryStrengthScale, - adaptation.maxSecondaryStrengthScale, - ); - - return layers.map((layer) { - return WaveLayer( - color: layer.color, - gradient: layer.gradient, - amplitude: layer.amplitude * amplitudeScale, - frequency: layer.frequency * frequencyScale, - speed: layer.speed * speedScale, - phaseShift: layer.phaseShift, - horizontalShift: layer.horizontalShift * shiftScale, - secondaryStrength: layer.secondaryStrength * harmonicScale, - heightFactor: layer.heightFactor, - slope: layer.slope, - blur: layer.blur, - ); - }).toList(growable: false); - } -} \ No newline at end of file diff --git a/lib/src/skeletonizer.dart b/lib/src/skeletonizer.dart deleted file mode 100644 index 15ce3e5..0000000 --- a/lib/src/skeletonizer.dart +++ /dev/null @@ -1 +0,0 @@ -export 'skeletonizer/skeletonizer.dart'; \ No newline at end of file diff --git a/lib/src/skeletonizer/config.dart b/lib/src/skeletonizer/config.dart deleted file mode 100644 index 0ec3535..0000000 --- a/lib/src/skeletonizer/config.dart +++ /dev/null @@ -1,9 +0,0 @@ -enum WaveDeformation { - topEdge, - fullOutline, -} - -enum WaveSynchronization { - global, - local, -} \ No newline at end of file diff --git a/lib/src/skeletonizer/effect.dart b/lib/src/skeletonizer/effect.dart deleted file mode 100644 index 86b152b..0000000 --- a/lib/src/skeletonizer/effect.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/material.dart'; - -@immutable -abstract class SkeletonizerEffect { - final double lowerBound; - final double upperBound; - final bool reverse; - final Duration duration; - - const SkeletonizerEffect({ - this.reverse = false, - this.lowerBound = 0.0, - this.upperBound = 1.0, - required this.duration, - }); - - Paint createPaint(double t, Rect rect, TextDirection? textDirection); - - SkeletonizerEffect lerp(SkeletonizerEffect? other, double t); -} \ No newline at end of file diff --git a/lib/src/skeletonizer/effect_adapter.dart b/lib/src/skeletonizer/effect_adapter.dart deleted file mode 100644 index 2c88386..0000000 --- a/lib/src/skeletonizer/effect_adapter.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:skeletonizer/skeletonizer.dart' as ext; - -import 'effect.dart'; - -@immutable -class SkeletonizerEffectAdapter extends ext.PaintingEffect { - final SkeletonizerEffect effect; - - SkeletonizerEffectAdapter(this.effect) - : super( - duration: effect.duration, - lowerBound: effect.lowerBound, - upperBound: effect.upperBound, - reverse: effect.reverse, - ); - - @override - Paint createPaint(double t, Rect rect, TextDirection? textDirection) { - return effect.createPaint(t, rect, textDirection); - } - - @override - ext.PaintingEffect lerp(ext.PaintingEffect? other, double t) { - if (other is! SkeletonizerEffectAdapter) { - return this; - } - - return SkeletonizerEffectAdapter(effect.lerp(other.effect, t)); - } -} \ No newline at end of file diff --git a/lib/src/skeletonizer/geometry_wave_effect.dart b/lib/src/skeletonizer/geometry_wave_effect.dart deleted file mode 100644 index 888c4f2..0000000 --- a/lib/src/skeletonizer/geometry_wave_effect.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'config.dart'; -import 'effect.dart'; -import 'wave_geometry.dart'; - -@immutable -abstract class GeometryWaveEffect extends SkeletonizerEffect { - final WaveDeformation deformation; - final WaveSynchronization synchronization; - - const GeometryWaveEffect({ - required super.duration, - required this.deformation, - required this.synchronization, - super.lowerBound = 0, - super.upperBound = 1, - super.reverse = false, - }); - - double resolvedAmplitude(Rect rect); - - double resolvedFrequency(Rect rect); - - double sampleOffset({ - required Rect rect, - required double x, - required double progress, - required double phase, - }) { - return WaveGeometry.waveOffset( - x: x, - width: rect.width, - amplitude: resolvedAmplitude(rect), - frequency: resolvedFrequency(rect), - progress: progress, - synchronization: synchronization, - phase: phase, - ); - } - - Path buildPath({ - required Rect rect, - required Radius radius, - required double progress, - required double phase, - }) { - return WaveGeometry.buildRectPath( - rect: rect, - radius: radius, - progress: progress, - amplitude: resolvedAmplitude(rect), - frequency: resolvedFrequency(rect), - deformation: deformation, - synchronization: synchronization, - phase: phase, - ); - } -} \ No newline at end of file diff --git a/lib/src/skeletonizer/painting.dart b/lib/src/skeletonizer/painting.dart deleted file mode 100644 index 0212a32..0000000 --- a/lib/src/skeletonizer/painting.dart +++ /dev/null @@ -1,279 +0,0 @@ -// ignore_for_file: implementation_imports - -import 'dart:typed_data'; -import 'dart:ui' as ui; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/rendering.dart'; -import 'package:skeletonizer/skeletonizer.dart' as ext; -import 'package:skeletonizer/src/painting/text_utils.dart'; -import 'package:skeletonizer/src/utils/utils.dart'; -import 'package:skeletonizer/src/widgets/skeleton.dart'; - -import 'config.dart'; -import 'effect.dart'; -import 'geometry_wave_effect.dart'; -import 'wave_geometry.dart'; - -class WavePaintingContext extends PaintingContext { - WavePaintingContext({ - required this.layer, - required Rect estimatedBounds, - required this.shaderPaint, - required this.config, - required this.isZone, - required this.animationValue, - required this.effect, - }) : super(layer, estimatedBounds); - - final ext.SkeletonizerConfigData config; - final double animationValue; - final bool isZone; - final ContainerLayer layer; - final Paint shaderPaint; - final SkeletonizerEffect effect; - - late final _treatedAsLeaf = {}; - - @override - ui.Canvas get canvas => isZone ? super.canvas : WaveCanvas(super.canvas, this); - - void createDefaultContext(Rect rect, Painter paint) { - final context = PaintingContext(layer, rect); - paint(context, rect.topLeft); - context.stopRecordingIfNeeded(); - } - - void createLeafContext(Rect rect, Painter painter) { - final context = _LeafPaintingContext( - layer: layer, - shaderPaint: shaderPaint, - config: config, - estimatedBounds: rect, - effect: effect, - animationValue: animationValue, - ); - painter(context, rect.topLeft); - context.stopRecordingIfNeeded(); - } - - @override - PaintingContext createChildContext(ContainerLayer childLayer, ui.Rect bounds) { - return WavePaintingContext( - layer: childLayer, - estimatedBounds: bounds, - shaderPaint: shaderPaint, - config: config, - isZone: isZone, - animationValue: animationValue, - effect: effect, - ); - } - - void finishRecording() { - stopRecordingIfNeeded(); - } - - @override - void paintChild(RenderObject child, ui.Offset offset) { - if (!isZone && child is RenderObjectWithChildMixin) { - final key = child.paintBounds.shift(offset).center; - final subChild = child.child; - var treatAsLeaf = subChild == null || (subChild is RenderIgnoredSkeleton && subChild.enabled); - if (child is RenderSemanticsAnnotations) { - treatAsLeaf |= child.properties.button == true; - } - if (treatAsLeaf) { - _treatedAsLeaf.add(key); - } - } - child.paint(this, offset); - } -} - -class _LeafPaintingContext extends WavePaintingContext { - _LeafPaintingContext({ - required super.layer, - required super.estimatedBounds, - required super.shaderPaint, - required super.config, - required super.effect, - required super.animationValue, - }) : super(isZone: true); -} - -class WaveCanvas implements Canvas { - WaveCanvas(this.parent, this.context); - - final Canvas parent; - final WavePaintingContext context; - - Paint get _shaderPaint => context.shaderPaint; - ext.SkeletonizerConfigData get _config => context.config; - SkeletonizerEffect get _effect => context.effect; - - @override - void drawParagraph(ui.Paragraph paragraph, ui.Offset offset) { - final lines = paragraph.computeLineMetrics(); - for (var i = 0; i < lines.length; i++) { - final rect = lineToRect( - line: lines[i], - offset: offset, - numberOfLines: lines.length, - justifyMultiLineText: _config.justifyMultiLineText, - paragraphWidth: paragraph.width, - ); - final borderRadius = _config.textBorderRadius.usesHeightFactor - ? BorderRadius.circular(rect.height * _config.textBorderRadius.heightPercentage!) - : _config.textBorderRadius.borderRadius?.resolve(TextDirection.ltr); - - if (borderRadius != null) { - _drawWaveRRect(borderRadius.toRRect(rect)); - } else { - _drawWaveRect(rect, Radius.zero); - } - } - } - - @override - void drawRect(ui.Rect rect, ui.Paint paint) { - if (!_isBone(rect.center) || paint.color.a == 0) { - _drawContainerRect(rect, paint); - return; - } - _drawWaveRect(rect, Radius.zero); - } - - @override - void drawRRect(ui.RRect rrect, ui.Paint paint) { - if (!_isBone(rrect.center) || paint.color.a == 0) { - _drawContainerRRect(rrect, paint); - return; - } - _drawWaveRRect(rrect); - } - - @override - void drawCircle(ui.Offset c, double radius, ui.Paint paint) { - if (!_isBone(c) || paint.color.a == 0) { - _drawContainerCircle(c, radius, paint); - return; - } - - final rect = Rect.fromCircle(center: c, radius: radius); - final path = _buildPath(rect, Radius.circular(radius)); - parent.drawPath(path, _shaderPaint); - } - - @override - void drawDRRect(ui.RRect outer, ui.RRect inner, ui.Paint paint) { - parent.drawDRRect(outer, inner, paint); - } - - @override - void drawPath(ui.Path path, ui.Paint paint) { - if (!_isBone(path.getBounds().center) || paint.color.a == 0) { - _drawContainerPath(path, paint); - return; - } - - final bounds = path.getBounds(); - final radius = Radius.circular(bounds.height / 2); - final wavePath = _buildPath(bounds, radius); - parent.drawPath(wavePath, _shaderPaint); - } - - GeometryWaveEffect? get _geometryEffect => _effect is GeometryWaveEffect ? _effect as GeometryWaveEffect : null; - - double _phase(Rect rect) { - return ((rect.left + rect.top) / 97.0) % 1.0; - } - - bool _isBone(Offset center) => context._treatedAsLeaf.containsFuzzy(center); - - void _drawWaveRect(Rect rect, Radius radius) { - final path = _buildPath(rect, radius); - parent.drawPath(path, _shaderPaint); - } - - void _drawWaveRRect(RRect rrect) { - final path = _buildPath(rrect.outerRect, Radius.circular(rrect.tlRadiusX)); - parent.drawPath(path, _shaderPaint); - } - - Path _buildPath(Rect rect, Radius radius) { - final geometryEffect = _geometryEffect; - if (geometryEffect == null) { - return WaveGeometry.buildRectPath( - rect: rect, - radius: radius, - progress: context.animationValue, - amplitude: rect.height * 0.28, - frequency: rect.width > 180 ? 1.6 : 1.2, - deformation: WaveDeformation.topEdge, - synchronization: WaveSynchronization.global, - phase: _phase(rect), - ); - } - - return geometryEffect.buildPath( - rect: rect, - radius: radius, - progress: context.animationValue, - phase: _phase(rect), - ); - } - - void _drawContainerRect(Rect rect, Paint paint) { - if (_config.ignoreContainers) return; - parent.drawRect(rect, _config.containersColor != null ? paint.copyWith(color: _config.containersColor!) : paint); - } - - void _drawContainerRRect(RRect rrect, Paint paint) { - if (_config.ignoreContainers) return; - parent.drawRRect(rrect, _config.containersColor != null ? paint.copyWith(color: _config.containersColor!) : paint); - } - - void _drawContainerCircle(Offset c, double radius, Paint paint) { - if (_config.ignoreContainers) return; - parent.drawCircle(c, radius, _config.containersColor != null ? paint.copyWith(color: _config.containersColor!) : paint); - } - - void _drawContainerPath(Path path, Paint paint) { - if (_config.ignoreContainers) return; - parent.drawPath(path, _config.containersColor != null ? paint.copyWith(color: _config.containersColor!) : paint); - } - - @override - int getSaveCount() => parent.getSaveCount(); - - @override - void save() => parent.save(); - - @override - void restore() => parent.restore(); - - @override - void saveLayer(Rect? bounds, Paint paint) => parent.saveLayer(bounds, paint); - - @override - void translate(double dx, double dy) => parent.translate(dx, dy); - - @override - void scale(double sx, [double? sy]) => parent.scale(sx, sy); - - @override - void rotate(double radians) => parent.rotate(radians); - - @override - void skew(double sx, double sy) => parent.skew(sx, sy); - - @override - void transform(Float64List matrix4) => parent.transform(matrix4); - - @override - Float64List getTransform() => parent.getTransform(); - - @override - noSuchMethod(Invocation invocation) => Function.apply(parent.noSuchMethod, [invocation]); -} \ No newline at end of file diff --git a/lib/src/skeletonizer/ping_pong_timing.dart b/lib/src/skeletonizer/ping_pong_timing.dart deleted file mode 100644 index aba8ff9..0000000 --- a/lib/src/skeletonizer/ping_pong_timing.dart +++ /dev/null @@ -1,65 +0,0 @@ -double _clampUnit(double value) { - return value.clamp(0.0, 1.0).toDouble(); -} - -class PingPongTimingProfile { - static const List outboundFrameDurations = [ - 120, - 110, - 100, - 90, - 80, - 70, - ]; - - static const double impactFrameDuration = 140; - - static const List returnFrameDurations = [ - 90, - 100, - 120, - 140, - 170, - 220, - ]; - - const PingPongTimingProfile(); - - double outbound(double value) { - return _mapSegmentProgress(_clampUnit(value), outboundFrameDurations); - } - - double reflectedReturn(double value) { - return _mapSegmentProgress(_clampUnit(value), returnFrameDurations); - } - - static double _mapSegmentProgress(double value, List frameDurations) { - if (frameDurations.isEmpty) { - return value; - } - if (value <= 0) { - return 0; - } - if (value >= 1) { - return 1; - } - - final totalDuration = frameDurations.fold(0, (sum, item) => sum + item); - final targetDuration = totalDuration * value; - final stepSize = 1 / frameDurations.length; - var elapsed = 0.0; - - for (var index = 0; index < frameDurations.length; index++) { - final frameDuration = frameDurations[index]; - final nextElapsed = elapsed + frameDuration; - if (targetDuration <= nextElapsed) { - final localDuration = targetDuration - elapsed; - final frameT = frameDuration == 0 ? 0.0 : localDuration / frameDuration; - return ((index + frameT) * stepSize).clamp(0.0, 1.0).toDouble(); - } - elapsed = nextElapsed; - } - - return 1; - } -} \ No newline at end of file diff --git a/lib/src/skeletonizer/ping_pong_wave_effect.dart b/lib/src/skeletonizer/ping_pong_wave_effect.dart deleted file mode 100644 index cc35409..0000000 --- a/lib/src/skeletonizer/ping_pong_wave_effect.dart +++ /dev/null @@ -1,444 +0,0 @@ -import 'dart:math' as math; -import 'dart:ui' show lerpDouble; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'config.dart'; -import 'effect.dart'; -import 'geometry_wave_effect.dart'; -import 'ping_pong_timing.dart'; -import 'wave_geometry.dart'; - -List _lerpStops(List left, List right, double t) { - return List.generate(left.length, (index) { - return lerpDouble(left[index], right[index], t)!; - }, growable: false); -} - -const double _progressEpsilon = 1e-9; - -Duration _lerpDuration(Duration left, Duration right, double t) { - final milliseconds = lerpDouble( - left.inMilliseconds.toDouble(), - right.inMilliseconds.toDouble(), - t, - )!; - return Duration(milliseconds: milliseconds.round()); -} - -double _smoothStep(double value) { - final clamped = value.clamp(0.0, 1.0); - return clamped * clamped * (3 - (2 * clamped)); -} - -double _lerpValue(double begin, double end, double t) { - return begin + ((end - begin) * t); -} - -const PingPongTimingProfile _timingProfile = PingPongTimingProfile(); - -@immutable -class PingPongWaveEffect extends GeometryWaveEffect { - final Color baseColor; - final Color highlightColor; - final AlignmentGeometry begin; - final AlignmentGeometry end; - final List stops; - final TileMode tileMode; - final double spread; - final double amplitudeFactor; - final double frequency; - final Duration pauseDuration; - - const PingPongWaveEffect({ - this.baseColor = const Color(0xFFF24B5A), - this.highlightColor = const Color(0xFFFF97A0), - super.deformation = WaveDeformation.topEdge, - super.synchronization = WaveSynchronization.global, - this.begin = const AlignmentDirectional(-0.8, 0), - this.end = const AlignmentDirectional(0.8, 0), - this.stops = const [0.22, 0.5, 0.78], - this.tileMode = TileMode.clamp, - this.spread = 0.18, - this.amplitudeFactor = 0.26, - this.frequency = 1.15, - this.pauseDuration = const Duration(seconds: 2), - super.lowerBound = 0, - super.upperBound = 1, - super.reverse = false, - super.duration = const Duration(seconds: 5), - }); - - List get colors => [baseColor, highlightColor, baseColor]; - - double get _impactFraction { - final totalMillis = duration.inMilliseconds; - if (totalMillis <= 0) { - return 0; - } - - return (pauseDuration.inMilliseconds.clamp(0, totalMillis) / totalMillis) - .toDouble(); - } - - double get _travelFraction { - return math.max(0, (1 - _impactFraction) / 2).toDouble(); - } - - double _normalizedProgress(double cycleProgress) { - return cycleProgress.clamp(lowerBound, upperBound).toDouble(); - } - - double get _impactStart => lowerBound + _travelFraction; - - double get _impactEnd => upperBound - _travelFraction; - - double outboundProgress(double cycleProgress) { - final normalized = _normalizedProgress(cycleProgress); - if (_travelFraction <= _progressEpsilon) { - return 1; - } - if (normalized >= _impactStart) { - return 1; - } - - return _timingProfile.outbound((normalized - lowerBound) / _travelFraction); - } - - double impactProgress(double cycleProgress) { - final normalized = _normalizedProgress(cycleProgress); - final width = _impactEnd - _impactStart; - if (width <= _progressEpsilon || - normalized <= _impactStart || - normalized >= _impactEnd) { - return 0; - } - - final t = (normalized - _impactStart) / width; - return math.sin(t * math.pi).abs(); - } - - double reflectionProgress(double cycleProgress) { - final normalized = _normalizedProgress(cycleProgress); - final width = _impactEnd - _impactStart; - if (normalized <= _impactStart || width <= _progressEpsilon) { - return 0; - } - if (normalized >= _impactEnd) { - return 1; - } - - return _smoothStep((normalized - _impactStart) / width); - } - - double returnProgress(double cycleProgress) { - final normalized = _normalizedProgress(cycleProgress); - if (_travelFraction <= _progressEpsilon || normalized <= _impactEnd) { - return 0; - } - - return _timingProfile.reflectedReturn( - (normalized - _impactEnd) / _travelFraction, - ); - } - - double outgoingCenterProgress(double cycleProgress) { - return outboundProgress(cycleProgress); - } - - double reflectedCenterProgress(double cycleProgress) { - final returning = returnProgress(cycleProgress); - if (returning <= 0) { - return 1; - } - - return 1 - returning; - } - - double centerProgress(double cycleProgress) { - final returning = returnProgress(cycleProgress); - if (returning > 0) { - return reflectedCenterProgress(cycleProgress); - } - if (reflectionProgress(cycleProgress) > 0) { - return 1; - } - - return outgoingCenterProgress(cycleProgress); - } - - double visibilityProgress(double cycleProgress) { - return 1; - } - - double crestAmplitudeProgress(double cycleProgress) { - final outgoing = outboundProgress(cycleProgress); - final impact = impactProgress(cycleProgress); - final returning = returnProgress(cycleProgress); - if (returning > 0) { - return 0.88 - (0.38 * returning); - } - - return 0.58 + (outgoing * 0.42) + (impact * 0.18); - } - - double outgoingWaveMix(double cycleProgress) { - final returning = returnProgress(cycleProgress); - if (returning > 0) { - return 0.26 * (1 - returning); - } - - return 0.62 + (0.38 * outboundProgress(cycleProgress)); - } - - double reflectedWaveMix(double cycleProgress) { - final reflection = reflectionProgress(cycleProgress); - if (reflection <= 0) { - return 0; - } - - final returning = returnProgress(cycleProgress); - if (returning > 0) { - return 0.82 - (0.32 * returning); - } - - return 0.24 + (reflection * 0.52); - } - - double wallRippleMix(double cycleProgress) { - final impact = impactProgress(cycleProgress); - if (impact > 0) { - return 0.24 + (impact * 0.34); - } - - final returning = returnProgress(cycleProgress); - if (returning <= 0) { - return 0; - } - - return 0.36 * (1 - (0.6 * returning)); - } - - double _phaseShift(double phase) { - if (synchronization == WaveSynchronization.global) { - return 0; - } - - return phase * math.pi * 2; - } - - double _wallRippleOffset({ - required Rect rect, - required double x, - required double cycleProgress, - required double phaseShift, - required double amplitude, - }) { - final rippleMix = wallRippleMix(cycleProgress); - if (rippleMix <= 0 || rect.width <= 0) { - return 0; - } - - final rippleReach = math.max(spread * 1.35, 0.16); - final normalizedFromWall = (rect.right - x) / rect.width; - if (normalizedFromWall < 0 || normalizedFromWall >= rippleReach) { - return 0; - } - - final distanceT = normalizedFromWall / rippleReach; - final envelope = 1 - _smoothStep(distanceT); - final travelPhase = impactProgress(cycleProgress) > 0 - ? impactProgress(cycleProgress) - : 1 + returnProgress(cycleProgress); - final ripple = math.sin( - ((1 - distanceT) * math.pi * 3.6) - (travelPhase * math.pi * 1.4) + phaseShift, - ); - final crest = math.max(0.0, ripple); - return -amplitude * rippleMix * envelope * crest; - } - - double _sampleCompositeOffset({ - required Rect rect, - required double x, - required double progress, - required double phaseShift, - }) { - final amplitude = resolvedAmplitude(rect) * crestAmplitudeProgress(progress); - final outgoing = WaveGeometry.travelingWaveOffset( - x: x, - width: rect.width, - centerX: rect.left + (rect.width * outgoingCenterProgress(progress)), - amplitude: amplitude * outgoingWaveMix(progress), - frequency: resolvedFrequency(rect), - spread: _lerpValue(spread * 0.7, spread * 1.05, outboundProgress(progress)), - phase: phaseShift, - ); - final reflected = WaveGeometry.travelingWaveOffset( - x: x, - width: rect.width, - centerX: rect.left + (rect.width * reflectedCenterProgress(progress)), - amplitude: amplitude * reflectedWaveMix(progress), - frequency: resolvedFrequency(rect) * 1.08, - spread: math.max(spread * 0.85, 0.12), - phase: phaseShift + (math.pi / 5), - ); - final wall = _wallRippleOffset( - rect: rect, - x: x, - cycleProgress: progress, - phaseShift: phaseShift, - amplitude: amplitude, - ); - return outgoing + reflected + wall; - } - - @override - Paint createPaint(double t, Rect rect, TextDirection? textDirection) { - final visibility = visibilityProgress(t); - if (visibility == 0) { - return Paint()..color = baseColor; - } - - final progress = centerProgress(t); - return Paint() - ..shader = LinearGradient( - colors: colors, - stops: stops, - begin: begin, - end: end, - tileMode: tileMode, - transform: _PingPongGradientTransform(offset: progress), - ).createShader(rect, textDirection: textDirection); - } - - @override - double resolvedAmplitude(Rect rect) => rect.height * amplitudeFactor; - - @override - double resolvedFrequency(Rect rect) => frequency; - - @override - double sampleOffset({ - required Rect rect, - required double x, - required double progress, - required double phase, - }) { - return _sampleCompositeOffset( - rect: rect, - x: x, - progress: progress, - phaseShift: _phaseShift(phase), - ); - } - - @override - Path buildPath({ - required Rect rect, - required Radius radius, - required double progress, - required double phase, - }) { - final phaseShift = _phaseShift(phase); - return WaveGeometry.buildSampledRectPath( - rect: rect, - radius: radius, - deformation: deformation, - topOffset: (x) => _sampleCompositeOffset( - rect: rect, - x: x, - progress: progress, - phaseShift: phaseShift, - ), - bottomOffset: (x) => _sampleCompositeOffset( - rect: rect, - x: x, - progress: progress, - phaseShift: phaseShift + (math.pi / 3), - ), - ); - } - - @override - PingPongWaveEffect lerp(SkeletonizerEffect? other, double t) { - if (other is! PingPongWaveEffect) { - return this; - } - - return PingPongWaveEffect( - duration: duration, - lowerBound: lerpDouble(lowerBound, other.lowerBound, t)!, - upperBound: lerpDouble(upperBound, other.upperBound, t)!, - reverse: t <= 0.5 ? reverse : other.reverse, - baseColor: Color.lerp(baseColor, other.baseColor, t)!, - highlightColor: Color.lerp(highlightColor, other.highlightColor, t)!, - deformation: t <= 0.5 ? deformation : other.deformation, - synchronization: t <= 0.5 ? synchronization : other.synchronization, - begin: AlignmentGeometry.lerp(begin, other.begin, t)!, - end: AlignmentGeometry.lerp(end, other.end, t)!, - stops: _lerpStops(stops, other.stops, t), - tileMode: t <= 0.5 ? tileMode : other.tileMode, - spread: lerpDouble(spread, other.spread, t)!, - amplitudeFactor: lerpDouble(amplitudeFactor, other.amplitudeFactor, t)!, - frequency: lerpDouble(frequency, other.frequency, t)!, - pauseDuration: _lerpDuration(pauseDuration, other.pauseDuration, t), - ); - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - other is PingPongWaveEffect && - baseColor == other.baseColor && - highlightColor == other.highlightColor && - deformation == other.deformation && - synchronization == other.synchronization && - begin == other.begin && - end == other.end && - listEquals(stops, other.stops) && - tileMode == other.tileMode && - spread == other.spread && - amplitudeFactor == other.amplitudeFactor && - frequency == other.frequency && - pauseDuration == other.pauseDuration && - lowerBound == other.lowerBound && - upperBound == other.upperBound && - reverse == other.reverse && - duration == other.duration; - } - - @override - int get hashCode { - return Object.hash( - baseColor, - highlightColor, - deformation, - synchronization, - begin, - end, - Object.hashAll(stops), - tileMode, - spread, - amplitudeFactor, - frequency, - pauseDuration, - lowerBound, - upperBound, - reverse, - duration, - ); - } -} - -class _PingPongGradientTransform extends GradientTransform { - final double offset; - - const _PingPongGradientTransform({required this.offset}); - - @override - Matrix4? transform(Rect bounds, {TextDirection? textDirection}) { - final horizontalShift = bounds.width * ((offset - 0.5) * 0.7); - return Matrix4.translationValues(horizontalShift, 0, 0); - } -} \ No newline at end of file diff --git a/lib/src/skeletonizer/render_object.dart b/lib/src/skeletonizer/render_object.dart deleted file mode 100644 index e02281e..0000000 --- a/lib/src/skeletonizer/render_object.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:flutter/rendering.dart'; -import 'package:skeletonizer/skeletonizer.dart' as ext; - -import 'effect.dart'; -import 'painting.dart'; - -class RenderWaveSkeletonizer extends RenderProxyBox { - RenderWaveSkeletonizer({ - required TextDirection textDirection, - required double animationValue, - required ext.SkeletonizerConfigData config, - required bool ignorePointers, - required bool isZone, - required SkeletonizerEffect effect, - RenderBox? child, - }) : _textDirection = textDirection, - _animationValue = animationValue, - _config = config, - _isZone = isZone, - _ignorePointers = ignorePointers, - _effect = effect, - super(child); - - TextDirection _textDirection; - ext.SkeletonizerConfigData _config; - bool _ignorePointers; - bool _isZone; - double _animationValue; - SkeletonizerEffect _effect; - - set textDirection(TextDirection value) { - if (_textDirection != value) { - _textDirection = value; - markNeedsPaint(); - } - } - - set config(ext.SkeletonizerConfigData value) { - if (_config != value) { - _config = value; - markNeedsPaint(); - } - } - - set ignorePointers(bool value) { - if (_ignorePointers != value) { - _ignorePointers = value; - markNeedsPaint(); - } - } - - set isZone(bool value) { - if (_isZone != value) { - _isZone = value; - markNeedsPaint(); - } - } - - set animationValue(double value) { - if (_animationValue != value) { - _animationValue = value; - markNeedsPaint(); - } - } - - set effect(SkeletonizerEffect value) { - if (_effect != value) { - _effect = value; - markNeedsPaint(); - } - } - - @override - bool get alwaysNeedsCompositing => true; - - @override - bool hitTest(BoxHitTestResult result, {required Offset position}) { - if (_ignorePointers) return false; - return super.hitTest(result, position: position); - } - - @override - OffsetLayer? get layer => super.layer as OffsetLayer?; - - @override - void paint(PaintingContext context, Offset offset) { - layer ??= OffsetLayer(); - if (layer!.hasChildren) { - layer!.removeAllChildren(); - } - context.addLayer(layer!); - final estimatedBounds = paintBounds.shift(offset); - final shaderPaint = _effect.createPaint( - _animationValue, - estimatedBounds, - _textDirection, - ); - final waveContext = WavePaintingContext( - layer: layer!, - estimatedBounds: estimatedBounds, - shaderPaint: shaderPaint, - config: _config, - isZone: _isZone, - animationValue: _animationValue, - effect: _effect, - ); - super.paint(waveContext, offset); - waveContext.finishRecording(); - } -} \ No newline at end of file diff --git a/lib/src/skeletonizer/render_widget.dart b/lib/src/skeletonizer/render_widget.dart deleted file mode 100644 index 260a789..0000000 --- a/lib/src/skeletonizer/render_widget.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:skeletonizer/skeletonizer.dart' as ext; - -import 'effect.dart'; -import 'render_object.dart'; - -class WaveSkeletonizerRenderObjectWidget extends SingleChildRenderObjectWidget { - const WaveSkeletonizerRenderObjectWidget({ - super.key, - required super.child, - required this.animationValue, - required this.textDirection, - required this.config, - required this.ignorePointers, - required this.isZone, - required this.effect, - }); - - final double animationValue; - final TextDirection textDirection; - final ext.SkeletonizerConfigData config; - final bool ignorePointers; - final bool isZone; - final SkeletonizerEffect effect; - - @override - RenderWaveSkeletonizer createRenderObject(BuildContext context) { - return RenderWaveSkeletonizer( - animationValue: animationValue, - textDirection: textDirection, - config: config, - ignorePointers: ignorePointers, - isZone: isZone, - effect: effect, - ); - } - - @override - void updateRenderObject(BuildContext context, covariant RenderWaveSkeletonizer renderObject) { - renderObject - ..animationValue = animationValue - ..textDirection = textDirection - ..config = config - ..ignorePointers = ignorePointers - ..isZone = isZone - ..effect = effect; - } -} \ No newline at end of file diff --git a/lib/src/skeletonizer/skeletonizer.dart b/lib/src/skeletonizer/skeletonizer.dart deleted file mode 100644 index 372b260..0000000 --- a/lib/src/skeletonizer/skeletonizer.dart +++ /dev/null @@ -1,5 +0,0 @@ -export 'config.dart'; -export 'effect.dart'; -export 'ping_pong_wave_effect.dart'; -export 'wave_effect.dart'; -export 'widget.dart'; \ No newline at end of file diff --git a/lib/src/skeletonizer/wave_effect.dart b/lib/src/skeletonizer/wave_effect.dart deleted file mode 100644 index 3999c3d..0000000 --- a/lib/src/skeletonizer/wave_effect.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'dart:math' as math; -import 'dart:ui' show lerpDouble; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'config.dart'; -import 'effect.dart'; -import 'geometry_wave_effect.dart'; - -@immutable -class WaveEffect extends GeometryWaveEffect { - final Color baseColor; - final Color highlightColor; - final AlignmentGeometry begin; - final AlignmentGeometry end; - final List stops; - final TileMode tileMode; - final double verticalDrift; - - const WaveEffect({ - this.baseColor = const Color(0xFF3844A8), - this.highlightColor = const Color(0xFF89A6FF), - super.deformation = WaveDeformation.topEdge, - super.synchronization = WaveSynchronization.global, - this.begin = const AlignmentDirectional(-1.1, -0.2), - this.end = const AlignmentDirectional(1.1, 0.2), - this.stops = const [0.08, 0.22, 0.36, 0.58, 0.84], - this.tileMode = TileMode.clamp, - this.verticalDrift = 0.1, - super.lowerBound = -0.8, - super.upperBound = 1.8, - super.reverse = false, - super.duration = const Duration(seconds: 9), - }); - - @override - double resolvedAmplitude(Rect rect) => rect.height * 0.28; - - @override - double resolvedFrequency(Rect rect) => rect.width > 180 ? 1.6 : 1.2; - - List get colors => [ - baseColor, - Color.lerp(baseColor, highlightColor, 0.55)!, - highlightColor, - Color.lerp(baseColor, highlightColor, 0.45)!, - baseColor, - ]; - - @override - Paint createPaint(double t, Rect rect, TextDirection? textDirection) { - return Paint() - ..shader = LinearGradient( - colors: colors, - stops: stops, - begin: begin, - end: end, - tileMode: tileMode, - transform: _WaveGradientTransform( - offset: t, - verticalDrift: verticalDrift, - ), - ).createShader(rect, textDirection: textDirection); - } - - @override - WaveEffect lerp(SkeletonizerEffect? other, double t) { - if (other is! WaveEffect) { - return this; - } - - return WaveEffect( - duration: duration, - lowerBound: lerpDouble(lowerBound, other.lowerBound, t)!, - upperBound: lerpDouble(upperBound, other.upperBound, t)!, - reverse: t <= 0.5 ? reverse : other.reverse, - baseColor: Color.lerp(baseColor, other.baseColor, t)!, - highlightColor: Color.lerp(highlightColor, other.highlightColor, t)!, - deformation: t <= 0.5 ? deformation : other.deformation, - synchronization: t <= 0.5 ? synchronization : other.synchronization, - begin: AlignmentGeometry.lerp(begin, other.begin, t)!, - end: AlignmentGeometry.lerp(end, other.end, t)!, - stops: _lerpStops(stops, other.stops, t), - tileMode: t <= 0.5 ? tileMode : other.tileMode, - verticalDrift: lerpDouble(verticalDrift, other.verticalDrift, t)!, - ); - } - - static List _lerpStops(List left, List right, double t) { - return List.generate(left.length, (index) { - return lerpDouble(left[index], right[index], t)!; - }, growable: false); - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - other is WaveEffect && - baseColor == other.baseColor && - highlightColor == other.highlightColor && - deformation == other.deformation && - synchronization == other.synchronization && - begin == other.begin && - end == other.end && - listEquals(stops, other.stops) && - tileMode == other.tileMode && - verticalDrift == other.verticalDrift && - lowerBound == other.lowerBound && - upperBound == other.upperBound && - reverse == other.reverse && - duration == other.duration; - } - - @override - int get hashCode { - return Object.hash( - baseColor, - highlightColor, - deformation, - synchronization, - begin, - end, - Object.hashAll(stops), - tileMode, - verticalDrift, - lowerBound, - upperBound, - reverse, - duration, - ); - } -} - -class _WaveGradientTransform extends GradientTransform { - final double offset; - final double verticalDrift; - - const _WaveGradientTransform({ - required this.offset, - required this.verticalDrift, - }); - - @override - Matrix4? transform(Rect bounds, {TextDirection? textDirection}) { - final horizontalShift = bounds.width * offset; - final verticalShift = - bounds.height * (verticalDrift * 0.5) * (1 + math.sin(offset * math.pi)); - return Matrix4.translationValues(horizontalShift, verticalShift, 0.0); - } -} \ No newline at end of file diff --git a/lib/src/skeletonizer/wave_geometry.dart b/lib/src/skeletonizer/wave_geometry.dart deleted file mode 100644 index 0d78e87..0000000 --- a/lib/src/skeletonizer/wave_geometry.dart +++ /dev/null @@ -1,275 +0,0 @@ -import 'dart:math' as math; - -import 'package:flutter/widgets.dart'; - -import 'config.dart'; - -typedef WaveOffsetSampler = double Function(double x); - -const double _travelingWavePhaseBiasScale = 0.08; - -class WaveGeometry { - static Path buildRectPath({ - required Rect rect, - required Radius radius, - required double progress, - required double amplitude, - required double frequency, - required WaveDeformation deformation, - required WaveSynchronization synchronization, - required double phase, - }) { - final resolvedRadius = math.min(radius.x, rect.height / 2); - final topPhase = _phase(progress, synchronization, phase); - final bottomPhase = deformation == WaveDeformation.fullOutline - ? topPhase + math.pi / 3 - : topPhase; - return _buildPath( - rect: rect, - radius: resolvedRadius, - deformation: deformation, - topOffset: (x) => _offset( - x: x, - width: rect.width, - amplitude: amplitude, - frequency: frequency, - phase: topPhase, - ), - bottomOffset: (x) => _offset( - x: x, - width: rect.width, - amplitude: amplitude, - frequency: frequency, - phase: bottomPhase, - ), - ); - } - - static Path buildTravelingRectPath({ - required Rect rect, - required Radius radius, - required double progress, - required double amplitude, - required double frequency, - required double spread, - required WaveDeformation deformation, - required WaveSynchronization synchronization, - required double phase, - }) { - final resolvedRadius = math.min(radius.x, rect.height / 2); - final centerX = rect.left + (rect.width * progress); - final phaseShift = synchronization == WaveSynchronization.global - ? 0.0 - : phase * math.pi * 2; - return _buildPath( - rect: rect, - radius: resolvedRadius, - deformation: deformation, - topOffset: (x) => travelingWaveOffset( - x: x, - width: rect.width, - centerX: centerX, - amplitude: amplitude, - frequency: frequency, - spread: spread, - phase: phaseShift, - ), - bottomOffset: (x) => travelingWaveOffset( - x: x, - width: rect.width, - centerX: centerX, - amplitude: amplitude, - frequency: frequency, - spread: spread, - phase: phaseShift + (math.pi / 3), - ), - ); - } - - static Path buildSampledRectPath({ - required Rect rect, - required Radius radius, - required WaveDeformation deformation, - required WaveOffsetSampler topOffset, - required WaveOffsetSampler bottomOffset, - }) { - final resolvedRadius = math.min(radius.x, rect.height / 2); - return _buildPath( - rect: rect, - radius: resolvedRadius, - deformation: deformation, - topOffset: topOffset, - bottomOffset: bottomOffset, - ); - } - - static double waveOffset({ - required double x, - required double width, - required double amplitude, - required double frequency, - required double progress, - required WaveSynchronization synchronization, - required double phase, - }) { - return _offset( - x: x, - width: width, - amplitude: amplitude, - frequency: frequency, - phase: _phase(progress, synchronization, phase), - ); - } - - static double travelingWaveOffset({ - required double x, - required double width, - required double centerX, - required double amplitude, - required double frequency, - required double spread, - required double phase, - }) { - if (width == 0 || spread <= 0 || amplitude <= 0) { - return 0; - } - - final distance = (x - centerX) / width; - final normalizedDistance = distance.abs() / spread; - if (normalizedDistance >= 1) { - return 0; - } - - final envelope = 1 - _smoothStep(normalizedDistance); - final sharpness = 1 + ((frequency.abs() - 1).clamp(0.0, 1.5) * 0.35); - final edgeBump = _edgeBump(normalizedDistance, frequency); - final phaseBias = 1 + (math.sin(phase) * _travelingWavePhaseBiasScale); - final profile = math.max(0.0, math.pow(envelope, sharpness).toDouble() + edgeBump); - return -amplitude * profile * phaseBias; - } - - static double _smoothStep(double value) { - final clamped = value.clamp(0.0, 1.0); - return clamped * clamped * (3 - (2 * clamped)); - } - - static double _edgeBump(double normalizedDistance, double frequency) { - if (normalizedDistance <= 0.58 || normalizedDistance >= 1) { - return 0; - } - - final rippleDistance = (normalizedDistance - 0.58) / 0.42; - final window = math.sin(rippleDistance * math.pi); - final ripple = math.sin( - ((rippleDistance * (1.15 + (frequency.abs() * 0.15))) - 0.2) * math.pi, - ); - return math.max(0.0, window * ripple) * 0.12; - } - - static Iterable _topPoints( - Rect rect, - double radius, - int sampleCount, - WaveOffsetSampler offset, - ) sync* { - for (int index = 1; index <= sampleCount; index++) { - final x = rect.left + radius + ((rect.width - (radius * 2)) * index / sampleCount); - yield Offset(x, rect.top + offset(x)); - } - } - - static Iterable _bottomPoints( - Rect rect, - double radius, - int sampleCount, - WaveOffsetSampler offset, - ) sync* { - for (int index = sampleCount; index >= 0; index--) { - final x = rect.left + radius + ((rect.width - (radius * 2)) * index / sampleCount); - yield Offset(x, rect.bottom + offset(x)); - } - } - - static Path _buildPath({ - required Rect rect, - required double radius, - required WaveDeformation deformation, - required WaveOffsetSampler topOffset, - required WaveOffsetSampler bottomOffset, - }) { - final sampleCount = math.max(24, (rect.width / 8).round()); - final path = Path(); - path.moveTo(rect.left + radius, rect.top + topOffset(rect.left + radius)); - - for (final point in _topPoints(rect, radius, sampleCount, topOffset)) { - path.lineTo(point.dx, point.dy); - } - - path.quadraticBezierTo( - rect.right, - rect.top + topOffset(rect.right), - rect.right, - rect.top + radius, - ); - - if (deformation == WaveDeformation.topEdge) { - path.lineTo(rect.right, rect.bottom - radius); - path.quadraticBezierTo(rect.right, rect.bottom, rect.right - radius, rect.bottom); - path.lineTo(rect.left + radius, rect.bottom); - path.quadraticBezierTo(rect.left, rect.bottom, rect.left, rect.bottom - radius); - path.lineTo(rect.left, rect.top + radius); - } else { - path.lineTo(rect.right, rect.bottom - radius); - path.quadraticBezierTo( - rect.right, - rect.bottom + bottomOffset(rect.right), - rect.right - radius, - rect.bottom + bottomOffset(rect.right - radius), - ); - - for (final point in _bottomPoints(rect, radius, sampleCount, bottomOffset)) { - path.lineTo(point.dx, point.dy); - } - - path.quadraticBezierTo( - rect.left, - rect.bottom + bottomOffset(rect.left), - rect.left, - rect.bottom - radius, - ); - path.lineTo(rect.left, rect.top + radius); - } - - path.quadraticBezierTo( - rect.left, - rect.top, - rect.left + radius, - rect.top + topOffset(rect.left + radius), - ); - path.close(); - return path; - } - - static double _phase( - double progress, - WaveSynchronization synchronization, - double phase, - ) { - return synchronization == WaveSynchronization.global - ? progress * math.pi * 2 - : (progress * math.pi * 2) + (phase * math.pi * 2); - } - - static double _offset({ - required double x, - required double width, - required double amplitude, - required double frequency, - required double phase, - }) { - final normalized = width == 0 ? 0.0 : x / width; - final baseWave = math.sin((normalized * math.pi * 2 * frequency) + phase); - final harmonic = math.cos((normalized * math.pi * 2 * (frequency * 0.5)) - (phase * 1.3)); - return ((baseWave * 0.72) + (harmonic * 0.28)) * amplitude; - } -} \ No newline at end of file diff --git a/lib/src/skeletonizer/widget.dart b/lib/src/skeletonizer/widget.dart deleted file mode 100644 index e8636b0..0000000 --- a/lib/src/skeletonizer/widget.dart +++ /dev/null @@ -1,184 +0,0 @@ -// ignore_for_file: implementation_imports - -import 'package:flutter/material.dart'; -import 'package:skeletonizer/skeletonizer.dart' as ext; -import 'package:skeletonizer/src/widgets/skeletonizer.dart' as ext_src; - -import '../screen/screen.dart'; -import 'effect.dart'; -import 'effect_adapter.dart'; -import 'render_widget.dart'; -import 'wave_effect.dart'; - -class WaveSkeletonizer extends StatefulWidget { - final Widget child; - final bool enabled; - final SkeletonizerEffect effect; - final WaveScreenStyle? surfaceStyle; - final ext.TextBoneBorderRadius? textBoneBorderRadius; - final bool? ignoreContainers; - final bool? justifyMultiLineText; - final Color? containersColor; - final bool ignorePointers; - final bool? enableSwitchAnimation; - final ext.SwitchAnimationConfig? switchAnimationConfig; - - const WaveSkeletonizer({ - super.key, - required this.child, - this.enabled = true, - this.effect = const WaveEffect(), - this.surfaceStyle, - this.textBoneBorderRadius, - this.ignoreContainers, - this.justifyMultiLineText, - this.containersColor, - this.ignorePointers = true, - this.enableSwitchAnimation, - this.switchAnimationConfig, - }); - - @override - State createState() => _WaveSkeletonizerState(); -} - -class _WaveSkeletonizerState extends State - with TickerProviderStateMixin { - AnimationController? _animationController; - late bool _enabled = widget.enabled; - ext.SkeletonizerConfigData? _config; - TextDirection _textDirection = TextDirection.ltr; - - double get _animationValue => _animationController?.value ?? 0.0; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _setupEffect(); - } - - @override - void didUpdateWidget(covariant WaveSkeletonizer oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.enabled != widget.enabled) { - _enabled = widget.enabled; - if (!_enabled) { - _animationController?.reset(); - _animationController?.stop(canceled: true); - } else { - _startAnimationIfNeeded(); - } - } - _setupEffect(); - } - - @override - void dispose() { - _animationController - ?..removeListener(_onAnimationChange) - ..dispose(); - super.dispose(); - } - - void _setupEffect() { - _textDirection = Directionality.of(context); - final brightness = Theme.of(context).brightness; - var resolvedConfig = ext.SkeletonizerConfig.maybeOf(context) ?? - (brightness == Brightness.light - ? const ext.SkeletonizerConfigData() - : const ext.SkeletonizerConfigData.dark()); - - resolvedConfig = resolvedConfig.copyWith( - effect: SkeletonizerEffectAdapter(widget.effect), - textBorderRadius: widget.textBoneBorderRadius, - ignoreContainers: widget.ignoreContainers, - justifyMultiLineText: widget.justifyMultiLineText, - containersColor: widget.containersColor, - enableSwitchAnimation: widget.enableSwitchAnimation, - switchAnimationConfig: widget.switchAnimationConfig, - ); - - if (resolvedConfig != _config) { - _config = resolvedConfig; - _stopAnimation(); - if (widget.enabled) { - _startAnimationIfNeeded(); - } - } - } - - void _stopAnimation() { - _animationController - ?..removeListener(_onAnimationChange) - ..stop(canceled: true) - ..dispose(); - _animationController = null; - } - - void _startAnimationIfNeeded() { - if (widget.effect.duration.inMilliseconds == 0) { - return; - } - _animationController = AnimationController.unbounded(vsync: this) - ..addListener(_onAnimationChange) - ..repeat( - reverse: widget.effect.reverse, - min: widget.effect.lowerBound, - max: widget.effect.upperBound, - period: widget.effect.duration, - ); - } - - void _onAnimationChange() { - if (mounted && widget.enabled) { - setState(() {}); - } - } - - @override - Widget build(BuildContext context) { - final parent = ext.Skeletonizer.maybeOf(context); - final body = _enabled - ? WaveSkeletonizerRenderObjectWidget( - animationValue: _animationValue, - textDirection: _textDirection, - config: _config!, - ignorePointers: widget.ignorePointers, - isZone: false, - effect: widget.effect, - child: widget.child, - ) - : KeyedSubtree( - key: const ValueKey('content'), - child: widget.child, - ); - - Widget skeletonizer = ext_src.SkeletonizerScope( - enabled: _enabled, - config: _config!, - isZone: false, - isInsideZone: parent?.isZone ?? false, - animationController: _animationController, - child: _config!.enableSwitchAnimation - ? AnimatedSwitcher( - duration: _config!.switchAnimationConfig.duration, - reverseDuration: _config!.switchAnimationConfig.reverseDuration, - switchInCurve: _config!.switchAnimationConfig.switchInCurve, - switchOutCurve: _config!.switchAnimationConfig.switchOutCurve, - transitionBuilder: _config!.switchAnimationConfig.transitionBuilder, - layoutBuilder: _config!.switchAnimationConfig.layoutBuilder, - child: body, - ) - : body, - ); - - if (widget.surfaceStyle == null) { - return skeletonizer; - } - - return WaveScreen( - style: widget.surfaceStyle!, - child: skeletonizer, - ); - } -} \ No newline at end of file diff --git a/lib/src/wave/config.dart b/lib/src/wave/config.dart deleted file mode 100644 index dbfb72a..0000000 --- a/lib/src/wave/config.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/widgets.dart'; - -@immutable -class WaveLayer { - final Color? color; - final Gradient? gradient; - final double amplitude; - final double frequency; - final double speed; - final double phaseShift; - final double horizontalShift; - final double secondaryStrength; - final double heightFactor; - final double slope; - final MaskFilter? blur; - - const WaveLayer({ - this.color, - this.gradient, - required this.amplitude, - required this.frequency, - required this.speed, - required this.phaseShift, - this.horizontalShift = 0, - this.secondaryStrength = 0.22, - required this.heightFactor, - this.slope = 0, - this.blur, - }) : assert( - color != null || gradient != null, - 'A wave layer must define either a color or a gradient.', - ), - assert( - color == null || gradient == null, - 'A wave layer cannot define both a color and a gradient.', - ), - assert(amplitude >= 0, 'amplitude must be positive.'), - assert(frequency > 0, 'frequency must be greater than zero.'), - assert( - heightFactor >= 0 && heightFactor <= 1, - 'heightFactor must be between 0 and 1.', - ); - - const WaveLayer.color({ - required Color this.color, - required this.amplitude, - required this.frequency, - required this.speed, - required this.phaseShift, - this.horizontalShift = 0, - this.secondaryStrength = 0.22, - required this.heightFactor, - this.slope = 0, - this.blur, - }) : gradient = null; - - const WaveLayer.gradient({ - required Gradient this.gradient, - required this.amplitude, - required this.frequency, - required this.speed, - required this.phaseShift, - this.horizontalShift = 0, - this.secondaryStrength = 0.22, - required this.heightFactor, - this.slope = 0, - this.blur, - }) : color = null; -} diff --git a/lib/src/wave/pointer_ripple_effect.dart b/lib/src/wave/pointer_ripple_effect.dart new file mode 100644 index 0000000..47d8c22 --- /dev/null +++ b/lib/src/wave/pointer_ripple_effect.dart @@ -0,0 +1,60 @@ +import 'dart:math' as math; + +import 'wave_effect.dart'; + +/// A pointer-driven ripple. Placed in a [Wave]'s effects, it makes the enclosing +/// `WaveField` interactive: taps and drags spawn ripples that radiate out from +/// the pointer and fade. The displacement is a pure function of position and +/// age, so both the CPU and GPU paths — and tests — agree. +class PointerRippleEffect extends WaveEffect { + /// Peak height displacement (normalized to field height) at impact. + final double strength; + + /// Amplitude decay per second. + final double decay; + + /// Outward propagation speed, in normalized x per second. + final double speed; + + /// Spatial wavelength of the ripple, in normalized x. + final double wavelength; + + const PointerRippleEffect({ + this.strength = 0.08, + this.decay = 1.6, + this.speed = 0.6, + this.wavelength = 0.18, + }) : assert(decay > 0, 'decay must be greater than zero.'), + assert(wavelength > 0, 'wavelength must be greater than zero.'); + + /// The height displacement this ripple contributes at normalized [x] for a + /// ripple that originated at [originX] and has existed for [age] seconds. + double displacementAt({ + required double x, + required double originX, + required double age, + }) { + final amplitude = strength * math.exp(-decay * age); + if (amplitude < 1e-4) { + return 0.0; + } + final offset = (x - originX).abs() - (speed * age); + final ring = math.cos((2 * math.pi * offset) / wavelength); + final envelope = math.exp(-math.pow(offset / (wavelength * 1.5), 2).toDouble()); + return amplitude * ring * envelope; + } + + /// Seconds after which this ripple's amplitude is negligible. + double get lifetime => math.log(strength / 1e-4).clamp(0, double.infinity) / decay; + + @override + bool operator ==(Object other) => + other is PointerRippleEffect && + other.strength == strength && + other.decay == decay && + other.speed == speed && + other.wavelength == wavelength; + + @override + int get hashCode => Object.hash(strength, decay, speed, wavelength); +} diff --git a/lib/src/wave/ripple_field.dart b/lib/src/wave/ripple_field.dart new file mode 100644 index 0000000..5cb5aeb --- /dev/null +++ b/lib/src/wave/ripple_field.dart @@ -0,0 +1,54 @@ +import 'pointer_ripple_effect.dart'; + +/// Accumulates the live ripples spawned by pointer interaction and sums their +/// displacement. Pure Dart (no widgets) so it is unit-testable on its own. +class RippleField { + final PointerRippleEffect effect; + final List<_ActiveRipple> _ripples = <_ActiveRipple>[]; + + RippleField(this.effect); + + /// Spawn a ripple originating at normalized [originX] at time [now] (seconds). + void spawn(double originX, double now) { + _ripples.add(_ActiveRipple(originX, now)); + } + + /// Total displacement at normalized [x] at time [now], summed over all live + /// ripples. + double displacementAt(double x, double now) { + var sum = 0.0; + for (final ripple in _ripples) { + sum += effect.displacementAt( + x: x, + originX: ripple.originX, + age: now - ripple.startTime, + ); + } + return sum; + } + + /// Drop ripples whose amplitude has decayed below usefulness by [now]. + void prune(double now) { + _ripples.removeWhere((r) => (now - r.startTime) > effect.lifetime); + } + + bool get isEmpty => _ripples.isEmpty; + + int get activeCount => _ripples.length; + + /// The most recent live ripples, up to [limit], newest last. + List<({double originX, double startTime})> recent(int limit) { + final start = _ripples.length > limit ? _ripples.length - limit : 0; + return [ + for (var i = start; i < _ripples.length; i++) + (originX: _ripples[i].originX, startTime: _ripples[i].startTime), + ]; + } +} + +class _ActiveRipple { + final double originX; + final double startTime; + + const _ActiveRipple(this.originX, this.startTime); +} diff --git a/lib/src/wave/shader/wave_shader.dart b/lib/src/wave/shader/wave_shader.dart new file mode 100644 index 0000000..a8e558a --- /dev/null +++ b/lib/src/wave/shader/wave_shader.dart @@ -0,0 +1,34 @@ +import 'dart:ui' as ui; + +/// Loads and caches the wave fragment program. The shader asset is bundled with +/// the package, so its key is package-prefixed when consumed by a dependent app; +/// the bare key is tried as a fallback for in-package tooling. A load failure is +/// non-fatal — callers render a CPU fallback so the surface never blanks. +class WaveShaderLoader { + WaveShaderLoader._(); + + static const List _assetKeys = [ + 'packages/wave_screen/shaders/wave.frag', + 'shaders/wave.frag', + ]; + + static ui.FragmentProgram? _program; + static bool _attempted = false; + + /// Attempts to load the fragment program once. Returns `null` on failure. + static Future load() async { + if (_program != null || _attempted) { + return _program; + } + _attempted = true; + for (final key in _assetKeys) { + try { + _program = await ui.FragmentProgram.fromAsset(key); + return _program; + } catch (_) { + // Try the next candidate key; fall through to null on exhaustion. + } + } + return null; + } +} diff --git a/lib/src/wave/shader/wave_uniforms.dart b/lib/src/wave/shader/wave_uniforms.dart new file mode 100644 index 0000000..6f62e74 --- /dev/null +++ b/lib/src/wave/shader/wave_uniforms.dart @@ -0,0 +1,143 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import '../ripple_field.dart'; +import '../wave.dart'; +import '../wave_shape.dart'; + +/// Packs [Wave] layers and pointer ripples into the `wave.frag` uniform layout. +/// The float order here must match the uniform declaration order in +/// `shaders/wave.frag`: +/// `uSize`(2) + `uLayerCount`(1) + `uGeometry`(vec4 × 8) + `uGeometryB`(vec4 × 8) +/// + `uColor`(vec4 × 8) + `uRippleParams`(vec4) + `uRippleCount`(1) + +/// `uRipple`(vec4 × 8). [floatCount] is the single source of truth for how many +/// floats the shader declares; a mismatch is what makes a stale shader crash. +class WaveUniforms { + WaveUniforms._(); + + /// Maximum layers the shader supports (`MAX_LAYERS` in `wave.frag`). + static const int maxLayers = 8; + + /// Maximum concurrent ripples the shader renders (`MAX_RIPPLES`). + static const int maxRipples = 8; + + static const int _geometryBase = 3; + static const int _geometryBBase = _geometryBase + maxLayers * 4; + static const int _colorBase = _geometryBBase + maxLayers * 4; + static const int _rippleParamsBase = _colorBase + maxLayers * 4; + static const int _rippleCountIndex = _rippleParamsBase + 4; + static const int _rippleBase = _rippleCountIndex + 1; + + /// Total number of float uniforms the shader declares. + static const int floatCount = _rippleBase + maxRipples * 4; + + static const double _sine = 0; + static const double _gerstner = 1; + static const double _metaball = 2; + + /// Builds the full uniform float vector for [waves] sampled at time [t] + /// (seconds) over a [size] area, plus any live ripples from [rippleField]. + /// Pure and deterministic — no shader required. + static Float32List pack({ + required ui.Size size, + required List waves, + required double t, + RippleField? rippleField, + }) { + final data = Float32List(floatCount); + final count = waves.length.clamp(0, maxLayers); + data[0] = size.width; + data[1] = size.height; + data[2] = count.toDouble(); + + for (var i = 0; i < count; i++) { + _packLayer(data, waves[i], t, _geometryBase + i * 4, + _geometryBBase + i * 4, _colorBase + i * 4); + } + + _packRipples(data, rippleField, t); + return data; + } + + /// Writes the packed uniforms into [shader]. + static void apply( + ui.FragmentShader shader, { + required ui.Size size, + required List waves, + required double t, + RippleField? rippleField, + }) { + final data = pack(size: size, waves: waves, t: t, rippleField: rippleField); + for (var i = 0; i < data.length; i++) { + shader.setFloat(i, data[i]); + } + } + + static void _packLayer( + Float32List data, + Wave wave, + double t, + int gi, + int gbi, + int ci, + ) { + final shape = wave.shape; + final color = wave.style.resolvedColor; + + var shapeType = _sine; + var amplitude = 0.0; + var frequency = 0.0; + var steepness = 0.0; + var blobCount = 0.0; + var radius = 0.0; + if (shape is SineWaveShape) { + amplitude = shape.amplitude; + frequency = shape.frequency; + } else if (shape is GerstnerWaveShape) { + shapeType = _gerstner; + amplitude = shape.amplitude; + frequency = shape.frequency; + steepness = shape.steepness; + } else if (shape is MetaballWaveShape) { + shapeType = _metaball; + amplitude = shape.amplitude; + blobCount = shape.blobCount.toDouble(); + radius = shape.radius; + } + + data[gi] = shape.baseline; + data[gi + 1] = amplitude; + data[gi + 2] = frequency; + data[gi + 3] = wave.motion.phaseAt(t); + data[gbi] = shapeType; + data[gbi + 1] = steepness; + data[gbi + 2] = blobCount; + data[gbi + 3] = radius; + data[ci] = color.r; + data[ci + 1] = color.g; + data[ci + 2] = color.b; + data[ci + 3] = color.a; + } + + static void _packRipples( + Float32List data, + RippleField? rippleField, + double t, + ) { + final effect = rippleField?.effect; + data[_rippleParamsBase] = effect?.strength ?? 0.0; + data[_rippleParamsBase + 1] = effect?.decay ?? 1.0; + data[_rippleParamsBase + 2] = effect?.speed ?? 0.0; + data[_rippleParamsBase + 3] = effect?.wavelength ?? 1.0; + + final ripples = rippleField?.recent(maxRipples) ?? const []; + final count = ripples.length.clamp(0, maxRipples); + data[_rippleCountIndex] = count.toDouble(); + + for (var i = 0; i < count; i++) { + final ri = _rippleBase + i * 4; + data[ri] = ripples[i].originX; + data[ri + 1] = t - ripples[i].startTime; + } + } +} diff --git a/lib/src/wave/wave.dart b/lib/src/wave/wave.dart index fdd1e4e..aedbeb0 100644 --- a/lib/src/wave/wave.dart +++ b/lib/src/wave/wave.dart @@ -1,2 +1,41 @@ -export './config.dart'; -export './widget.dart'; \ No newline at end of file +import 'package:flutter/foundation.dart'; + +import 'wave_effect.dart'; +import 'wave_motion.dart'; +import 'wave_shape.dart'; +import 'wave_style.dart'; + +/// A single wave surface, described entirely by swappable traits: +/// [shape] (geometry), [style] (appearance), [motion] (time evolution), and +/// composable [effects]. Compose several [Wave]s in a `WaveField` to build a +/// layered scene. +@immutable +class Wave { + final WaveShape shape; + final WaveStyle style; + final WaveMotion motion; + final List effects; + + const Wave({ + required this.shape, + required this.style, + required this.motion, + this.effects = const [], + }); + + /// The composed surface height at normalized [x] in `[0, 1]` and time [t] + /// (seconds): the [shape] sampled with the phase the [motion] supplies at [t]. + double heightAt(double x, double t) => shape.sampleAt(x, motion.phaseAt(t)); + + @override + bool operator ==(Object other) => + other is Wave && + other.shape == shape && + other.style == style && + other.motion == motion && + listEquals(other.effects, effects); + + @override + int get hashCode => + Object.hash(shape, style, motion, Object.hashAll(effects)); +} diff --git a/lib/src/wave/wave_effect.dart b/lib/src/wave/wave_effect.dart new file mode 100644 index 0000000..7b2313e --- /dev/null +++ b/lib/src/wave/wave_effect.dart @@ -0,0 +1,9 @@ +import 'package:flutter/foundation.dart'; + +/// A composable overlay applied to a wave — interaction (pointer ripples) or +/// decoration (foam). The effect system is fleshed out in a later milestone; +/// this base exists so [Wave] can already carry an (empty) effect list. +@immutable +abstract class WaveEffect { + const WaveEffect(); +} diff --git a/lib/src/wave/wave_field.dart b/lib/src/wave/wave_field.dart new file mode 100644 index 0000000..eca4c65 --- /dev/null +++ b/lib/src/wave/wave_field.dart @@ -0,0 +1,125 @@ +import 'dart:ui' as ui; + +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import 'pointer_ripple_effect.dart'; +import 'ripple_field.dart'; +import 'shader/wave_shader.dart'; +import 'wave.dart'; +import 'wave_field_painter.dart'; + +/// Composites a list of [Wave] layers into a single animated surface. Rendering +/// runs on the GPU via the wave fragment shader; a CPU path is used as a fallback +/// so the field never blanks. When any wave carries a [PointerRippleEffect] the +/// field becomes interactive: taps and drags spawn ripples that displace the +/// surface, reported through [onRipple] as a normalized position. +class WaveField extends StatefulWidget { + final List waves; + final ValueChanged? onRipple; + + const WaveField({super.key, required this.waves, this.onRipple}); + + @override + State createState() => _WaveFieldState(); +} + +class _WaveFieldState extends State + with SingleTickerProviderStateMixin { + final ValueNotifier _clock = ValueNotifier(0.0); + late final Ticker _ticker; + ui.FragmentShader? _shader; + RippleField? _rippleField; + + @override + void initState() { + super.initState(); + _ticker = createTicker(_onTick)..start(); + _rippleField = _buildRippleField(); + _loadShader(); + } + + @override + void didUpdateWidget(covariant WaveField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.waves != widget.waves) { + _rippleField = _buildRippleField(); + } + } + + RippleField? _buildRippleField() { + for (final wave in widget.waves) { + for (final effect in wave.effects) { + if (effect is PointerRippleEffect) { + return RippleField(effect); + } + } + } + return null; + } + + void _onTick(Duration elapsed) { + _clock.value = elapsed.inMicroseconds / Duration.microsecondsPerSecond; + _rippleField?.prune(_clock.value); + } + + Future _loadShader() async { + final program = await WaveShaderLoader.load(); + if (!mounted || program == null) { + return; + } + setState(() => _shader = program.fragmentShader()); + } + + void _emitRipple(Offset local, Size size) { + final field = _rippleField; + if (field == null || size.isEmpty) { + return; + } + final normalized = Offset( + (local.dx / size.width).clamp(0.0, 1.0), + (local.dy / size.height).clamp(0.0, 1.0), + ); + field.spawn(normalized.dx, _clock.value); + widget.onRipple?.call(normalized); + } + + @override + void dispose() { + _ticker.dispose(); + _clock.dispose(); + _shader?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final content = RepaintBoundary( + child: CustomPaint( + size: Size.infinite, + painter: WaveFieldPainter( + waves: widget.waves, + clock: _clock, + shader: _shader, + rippleField: _rippleField, + ), + ), + ); + + if (_rippleField == null) { + return content; + } + + return LayoutBuilder( + builder: (context, constraints) { + final size = constraints.biggest; + return Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (event) => _emitRipple(event.localPosition, size), + onPointerMove: (event) => _emitRipple(event.localPosition, size), + child: content, + ); + }, + ); + } +} diff --git a/lib/src/wave/wave_field_painter.dart b/lib/src/wave/wave_field_painter.dart new file mode 100644 index 0000000..90e2112 --- /dev/null +++ b/lib/src/wave/wave_field_painter.dart @@ -0,0 +1,85 @@ +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; + +import 'ripple_field.dart'; +import 'shader/wave_uniforms.dart'; +import 'wave.dart'; + +/// Paints composited wave layers each frame, driven by [clock]. Uses the GPU +/// [shader] when available and a CPU path otherwise, so the surface never blanks. +/// An optional [rippleField] adds pointer-driven displacement to every layer. +class WaveFieldPainter extends CustomPainter { + final List waves; + final ValueNotifier clock; + final ui.FragmentShader? shader; + final RippleField? rippleField; + + WaveFieldPainter({ + required this.waves, + required this.clock, + required this.shader, + this.rippleField, + }) : super(repaint: clock); + + static const int _cpuSamples = 96; + + @override + void paint(Canvas canvas, Size size) { + final t = clock.value; + final activeShader = shader; + if (activeShader != null && _paintShader(canvas, size, t, activeShader)) { + return; + } + _paintCpuFallback(canvas, size, t); + } + + /// Paints via the GPU shader. Returns `false` (so the caller falls back to the + /// CPU path) if the shader's uniform layout does not match what we write — + /// e.g. a stale compiled shader from an older uniform layout. Degrading is far + /// better than crash-flooding every frame. + bool _paintShader(Canvas canvas, Size size, double t, ui.FragmentShader s) { + try { + WaveUniforms.apply( + s, + size: size, + waves: waves, + t: t, + rippleField: rippleField, + ); + } on Error { + return false; + } + canvas.drawRect(Offset.zero & size, Paint()..shader = s); + return true; + } + + void _paintCpuFallback(Canvas canvas, Size size, double t) { + final ripple = rippleField; + for (final wave in waves) { + final baseline = wave.shape.baseline; + final path = Path()..moveTo(0, size.height); + for (var s = 0; s <= _cpuSamples; s++) { + final x = s / _cpuSamples; + final displacement = ripple?.displacementAt(x, t) ?? 0.0; + final crest = (baseline + wave.heightAt(x, t) + displacement) * size.height; + path.lineTo(x * size.width, crest); + } + path + ..lineTo(size.width, size.height) + ..close(); + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.fill + ..color = wave.style.resolvedColor, + ); + } + } + + @override + bool shouldRepaint(covariant WaveFieldPainter oldDelegate) => + oldDelegate.waves != waves || + oldDelegate.shader != shader || + oldDelegate.rippleField != rippleField; +} diff --git a/lib/src/wave/wave_motion.dart b/lib/src/wave/wave_motion.dart new file mode 100644 index 0000000..50a8a8c --- /dev/null +++ b/lib/src/wave/wave_motion.dart @@ -0,0 +1,80 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; + +/// How a wave surface evolves over time. A motion contributes a *phase* (in +/// radians) for a given time `t` (seconds); the shape turns that phase into a +/// height. This keeps time-evolution a swappable trait, independent of geometry. +@immutable +abstract class WaveMotion { + const WaveMotion(); + + /// A wave that travels: phase advances linearly with time at [speed] + /// radians per second. + const factory WaveMotion.drift({double speed}) = DriftMotion; + + /// A wave frozen in place: phase never advances. + const factory WaveMotion.still() = StillMotion; + + /// A wave that eases forward then back: the phase oscillates smoothly between + /// `-sway` and `+sway` once per [period] seconds. + const factory WaveMotion.pingPong({double sway, double period}) = + PingPongMotion; + + /// The phase, in radians, contributed at time [t] (seconds). + double phaseAt(double t); +} + +/// Linear, endlessly travelling motion. +@immutable +class DriftMotion extends WaveMotion { + final double speed; + + const DriftMotion({this.speed = 1.0}); + + @override + double phaseAt(double t) => speed * t; + + @override + bool operator ==(Object other) => + other is DriftMotion && other.speed == speed; + + @override + int get hashCode => speed.hashCode; +} + +/// A wave that does not move. +@immutable +class StillMotion extends WaveMotion { + const StillMotion(); + + @override + double phaseAt(double t) => 0.0; + + @override + bool operator ==(Object other) => other is StillMotion; + + @override + int get hashCode => 0; +} + +/// Smooth back-and-forth motion: the phase follows a sine of the elapsed time, +/// easing to `+sway` and `-sway` once per [period] seconds. +@immutable +class PingPongMotion extends WaveMotion { + final double sway; + final double period; + + const PingPongMotion({this.sway = 1.0, this.period = 6.0}) + : assert(period > 0, 'period must be greater than zero.'); + + @override + double phaseAt(double t) => sway * math.sin((2 * math.pi * t) / period); + + @override + bool operator ==(Object other) => + other is PingPongMotion && other.sway == sway && other.period == period; + + @override + int get hashCode => Object.hash(sway, period); +} diff --git a/lib/src/wave/wave_shape.dart b/lib/src/wave/wave_shape.dart new file mode 100644 index 0000000..0ec1782 --- /dev/null +++ b/lib/src/wave/wave_shape.dart @@ -0,0 +1,166 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; + +const double _twoPi = 2 * math.pi; + +/// The geometry trait of a wave: a height field sampled at a normalized +/// horizontal position with a motion-supplied phase. The GPU shader mirrors +/// this exact model so CPU-side [sampleAt] and the rendered surface agree. +@immutable +abstract class WaveShape { + const WaveShape(); + + /// A single clean sine surface of the given [amplitude] and [frequency] + /// (cycles across the full width). [baseline] is the normalized vertical + /// anchor of the crest line, `0` at the top and `1` at the bottom. + const factory WaveShape.sine({ + required double amplitude, + required double frequency, + double baseline, + }) = SineWaveShape; + + /// A sharpened, Gerstner-style crest of the given [amplitude] and [frequency]. + /// [steepness] `0` is identical to a sine; higher values sharpen the crests + /// and flatten the troughs. + const factory WaveShape.gerstner({ + required double amplitude, + required double frequency, + double steepness, + double baseline, + }) = GerstnerWaveShape; + + /// A gooey crest built from [blobCount] evenly-spaced merging blobs of the + /// given [radius]. Nearby blobs bridge into one bulge; the bulge peaks at + /// [amplitude]. Blobs drift with the motion phase. + const factory WaveShape.metaball({ + required int blobCount, + required double amplitude, + double radius, + double baseline, + }) = MetaballWaveShape; + + /// Sample the surface height (in amplitude units) at normalized [x] in + /// `[0, 1]` with the given [phase] in radians. This is the crest offset only; + /// vertical placement is carried separately by [baseline]. + double sampleAt(double x, double phase); + + /// The normalized vertical anchor of the crest line, `[0, 1]`. + double get baseline; +} + +/// A pure single-frequency sine surface. +@immutable +class SineWaveShape extends WaveShape { + final double amplitude; + final double frequency; + + @override + final double baseline; + + const SineWaveShape({ + required this.amplitude, + required this.frequency, + this.baseline = 0.5, + }); + + @override + double sampleAt(double x, double phase) => + amplitude * math.sin((x * _twoPi * frequency) + phase); + + @override + bool operator ==(Object other) => + other is SineWaveShape && + other.amplitude == amplitude && + other.frequency == frequency && + other.baseline == baseline; + + @override + int get hashCode => Object.hash(amplitude, frequency, baseline); +} + +/// A sharpened, Gerstner-style crest. The profile is a gamma-shaped sine: with +/// [steepness] `0` it equals a sine; larger values narrow the crests and widen +/// the troughs, approximating the trochoidal ocean look as a pure height field. +@immutable +class GerstnerWaveShape extends WaveShape { + final double amplitude; + final double frequency; + final double steepness; + + @override + final double baseline; + + const GerstnerWaveShape({ + required this.amplitude, + required this.frequency, + this.steepness = 0.6, + this.baseline = 0.5, + }) : assert(steepness >= 0, 'steepness must be non-negative.'); + + @override + double sampleAt(double x, double phase) { + final theta = (x * _twoPi * frequency) + phase; + final u = 0.5 * (1 + math.sin(theta)); + final sharpened = math.pow(u, 1 + steepness).toDouble(); + return amplitude * ((2 * sharpened) - 1); + } + + @override + bool operator ==(Object other) => + other is GerstnerWaveShape && + other.amplitude == amplitude && + other.frequency == frequency && + other.steepness == steepness && + other.baseline == baseline; + + @override + int get hashCode => Object.hash(amplitude, frequency, steepness, baseline); +} + +/// A gooey crest made of [blobCount] evenly-spaced blobs that drift with the +/// motion phase. Each blob is a Gaussian bump of the given [radius]; blobs are +/// combined with a smooth union so nearby blobs merge into one bulge peaking at +/// [amplitude], while distant blobs stay separate. +@immutable +class MetaballWaveShape extends WaveShape { + final int blobCount; + final double amplitude; + final double radius; + + @override + final double baseline; + + const MetaballWaveShape({ + required this.blobCount, + required this.amplitude, + this.radius = 0.12, + this.baseline = 0.5, + }) : assert(blobCount > 0, 'blobCount must be positive.'), + assert(radius > 0, 'radius must be greater than zero.'); + + @override + double sampleAt(double x, double phase) { + final drift = phase / _twoPi; + var product = 1.0; + for (var i = 0; i < blobCount; i++) { + final center = (((i + 0.5) / blobCount) + drift) % 1.0; + final raw = (x - center).abs(); + final distance = math.min(raw, 1 - raw); // wrap around the edges + final bump = math.exp(-math.pow(distance / radius, 2).toDouble()); + product *= 1 - bump; + } + return -amplitude * (1 - product); + } + + @override + bool operator ==(Object other) => + other is MetaballWaveShape && + other.blobCount == blobCount && + other.amplitude == amplitude && + other.radius == radius && + other.baseline == baseline; + + @override + int get hashCode => Object.hash(blobCount, amplitude, radius, baseline); +} diff --git a/lib/src/wave/wave_style.dart b/lib/src/wave/wave_style.dart new file mode 100644 index 0000000..b50c46f --- /dev/null +++ b/lib/src/wave/wave_style.dart @@ -0,0 +1,28 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; + +/// The appearance trait of a wave: how the surface is filled. Kept independent +/// of geometry and motion so the same shape can be restyled freely. +@immutable +class WaveStyle { + /// Solid fill colour of the wave body. + final Color fill; + + /// Overall opacity multiplier applied to [fill], in `[0, 1]`. + final double opacity; + + const WaveStyle({ + this.fill = const Color(0xFF3B2CC5), + this.opacity = 1.0, + }) : assert(opacity >= 0 && opacity <= 1, 'opacity must be within [0, 1].'); + + /// The fill colour with [opacity] folded into its alpha channel. + Color get resolvedColor => fill.withValues(alpha: fill.a * opacity); + + @override + bool operator ==(Object other) => + other is WaveStyle && other.fill == fill && other.opacity == opacity; + + @override + int get hashCode => Object.hash(fill, opacity); +} diff --git a/lib/src/wave/widget.dart b/lib/src/wave/widget.dart deleted file mode 100644 index b18b97c..0000000 --- a/lib/src/wave/widget.dart +++ /dev/null @@ -1,423 +0,0 @@ -// Apache License -// Version 2.0, January 2004 -// http://www.apache.org/licenses/ -// -// TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -// -// 1. Definitions. -// -// "License" shall mean the terms and conditions for use, reproduction, -// and distribution as defined by Sections 1 through 9 of this document. -// -// "Licensor" shall mean the copyright owner or entity authorized by -// the copyright owner that is granting the License. -// -// "Legal Entity" shall mean the union of the acting entity and all -// other entities that control, are controlled by, or are under common -// control with that entity. For the purposes of this definition, -// "control" means (i) the power, direct or indirect, to cause the -// direction or management of such entity, whether by contract or -// otherwise, or (ii) ownership of fifty percent (50%) or more of the -// outstanding shares, or (iii) beneficial ownership of such entity. -// -// "You" (or "Your") shall mean an individual or Legal Entity -// exercising permissions granted by this License. -// -// "Source" form shall mean the preferred form for making modifications, -// including but not limited to software source code, documentation -// source, and configuration files. -// -// "Object" form shall mean any form resulting from mechanical -// transformation or translation of a Source form, including but -// not limited to compiled object code, generated documentation, -// and conversions to other media types. -// -// "Work" shall mean the work of authorship, whether in Source or -// Object form, made available under the License, as indicated by a -// copyright notice that is included in or attached to the work -// (an example is provided in the Appendix below). -// -// "Derivative Works" shall mean any work, whether in Source or Object -// form, that is based on (or derived from) the Work and for which the -// editorial revisions, annotations, elaborations, or other modifications -// represent, as a whole, an original work of authorship. For the purposes -// of this License, Derivative Works shall not include works that remain -// separable from, or merely link (or bind by name) to the interfaces of, -// the Work and Derivative Works thereof. -// -// "Contribution" shall mean any work of authorship, including -// the original version of the Work and any modifications or additions -// to that Work or Derivative Works thereof, that is intentionally -// submitted to Licensor for inclusion in the Work by the copyright owner -// or by an individual or Legal Entity authorized to submit on behalf of -// the copyright owner. For the purposes of this definition, "submitted" -// means any form of electronic, verbal, or written communication sent -// to the Licensor or its representatives, including but not limited to -// communication on electronic mailing lists, source code control systems, -// and issue tracking systems that are managed by, or on behalf of, the -// Licensor for the purpose of discussing and improving the Work, but -// excluding communication that is conspicuously marked or otherwise -// designated in writing by the copyright owner as "Not a Contribution." -// -// "Contributor" shall mean Licensor and any individual or Legal Entity -// on behalf of whom a Contribution has been received by Licensor and -// subsequently incorporated within the Work. -// -// 2. Grant of Copyright License. Subject to the terms and conditions of -// this License, each Contributor hereby grants to You a perpetual, -// worldwide, non-exclusive, no-charge, royalty-free, irrevocable -// copyright license to reproduce, prepare Derivative Works of, -// publicly display, publicly perform, sublicense, and distribute the -// Work and such Derivative Works in Source or Object form. -// -// 3. Grant of Patent License. Subject to the terms and conditions of -// this License, each Contributor hereby grants to You a perpetual, -// worldwide, non-exclusive, no-charge, royalty-free, irrevocable -// (except as stated in this section) patent license to make, have made, -// use, offer to sell, sell, import, and otherwise transfer the Work, -// where such license applies only to those patent claims licensable -// by such Contributor that are necessarily infringed by their -// Contribution(s) alone or by combination of their Contribution(s) -// with the Work to which such Contribution(s) was submitted. If You -// institute patent litigation against any entity (including a -// cross-claim or counterclaim in a lawsuit) alleging that the Work -// or a Contribution incorporated within the Work constitutes direct -// or contributory patent infringement, then any patent licenses -// granted to You under this License for that Work shall terminate -// as of the date such litigation is filed. -// -// 4. Redistribution. You may reproduce and distribute copies of the -// Work or Derivative Works thereof in any medium, with or without -// modifications, and in Source or Object form, provided that You -// meet the following conditions: -// -// (a) You must give any other recipients of the Work or -// Derivative Works a copy of this License; and -// -// (b) You must cause any modified files to carry prominent notices -// stating that You changed the files; and -// -// (c) You must retain, in the Source form of any Derivative Works -// that You distribute, all copyright, patent, trademark, and -// attribution notices from the Source form of the Work, -// excluding those notices that do not pertain to any part of -// the Derivative Works; and -// -// (d) If the Work includes a "NOTICE" text file as part of its -// distribution, then any Derivative Works that You distribute must -// include a readable copy of the attribution notices contained -// within such NOTICE file, excluding those notices that do not -// pertain to any part of the Derivative Works, in at least one -// of the following places: within a NOTICE text file distributed -// as part of the Derivative Works; within the Source form or -// documentation, if provided along with the Derivative Works; or, -// within a display generated by the Derivative Works, if and -// wherever such third-party notices normally appear. The contents -// of the NOTICE file are for informational purposes only and -// do not modify the License. You may add Your own attribution -// notices within Derivative Works that You distribute, alongside -// or as an addendum to the NOTICE text from the Work, provided -// that such additional attribution notices cannot be construed -// as modifying the License. -// -// You may add Your own copyright statement to Your modifications and -// may provide additional or different license terms and conditions -// for use, reproduction, or distribution of Your modifications, or -// for any such Derivative Works as a whole, provided Your use, -// reproduction, and distribution of the Work otherwise complies with -// the conditions stated in this License. -// -// 5. Submission of Contributions. Unless You explicitly state otherwise, -// any Contribution intentionally submitted for inclusion in the Work -// by You to the Licensor shall be under the terms and conditions of -// this License, without any additional terms or conditions. -// Notwithstanding the above, nothing herein shall supersede or modify -// the terms of any separate license agreement you may have executed -// with Licensor regarding such Contributions. -// -// 6. Trademarks. This License does not grant permission to use the trade -// names, trademarks, service marks, or product names of the Licensor, -// except as required for reasonable and customary use in describing the -// origin of the Work and reproducing the content of the NOTICE file. -// -// 7. Disclaimer of Warranty. Unless required by applicable law or -// agreed to in writing, Licensor provides the Work (and each -// Contributor provides its Contributions) on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or -// implied, including, without limitation, any warranties or conditions -// of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A -// PARTICULAR PURPOSE. You are solely responsible for determining the -// appropriateness of using or redistributing the Work and assume any -// risks associated with Your exercise of permissions under this License. -// -// 8. Limitation of Liability. In no event and under no legal theory, -// whether in tort (including negligence), contract, or otherwise, -// unless required by applicable law (such as deliberate and grossly -// negligent acts) or agreed to in writing, shall any Contributor be -// liable to You for damages, including any direct, indirect, special, -// incidental, or consequential damages of any character arising as a -// result of this License or out of the use or inability to use the -// Work (including but not limited to damages for loss of goodwill, -// work stoppage, computer failure or malfunction, or any and all -// other commercial damages or losses), even if such Contributor -// has been advised of the possibility of such damages. -// -// 9. Accepting Warranty or Additional Liability. While redistributing -// the Work or Derivative Works thereof, You may choose to offer, -// and charge a fee for, acceptance of support, warranty, indemnity, -// or other liability obligations and/or rights consistent with this -// License. However, in accepting such obligations, You may act only -// on Your own behalf and on Your sole responsibility, not on behalf -// of any other Contributor, and only if You agree to indemnify, -// defend, and hold each Contributor harmless for any liability -// incurred by, or claims asserted against, such Contributor by reason -// of your accepting any such warranty or additional liability. -// -// END OF TERMS AND CONDITIONS -// -// APPENDIX: How to apply the Apache License to your work. -// -// To apply the Apache License to your work, attach the following -// boilerplate notice, with the fields enclosed by brackets "[]" -// replaced with your own identifying information. (Don't include -// the brackets!) The text should be enclosed in the appropriate -// comment syntax for the file format. We also recommend that a -// file or class name and description of purpose be included on the -// same "printed page" as the copyright notice for easier -// identification within third-party archives. -// -// Copyright [2018] [While1true] -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'dart:math'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'config.dart'; - -class WaveWidget extends StatefulWidget { - final List layers; - final Duration duration; - final Color? backgroundColor; - final Gradient? backgroundGradient; - final DecorationImage? backgroundImage; - final double? width; - final double? height; - final Clip clipBehavior; - final bool repeat; - final Widget? child; - - const WaveWidget({ - super.key, - required this.layers, - this.duration = const Duration(seconds: 16), - this.backgroundColor, - this.backgroundGradient, - this.backgroundImage, - this.width, - this.height, - this.clipBehavior = Clip.hardEdge, - this.repeat = true, - this.child, - }) : assert(layers.length > 0, 'WaveWidget requires at least one wave layer.'); - - @override - State createState() => _WaveWidgetState(); -} - -class _WaveWidgetState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController(vsync: this, duration: widget.duration); - _startAnimation(); - } - - @override - void didUpdateWidget(covariant WaveWidget oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.duration != widget.duration) { - _controller.duration = widget.duration; - } - if (oldWidget.repeat != widget.repeat || oldWidget.duration != widget.duration) { - _startAnimation(); - } - } - - void _startAnimation() { - _controller - ..stop() - ..reset(); - if (widget.repeat) { - _controller.repeat(); - return; - } - _controller.forward(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final width = widget.width ?? - (constraints.maxWidth.isFinite ? constraints.maxWidth : 320.0); - final height = widget.height ?? - (constraints.maxHeight.isFinite ? constraints.maxHeight : 240.0); - - return ClipRect( - clipBehavior: widget.clipBehavior, - child: DecoratedBox( - decoration: BoxDecoration( - color: widget.backgroundColor, - gradient: widget.backgroundGradient, - image: widget.backgroundImage, - ), - child: SizedBox( - width: width, - height: height, - child: Stack( - fit: StackFit.expand, - children: [ - RepaintBoundary( - child: AnimatedBuilder( - animation: _controller, - builder: (context, _) { - return CustomPaint( - painter: _WavePainter( - layers: widget.layers, - progress: _controller.value, - ), - ); - }, - ), - ), - if (widget.child != null) widget.child!, - ], - ), - ), - ), - ); - }, - ); - } -} - -class _WavePainter extends CustomPainter { - final List layers; - final double progress; - - const _WavePainter({required this.layers, required this.progress}); - - @override - void paint(Canvas canvas, Size size) { - final enableBlurEffects = !kIsWeb; - - for (final layer in layers) { - final geometry = _buildGeometry(size, layer); - final paint = Paint()..style = PaintingStyle.fill; - if (layer.gradient != null) { - paint.shader = layer.gradient!.createShader(geometry.fillPath.getBounds()); - } else { - paint.color = layer.color!; - } - if (enableBlurEffects && layer.blur != null) { - paint.maskFilter = layer.blur; - } - canvas.drawPath(geometry.fillPath, paint); - - final shadowStroke = (layer.amplitude * 0.18).clamp(3.0, 8.0); - canvas.save(); - canvas.translate(0, shadowStroke * 0.45); - canvas.drawPath( - geometry.crestPath, - Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = shadowStroke - ..strokeCap = StrokeCap.round - ..color = const Color(0x22000000) - ..maskFilter = enableBlurEffects - ? MaskFilter.blur(BlurStyle.normal, shadowStroke) - : null, - ); - canvas.restore(); - - canvas.drawPath( - geometry.crestPath, - Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = (shadowStroke * 0.55).clamp(1.25, 3.5) - ..strokeCap = StrokeCap.round - ..color = const Color(0x16FFFFFF), - ); - } - } - - _WaveGeometry _buildGeometry(Size size, WaveLayer layer) { - final fillPath = Path(); - final crestPath = Path(); - final baseline = size.height * layer.heightFactor; - final step = max(1.0, size.width / 180); - final startY = _pointY(0, size, layer, baseline); - - fillPath.moveTo(0, size.height); - fillPath.lineTo(0, startY); - crestPath.moveTo(0, startY); - - for (double x = step; x <= size.width; x += step) { - final y = _pointY(x, size, layer, baseline); - fillPath.lineTo(x, y); - crestPath.lineTo(x, y); - } - - fillPath.lineTo(size.width, size.height); - fillPath.close(); - return _WaveGeometry(fillPath: fillPath, crestPath: crestPath); - } - - double _pointY(double x, Size size, WaveLayer layer, double baseline) { - final normalizedX = x / size.width; - final motionOffset = progress * layer.speed * 1.8; - final shiftedX = normalizedX + layer.horizontalShift + motionOffset; - final phase = layer.phaseShift + motionOffset * pi * 0.35; - final primary = sin((shiftedX * layer.frequency * 2 * pi) + phase); - final secondary = cos( - (shiftedX * (layer.frequency * 1.18) * 2 * pi) - phase * 0.42, - ); - final slopeOffset = (normalizedX - 0.5) * size.height * layer.slope; - final crestBoost = 1.0 + 0.12 * cos((shiftedX * 2 * pi) - phase * 0.25); - final wave = primary * 0.94 + secondary * layer.secondaryStrength; - return baseline + slopeOffset + wave * layer.amplitude * crestBoost; - } - - @override - bool shouldRepaint(covariant _WavePainter oldDelegate) { - return oldDelegate.progress != progress || oldDelegate.layers != layers; - } -} - -class _WaveGeometry { - final Path fillPath; - final Path crestPath; - - const _WaveGeometry({required this.fillPath, required this.crestPath}); -} diff --git a/lib/wave_screen.dart b/lib/wave_screen.dart index 8804a46..3ac0243 100644 --- a/lib/wave_screen.dart +++ b/lib/wave_screen.dart @@ -1,3 +1,13 @@ +/// Composable, GPU-shader-driven animated wave surfaces for Flutter. +library; + +export 'src/screen/wave_presets.dart'; +export 'src/screen/wave_screen.dart'; +export 'src/screen/wave_screen_preset.dart'; +export 'src/wave/pointer_ripple_effect.dart'; export 'src/wave/wave.dart'; -export 'src/screen/screen.dart'; -export 'src/skeletonizer.dart'; +export 'src/wave/wave_effect.dart'; +export 'src/wave/wave_field.dart'; +export 'src/wave/wave_motion.dart'; +export 'src/wave/wave_shape.dart'; +export 'src/wave/wave_style.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 8e1c91b..86045e8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: wave_screen -description: "Animated Flutter wave surfaces for standalone waves, layered screens, and geometry-aware skeleton loading states." -version: 0.0.1 +description: "Composable, GPU-shader-driven animated wave surfaces for Flutter — build a Wave from swappable shape, style, and motion traits." +version: 0.1.0 homepage: https://github.com/Pathverse/wave-screen repository: https://github.com/Pathverse/wave-screen issue_tracker: https://github.com/Pathverse/wave-screen/issues @@ -12,7 +12,6 @@ environment: dependencies: flutter: sdk: flutter - skeletonizer: ^2.1.3 dev_dependencies: flutter_test: @@ -24,4 +23,6 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - uses-material-design: true \ No newline at end of file + uses-material-design: true + shaders: + - shaders/wave.frag \ No newline at end of file diff --git a/shaders/wave.frag b/shaders/wave.frag new file mode 100644 index 0000000..7f18520 --- /dev/null +++ b/shaders/wave.frag @@ -0,0 +1,115 @@ +// wave.frag — GPU height-field renderer for wave_screen. +// +// Renders up to MAX_LAYERS stacked wave surfaces. Each layer fills the region +// below its crest with its fill colour, composited back-to-front. The crest +// height field mirrors the Dart WaveShape models (sine / gerstner / metaball) so +// the CPU-side `Wave.heightAt` and the GPU render agree. +#include + +const int MAX_LAYERS = 8; +const int MAX_BLOBS = 8; +const int MAX_RIPPLES = 8; +const float TWO_PI = 6.28318530717958647692; + +uniform vec2 uSize; // pixel size of the paint area +uniform float uLayerCount; // active layer count (<= MAX_LAYERS) + +// Per-layer geometry A: x = baseline (0..1 of height), y = amplitude (0..1), +// z = frequency (cycles across width), w = phase (radians). +uniform vec4 uGeometry[MAX_LAYERS]; +// Per-layer geometry B: x = shapeType (0 sine, 1 gerstner, 2 metaball), +// y = steepness (gerstner), z = blobCount (metaball), w = radius (metaball). +uniform vec4 uGeometryB[MAX_LAYERS]; +// Per-layer fill colour, straight RGBA. +uniform vec4 uColor[MAX_LAYERS]; +// Pointer ripple parameters: x = strength, y = decay, z = speed, w = wavelength. +uniform vec4 uRippleParams; +uniform float uRippleCount; +// Per-ripple state: x = originX (0..1), y = age (seconds). +uniform vec4 uRipple[MAX_RIPPLES]; + +out vec4 fragColor; + +// Summed pointer-ripple displacement at horizontal ux (normalized height units). +float rippleDisplacement(float ux) { + int n = int(uRippleCount + 0.5); + if (n <= 0) { + return 0.0; + } + float strength = uRippleParams.x; + float decay = uRippleParams.y; + float speed = uRippleParams.z; + float wl = max(uRippleParams.w, 1e-4); + float sum = 0.0; + for (int i = 0; i < MAX_RIPPLES; i++) { + if (i >= n) { + break; + } + float age = uRipple[i].y; + float amp = strength * exp(-decay * age); + float offset = abs(ux - uRipple[i].x) - (speed * age); + float ring = cos((TWO_PI * offset) / wl); + float env = exp(-pow(offset / (wl * 1.5), 2.0)); + sum += amp * ring * env; + } + return sum; +} + +// Crest offset (in normalized height units) for a layer at horizontal ux. +float crestOffset(vec4 g, vec4 gb, float ux) { + float theta = (ux * TWO_PI * g.z) + g.w; + int shapeType = int(gb.x + 0.5); + + if (shapeType == 1) { + // Gerstner: gamma-sharpened sine (steepness 0 == sine). + float u = 0.5 * (1.0 + sin(theta)); + float sharpened = pow(u, 1.0 + gb.y); + return g.y * ((2.0 * sharpened) - 1.0); + } + + if (shapeType == 2) { + // Metaball: smooth union of evenly-spaced drifting Gaussian blobs. + float drift = g.w / TWO_PI; + int n = int(gb.z + 0.5); + float radius = max(gb.w, 1e-4); + float product = 1.0; + for (int i = 0; i < MAX_BLOBS; i++) { + if (i >= n) { + break; + } + float center = fract(((float(i) + 0.5) / float(n)) + drift); + float raw = abs(ux - center); + float dist = min(raw, 1.0 - raw); + float bump = exp(-pow(dist / radius, 2.0)); + product *= (1.0 - bump); + } + return -g.y * (1.0 - product); + } + + // Sine. + return g.y * sin(theta); +} + +void main() { + vec2 uv = FlutterFragCoord().xy / uSize; // 0..1, y grows downward + vec4 color = vec4(0.0); + int count = int(uLayerCount + 0.5); + + for (int i = 0; i < MAX_LAYERS; i++) { + if (i >= count) { + break; + } + vec4 g = uGeometry[i]; + vec4 gb = uGeometryB[i]; + float crest = g.x + crestOffset(g, gb, uv.x) + rippleDisplacement(uv.x); + // Anti-aliased fill over roughly one pixel around the crest line. Skia's + // SkSL runtime effects forbid derivative functions (fwidth), so the pixel + // size is derived from the paint area instead. + float aa = 1.5 / max(uSize.y, 1.0); + float fill = smoothstep(crest - aa, crest + aa, uv.y); + vec4 lc = uColor[i]; + color = mix(color, lc, lc.a * fill); + } + + fragColor = color; +} diff --git a/test/proofs/gerstner_proof_test.dart b/test/proofs/gerstner_proof_test.dart new file mode 100644 index 0000000..369fc4e --- /dev/null +++ b/test/proofs/gerstner_proof_test.dart @@ -0,0 +1,27 @@ +@Tags(['proof_gerstner_sharpens_crest']) +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; + +/// M2 proof: the Gerstner shape peaks at the same crest as a sine of the same +/// amplitude/frequency, but sharpens — sitting below the sine at the sine's +/// zero-crossing (flatter troughs, sharper peaks). +void main() { + test('gerstner peaks like a sine but flattens between crests', () { + final gerstner = WaveShape.gerstner( + amplitude: 1.0, + frequency: 1.0, + steepness: 0.8, + ); + final sine = WaveShape.sine(amplitude: 1.0, frequency: 1.0); + + // Crest of both is at x = 0.25 (theta = pi/2). + expect(gerstner.sampleAt(0.25, 0.0), closeTo(1.0, 1e-9)); + expect(sine.sampleAt(0.25, 0.0), closeTo(1.0, 1e-9)); + + // At the sine's zero-crossing the sharpened profile dips well below zero. + expect(sine.sampleAt(0.0, 0.0), closeTo(0.0, 1e-9)); + expect(gerstner.sampleAt(0.0, 0.0), lessThan(-0.1)); + }); +} diff --git a/test/proofs/metaball_proof_test.dart b/test/proofs/metaball_proof_test.dart new file mode 100644 index 0000000..7ac83f0 --- /dev/null +++ b/test/proofs/metaball_proof_test.dart @@ -0,0 +1,27 @@ +@Tags(['proof_metaball_blobs_merge']) +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; + +/// M2 proof: a metaball surface bulges at each blob center. With two blobs, a +/// large radius bridges them into one gooey crest at the midpoint, while a small +/// radius leaves the midpoint back near the baseline. +void main() { + test('metaball blobs bulge at centers and merge as radius grows', () { + // Two blobs => centers at x = 0.25 and 0.75; midpoint at 0.5. + final merged = WaveShape.metaball(blobCount: 2, radius: 0.35, amplitude: 1.0); + final separated = + WaveShape.metaball(blobCount: 2, radius: 0.08, amplitude: 1.0); + + // A clear bulge sits over a blob center. + expect(merged.sampleAt(0.25, 0.0).abs(), greaterThan(0.5)); + + // Merged blobs keep the midpoint elevated; separated blobs do not. + expect( + merged.sampleAt(0.5, 0.0).abs(), + greaterThan(separated.sampleAt(0.5, 0.0).abs()), + ); + expect(separated.sampleAt(0.5, 0.0).abs(), lessThan(0.2)); + }); +} diff --git a/test/proofs/pingpong_proof_test.dart b/test/proofs/pingpong_proof_test.dart new file mode 100644 index 0000000..4b3f719 --- /dev/null +++ b/test/proofs/pingpong_proof_test.dart @@ -0,0 +1,23 @@ +@Tags(['proof_pingpong_reverses']) +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; + +/// M3 proof: ping-pong motion eases the phase forward then back in a smooth +/// oscillation — through zero at the half period and reversed sign beyond it. +void main() { + test('pingpong phase oscillates and reverses over a period', () { + final motion = WaveMotion.pingPong(sway: 1.0, period: 4.0); + + expect(motion.phaseAt(0.0), closeTo(0.0, 1e-9)); // start + expect(motion.phaseAt(1.0), closeTo(1.0, 1e-9)); // quarter -> peak + expect(motion.phaseAt(2.0), closeTo(0.0, 1e-9)); // half -> back to zero + expect(motion.phaseAt(3.0), closeTo(-1.0, 1e-9)); // three-quarter -> reversed + + // Reversal: later in the cycle the phase moves opposite to earlier. + expect(motion.phaseAt(3.0) < motion.phaseAt(1.0), isTrue); + // Periodic. + expect(motion.phaseAt(4.0), closeTo(motion.phaseAt(0.0), 1e-9)); + }); +} diff --git a/test/proofs/pointer_ripple_proof_test.dart b/test/proofs/pointer_ripple_proof_test.dart new file mode 100644 index 0000000..84a88c5 --- /dev/null +++ b/test/proofs/pointer_ripple_proof_test.dart @@ -0,0 +1,48 @@ +@Tags(['proof_pointer_spawns_ripple']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; + +/// M3 proof: an interactive [WaveField] (a wave carrying a [PointerRippleEffect]) +/// emits a ripple at the normalized pointer position on both tap and drag, +/// reported through the onRipple callback. +void main() { + testWidgets('tap and drag each spawn ripples at the pointer', (tester) async { + final spawns = []; + final wave = Wave( + shape: WaveShape.sine(amplitude: 0.06, frequency: 1.0), + style: const WaveStyle(fill: Color(0xFF3A7BD5)), + motion: WaveMotion.drift(speed: 0.3), + effects: const [PointerRippleEffect()], + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 200, + height: 200, + child: WaveField(waves: [wave], onRipple: spawns.add), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 16)); + + // A tap at the center emits a ripple at normalized (0.5, 0.5). + await tester.tapAt(tester.getCenter(find.byType(WaveField))); + await tester.pump(); + expect(spawns, isNotEmpty); + expect(spawns.first.dx, closeTo(0.5, 0.1)); + + // Dragging emits further ripples along the path. + final tapsAfterTap = spawns.length; + await tester.drag(find.byType(WaveField), const Offset(40, 0)); + await tester.pump(); + expect(spawns.length, greaterThan(tapsAfterTap)); + }); +} diff --git a/test/proofs/preset_gallery_proof_test.dart b/test/proofs/preset_gallery_proof_test.dart new file mode 100644 index 0000000..7c65a81 --- /dev/null +++ b/test/proofs/preset_gallery_proof_test.dart @@ -0,0 +1,34 @@ +@Tags(['proof_preset_gallery_is_valid']) +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; + +/// M4 proof: the gallery is a broad set of valid presets, each reachable by a +/// unique name and each renderable (within the shader's layer budget). +void main() { + test('preset gallery is broad, valid, and uniquely named', () { + final gallery = WavePresets.all; + + // Broad — a real catalogue, not a handful. + expect(gallery.length, greaterThanOrEqualTo(10)); + + for (final preset in gallery) { + expect(preset.waves, isNotEmpty); + expect(preset.waves.length, lessThanOrEqualTo(8)); // shader MAX_LAYERS + expect( + preset.background != null || preset.backgroundColor != null, + isTrue, + reason: 'every preset needs a background', + ); + } + + // Every preset is reachable by a unique name. + final names = WavePresets.byName.keys.toList(); + expect(names.length, gallery.length); + expect(names.toSet().length, names.length, reason: 'names must be unique'); + for (final name in names) { + expect(WavePresets.byName[name]!.waves, isNotEmpty); + } + }); +} diff --git a/test/proofs/ripple_proof_test.dart b/test/proofs/ripple_proof_test.dart new file mode 100644 index 0000000..30aecc1 --- /dev/null +++ b/test/proofs/ripple_proof_test.dart @@ -0,0 +1,40 @@ +@Tags(['proof_ripple_decays_and_propagates']) +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; + +/// M3 proof: a pointer ripple is a pure function of position and age. It carries +/// real displacement at impact, its wavefront moves outward as it ages, and it +/// fades to nothing long after. +void main() { + test('ripple propagates outward and decays', () { + final ripple = PointerRippleEffect( + strength: 0.1, + decay: 1.5, + speed: 0.6, + wavelength: 0.2, + ); + + // Real displacement at impact near the origin. + expect( + ripple.displacementAt(x: 0.0, originX: 0.0, age: 0.0).abs(), + greaterThan(0.05), + ); + + // Fades away long after the tap. + expect( + ripple.displacementAt(x: 0.0, originX: 0.0, age: 10.0).abs(), + lessThan(0.001), + ); + + // Propagation: a point away from the origin responds more strongly once the + // wavefront reaches it than at the instant of impact. + const away = 0.3; + final atArrival = + ripple.displacementAt(x: away, originX: 0.0, age: away / 0.6); + final atImpact = + ripple.displacementAt(x: away, originX: 0.0, age: 0.0); + expect(atArrival.abs(), greaterThan(atImpact.abs())); + }); +} diff --git a/test/proofs/wave_field_proof_test.dart b/test/proofs/wave_field_proof_test.dart new file mode 100644 index 0000000..e602516 --- /dev/null +++ b/test/proofs/wave_field_proof_test.dart @@ -0,0 +1,38 @@ +@Tags(['proof_wave_field_renders_and_animates']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; + +/// M1 proof: a composed [WaveField] mounts, fills the box it is given, and +/// runs a repeating animation when it carries a drifting wave. +void main() { + testWidgets('WaveField fills its constraints and animates over time', ( + tester, + ) async { + final wave = Wave( + shape: WaveShape.sine(amplitude: 0.3, frequency: 1.2), + style: const WaveStyle(fill: Color(0xFF3B2CC5)), + motion: WaveMotion.drift(speed: 0.5), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 300, + height: 200, + child: WaveField(waves: [wave]), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 16)); + + expect(tester.getSize(find.byType(WaveField)), const Size(300, 200)); + expect(tester.hasRunningAnimations, isTrue); + }); +} diff --git a/test/proofs/wave_screen_preset_proof_test.dart b/test/proofs/wave_screen_preset_proof_test.dart new file mode 100644 index 0000000..7a6fe4b --- /dev/null +++ b/test/proofs/wave_screen_preset_proof_test.dart @@ -0,0 +1,34 @@ +@Tags(['proof_wave_screen_preset_renders']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; + +/// M1 proof: a preset produces a full-bleed [WaveScreen] whose composited +/// [WaveField] carries exactly the preset's waves and fills the given space. +void main() { + testWidgets('WaveScreen renders the preset waves across its constraints', ( + tester, + ) async { + final preset = WavePresets.aurora; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 600, + child: WaveScreen(preset: preset), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 16)); + + final field = find.byType(WaveField); + expect(field, findsOneWidget); + expect(tester.widget(field).waves.length, preset.waves.length); + expect(tester.getSize(field), const Size(400, 600)); + }); +} diff --git a/test/proofs/wave_traits_proof_test.dart b/test/proofs/wave_traits_proof_test.dart new file mode 100644 index 0000000..5cbe9db --- /dev/null +++ b/test/proofs/wave_traits_proof_test.dart @@ -0,0 +1,36 @@ +@Tags(['proof_wave_traits_drive_surface']) +library; + +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; + +/// M1 proof: shape + motion traits determine the wave surface. The height field +/// is a real pure function (the shader mirrors it). A drifting wave's surface at +/// a fixed point moves through time; a still wave's surface holds constant. +void main() { + test('drift changes the surface over time while still holds it', () { + final drifting = Wave( + shape: WaveShape.sine(amplitude: 0.3, frequency: 1.5), + style: const WaveStyle(fill: Color(0xFF3B2CC5)), + motion: WaveMotion.drift(speed: 1.0), + ); + final still = Wave( + shape: WaveShape.sine(amplitude: 0.3, frequency: 1.5), + style: const WaveStyle(fill: Color(0xFF3B2CC5)), + motion: WaveMotion.still(), + ); + + const x = 0.25; + expect( + drifting.heightAt(x, 0.0), + isNot(closeTo(drifting.heightAt(x, 0.5), 1e-6)), + reason: 'a drifting wave must evolve over time', + ); + expect( + still.heightAt(x, 0.0), + closeTo(still.heightAt(x, 0.5), 1e-9), + reason: 'a still wave must not evolve over time', + ); + }); +} diff --git a/test/screen/wave_presets_test.dart b/test/screen/wave_presets_test.dart new file mode 100644 index 0000000..6f7e921 --- /dev/null +++ b/test/screen/wave_presets_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; + +/// The foundation preset library and the inline `WaveScreen.custom` builder. +void main() { + test('every foundation preset is a valid layered background', () { + expect(WavePresets.all.length, greaterThanOrEqualTo(10)); + for (final preset in WavePresets.all) { + expect(preset.waves, isNotEmpty); + // Must fit within the shader's MAX_LAYERS budget. + expect(preset.waves.length, lessThanOrEqualTo(8)); + expect(preset.background, isNotNull); + } + }); + + testWidgets('WaveScreen.custom builds a field from loose waves', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 200, + height: 200, + child: WaveScreen.custom( + backgroundColor: const Color(0xFF10131F), + waves: [ + Wave( + shape: WaveShape.sine(amplitude: 0.3, frequency: 1.0), + style: const WaveStyle(fill: Color(0xFF3A7BD5)), + motion: WaveMotion.drift(speed: 0.3), + ), + ], + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 16)); + + expect(find.byType(WaveScreen), findsOneWidget); + expect(tester.widget(find.byType(WaveField)).waves, hasLength(1)); + }); +} diff --git a/test/screen_style_test.dart b/test/screen_style_test.dart deleted file mode 100644 index 6497fd0..0000000 --- a/test/screen_style_test.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:wave_screen/wave_screen.dart'; - -void main() { - test('preset factories keep palette, defaults, and deterministic layer count', () { - final violet = WaveScreenStyle.violet(); - final sunset = WaveScreenStyle.sunset(); - final lagoon = WaveScreenStyle.lagoon(); - - expect(violet.palette, same(WaveScreenPalette.violet)); - expect(sunset.palette, same(WaveScreenPalette.sunset)); - expect(lagoon.palette, same(WaveScreenPalette.lagoon)); - - expect(violet.duration, const Duration(seconds: 9)); - expect(sunset.buildLayers(), hasLength(11)); - expect(lagoon.buildLayers(sceneWidth: 360), hasLength(11)); - }); - - test('screen barrel export exposes split screen types', () { - const adaptation = WaveScreenAdaptation.none(); - const action = WaveScreenLayerAction.none(); - const placement = WavePlacement.fixed(0.5); - const recipe = WaveLayerRecipe( - amplitudeMin: 10, - amplitudeMax: 10, - frequencyMin: 0.8, - frequencyMax: 0.8, - speedMin: 0.1, - speedMax: 0.1, - placement: placement, - slopeMin: 0, - slopeMax: 0, - horizontalShiftMin: 0, - horizontalShiftMax: 0, - secondaryStrengthMin: 0.1, - secondaryStrengthMax: 0.1, - ); - const layer = WaveScreenLayer(zIndex: 0, recipe: recipe); - - final style = WaveScreenStyle( - palette: WaveScreenPalette.violet, - layers: const [layer], - seed: 1, - adaptation: adaptation, - layerAction: action, - ); - - expect(style.buildLayers(), hasLength(1)); - }); -} \ No newline at end of file diff --git a/test/wave/wave_interaction_test.dart b/test/wave/wave_interaction_test.dart new file mode 100644 index 0000000..75ef3cb --- /dev/null +++ b/test/wave/wave_interaction_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; +import 'package:wave_screen/src/wave/ripple_field.dart'; + +/// M3 value semantics and the RippleField controller, plus a field that swaps +/// its waves at runtime. +void main() { + test('PingPongMotion has value equality', () { + expect( + WaveMotion.pingPong(sway: 1.0, period: 4.0), + WaveMotion.pingPong(sway: 1.0, period: 4.0), + ); + expect( + WaveMotion.pingPong(sway: 1.0, period: 4.0).hashCode, + WaveMotion.pingPong(sway: 1.0, period: 4.0).hashCode, + ); + expect( + WaveMotion.pingPong(sway: 1.0, period: 4.0) == + WaveMotion.pingPong(sway: 2.0, period: 4.0), + isFalse, + ); + }); + + test('PointerRippleEffect has value equality and a finite lifetime', () { + const a = PointerRippleEffect(); + const b = PointerRippleEffect(); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a == const PointerRippleEffect(strength: 0.2), isFalse); + expect(a.lifetime, greaterThan(0)); + }); + + group('RippleField', () { + test('sums live ripples and reports counts', () { + final field = RippleField(const PointerRippleEffect()); + expect(field.isEmpty, isTrue); + + field.spawn(0.5, 0.0); + expect(field.activeCount, 1); + // At impact, the surface near the origin is displaced. + expect(field.displacementAt(0.5, 0.0).abs(), greaterThan(0.0)); + + field.spawn(0.2, 0.0); + expect(field.recent(1).length, 1); + expect(field.recent(5).length, 2); + }); + + test('prunes ripples once they decay past their lifetime', () { + final effect = const PointerRippleEffect(); + final field = RippleField(effect)..spawn(0.5, 0.0); + field.prune(effect.lifetime + 1.0); + expect(field.isEmpty, isTrue); + }); + }); + + testWidgets('WaveField rebuilds its ripple wiring when waves change', ( + tester, + ) async { + Widget build(List waves) => MaterialApp( + home: Scaffold( + body: SizedBox(width: 200, height: 200, child: WaveField(waves: waves)), + ), + ); + + final plain = [ + Wave( + shape: WaveShape.sine(amplitude: 0.06, frequency: 1.0), + style: const WaveStyle(fill: Color(0xFF3A7BD5)), + motion: WaveMotion.drift(speed: 0.3), + ), + ]; + final interactive = [ + Wave( + shape: WaveShape.sine(amplitude: 0.06, frequency: 1.0), + style: const WaveStyle(fill: Color(0xFF3A7BD5)), + motion: WaveMotion.pingPong(sway: 0.6, period: 5.0), + effects: const [PointerRippleEffect()], + ), + ]; + + await tester.pumpWidget(build(plain)); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pumpWidget(build(interactive)); + await tester.pump(const Duration(milliseconds: 16)); + + expect(tester.getSize(find.byType(WaveField)), const Size(200, 200)); + }); +} diff --git a/test/wave/wave_math_test.dart b/test/wave/wave_math_test.dart new file mode 100644 index 0000000..c95a164 --- /dev/null +++ b/test/wave/wave_math_test.dart @@ -0,0 +1,50 @@ +import 'dart:math' as math; + +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; + +void main() { + group('WaveMotion', () { + test('drift advances phase linearly with time', () { + final motion = WaveMotion.drift(speed: 2.0); + expect(motion.phaseAt(0.0), 0.0); + expect(motion.phaseAt(0.5), closeTo(1.0, 1e-12)); + expect(motion.phaseAt(1.0), closeTo(2.0, 1e-12)); + }); + + test('still holds phase at zero for all time', () { + final motion = WaveMotion.still(); + expect(motion.phaseAt(0.0), 0.0); + expect(motion.phaseAt(3.7), 0.0); + }); + }); + + group('WaveShape.sine', () { + test('samples a pure sine of amplitude and frequency', () { + final shape = WaveShape.sine(amplitude: 1.0, frequency: 1.0); + expect(shape.sampleAt(0.0, 0.0), closeTo(0.0, 1e-12)); + expect(shape.sampleAt(0.25, 0.0), closeTo(1.0, 1e-12)); + expect(shape.sampleAt(0.5, 0.0), closeTo(0.0, 1e-12)); + }); + + test('phase offset shifts the sample', () { + final shape = WaveShape.sine(amplitude: 2.0, frequency: 1.0); + expect(shape.sampleAt(0.0, math.pi / 2), closeTo(2.0, 1e-12)); + }); + }); + + group('Wave', () { + test('composes motion phase into the shape height', () { + final wave = Wave( + shape: WaveShape.sine(amplitude: 1.0, frequency: 1.0), + style: const WaveStyle(fill: Color(0xFF3B2CC5)), + motion: WaveMotion.drift(speed: math.pi), + ); + // At t=0 phase is 0, so height matches the raw shape. + expect(wave.heightAt(0.25, 0.0), closeTo(1.0, 1e-12)); + // Advancing time changes the composed height (drift). + expect(wave.heightAt(0.25, 0.5), isNot(closeTo(1.0, 1e-9))); + }); + }); +} diff --git a/test/wave/wave_shapes_test.dart b/test/wave/wave_shapes_test.dart new file mode 100644 index 0000000..9758ea0 --- /dev/null +++ b/test/wave/wave_shapes_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; + +/// Value semantics for the M2 shapes, and a field rendering mixed shapes (which +/// drives the shader's per-shape uniform packing). +void main() { + test('gerstner with steepness 0 equals a plain sine sample', () { + final gerstner = WaveShape.gerstner( + amplitude: 1.0, + frequency: 1.0, + steepness: 0.0, + ); + final sine = WaveShape.sine(amplitude: 1.0, frequency: 1.0); + for (final x in [0.0, 0.15, 0.5, 0.9]) { + expect(gerstner.sampleAt(x, 0.3), closeTo(sine.sampleAt(x, 0.3), 1e-9)); + } + }); + + test('GerstnerWaveShape has value equality', () { + final a = WaveShape.gerstner(amplitude: 0.3, frequency: 1.0, steepness: 0.6); + final b = WaveShape.gerstner(amplitude: 0.3, frequency: 1.0, steepness: 0.6); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect( + a == WaveShape.gerstner(amplitude: 0.3, frequency: 1.0, steepness: 0.9), + isFalse, + ); + }); + + test('MetaballWaveShape has value equality', () { + final a = WaveShape.metaball(blobCount: 3, amplitude: 0.3, radius: 0.15); + final b = WaveShape.metaball(blobCount: 3, amplitude: 0.3, radius: 0.15); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect( + a == WaveShape.metaball(blobCount: 4, amplitude: 0.3, radius: 0.15), + isFalse, + ); + }); + + testWidgets('WaveField renders mixed gerstner and metaball layers', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + height: 200, + child: WaveField( + waves: [ + Wave( + shape: WaveShape.gerstner( + amplitude: 0.06, + frequency: 1.0, + steepness: 0.9, + ), + style: const WaveStyle(fill: Color(0xFF1C6E8C)), + motion: WaveMotion.drift(speed: 0.3), + ), + Wave( + shape: WaveShape.metaball( + blobCount: 4, + radius: 0.18, + amplitude: 0.12, + ), + style: const WaveStyle(fill: Color(0xFFC85C9C)), + motion: WaveMotion.drift(speed: -0.2), + ), + ], + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + + expect(tester.getSize(find.byType(WaveField)), const Size(300, 200)); + }); +} diff --git a/test/wave/wave_traits_equality_test.dart b/test/wave/wave_traits_equality_test.dart new file mode 100644 index 0000000..0a279c7 --- /dev/null +++ b/test/wave/wave_traits_equality_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; + +/// Value semantics for the trait types — so equal configurations compare equal +/// (and drive `WaveField`/painter repaint decisions correctly). +void main() { + test('WaveMotion has value equality', () { + expect(WaveMotion.drift(speed: 1.0), WaveMotion.drift(speed: 1.0)); + expect( + WaveMotion.drift(speed: 1.0).hashCode, + WaveMotion.drift(speed: 1.0).hashCode, + ); + expect(WaveMotion.drift(speed: 1.0) == WaveMotion.drift(speed: 2.0), isFalse); + expect(WaveMotion.still(), WaveMotion.still()); + expect(WaveMotion.still().hashCode, WaveMotion.still().hashCode); + expect(WaveMotion.drift(speed: 1.0) == WaveMotion.still(), isFalse); + }); + + test('WaveShape.sine has value equality', () { + final a = WaveShape.sine(amplitude: 0.3, frequency: 1.0); + final b = WaveShape.sine(amplitude: 0.3, frequency: 1.0); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a == WaveShape.sine(amplitude: 0.4, frequency: 1.0), isFalse); + expect(a == WaveShape.sine(amplitude: 0.3, frequency: 1.0, baseline: 0.2), + isFalse); + }); + + test('WaveStyle has value equality and folds opacity into alpha', () { + const a = WaveStyle(fill: Color(0xFF112233)); + const b = WaveStyle(fill: Color(0xFF112233)); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a == const WaveStyle(fill: Color(0xFF000000)), isFalse); + expect(const WaveStyle(fill: Color(0xFFFFFFFF), opacity: 0.5).resolvedColor.a, + closeTo(0.5, 1e-6)); + }); + + test('Wave has value equality over its traits', () { + Wave build({double amplitude = 0.3}) => Wave( + shape: WaveShape.sine(amplitude: amplitude, frequency: 1.0), + style: const WaveStyle(fill: Color(0xFF112233)), + motion: WaveMotion.still(), + ); + expect(build(), build()); + expect(build().hashCode, build().hashCode); + expect(build() == build(amplitude: 0.9), isFalse); + }); +} diff --git a/test/wave/wave_uniforms_test.dart b/test/wave/wave_uniforms_test.dart new file mode 100644 index 0000000..36cb969 --- /dev/null +++ b/test/wave/wave_uniforms_test.dart @@ -0,0 +1,70 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave_screen/wave_screen.dart'; +import 'package:wave_screen/src/wave/ripple_field.dart'; +import 'package:wave_screen/src/wave/shader/wave_uniforms.dart'; + +/// Deterministic coverage of the shader uniform packer — no GPU required. The +/// float-count assertion is the guard against the shader and packer drifting +/// out of sync (the class of bug that crashes a stale shader). +void main() { + test('pack lays out sine, gerstner and metaball layers plus ripples', () { + final waves = [ + Wave( + shape: WaveShape.sine(amplitude: 0.3, frequency: 1.2, baseline: 0.4), + style: const WaveStyle(fill: Color(0xFF102030)), + motion: WaveMotion.still(), + ), + Wave( + shape: WaveShape.gerstner( + amplitude: 0.2, + frequency: 0.9, + steepness: 0.7, + ), + style: const WaveStyle(fill: Color(0xFF203040)), + motion: WaveMotion.still(), + ), + Wave( + shape: WaveShape.metaball(blobCount: 3, amplitude: 0.25, radius: 0.2), + style: const WaveStyle(fill: Color(0xFF304050)), + motion: WaveMotion.still(), + ), + ]; + final ripples = RippleField(const PointerRippleEffect())..spawn(0.5, 0.0); + + final data = WaveUniforms.pack( + size: const Size(300, 200), + waves: waves, + t: 1.0, + rippleField: ripples, + ); + + // Full vector — matches the shader's declared uniform count exactly. + expect(data.length, WaveUniforms.floatCount); + + // uSize + uLayerCount. + expect(data[0], 300); + expect(data[1], 200); + expect(data[2], 3); + + // Per-layer shapeType lives in the geometryB block (base 35, stride 4). + expect(data[35], 0); // sine + expect(data[39], 1); // gerstner + expect(data[43], 2); // metaball + + // Ripple count is non-zero and the first ripple's origin is packed. + expect(data[WaveUniforms.floatCount - 33], 1); // uRippleCount + expect(data[WaveUniforms.floatCount - 32], closeTo(0.5, 1e-6)); // originX + }); + + test('pack with no waves or ripples still fills the whole vector', () { + final data = WaveUniforms.pack( + size: const Size(100, 100), + waves: const [], + t: 0.0, + ); + expect(data.length, WaveUniforms.floatCount); + expect(data[2], 0); // layer count + }); +} diff --git a/test/wave_geometry_test.dart b/test/wave_geometry_test.dart deleted file mode 100644 index eff9203..0000000 --- a/test/wave_geometry_test.dart +++ /dev/null @@ -1,354 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:wave_screen/wave_screen.dart'; -import 'package:wave_screen/src/skeletonizer/wave_geometry.dart'; - -void main() { - test('top-edge deformation keeps bottom edge stable and lifts the crest', () { - const rect = Rect.fromLTWH(0, 20, 200, 40); - - final path = WaveGeometry.buildRectPath( - rect: rect, - radius: Radius.circular(20), - progress: 0.3, - amplitude: 12, - frequency: 1.6, - deformation: WaveDeformation.topEdge, - synchronization: WaveSynchronization.global, - phase: 0, - ); - - final bounds = path.getBounds(); - - expect(bounds.bottom, closeTo(rect.bottom, 0.5)); - expect(bounds.top, lessThan(rect.top)); - }); - - test('full-outline deformation changes both top and bottom bounds', () { - const rect = Rect.fromLTWH(0, 20, 200, 40); - - final path = WaveGeometry.buildRectPath( - rect: rect, - radius: Radius.circular(20), - progress: 0.3, - amplitude: 12, - frequency: 1.6, - deformation: WaveDeformation.fullOutline, - synchronization: WaveSynchronization.local, - phase: 0.25, - ); - - final bounds = path.getBounds(); - - expect(bounds.top, lessThan(rect.top)); - expect(bounds.bottom, greaterThan(rect.bottom)); - }); - - test('local synchronization changes geometry phase compared with global mode', () { - const rect = Rect.fromLTWH(0, 20, 200, 40); - - final globalPath = WaveGeometry.buildRectPath( - rect: rect, - radius: Radius.circular(20), - progress: 0.4, - amplitude: 10, - frequency: 1.2, - deformation: WaveDeformation.topEdge, - synchronization: WaveSynchronization.global, - phase: 0, - ); - final localPath = WaveGeometry.buildRectPath( - rect: rect, - radius: Radius.circular(20), - progress: 0.4, - amplitude: 10, - frequency: 1.2, - deformation: WaveDeformation.topEdge, - synchronization: WaveSynchronization.local, - phase: 0.35, - ); - - expect(localPath.getBounds().top, isNot(globalPath.getBounds().top)); - }); - - test('ping-pong crest shifts horizontally as animation progresses', () { - const rect = Rect.fromLTWH(0, 20, 200, 40); - const effect = PingPongWaveEffect(); - - final leftNear = effect.sampleOffset( - rect: rect, - x: rect.left + (rect.width * 0.42), - progress: 0.12, - phase: 0, - ); - final leftFar = effect.sampleOffset( - rect: rect, - x: rect.left + (rect.width * 0.8), - progress: 0.12, - phase: 0, - ); - final rightFar = effect.sampleOffset( - rect: rect, - x: rect.left + (rect.width * 0.2), - progress: 0.24, - phase: 0, - ); - final rightNear = effect.sampleOffset( - rect: rect, - x: rect.left + (rect.width * 0.78), - progress: 0.24, - phase: 0, - ); - - expect(leftNear.abs(), greaterThan(leftFar.abs())); - expect(rightNear.abs(), greaterThan(rightFar.abs())); - }); - - test('ping-pong uses a single crest around the moving center', () { - const rect = Rect.fromLTWH(0, 20, 200, 40); - const effect = PingPongWaveEffect(); - final centerX = rect.left + (rect.width * effect.centerProgress(0.15)); - - final leftShoulder = effect.sampleOffset( - rect: rect, - x: centerX - (rect.width * 0.08), - progress: 0.15, - phase: 0, - ); - final center = effect.sampleOffset( - rect: rect, - x: centerX, - progress: 0.15, - phase: 0, - ); - final rightShoulder = effect.sampleOffset( - rect: rect, - x: centerX + (rect.width * 0.08), - progress: 0.15, - phase: 0, - ); - final far = effect.sampleOffset( - rect: rect, - x: rect.left + (rect.width * 0.85), - progress: 0.15, - phase: 0, - ); - - expect(center.abs(), greaterThan(leftShoulder.abs())); - expect(center.abs(), greaterThan(rightShoulder.abs())); - expect(center.abs(), greaterThan(far.abs())); - expect(leftShoulder.sign, equals(rightShoulder.sign)); - }); - - test('ping-pong geometry stays active at the wall during impact', () { - const rect = Rect.fromLTWH(0, 20, 200, 40); - const effect = PingPongWaveEffect( - duration: Duration(seconds: 5), - pauseDuration: Duration(seconds: 2), - ); - - final nearWall = effect.sampleOffset( - rect: rect, - x: rect.right - 6, - progress: 0.5, - phase: 0, - ); - final mid = effect.sampleOffset( - rect: rect, - x: rect.left + (rect.width * 0.72), - progress: 0.5, - phase: 0, - ); - - expect(nearWall.abs(), greaterThan(0)); - expect(nearWall.abs(), greaterThan(mid.abs())); - }); - - test('ping-pong center reaches the wall before reflection begins', () { - const effect = PingPongWaveEffect( - duration: Duration(seconds: 5), - pauseDuration: Duration(seconds: 2), - ); - - final centerProgress = effect.centerProgress(0.3); - - expect(centerProgress, closeTo(1, 0.001)); - }); - - test('ping-pong crest grows through the outgoing travel and shrinks on return', () { - const rect = Rect.fromLTWH(0, 20, 200, 40); - const effect = PingPongWaveEffect( - duration: Duration(seconds: 5), - pauseDuration: Duration(seconds: 2), - ); - - final earlyCenterX = rect.left + (rect.width * effect.centerProgress(0.08)); - final midCenterX = rect.left + (rect.width * effect.centerProgress(0.22)); - final returnCenterX = rect.left + (rect.width * effect.centerProgress(0.84)); - - final early = effect.sampleOffset( - rect: rect, - x: earlyCenterX, - progress: 0.08, - phase: 0, - ); - final mid = effect.sampleOffset( - rect: rect, - x: midCenterX, - progress: 0.22, - phase: 0, - ); - final returning = effect.sampleOffset( - rect: rect, - x: returnCenterX, - progress: 0.84, - phase: 0, - ); - - expect(mid.abs(), greaterThan(early.abs())); - expect(mid.abs(), greaterThan(returning.abs())); - }); - - test('ping-pong return wave produces wall-side interference', () { - const rect = Rect.fromLTWH(0, 20, 200, 40); - const effect = PingPongWaveEffect( - duration: Duration(seconds: 5), - pauseDuration: Duration(seconds: 2), - ); - - final nearWall = effect.sampleOffset( - rect: rect, - x: rect.right - 10, - progress: 0.82, - phase: 0, - ); - final innerRipple = effect.sampleOffset( - rect: rect, - x: rect.right - 26, - progress: 0.82, - phase: 0, - ); - final farther = effect.sampleOffset( - rect: rect, - x: rect.right - 56, - progress: 0.82, - phase: 0, - ); - - expect(nearWall.abs(), greaterThan(0)); - expect(innerRipple.abs(), greaterThan(0)); - expect(nearWall.abs(), isNot(closeTo(innerRipple.abs(), 0.15))); - expect(nearWall.abs(), greaterThan(farther.abs())); - }); - - test('travelingWaveOffset creates one raised crest and flat outer baseline', () { - const width = 200.0; - const centerX = 100.0; - - final center = WaveGeometry.travelingWaveOffset( - x: centerX, - width: width, - centerX: centerX, - amplitude: 12, - frequency: 1.2, - spread: 0.18, - phase: 0, - ); - final shoulder = WaveGeometry.travelingWaveOffset( - x: 120, - width: width, - centerX: centerX, - amplitude: 12, - frequency: 1.2, - spread: 0.18, - phase: 0, - ); - final outside = WaveGeometry.travelingWaveOffset( - x: 160, - width: width, - centerX: centerX, - amplitude: 12, - frequency: 1.2, - spread: 0.18, - phase: 0, - ); - - expect(center, lessThan(0)); - expect(shoulder, lessThan(0)); - expect(center.abs(), greaterThan(shoulder.abs())); - expect(outside, 0); - }); - - test('travelingWaveOffset keeps a flatter tide-like baseline with softer shoulders', () { - const width = 200.0; - const centerX = 100.0; - - final center = WaveGeometry.travelingWaveOffset( - x: centerX, - width: width, - centerX: centerX, - amplitude: 12, - frequency: 1.2, - spread: 0.18, - phase: 0, - ); - final shoulder = WaveGeometry.travelingWaveOffset( - x: 116, - width: width, - centerX: centerX, - amplitude: 12, - frequency: 1.2, - spread: 0.18, - phase: 0, - ); - final midShoulder = WaveGeometry.travelingWaveOffset( - x: 120, - width: width, - centerX: centerX, - amplitude: 12, - frequency: 1.2, - spread: 0.18, - phase: 0, - ); - - expect(shoulder.abs(), lessThan(center.abs() * 0.7)); - expect(midShoulder.abs(), lessThan(center.abs() * 0.5)); - }); - - test('travelingWaveOffset adds a subtle bump near the lobe edge', () { - const width = 200.0; - const centerX = 100.0; - - final innerShoulder = WaveGeometry.travelingWaveOffset( - x: 116, - width: width, - centerX: centerX, - amplitude: 12, - frequency: 1.2, - spread: 0.22, - phase: 0, - ); - final edgeBump = WaveGeometry.travelingWaveOffset( - x: 138, - width: width, - centerX: centerX, - amplitude: 12, - frequency: 1.2, - spread: 0.22, - phase: 0, - ); - final edgeFlat = WaveGeometry.travelingWaveOffset( - x: 146, - width: width, - centerX: centerX, - amplitude: 12, - frequency: 1.2, - spread: 0.22, - phase: 0, - ); - - expect(edgeBump.abs(), greaterThan(0)); - expect(edgeBump.abs(), lessThan(innerShoulder.abs())); - expect(edgeBump.abs(), greaterThan(edgeFlat.abs())); - }); -} \ No newline at end of file diff --git a/test/wave_skeletonizer_test.dart b/test/wave_skeletonizer_test.dart deleted file mode 100644 index bfcef3a..0000000 --- a/test/wave_skeletonizer_test.dart +++ /dev/null @@ -1,257 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:wave_screen/wave_screen.dart'; -import 'package:wave_screen/src/skeletonizer/render_widget.dart'; - -void main() { - testWidgets('WaveSkeletonizer wraps Skeletonizer with a default wave effect', ( - tester, - ) async { - await tester.pumpWidget( - const Directionality( - textDirection: TextDirection.ltr, - child: WaveSkeletonizer( - child: SizedBox(width: 120, height: 48), - ), - ), - ); - - final renderWidget = tester.widget( - find.byType(WaveSkeletonizerRenderObjectWidget), - ); - - expect(find.byType(WaveSkeletonizer), findsOneWidget); - expect(find.byType(WaveScreen), findsNothing); - expect(renderWidget.effect, const WaveEffect()); - expect(renderWidget.effect.duration, const Duration(seconds: 9)); - }); - - testWidgets('WaveSkeletonizer can own the animated loading surface', ( - tester, - ) async { - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: WaveSkeletonizer( - surfaceStyle: WaveScreenStyle.violet( - layerAction: const WaveScreenLayerAction.none(), - ), - child: const SizedBox(width: 120, height: 48), - ), - ), - ); - - expect(find.byType(WaveScreen), findsOneWidget); - expect( - find.descendant( - of: find.byType(WaveSkeletonizer), - matching: find.byType(WaveScreen), - ), - findsOneWidget, - ); - }); - - testWidgets('WaveSkeletonizer forwards a custom package effect through the adapter', ( - tester, - ) async { - const effect = _TestEffect( - duration: Duration(milliseconds: 1400), - lowerBound: -0.25, - upperBound: 1.25, - reverse: true, - ); - - await tester.pumpWidget( - const Directionality( - textDirection: TextDirection.ltr, - child: WaveSkeletonizer( - effect: effect, - child: SizedBox(width: 120, height: 48), - ), - ), - ); - - final renderWidget = tester.widget( - find.byType(WaveSkeletonizerRenderObjectWidget), - ); - - expect(renderWidget.effect.duration, effect.duration); - expect(renderWidget.effect.lowerBound, effect.lowerBound); - expect(renderWidget.effect.upperBound, effect.upperBound); - expect(renderWidget.effect.reverse, effect.reverse); - }); - - test('WaveEffect lerps between configured values', () { - const start = WaveEffect( - duration: Duration(seconds: 6), - baseColor: Color(0xFF2230AA), - highlightColor: Color(0xFF6A8CFF), - deformation: WaveDeformation.topEdge, - synchronization: WaveSynchronization.global, - begin: Alignment.centerLeft, - end: Alignment.centerRight, - ); - const end = WaveEffect( - duration: Duration(seconds: 6), - baseColor: Color(0xFF101820), - highlightColor: Color(0xFFB6D7FF), - deformation: WaveDeformation.fullOutline, - synchronization: WaveSynchronization.local, - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ); - - final lerped = start.lerp(end, 0.5); - - expect(lerped.duration, start.duration); - expect(lerped.baseColor, isNot(start.baseColor)); - expect(lerped.highlightColor, isNot(start.highlightColor)); - expect(lerped.begin, isNot(start.begin)); - expect(lerped.end, isNot(start.end)); - expect(lerped.deformation, start.deformation); - expect(lerped.synchronization, start.synchronization); - }); - - test('WaveEffect exposes configurable scaffold motion defaults', () { - const effect = WaveEffect(); - - expect(effect.deformation, WaveDeformation.topEdge); - expect(effect.synchronization, WaveSynchronization.global); - }); - - test('WaveEffect accepts explicit scaffold motion configuration', () { - const effect = WaveEffect( - deformation: WaveDeformation.fullOutline, - synchronization: WaveSynchronization.local, - ); - - expect(effect.deformation, WaveDeformation.fullOutline); - expect(effect.synchronization, WaveSynchronization.local); - }); - - test('PingPongWaveEffect exposes scaffold motion defaults', () { - const effect = PingPongWaveEffect(); - - expect(effect.deformation, WaveDeformation.topEdge); - expect(effect.synchronization, WaveSynchronization.global); - }); - - test('PingPongWaveEffect uses reversing animation bounds', () { - const effect = PingPongWaveEffect(); - - expect(effect.lowerBound, 0); - expect(effect.upperBound, 1); - expect(effect.reverse, isFalse); - }); - - test('PingPongWaveEffect maps cycle progress to shore hit dwell and return', () { - const effect = PingPongWaveEffect( - duration: Duration(seconds: 5), - pauseDuration: Duration(seconds: 2), - ); - - expect(effect.centerProgress(0), 0); - expect(effect.centerProgress(0.15), greaterThan(0.4)); - expect(effect.centerProgress(0.3), closeTo(1, 0.001)); - expect(effect.centerProgress(0.5), closeTo(1, 0.001)); - expect(effect.centerProgress(0.85), lessThan(0.75)); - expect(effect.centerProgress(0.85), greaterThan(0)); - expect(effect.centerProgress(1), 0); - }); - - test('PingPongWaveEffect stays visible through impact and reflection', () { - const effect = PingPongWaveEffect( - duration: Duration(seconds: 5), - pauseDuration: Duration(seconds: 2), - ); - - expect(effect.visibilityProgress(0.15), 1); - expect(effect.visibilityProgress(0.3), 1); - expect(effect.visibilityProgress(0.5), 1); - expect(effect.visibilityProgress(0.71), 1); - expect(effect.visibilityProgress(0.85), 1); - }); - - test('PingPongWaveEffect accelerates as it approaches the wall', () { - const effect = PingPongWaveEffect( - duration: Duration(seconds: 5), - pauseDuration: Duration(seconds: 2), - ); - - final first = effect.centerProgress(0.05); - final second = effect.centerProgress(0.10); - final third = effect.centerProgress(0.15); - final fourth = effect.centerProgress(0.20); - - expect(second - first, lessThan(third - second)); - expect(third - second, lessThan(fourth - third)); - }); - - test('PingPongWaveEffect decelerates as the reflection settles outward', () { - const effect = PingPongWaveEffect( - duration: Duration(seconds: 5), - pauseDuration: Duration(seconds: 2), - ); - - final first = effect.centerProgress(0.72); - final second = effect.centerProgress(0.80); - final third = effect.centerProgress(0.88); - final fourth = effect.centerProgress(0.96); - - expect(first - second, greaterThan(second - third)); - expect(second - third, greaterThan(third - fourth)); - }); - - test('PingPongWaveEffect lerps between configured values', () { - const start = PingPongWaveEffect( - baseColor: Color(0xFFF24B5A), - highlightColor: Color(0xFFFF97A0), - deformation: WaveDeformation.topEdge, - synchronization: WaveSynchronization.global, - spread: 0.16, - amplitudeFactor: 0.22, - frequency: 1.0, - pauseDuration: Duration(seconds: 1), - ); - const end = PingPongWaveEffect( - baseColor: Color(0xFF8A1320), - highlightColor: Color(0xFFFFCDD2), - deformation: WaveDeformation.fullOutline, - synchronization: WaveSynchronization.local, - spread: 0.24, - amplitudeFactor: 0.3, - frequency: 1.4, - pauseDuration: Duration(seconds: 3), - ); - - final lerped = start.lerp(end, 0.5); - - expect(lerped.baseColor, isNot(start.baseColor)); - expect(lerped.highlightColor, isNot(start.highlightColor)); - expect(lerped.spread, isNot(start.spread)); - expect(lerped.amplitudeFactor, isNot(start.amplitudeFactor)); - expect(lerped.frequency, isNot(start.frequency)); - expect(lerped.pauseDuration, isNot(start.pauseDuration)); - expect(lerped.deformation, start.deformation); - expect(lerped.synchronization, start.synchronization); - }); -} - -class _TestEffect extends SkeletonizerEffect { - const _TestEffect({ - required super.duration, - required super.lowerBound, - required super.upperBound, - required super.reverse, - }); - - @override - Paint createPaint(double t, Rect rect, TextDirection? textDirection) { - return Paint()..color = const Color(0xFF000000); - } - - @override - SkeletonizerEffect lerp(SkeletonizerEffect? other, double t) { - return this; - } -} \ No newline at end of file diff --git a/test/wave_widget_test.dart b/test/wave_widget_test.dart deleted file mode 100644 index c6c88ff..0000000 --- a/test/wave_widget_test.dart +++ /dev/null @@ -1,195 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:wave_screen/wave_screen.dart'; - -void main() { - test('screen style supports overlapping placements with explicit z order', () { - final style = WaveScreenStyle( - palette: WaveScreenPalette.violet, - seed: 11, - adaptation: const WaveScreenAdaptation.none(), - layerAction: const WaveScreenLayerAction.none(), - layers: [ - WaveScreenLayer( - zIndex: 10, - recipe: WaveLayerRecipe( - amplitudeMin: 10, - amplitudeMax: 10, - frequencyMin: 0.8, - frequencyMax: 0.8, - speedMin: 0.1, - speedMax: 0.1, - placement: WavePlacement.fixed(0.42), - slopeMin: 0, - slopeMax: 0, - horizontalShiftMin: 0, - horizontalShiftMax: 0, - secondaryStrengthMin: 0.1, - secondaryStrengthMax: 0.1, - ), - ), - WaveScreenLayer( - zIndex: 0, - recipe: WaveLayerRecipe( - amplitudeMin: 30, - amplitudeMax: 30, - frequencyMin: 1.0, - frequencyMax: 1.0, - speedMin: -0.1, - speedMax: -0.1, - placement: WavePlacement.fixed(0.42), - slopeMin: 0, - slopeMax: 0, - horizontalShiftMin: 0, - horizontalShiftMax: 0, - secondaryStrengthMin: 0.2, - secondaryStrengthMax: 0.2, - ), - ), - ], - ); - - final layers = style.buildLayers(); - - expect(layers, hasLength(2)); - expect(layers[0].heightFactor, 0.42); - expect(layers[1].heightFactor, 0.42); - expect(layers[0].amplitude, 30); - expect(layers[1].amplitude, 10); - }); - - test('layer action adds waves as scene width grows', () { - final style = WaveScreenStyle( - palette: WaveScreenPalette.violet, - seed: 3, - adaptation: const WaveScreenAdaptation.none(), - layerAction: const WaveScreenLayerAction.growOnWideScreens( - referenceWidth: 300, - widthPerAddedLayer: 120, - maxAdditionalLayers: 2, - ), - layers: const [ - WaveScreenLayer( - zIndex: 0, - recipe: WaveLayerRecipe( - amplitudeMin: 10, - amplitudeMax: 10, - frequencyMin: 0.7, - frequencyMax: 0.7, - speedMin: 0.1, - speedMax: 0.1, - placement: WavePlacement.fixed(0.15), - slopeMin: 0, - slopeMax: 0, - horizontalShiftMin: 0, - horizontalShiftMax: 0, - secondaryStrengthMin: 0.1, - secondaryStrengthMax: 0.1, - ), - ), - WaveScreenLayer( - zIndex: 1, - recipe: WaveLayerRecipe( - amplitudeMin: 20, - amplitudeMax: 20, - frequencyMin: 0.9, - frequencyMax: 0.9, - speedMin: -0.1, - speedMax: -0.1, - placement: WavePlacement.fixed(0.5), - slopeMin: 0, - slopeMax: 0, - horizontalShiftMin: 0, - horizontalShiftMax: 0, - secondaryStrengthMin: 0.15, - secondaryStrengthMax: 0.15, - ), - ), - WaveScreenLayer( - zIndex: 2, - recipe: WaveLayerRecipe( - amplitudeMin: 30, - amplitudeMax: 30, - frequencyMin: 1.1, - frequencyMax: 1.1, - speedMin: 0.12, - speedMax: 0.12, - placement: WavePlacement.fixed(0.82), - slopeMin: 0, - slopeMax: 0, - horizontalShiftMin: 0, - horizontalShiftMax: 0, - secondaryStrengthMin: 0.2, - secondaryStrengthMax: 0.2, - ), - ), - ], - ); - - final narrowLayers = style.buildLayers(sceneWidth: 300); - final wideLayers = style.buildLayers(sceneWidth: 600); - - expect(narrowLayers, hasLength(3)); - expect(wideLayers, hasLength(5)); - }); - - testWidgets('paints each configured wave layer inside layout constraints', ( - tester, - ) async { - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: SizedBox( - width: 320, - height: 240, - child: WaveWidget( - backgroundGradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF3D2BC6), Color(0xFF2322B7)], - ), - layers: const [ - WaveLayer.gradient( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0x804A3BCD), Color(0x084A3BCD)], - ), - amplitude: 18, - frequency: 0.8, - speed: -0.1, - phaseShift: 0.6, - horizontalShift: 0.12, - heightFactor: 0.28, - slope: 0.03, - ), - WaveLayer.gradient( - gradient: LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [Color(0xA06B40E6), Color(0x22523BD3)], - ), - amplitude: 30, - frequency: 0.95, - speed: 0.07, - phaseShift: 2.2, - horizontalShift: -0.18, - secondaryStrength: 0.18, - heightFactor: 0.72, - slope: -0.05, - ), - ], - ), - ), - ), - ), - ); - - expect(find.byType(WaveWidget), findsOneWidget); - expect(find.byType(CustomPaint), findsOneWidget); - - await tester.pump(const Duration(milliseconds: 250)); - expect(tester.takeException(), isNull); - }); -} \ No newline at end of file