commit 0fec97163dabbe912b74ba99e70cbde3db7d47e9 Author: Hans Kokx Date: Wed Apr 15 00:51:17 2026 +0200 Initial commit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6de6726 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + pull_request: + +jobs: + quality: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Root pub get + run: flutter pub get + + - name: Example pub get + working-directory: example + run: flutter pub get + + - name: Format check + run: dart format --output=none --set-exit-if-changed . + + - name: Analyze root + run: flutter analyze + + - name: Tests + run: flutter test + + - name: Outdated root + run: flutter pub outdated + + - name: Outdated example + working-directory: example + run: flutter pub outdated + + - name: Dartdoc dry-run + run: dart doc --dry-run + + - name: Publish dry-run + run: dart pub publish --dry-run + + - name: Pana + run: | + dart pub global activate pana + pana . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..a8104ff --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: android + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2a74c81 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,45 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "shiny", + "request": "launch", + "type": "dart" + }, + { + "name": "shiny (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "shiny (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "example", + "cwd": "example", + "request": "launch", + "type": "dart" + }, + { + "name": "example (profile mode)", + "cwd": "example", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "example (release mode)", + "cwd": "example", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..56e5149 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 0.1.0 + +- Initial release of `holo_shiny`. +- Added `Shiny` widget with shader-driven holographic effects. +- Added optional `ShinyController` and `SensorTiltController` motion APIs. +- Added package example app and baseline tests. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..be4cfa7 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# holo_shiny + +`holo_shiny` is a Flutter package that renders shader-driven holographic shine. + +It exposes two widget layers: + +- `Shiny`: apply shine to any widget (no clipping and no rotation) +- `ShinyCard`: card-oriented convenience widget with shape clipping and tilt rotation + +## Features + +- GLSL shader-driven holographic effects +- Optional motion input via sensors or custom tilt stream +- Generic wrapper API for any widget via `Shiny(child: ...)` +- Card API with `background`, `foreground`, `shape`, and drag tilt via `ShinyCard` +- Built-in sparkle presets including 8-point star, 5-point star, rectangle, diamond, hexagon, random polygon, and confetti +- Custom sparkle shapes via parameterized `SparkleShapeSpec` factories +- Cross-platform Flutter support (mobile, web, desktop) + +## Installation + +Add the package: + +```yaml +dependencies: + holo_shiny: ^0.1.0 +``` + +Then run: + +```bash +flutter pub get +``` + +## Quick Start + +```dart +import 'package:flutter/material.dart'; +import 'package:holo_shiny/holo_shiny.dart'; + +class Demo extends StatelessWidget { + const Demo({super.key}); + + @override + Widget build(BuildContext context) { + return const Shiny( + child: ColoredBox( + color: Color(0xFF202A3A), + child: SizedBox(width: 280, height: 80), + ), + ); + } +} +``` + +## Card Usage + +```dart +ShinyCard( + controller: controller, + background: Container(color: const Color(0xFF1B2D4B)), + foreground: const Center(child: Text('HOLO')), + sparkleShape: SparkleShapeSpec.eightPointStar, +) +``` + +## Motion with Controller + +```dart +final ShinyController controller = ShinyController(useSensor: true); + +Shiny( + controller: controller, + child: Container(color: const Color(0xFF1B2D4B)), +) +``` + +## Sparkle Shapes + +Use built-in sparkle presets: + +```dart +ShinyCard( + sparkleShape: SparkleShapeSpec.hexagon, +) +``` + +Or create your own parameterized shapes: + +```dart +ShinyCard( + sparkleShape: SparkleShapeSpec.customStar( + points: 7, + innerRatio: 0.36, + ), +) + +Shiny( + sparkleShape: SparkleShapeSpec.customPolygon( + sides: 8, + aspectRatio: 1.2, + rotation: 0.2, + ), + child: const SizedBox(width: 240, height: 120), +) +``` + +You can also provide a custom stream: + +```dart +final StreamController input = StreamController.broadcast(); +final ShinyController controller = ShinyController(tiltStream: input.stream); +``` + +## API Summary + +- `Shiny`: generic effect wrapper +- `ShinyCard`: shape + rotation + composition convenience widget +- `SparkleShapeSpec`: built-in and custom sparkle silhouette configuration +- `ShinyController`: optional source selection for tilt +- `SensorTiltController`: low-level sensor fusion stream utility + +## Platform Notes + +- Shader uses Flutter runtime effects and avoids derivative functions (`dFdx`, `dFdy`, `fwidth`) for web compatibility. +- If a sensor is unavailable, motion input simply emits no events. + +## Example + +See the package example app in `example/` for: + +- Basic usage +- Any-widget shiny wrapper usage +- Sensor-driven motion +- External stream override +- Custom card shape/background/foreground composition +- Built-in sparkle shape toggles in the demo UI +- User-defined sparkle shape presets using `SparkleShapeSpec` + +## Publishing Notes + +Replace placeholder package metadata URLs in `pubspec.yaml` before publishing. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..733b056 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.holo_shiny" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.holo_shiny" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9b6acd9 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/holo_shiny/MainActivity.kt b/android/app/src/main/kotlin/com/example/holo_shiny/MainActivity.kt new file mode 100644 index 0000000..e58140d --- /dev/null +++ b/android/app/src/main/kotlin/com/example/holo_shiny/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.holo_shiny + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/example/.metadata b/example/.metadata new file mode 100644 index 0000000..a8104ff --- /dev/null +++ b/example/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: android + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..04d83e6 --- /dev/null +++ b/example/README.md @@ -0,0 +1,3 @@ +# holo_shiny_example + +A new Flutter project. diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/example/android/.gitignore b/example/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/example/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/example/android/app/build.gradle.kts b/example/android/app/build.gradle.kts new file mode 100644 index 0000000..6ef9ad4 --- /dev/null +++ b/example/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.holo_shiny_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.holo_shiny_example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4c36770 --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/kotlin/com/example/holo_shiny_example/MainActivity.kt b/example/android/app/src/main/kotlin/com/example/holo_shiny_example/MainActivity.kt new file mode 100644 index 0000000..7c65fde --- /dev/null +++ b/example/android/app/src/main/kotlin/com/example/holo_shiny_example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.holo_shiny_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/build.gradle.kts b/example/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/example/android/settings.gradle.kts b/example/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/example/assets/pokemon.png b/example/assets/pokemon.png new file mode 100644 index 0000000..1307cec Binary files /dev/null and b/example/assets/pokemon.png differ diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..7b3b087 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,418 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:holo_shiny/holo_shiny.dart'; + +void main() { + runApp(const ExampleApp()); +} + +class ExampleApp extends StatelessWidget { + const ExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF24A6A8)), + ), + home: const ExampleHome(), + ); + } +} + +class ExampleHome extends StatefulWidget { + const ExampleHome({super.key}); + + @override + State createState() => _ExampleHomeState(); +} + +class _ExampleHomeState extends State { + late final ShinyController _sensorController; + late final StreamController _externalTiltController; + late final ShinyController _overrideController; + + double _prismatic = 0.8; + double _sparkle = 0.8; + double _specular = 0.8; + double _diffraction = 0.8; + HolographStyle _style = HolographStyle.crackedIce; + SparkleShapeSpec _sparkleShape = SparkleShapeSpec.eightPointStar; + + static const Map _sparkleChoices = + { + '8-Point Star': SparkleShapeSpec.eightPointStar, + '5-Point Star': SparkleShapeSpec.fivePointStar, + 'Rectangle': SparkleShapeSpec.rectangle, + 'Diamond': SparkleShapeSpec.diamond, + 'Hexagon': SparkleShapeSpec.hexagon, + 'Random Polygon': SparkleShapeSpec.randomPolygon, + 'Confetti': SparkleShapeSpec.confetti, + }; + + @override + void initState() { + super.initState(); + _sensorController = ShinyController(useSensor: true); + _externalTiltController = StreamController.broadcast(); + _overrideController = + ShinyController(tiltStream: _externalTiltController.stream); + } + + @override + void dispose() { + _sensorController.dispose(); + _overrideController.dispose(); + _externalTiltController.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0D121A), + appBar: AppBar( + title: const Text('holo_shiny example'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('Shiny on Any Widget'), + const SizedBox(height: 8), + Center( + child: SizedBox( + width: 320, + height: 120, + child: Shiny( + controller: _sensorController, + style: _style, + sparkleShape: _sparkleShape, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF18263E), + borderRadius: BorderRadius.circular(14), + ), + child: const Row( + children: [ + Icon(Icons.auto_awesome, color: Colors.white), + SizedBox(width: 12), + Expanded( + child: Text( + 'This can wrap any widget, including app chrome.', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 24), + const Text('ShinyCard (shape + rotation + shine)'), + const SizedBox(height: 8), + Center( + child: ShinyCard( + controller: _sensorController, + style: _style, + sparkleShape: _sparkleShape, + background: const _DemoCardBackground(), + foreground: const _RareBadge(), + prismatic: _prismatic, + sparkle: _sparkle, + specular: _specular, + diffraction: _diffraction, + ), + ), + const SizedBox(height: 16), + _StylePicker( + selectedStyle: _style, + onChanged: (HolographStyle style) { + setState(() { + _style = style; + }); + }, + ), + const SizedBox(height: 8), + _SparkleShapePicker( + selectedShape: _sparkleShape, + choices: _sparkleChoices, + onChanged: (SparkleShapeSpec shape) { + setState(() { + _sparkleShape = shape; + }); + }, + ), + const SizedBox(height: 8), + _LabeledSlider('Prismatic', _prismatic, (double value) { + setState(() { + _prismatic = value; + }); + }), + _LabeledSlider('Sparkle', _sparkle, (double value) { + setState(() { + _sparkle = value; + }); + }), + _LabeledSlider('Specular', _specular, (double value) { + setState(() { + _specular = value; + }); + }), + _LabeledSlider('Diffraction', _diffraction, (double value) { + setState(() { + _diffraction = value; + }); + }), + const SizedBox(height: 24), + const Text('External Tilt Stream + Custom Shape (card)'), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () => + _externalTiltController.add(const Offset(0.7, 0.0)), + child: const Text('Tilt Right'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: () => + _externalTiltController.add(const Offset(-0.7, 0.0)), + child: const Text('Tilt Left'), + ), + ), + ], + ), + const SizedBox(height: 8), + Center( + child: ShinyCard( + controller: _overrideController, + style: _style, + sparkleShape: _sparkleShape, + shape: const StadiumBorder(), + background: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFF1A2A44), Color(0xFF15263D)], + ), + ), + ), + foreground: const Center( + child: Text( + 'EXTERNAL STREAM', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w900, + letterSpacing: 1.2, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _LabeledSlider extends StatelessWidget { + const _LabeledSlider(this.label, this.value, this.onChanged); + + final String label; + final double value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label), + Slider( + value: value, + onChanged: onChanged, + ), + ], + ); + } +} + +class _StylePicker extends StatelessWidget { + const _StylePicker({ + required this.selectedStyle, + required this.onChanged, + }); + + final HolographStyle selectedStyle; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Style'), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 8, + children: HolographStyle.values.map((HolographStyle style) { + return ChoiceChip( + label: Text(_styleLabel(style)), + selected: selectedStyle == style, + onSelected: (bool selected) { + if (!selected) { + return; + } + onChanged(style); + }, + ); + }).toList(), + ), + ], + ); + } + + String _styleLabel(HolographStyle style) { + switch (style) { + case HolographStyle.holographicSilver: + return 'Holographic Silver'; + case HolographStyle.crackedIce: + return 'Cracked Ice'; + case HolographStyle.silverMosaic: + return 'Silver Mosaic'; + case HolographStyle.superGoldVinyl: + return 'Super Gold Vinyl'; + } + } +} + +class _SparkleShapePicker extends StatelessWidget { + const _SparkleShapePicker({ + required this.selectedShape, + required this.choices, + required this.onChanged, + }); + + final SparkleShapeSpec selectedShape; + final Map choices; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Sparkle Shape'), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 8, + children: + choices.entries.map((MapEntry entry) { + return ChoiceChip( + label: Text(entry.key), + selected: selectedShape == entry.value, + onSelected: (bool selected) { + if (!selected) { + return; + } + onChanged(entry.value); + }, + ); + }).toList(), + ), + ], + ); + } +} + +class _DemoCardBackground extends StatelessWidget { + const _DemoCardBackground(); + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + Image.asset( + 'assets/pokemon.png', + fit: BoxFit.cover, + alignment: Alignment.topCenter, + errorBuilder: + (BuildContext context, Object error, StackTrace? stackTrace) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF2D1B1B), Color(0xFF120D18)], + ), + ), + padding: const EdgeInsets.all(16), + child: const Align( + alignment: Alignment.bottomLeft, + child: Text( + 'Demo Card', + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w900, + ), + ), + ), + ); + }, + ), + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.10), + Colors.black.withValues(alpha: 0.22), + ], + ), + ), + ), + ], + ); + } +} + +class _RareBadge extends StatelessWidget { + const _RareBadge(); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topRight, + child: Container( + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(99), + ), + child: const Text( + 'RARE', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + letterSpacing: 1, + ), + ), + ), + ); + } +} diff --git a/example/linux/.gitignore b/example/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/example/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/example/linux/CMakeLists.txt b/example/linux/CMakeLists.txt new file mode 100644 index 0000000..1761446 --- /dev/null +++ b/example/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "holo_shiny_example") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.holo_shiny_example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/example/linux/flutter/CMakeLists.txt b/example/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/example/linux/flutter/generated_plugin_registrant.cc b/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/example/linux/flutter/generated_plugin_registrant.h b/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..2e1de87 --- /dev/null +++ b/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/example/linux/runner/CMakeLists.txt b/example/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/example/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/example/linux/runner/main.cc b/example/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/example/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/example/linux/runner/my_application.cc b/example/linux/runner/my_application.cc new file mode 100644 index 0000000..448e8c8 --- /dev/null +++ b/example/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "holo_shiny_example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "holo_shiny_example"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/example/linux/runner/my_application.h b/example/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/example/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..d5a6bf5 --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,23 @@ +name: holo_shiny_example +description: Example app for holo_shiny. +publish_to: none + +environment: + sdk: ^3.6.0 + flutter: ">=3.22.0" + +dependencies: + flutter: + sdk: flutter + holo_shiny: + path: .. + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + assets: + - assets/ diff --git a/lib/holo_shiny.dart b/lib/holo_shiny.dart new file mode 100644 index 0000000..e1cfbd4 --- /dev/null +++ b/lib/holo_shiny.dart @@ -0,0 +1,3 @@ +export 'src/sensor_tilt_controller.dart'; +export 'src/shiny_controller.dart'; +export 'src/shiny_widget.dart'; diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..a725658 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +void main() { + runApp(const MainApp()); +} + +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold( + body: Center( + child: Text('Hello World!'), + ), + ), + ); + } +} diff --git a/lib/src/sensor_tilt_controller.dart b/lib/src/sensor_tilt_controller.dart new file mode 100644 index 0000000..305e504 --- /dev/null +++ b/lib/src/sensor_tilt_controller.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:sensors_plus/sensors_plus.dart'; + +/// Produces a normalized tilt stream from gyroscope and accelerometer data. +class SensorTiltController { + /// Creates and starts the sensor controller. + SensorTiltController({ + this.alpha = 0.98, + this.maxTiltRadians = 0.5, + }) : _invAlpha = 1.0 - alpha { + _start(); + } + + /// Complementary filter blending coefficient. + final double alpha; + + /// Cached inverse alpha to avoid repetitive subtractions. + final double _invAlpha; + + /// Physical angle that maps to normalized magnitude `1.0`. + final double maxTiltRadians; + + final StreamController _controller = + StreamController.broadcast(); + + // High-performance timing for tight physics loops + final Stopwatch _stopwatch = Stopwatch(); + + StreamSubscription? _gyroSub; + StreamSubscription? _accelSub; + + double _roll = 0.0; + double _pitch = 0.0; + double _accelRoll = 0.0; + double _accelPitch = 0.0; + int? _lastMicroseconds; + + /// The normalized tilt stream. + Stream get stream => _controller.stream; + + void _start() { + if (!_supportsSensorStreams) return; + + _stopwatch.start(); + + try { + _gyroSub = gyroscopeEventStream().listen( + _onGyro, + onError: (_) {}, + cancelOnError: false, + ); + } on MissingPluginException { + _gyroSub = null; + } + + try { + _accelSub = accelerometerEventStream().listen( + _onAccel, + onError: (_) {}, + cancelOnError: false, + ); + } on MissingPluginException { + _accelSub = null; + } + } + + bool get _supportsSensorStreams { + if (kIsWeb) return true; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return true; + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + return false; + } + } + + void _onGyro(GyroscopeEvent event) { + final int nowMicros = _stopwatch.elapsedMicroseconds; + final int? last = _lastMicroseconds; + _lastMicroseconds = nowMicros; + + if (last == null) return; + + final double dt = (nowMicros - last) / 1000000.0; + if (dt <= 0.0) return; + + final double gyroRoll = _roll + (event.y * dt); + final double gyroPitch = _pitch + (event.x * dt); + + _roll = (alpha * gyroRoll) + (_invAlpha * _accelRoll); + _pitch = (alpha * gyroPitch) + (_invAlpha * _accelPitch); + + _controller.add(_normalizedOffset()); + } + + void _onAccel(AccelerometerEvent event) { + const double g = 9.81; + _accelRoll = (event.x / g).clamp(-1.0, 1.0); + _accelPitch = (event.y / g).clamp(-1.0, 1.0); + } + + Offset _normalizedOffset() { + final double dx = (_roll / maxTiltRadians).clamp(-1.0, 1.0); + final double dy = (_pitch / maxTiltRadians).clamp(-1.0, 1.0); + return Offset(dx, dy); + } + + /// Stops sensor streams and releases resources. + Future dispose() async { + _stopwatch.stop(); + await _gyroSub?.cancel(); + await _accelSub?.cancel(); + await _controller.close(); + } +} diff --git a/lib/src/shiny_controller.dart b/lib/src/shiny_controller.dart new file mode 100644 index 0000000..8a01542 --- /dev/null +++ b/lib/src/shiny_controller.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'sensor_tilt_controller.dart'; + +/// Optional controller for providing tilt input to [Shiny] widgets. +/// +/// By default, no stream is emitted. Set [useSensor] to true to use built-in +/// motion sensors, or provide [tiltStream] to fully control tilt externally. +class ShinyController { + /// Creates a [ShinyController]. + /// + /// If [tiltStream] is provided, it overrides the built-in sensor stream. + ShinyController({ + this.useSensor = false, + this.tiltStream, + }) { + if (tiltStream == null && useSensor) { + _sensorController = SensorTiltController(); + } + } + + /// Enables internal sensor-based tilt when [tiltStream] is null. + final bool useSensor; + + /// Optional external tilt stream in normalized `[-1.0, 1.0]` space. + final Stream? tiltStream; + + SensorTiltController? _sensorController; + + /// The effective tilt stream consumed by [Shiny]. + Stream get stream { + if (tiltStream != null) { + return tiltStream!; + } + if (_sensorController != null) { + return _sensorController!.stream; + } + return const Stream.empty(); + } + + /// Disposes internally-owned resources. + Future dispose() async { + await _sensorController?.dispose(); + } +} diff --git a/lib/src/shiny_widget.dart b/lib/src/shiny_widget.dart new file mode 100644 index 0000000..615f1cf --- /dev/null +++ b/lib/src/shiny_widget.dart @@ -0,0 +1,490 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +import 'shiny_controller.dart'; + +enum HolographStyle { + holographicSilver, + crackedIce, + silverMosaic, + superGoldVinyl, +} + +enum SparkleShapeKind { + star, + rectangle, + polygon, + randomPolygon, + confetti, +} + +class SparkleShapeSpec { + const SparkleShapeSpec._({ + required this.kind, + required this.primary, + required this.secondary, + required this.tertiary, + }); + + static const SparkleShapeSpec eightPointStar = SparkleShapeSpec._( + kind: SparkleShapeKind.star, + primary: 8.0, + secondary: 0.42, + tertiary: 0.0, + ); + + static const SparkleShapeSpec fivePointStar = SparkleShapeSpec._( + kind: SparkleShapeKind.star, + primary: 5.0, + secondary: 0.42, + tertiary: 0.0, + ); + + static const SparkleShapeSpec rectangle = SparkleShapeSpec._( + kind: SparkleShapeKind.rectangle, + primary: 0.24, + secondary: 0.055, + tertiary: 1.0, + ); + + static const SparkleShapeSpec diamond = SparkleShapeSpec._( + kind: SparkleShapeKind.polygon, + primary: 4.0, + secondary: 1.0, + tertiary: 0.78539816339, + ); + + static const SparkleShapeSpec hexagon = SparkleShapeSpec._( + kind: SparkleShapeKind.polygon, + primary: 6.0, + secondary: 1.0, + tertiary: 0.0, + ); + + static const SparkleShapeSpec randomPolygon = SparkleShapeSpec._( + kind: SparkleShapeKind.randomPolygon, + primary: 0.0, + secondary: 0.0, + tertiary: 0.0, + ); + + static const SparkleShapeSpec confetti = SparkleShapeSpec._( + kind: SparkleShapeKind.confetti, + primary: 0.34, + secondary: 0.045, + tertiary: 1.0, + ); + + factory SparkleShapeSpec.customStar( + {int points = 8, double innerRatio = 0.42}) { + return SparkleShapeSpec._( + kind: SparkleShapeKind.star, + primary: points.toDouble().clamp(4.0, 12.0), + secondary: innerRatio.clamp(0.15, 0.85), + tertiary: 0.0, + ); + } + + factory SparkleShapeSpec.customPolygon( + {int sides = 6, double aspectRatio = 1.0, double rotation = 0.0}) { + return SparkleShapeSpec._( + kind: SparkleShapeKind.polygon, + primary: sides.toDouble().clamp(3.0, 10.0), + secondary: aspectRatio.clamp(0.4, 2.0), + tertiary: rotation, + ); + } + + factory SparkleShapeSpec.customRectangle( + {double halfWidth = 0.24, + double halfHeight = 0.055, + double rotationJitter = 1.0}) { + return SparkleShapeSpec._( + kind: SparkleShapeKind.rectangle, + primary: halfWidth.clamp(0.05, 0.45), + secondary: halfHeight.clamp(0.02, 0.30), + tertiary: rotationJitter.clamp(0.0, 1.0), + ); + } + + factory SparkleShapeSpec.customConfetti( + {double length = 0.34, + double thickness = 0.045, + double rotationJitter = 1.0}) { + return SparkleShapeSpec._( + kind: SparkleShapeKind.confetti, + primary: length.clamp(0.08, 0.48), + secondary: thickness.clamp(0.01, 0.14), + tertiary: rotationJitter.clamp(0.0, 1.0), + ); + } + + final SparkleShapeKind kind; + final double primary; + final double secondary; + final double tertiary; +} + +class Shiny extends StatefulWidget { + const Shiny({ + super.key, + required this.child, + this.controller, + this.tilt, + this.prismatic = 0.8, + this.sparkle = 0.8, + this.specular = 0.8, + this.diffraction = 0.8, + this.sparkleShape = SparkleShapeSpec.eightPointStar, + this.style = HolographStyle.crackedIce, + this.blendMode = BlendMode.screen, + this.enableShader = true, + }); + + final Widget child; + final ShinyController? controller; + final Offset? tilt; + final double prismatic; + final double sparkle; + final double specular; + final double diffraction; + final SparkleShapeSpec sparkleShape; + final HolographStyle style; + final BlendMode blendMode; + final bool enableShader; + + @override + State createState() => _ShinyState(); +} + +class _ShinyState extends State with TickerProviderStateMixin { + static Future? _programFuture; + + ui.FragmentShader? _shader; + StreamSubscription? _tiltSub; + late final Ticker _ticker; + + double _time = 0.0; + Offset _tilt = Offset.zero; + + @override + void initState() { + super.initState(); + _ticker = createTicker((Duration elapsed) { + if (!mounted || _shader == null) return; + setState(() { + _time = elapsed.inMicroseconds / 1000000.0; + }); + }); + + _attachController(); + if (widget.enableShader) { + _loadShader(); + _ticker.start(); + } + } + + @override + void didUpdateWidget(covariant Shiny oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller || + oldWidget.tilt != widget.tilt) { + _tiltSub?.cancel(); + _attachController(); + } + if (oldWidget.enableShader != widget.enableShader) { + if (widget.enableShader) { + if (_shader == null) _loadShader(); + _ticker.start(); + } else { + _ticker.stop(); + } + } + } + + Future _loadShader() async { + try { + _programFuture ??= _loadProgram(); + final ui.FragmentProgram program = await _programFuture!; + if (!mounted) return; + setState(() { + _shader = program.fragmentShader(); + }); + } catch (_) { + // Keep rendering without shader when runtime effects are unavailable. + } + } + + Future _loadProgram() async { + try { + return await ui.FragmentProgram.fromAsset('shaders/shiny_card.frag'); + } catch (_) { + return await ui.FragmentProgram.fromAsset( + 'packages/holo_shiny/shaders/shiny_card.frag'); + } + } + + void _attachController() { + if (widget.tilt != null) return; + final ShinyController? controller = widget.controller; + if (controller == null) return; + + _tiltSub = controller.stream.listen((Offset value) { + if (!mounted || widget.tilt != null) return; + final clamped = _clampOffset(value); + if (_tilt != clamped) { + setState(() { + _tilt = clamped; + }); + } + }); + } + + @override + void dispose() { + _ticker.dispose(); + _tiltSub?.cancel(); + _shader?.dispose(); + super.dispose(); + } + + ui.Shader _configureShader(Rect bounds) { + final Offset effectiveTilt = _clampOffset(widget.tilt ?? _tilt); + final ui.FragmentShader shader = _shader!; + shader + ..setFloat(0, bounds.width) + ..setFloat(1, bounds.height) + ..setFloat(2, effectiveTilt.dx) + ..setFloat(3, effectiveTilt.dy) + ..setFloat(4, _time) + ..setFloat(5, widget.prismatic) + ..setFloat(6, widget.sparkle) + ..setFloat(7, widget.specular) + ..setFloat(8, widget.diffraction) + ..setFloat(9, widget.style.index.toDouble()) + ..setFloat(10, widget.sparkleShape.kind.index.toDouble()) + ..setFloat(11, widget.sparkleShape.primary) + ..setFloat(12, widget.sparkleShape.secondary) + ..setFloat(13, widget.sparkleShape.tertiary); + return shader; + } + + @override + Widget build(BuildContext context) { + if (!widget.enableShader || _shader == null) return widget.child; + + return ShaderMask( + blendMode: widget.blendMode, + shaderCallback: _configureShader, + child: widget.child, + ); + } + + Offset _clampOffset(Offset value) { + return Offset(value.dx.clamp(-1.0, 1.0), value.dy.clamp(-1.0, 1.0)); + } +} + +class ShinyCard extends StatefulWidget { + const ShinyCard({ + super.key, + this.background, + this.foreground, + this.shape = const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16))), + this.width = 300, + this.height = 420, + this.controller, + this.prismatic = 0.8, + this.sparkle = 0.8, + this.specular = 0.8, + this.diffraction = 0.8, + this.sparkleShape = SparkleShapeSpec.eightPointStar, + this.style = HolographStyle.crackedIce, + this.enableShader = true, + }); + + final Widget? background; + final Widget? foreground; + final ShapeBorder shape; + final double width; + final double height; + final ShinyController? controller; + final double prismatic; + final double sparkle; + final double specular; + final double diffraction; + final SparkleShapeSpec sparkleShape; + final HolographStyle style; + final bool enableShader; + + @override + State createState() => _ShinyCardState(); +} + +class _ShinyCardState extends State + with SingleTickerProviderStateMixin { + static const ValueKey transformKey = + ValueKey('holo_shiny.card.transform'); + + StreamSubscription? _tiltSub; + late final AnimationController _spring; + + // State segregation: prevents total widget rebuilds during drag events + late final ValueNotifier _tiltNotifier = ValueNotifier(Offset.zero); + late final StreamController _internalTiltStream = + StreamController.broadcast(); + late final ShinyController _internalShinyController = + ShinyController(tiltStream: _internalTiltStream.stream); + + Offset _sensorTilt = Offset.zero; + Offset _dragTilt = Offset.zero; + Offset _dragTiltAtRelease = Offset.zero; + bool _isDragging = false; + bool _isReturning = false; + + void _updateTilt() { + final tilt = (_isDragging || _isReturning) ? _dragTilt : _sensorTilt; + if (_tiltNotifier.value != tilt) { + _tiltNotifier.value = tilt; + if (!_internalTiltStream.isClosed) { + _internalTiltStream.add(tilt); + } + } + } + + @override + void initState() { + super.initState(); + _spring = AnimationController( + vsync: this, duration: const Duration(milliseconds: 850)) + ..addListener(() { + if (!mounted || !_isReturning) return; + final double t = Curves.elasticOut.transform(_spring.value); + _dragTilt = + Offset.lerp(_dragTiltAtRelease, Offset.zero, t) ?? Offset.zero; + _updateTilt(); + }) + ..addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.completed) { + _isReturning = false; + _updateTilt(); + } + }); + + _attachController(); + } + + @override + void didUpdateWidget(covariant ShinyCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _tiltSub?.cancel(); + _attachController(); + } + } + + void _attachController() { + final ShinyController? controller = widget.controller; + if (controller == null) return; + + _tiltSub = controller.stream.listen((Offset value) { + if (!mounted || _isDragging || _isReturning) return; + _sensorTilt = _clampOffset(value); + _updateTilt(); + }); + } + + void _onPanUpdate(DragUpdateDetails details) { + _isDragging = true; + _isReturning = false; + _spring.stop(); + + final double dx = (details.delta.dx / widget.width) * 2.0; + final double dy = (details.delta.dy / widget.height) * 2.0; + + _dragTilt = _clampOffset(_dragTilt + Offset(dx, dy)); + _updateTilt(); + } + + void _onPanEnd(DragEndDetails details) { + _isDragging = false; + _isReturning = true; + _dragTiltAtRelease = _dragTilt; + _spring + ..reset() + ..forward(); + } + + @override + void dispose() { + _tiltSub?.cancel(); + _spring.dispose(); + _tiltNotifier.dispose(); + _internalTiltStream.close(); + _internalShinyController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Widget base = widget.background ?? + const ColoredBox(color: Color(0xFF212121), child: SizedBox.expand()); + + // Isolate the static tree structure so it never rebuilds when tilted + final Widget staticChild = ClipPath( + clipper: ShapeBorderClipper(shape: widget.shape), + child: SizedBox( + width: widget.width, + height: widget.height, + child: Stack( + fit: StackFit.expand, + children: [ + Shiny( + enableShader: widget.enableShader, + controller: _internalShinyController, + tilt: null, // Managed natively via the internal stream controller + prismatic: widget.prismatic, + sparkle: widget.sparkle, + specular: widget.specular, + diffraction: widget.diffraction, + sparkleShape: widget.sparkleShape, + style: widget.style, + child: base, + ), + if (widget.foreground != null) widget.foreground!, + ], + ), + ), + ); + + return GestureDetector( + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + child: ValueListenableBuilder( + valueListenable: _tiltNotifier, + builder: (context, tilt, child) { + final Matrix4 transform = Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateX(-tilt.dy * 0.3) + ..rotateY(tilt.dx * 0.3); + + return Transform( + key: transformKey, + alignment: Alignment.center, + transform: transform, + child: child, + ); + }, + child: staticChild, + ), + ); + } + + Offset _clampOffset(Offset value) { + return Offset(value.dx.clamp(-1.0, 1.0), value.dy.clamp(-1.0, 1.0)); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..4457941 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,26 @@ +name: holo_shiny +description: Holographic shiny card widget powered by GLSL fragment shaders. +version: 0.1.0 +publish_to: none +repository: https://example.com/your-org/holo_shiny +homepage: https://example.com/your-org/holo_shiny +issue_tracker: https://example.com/your-org/holo_shiny/issues +documentation: https://example.com/your-org/holo_shiny/docs + +environment: + sdk: ^3.6.0 + flutter: ">=3.22.0" + +dependencies: + flutter: + sdk: flutter + sensors_plus: ^7.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + shaders: + - shaders/shiny_card.frag diff --git a/shaders/shiny_card.frag b/shaders/shiny_card.frag new file mode 100644 index 0000000..2c63131 --- /dev/null +++ b/shaders/shiny_card.frag @@ -0,0 +1,378 @@ +#version 460 core +#include + +out vec4 fragColor; + +uniform vec2 uSize; +uniform vec2 uTilt; +uniform float uTime; +uniform float uPrismatic; +uniform float uSparkle; +uniform float uSpecular; +uniform float uDiffraction; +uniform float uStyle; +uniform float uSparkleShapeKind; +uniform float uSparklePrimary; +uniform float uSparkleSecondary; +uniform float uSparkleTertiary; + +#define TWO_PI 6.28318530718 + +// Precomputed rotation matrices to avoid expensive runtime sin/cos calculations +#define ROT_0_78 mat2( 0.71091, 0.70328, -0.70328, 0.71091) +#define ROT_M0_5 mat2( 0.87758, -0.47943, 0.47943, 0.87758) +#define ROT_1_2 mat2( 0.36236, 0.93204, -0.93204, 0.36236) + +float hash(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +vec3 hsv2rgb(float h, float s, float v) { + vec3 k = vec3(1.0, 2.0 / 3.0, 1.0 / 3.0); + vec3 p = abs(fract(vec3(h) + k) * 6.0 - 3.0); + return v * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), s); +} + +vec3 rainbow(float phase, float saturation, float value) { + return hsv2rgb(fract(phase), saturation, value); +} + +float sdBox(vec2 p, vec2 b) { + vec2 d = abs(p) - b; + return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); +} + +float sdRegularPolygon(vec2 p, float sides, float radius) { + float angle = atan(p.y, p.x); + float sector = TWO_PI / sides; + return cos(floor(0.5 + angle / sector) * sector - angle) * length(p) - radius; +} + +float sparkleStarMask(vec2 p, float points, float innerRatio) { + float angle = atan(p.y, p.x); + float radius = dot(p, p); // Optimized: Use squared distance to delay sqrt + float spikes = 0.5 + 0.5 * cos(angle * points); + float starRadius = mix(0.34 * innerRatio, 0.34, pow(spikes, 1.4)); + return smoothstep(0.0009, -0.0009, radius - (starRadius * starRadius)); +} + +vec2 rotatePoint(vec2 p, float rotation) { + float c = cos(rotation); + float s = sin(rotation); + return vec2(c * p.x - s * p.y, s * p.x + c * p.y); +} + +float sparkleRectangleMask(vec2 p, float halfWidth, float halfHeight, float rotation) { + vec2 q = rotatePoint(p, rotation); + float body = sdBox(q, vec2(halfWidth, halfHeight)); + float bevel = sdBox(q, vec2(halfWidth * 0.78, halfHeight * 0.78)); + float bodyMask = smoothstep(0.03, -0.03, body); + + float centerLine = 1.0 - smoothstep(0.0, halfHeight * 1.25, abs(q.y)); + float endFade = 1.0 - smoothstep(halfWidth * 0.55, halfWidth * 1.05, abs(q.x)); + float streak = centerLine * endFade; + float bevelRing = smoothstep(0.025, -0.025, body) - smoothstep(0.025, -0.025, bevel); + return clamp(bodyMask * (0.58 + 0.42 * streak) + bevelRing * 0.25, 0.0, 1.0); +} + +float sparklePolygonMask(vec2 p, float sides, float aspectRatio, float rotation) { + vec2 q = rotatePoint(p, rotation); + q.x /= aspectRatio; + float poly = sdRegularPolygon(q, sides, 0.26); + return smoothstep(0.03, -0.03, poly); +} + +float sparkleShapeMask(vec2 p, vec2 id) { + float randomRotation = hash(id + 5.1) * TWO_PI; + if (uSparkleShapeKind < 0.5) { + return sparkleStarMask(p, uSparklePrimary, uSparkleSecondary); + } + if (uSparkleShapeKind < 1.5) { + return sparkleRectangleMask(p, uSparklePrimary, uSparkleSecondary, randomRotation * uSparkleTertiary); + } + if (uSparkleShapeKind < 2.5) { + return sparklePolygonMask(p, uSparklePrimary, uSparkleSecondary, uSparkleTertiary); + } + if (uSparkleShapeKind < 3.5) { + float sides = 5.0 + floor(hash(id + 9.4) * 4.0); + float aspect = 0.7 + hash(id + 13.1) * 0.7; + return sparklePolygonMask(p, sides, aspect, randomRotation); + } + return sparkleRectangleMask(p, uSparklePrimary, uSparkleSecondary, randomRotation * uSparkleTertiary); +} + +vec3 styleHolographicSilver(vec2 uv, vec2 tilt, float time) { + vec2 p = uv * 2.0 - 1.0; + float flowA = noise(uv * 3.5 + vec2(time * 0.03, -time * 0.02)); + float flowB = noise(uv * 7.0 + vec2(-time * 0.02, time * 0.03)); + vec2 warp = p + 0.25 * vec2(flowA - 0.5, flowB - 0.5); + float ribbon = sin(dot(warp, vec2(6.5, 2.8)) + flowA * 5.0 + dot(tilt, vec2(2.2, -1.7))); + float plume = sin(length(warp + vec2(flowB - 0.5, flowA - 0.5)) * 9.0 - tilt.x * 3.0 + time * 0.2); + float phase = flowA * 1.8 + ribbon * 0.28 + plume * 0.22 + time * 0.03; + + vec3 holo = rainbow(phase, 0.72, 1.0); + float blend = smoothstep(-0.65, 0.95, ribbon + plume * 0.55); + vec3 silver = vec3(0.62, 0.65, 0.70) * (0.85 + 0.15 * flowB); + + return mix(silver, holo, 0.65 * blend); +} + +vec3 styleCrackedIce(vec2 uv, vec2 tilt, float time) { + float maxZ = -999.0; + vec2 bestSlope = vec2(0.0); + float bestHash = 0.0; + float bestEdge = 1.0; + + vec2 warp = vec2(sin(uv.y * 10.0), cos(uv.x * 10.0)) * 0.05; + vec2 baseUV = uv + warp; + + // --- LAYER 1 --- + vec2 p1 = baseUV * 12.0; + vec2 id1 = floor(p1); + vec2 f1 = fract(p1) - 0.5; + vec2 slope1 = vec2(hash(id1 + 1.2), hash(id1 + 1.3)) * 2.0 - 1.0; + float z1 = dot(f1, slope1) * 3.5; + if (z1 > maxZ) { + maxZ = z1; bestSlope = slope1; bestHash = hash(id1 + 1.1); bestEdge = min(0.5 - abs(f1.x), 0.5 - abs(f1.y)); + } + + // --- LAYER 2 (Optimized Rotation) --- + vec2 p2 = (ROT_0_78 * baseUV) * 15.0; + vec2 id2 = floor(p2); + vec2 f2 = fract(p2) - 0.5; + vec2 slope2 = vec2(hash(id2 + 2.2), hash(id2 + 2.3)) * 2.0 - 1.0; + float z2 = 0.8 + dot(f2, slope2) * 3.5; + if (z2 > maxZ) { + maxZ = z2; bestSlope = slope2; bestHash = hash(id2 + 2.1); bestEdge = min(0.5 - abs(f2.x), 0.5 - abs(f2.y)); + } + + // --- LAYER 3 (Optimized Rotation) --- + vec2 p3 = (ROT_M0_5 * baseUV) * 18.0; + vec2 id3 = floor(p3); + vec2 f3 = fract(p3) - 0.5; + vec2 slope3 = vec2(hash(id3 + 3.2), hash(id3 + 3.3)) * 2.0 - 1.0; + float z3 = 1.6 + dot(f3, slope3) * 3.5; + if (z3 > maxZ) { + maxZ = z3; bestSlope = slope3; bestHash = hash(id3 + 3.1); bestEdge = min(0.5 - abs(f3.x), 0.5 - abs(f3.y)); + } + + // --- LAYER 4 (Optimized Rotation) --- + vec2 p4 = (ROT_1_2 * baseUV) * 21.0; + vec2 id4 = floor(p4); + vec2 f4 = fract(p4) - 0.5; + vec2 slope4 = vec2(hash(id4 + 4.2), hash(id4 + 4.3)) * 2.0 - 1.0; + float z4 = 2.4 + dot(f4, slope4) * 3.5; + if (z4 > maxZ) { + maxZ = z4; bestSlope = slope4; bestHash = hash(id4 + 4.1); bestEdge = min(0.5 - abs(f4.x), 0.5 - abs(f4.y)); + } + + // --- VIBRANT COLOR OPTICS --- + float alignment = dot(tilt, normalize(bestSlope + vec2(0.001))); + float phase = dot(uv, vec2(0.6, -0.4)) + time * 0.05 + bestHash * 0.6 + alignment * 0.5; + float saturation = 0.7 + 0.3 * hash(vec2(bestHash, 6.1)); + vec3 baseColor = rainbow(phase, saturation, 1.0); + + float lightCycle = sin(alignment * 4.0 + bestHash * TWO_PI + time * 0.15); + float ambient = 0.15 + 0.25 * max(0.0, lightCycle); + vec3 finalColor = baseColor * ambient; + float flash = pow(max(0.0, lightCycle), 5.0); + finalColor += mix(baseColor, vec3(1.0, 0.95, 0.85), 0.6) * flash * 0.55; + finalColor += baseColor * smoothstep(0.05, 0.0, bestEdge) * flash * 0.4; + + return finalColor; +} + +vec3 styleSilverMosaic(vec2 uv, vec2 tilt, float time) { + // Setup grid - Scaled up 300% (18.0 -> 6.0) + vec2 g = uv * 6.0; + vec2 id = floor(g); + + // Center coordinates around 0.0 for the radial math + vec2 f = fract(g) - 0.5; + + // Real foil sheets often alternate the grain of the squares to catch light from all angles. + if (mod(id.x + id.y, 2.0) > 0.5) { + f = vec2(-f.y, f.x); + } + + // Get the radial vector (pointing outward from the center of the square) + vec2 radialDir = normalize(f + vec2(0.0001)); + + // Calculate the simulated light direction based on device tilt. + vec2 lightDir = normalize(tilt + vec2(0.001)); + + // --- THE OPTICS --- + // A radial highlight occurs where the radial vector aligns with the light vector. + float alignment = abs(dot(radialDir, lightDir)); + + // Raise to a high power to narrow the reflection into a sharp, focused beam + float highlight = pow(alignment, 12.0); + + // --- THE DIFFRACTION (Rainbow) --- + float angleDiff = acos(alignment); + + // 2D cross product to find which side of the highlight we are on + float side = sign(radialDir.x * lightDir.y - radialDir.y * lightDir.x); + + // Phase shift based on tilt magnitude and time. + float basePhase = length(tilt) * 2.5 + time * 0.1; + + // Calculate the final color phase. + float phase = basePhase + side * angleDiff * 1.8; + vec3 holoColor = rainbow(phase, 0.85, 1.0); + + // Add a pure white "specular core" where alignment is absolutely perfect + vec3 core = vec3(1.0) * smoothstep(0.98, 1.0, alignment); + + // Base foil material + vec3 foilBase = vec3(0.25, 0.27, 0.30); + + // Combine the light components + vec3 finalColor = foilBase + (holoColor * highlight * 1.5) + (core * 0.8); + + // --- BORDERS AND DEPTH --- + // Crisp borders between the squares + float edgeX = 0.5 - abs(f.x); + float edgeY = 0.5 - abs(f.y); + + // Tightened the border width to compensate for the larger tile size + float border = smoothstep(0.0, 0.015, min(edgeX, edgeY)); + + // Add a subtle darkening toward the edges of the tiles + float dist = length(f); + finalColor *= mix(0.7, 1.0, 1.0 - smoothstep(0.0, 0.5, dist) * 0.3); + + return clamp(finalColor * border, 0.0, 1.0); +} + +vec3 styleSuperGoldVinyl(vec2 uv, vec2 tilt, float time) { + vec2 g = uv * 60.0; + vec2 staggered = vec2(g.x + 0.5 * mod(floor(g.y), 2.0), g.y); + vec2 f = fract(staggered) - 0.5; + + // Optimized: Use dot product for squared distance to avoid expensive sqrt/length. + // Original smoothstep values (0.42, 0.10) squared become (0.1764, 0.0100) + // Original smoothstep values (0.45, 0.18) squared become (0.2025, 0.0324) + float d2 = dot(f, f); + float dotMask = smoothstep(0.1764, 0.01, d2); + float emboss = smoothstep(0.2025, 0.0324, d2); + + float sheen = 0.5 + 0.5 * sin(dot(uv, vec2(18.0, -6.0)) + noise(uv * 6.0) * 2.5 + dot(tilt, vec2(2.4, 1.8)) * 2.0 + time * 0.2); + vec3 gold = vec3(0.84, 0.70, 0.24); + vec3 warm = vec3(1.0, 0.88, 0.42); + vec3 shadow = vec3(0.18, 0.14, 0.05); + + return gold * (0.75 + 0.25 * sheen) + warm * dotMask * 0.28 + shadow * emboss * 0.08; +} + +void main() { + // Standard normalized UVs [0.0 to 1.0] for macro lighting, tilt, and glare + vec2 uv = FlutterFragCoord().xy / uSize; + vec2 center = vec2(0.5, 0.5); + vec2 fromCenter = uv - center; + + // Aspect-corrected UVs for physical patterns (grid, sparkles, mosaics) + // Dividing by the maximum dimension ensures squares remain squares. + float maxDim = max(uSize.x, uSize.y); + vec2 aspectUV = FlutterFragCoord().xy / maxDim; + + float tiltMag = length(uTilt); + float safeTiltMag = max(tiltMag, 0.001); + vec2 tiltDir = uTilt / safeTiltMag; + + // The highlight uses the stretched UV so the glare covers the whole widget nicely + float highlightDistance = length(uv - (center + (uTilt * 0.35))); + float specularMask = pow(max(0.0, 1.0 - highlightDistance * 2.6), 3.2); + vec3 specular = vec3(specularMask) * uSpecular * (0.4 + 0.6 * tiltMag); + + bool isCrackedIce = uStyle >= 0.5 && uStyle < 1.5; + float sparkleGridScale = isCrackedIce ? 7.0 : 18.0; + + // ---> USE aspectUV HERE so sparkles are drawn perfectly proportionally + vec2 sparkleGrid = aspectUV * sparkleGridScale; + vec2 grid = floor(sparkleGrid); + + float rarityThreshold = isCrackedIce ? 0.965 : 0.83; + float rarity = step(rarityThreshold, hash(grid + 0.31)); + + vec3 sparkle = vec3(0.0); + if (rarity > 0.0) { + vec2 cell = fract(sparkleGrid) - 0.5; + float sparkleHash = hash(grid); + vec2 sparkleNormal = normalize(vec2(sparkleHash * 2.0 - 1.0, hash(grid + 19.7) * 2.0 - 1.0)); + float alignment = max(0.0, dot(tiltDir, sparkleNormal)); + + if (alignment > 0.0) { + float twinkle = 0.5 + 0.5 * sin(uTime * 5.5 + sparkleHash * TWO_PI); + float shapeMask = 0.0; + + if (isCrackedIce) { + float sides = 5.0 + floor(hash(grid + 9.4) * 4.0); + float aspect = 0.75 + hash(grid + 13.1) * 0.55; + shapeMask = sparklePolygonMask(cell, sides, aspect, hash(grid + 5.1) * TWO_PI); + } else { + shapeMask = sparkleShapeMask(cell, grid); + } + + float sparklePower = isCrackedIce ? 4.8 : 3.2; + float sparkleIntensity = isCrackedIce ? 0.25 : 1.0; + float sparkleChroma = isCrackedIce ? 0.20 : 0.35; + float sparkleMask = pow(alignment, sparklePower) * twinkle * rarity * shapeMask; + vec3 sparkleColor = mix(vec3(1.0), rainbow(sparkleHash + dot(uTilt, vec2(0.2, -0.15)), 0.55, 1.0), sparkleChroma); + sparkle = sparkleColor * sparkleMask * uSparkle * sparkleIntensity; + } + } + + vec3 styleBase; + // ---> USE aspectUV HERE so the foil patterns tile properly + if (uStyle < 0.5) { + styleBase = styleHolographicSilver(aspectUV, uTilt, uTime); + } else if (uStyle < 1.5) { + styleBase = styleCrackedIce(aspectUV, uTilt, uTime); + } else if (uStyle < 2.5) { + styleBase = styleSilverMosaic(aspectUV, uTilt, uTime); + } else { + styleBase = styleSuperGoldVinyl(aspectUV, uTilt, uTime); + } + + float styleLuma = dot(styleBase, vec3(0.299, 0.587, 0.114)); + vec3 chromaAdjusted = mix(vec3(styleLuma), styleBase, 0.2 + 0.8 * uPrismatic); + + // General 3D lighting normal uses the stretched UV + vec2 normal2d = normalize(fromCenter + vec2(0.001)); + float directional = 0.5 + 0.5 * dot(normal2d, tiltDir); + float tiltLighting = mix(0.86, 1.20, pow(directional, 1.2)); + chromaAdjusted *= tiltLighting; + + float microStrength = isCrackedIce ? 0.08 : 0.18; + + // ---> USE aspectUV HERE so the microscopic diffraction doesn't stretch + float microMask = 0.5 + 0.5 * sin(dot(aspectUV, vec2(137.0, 57.0)) + noise(aspectUV * 14.0) * 4.0 + dot(uTilt, vec2(9.0, 4.0)) * 2.0); + vec3 microShimmer = rainbow(noise(aspectUV * 10.0) + dot(uTilt, vec2(0.4, -0.3)) * 0.3, 0.65, 1.0) * microMask * uDiffraction * microStrength; + + vec3 styleColor = chromaAdjusted + microShimmer + (specular * 0.15) + sparkle; + vec3 mappedColor = styleColor / (1.0 + styleColor * 0.3); + float luma = dot(mappedColor, vec3(0.299, 0.587, 0.114)); + vec3 vibrantColor = mix(vec3(luma), mappedColor, 1.4); + + float foilBrightness = dot(vibrantColor, vec3(0.333)); + float alpha = clamp(foilBrightness * 0.5, 0.0, 0.35); + vec3 finalColor = clamp(vibrantColor, 0.0, 1.0); + + fragColor = vec4(finalColor * alpha, alpha); +} \ No newline at end of file diff --git a/test/shiny_controller_test.dart b/test/shiny_controller_test.dart new file mode 100644 index 0000000..94b97b3 --- /dev/null +++ b/test/shiny_controller_test.dart @@ -0,0 +1,28 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:holo_shiny/holo_shiny.dart'; + +void main() { + test('ShinyController uses external stream when provided', () async { + final StreamController source = + StreamController.broadcast(); + final ShinyController controller = + ShinyController(useSensor: true, tiltStream: source.stream); + + final Future next = controller.stream.first; + source.add(const Offset(0.5, -0.25)); + + expect(await next, const Offset(0.5, -0.25)); + + await controller.dispose(); + await source.close(); + }); + + test('ShinyController emits nothing by default', () async { + final ShinyController controller = ShinyController(); + final bool hasEvent = await controller.stream.isEmpty; + expect(hasEvent, isTrue); + await controller.dispose(); + }); +} diff --git a/test/shiny_widget_test.dart b/test/shiny_widget_test.dart new file mode 100644 index 0000000..6f394e2 --- /dev/null +++ b/test/shiny_widget_test.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:holo_shiny/holo_shiny.dart'; + +const ValueKey _cardTransformKey = + ValueKey('holo_shiny.card.transform'); + +void main() { + test('default holograph style is crackedIce', () { + const Shiny shiny = Shiny(child: SizedBox.shrink()); + const ShinyCard shinyCard = ShinyCard(); + + expect(shiny.style, HolographStyle.crackedIce); + expect(shinyCard.style, HolographStyle.crackedIce); + }); + + test('default sparkle shape is star', () { + const Shiny shiny = Shiny(child: SizedBox.shrink()); + const ShinyCard shinyCard = ShinyCard(); + + expect(shiny.sparkleShape.kind, SparkleShapeKind.star); + expect(shiny.sparkleShape.primary, 8.0); + expect(shinyCard.sparkleShape.kind, SparkleShapeKind.star); + expect(shinyCard.sparkleShape.primary, 8.0); + }); + + test('custom sparkle factories produce expected specs', () { + final SparkleShapeSpec star = + SparkleShapeSpec.customStar(points: 7, innerRatio: 0.33); + final SparkleShapeSpec polygon = SparkleShapeSpec.customPolygon( + sides: 8, aspectRatio: 1.2, rotation: 0.4); + final SparkleShapeSpec rectangle = SparkleShapeSpec.customRectangle( + halfWidth: 0.20, halfHeight: 0.05, rotationJitter: 0.7); + + expect(star.kind, SparkleShapeKind.star); + expect(star.primary, 7.0); + expect(star.secondary, 0.33); + + expect(polygon.kind, SparkleShapeKind.polygon); + expect(polygon.primary, 8.0); + expect(polygon.secondary, 1.2); + expect(polygon.tertiary, 0.4); + + expect(rectangle.kind, SparkleShapeKind.rectangle); + expect(rectangle.primary, 0.20); + expect(rectangle.secondary, 0.05); + expect(rectangle.tertiary, 0.7); + }); + + testWidgets('Shiny wraps any child without card transform', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Shiny( + enableShader: false, + child: ColoredBox( + color: Colors.blue, + child: SizedBox(width: 80, height: 40), + ), + ), + ), + ), + ); + + expect(find.byType(ColoredBox), findsWidgets); + expect(find.byKey(_cardTransformKey), findsNothing); + }); + + testWidgets('ShinyCard renders clipping and foreground', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ShinyCard( + enableShader: false, + background: ColoredBox(color: Colors.black), + foreground: Text('FOREGROUND'), + ), + ), + ), + ); + + expect(find.text('FOREGROUND'), findsOneWidget); + expect(find.byType(ClipPath), findsOneWidget); + expect(find.byKey(_cardTransformKey), findsOneWidget); + }); + + testWidgets('ShinyCard responds to external tilt stream', + (WidgetTester tester) async { + final StreamController stream = + StreamController.broadcast(); + final ShinyController controller = + ShinyController(tiltStream: stream.stream); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ShinyCard( + enableShader: false, + controller: controller, + background: const ColoredBox(color: Colors.black), + ), + ), + ), + ), + ); + + final Matrix4 before = + tester.widget(find.byKey(_cardTransformKey)).transform; + + stream.add(const Offset(0.7, 0.2)); + await tester.pump(); + + final Matrix4 after = + tester.widget(find.byKey(_cardTransformKey)).transform; + + expect(after.storage[8], isNot(before.storage[8])); + + await controller.dispose(); + await stream.close(); + }); + + testWidgets('Dragging ShinyCard updates transform', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: ShinyCard( + enableShader: false, + background: ColoredBox(color: Colors.black), + ), + ), + ), + ), + ); + + final Finder shinyCard = find.byType(ShinyCard); + final Matrix4 before = + tester.widget(find.byKey(_cardTransformKey)).transform; + + await tester.drag(shinyCard, const Offset(50, 20)); + await tester.pump(); + + final Matrix4 after = + tester.widget(find.byKey(_cardTransformKey)).transform; + expect(after.storage[8], isNot(before.storage[8])); + }); +}